【C++】プロトタイプ宣言とは?|必要性・使い方をわかりやすく解説

サムネイル画像 C++

「関数の宣言と定義って何が違うの?」「プロトタイプ宣言って書かなくてもコンパイルできることがあるのはなぜ?」「そもそもこれ、C++でも必要なの?」

こんな疑問はありませんか?

今回は、C++の「プロトタイプ宣言」について解説していきます。実際にコンパイル・実行した結果を交えながら、いつ必要で、いつ不要なのか、そして書き方を間違えるとどんなエラーになるのかまで一通り見ていきます。読み終えると、プロトタイプ宣言の役割を自信を持って説明できるようになると思いますので、ぜひ最後まで読んでください。

プロトタイプ宣言とは?

プロトタイプ宣言とは、

・「この名前・引数・戻り値の関数が、後のどこかで定義されていますよ」
関数の存在をコンパイラに先に教えるための宣言

です。

C++のコンパイラはソースコードを上から下に向かって読んでいくため、まだ読んでいない(下に書かれた)関数を呼び出そうとすると、その関数の存在を知らずにエラーになります。プロトタイプ宣言は、この「まだ出てきていない関数」の存在を先に教えておくための一文です。

書き方はシンプルで、戻り値の型 関数名(引数の型...);のように中身({ }の処理)を書かずにセミコロンで終えるだけです。

【実験】プロトタイプ宣言がないとどうなる?

百聞は一見にしかずなので、まずはプロトタイプ宣言なしでコンパイルしてみます。

#include <iostream>

int main() {
    Greet(); // まだ宣言も定義もされていない関数を呼ぶ
    return 0;
}

void Greet() {
    std::cout << "Hello!" << std::endl;
}

Visual Studioのcl.exeで実際にコンパイルすると、次のエラーになりました。

no_prototype.cpp(4): error C3861: 'Greet': 識別子が見つかりませんでした

mainの時点では、コンパイラはまだGreetという関数の存在を知りません。だから「そんな識別子(名前)は見つからない」と言われてしまいます。Greetの定義自体はソースの下の方にちゃんと書いてあるのに、です。

プロトタイプ宣言を書くとどうなる?

同じコードに、1行だけプロトタイプ宣言を足してみます。

#include <iostream>

void Greet(); // プロトタイプ宣言:Greetという関数が後で出てくることを先に教える

int main() {
    Greet();
    return 0;
}

void Greet() {
    std::cout << "Hello!" << std::endl;
}

実行結果です。

Hello!

コンパイルも実行も問題なく通りました。void Greet();という1行だけで、「後でGreetという関数が出てきますよ」とコンパイラに伝えられたわけです。

ちなみに、関数の定義自体がmainより上にある場合は、プロトタイプ宣言は不要です。コンパイラが上から読んでいく時点で、既に関数の中身を知っているからです。

プロトタイプ宣言が本当に必要になる場面:相互再帰

「定義を上に書けば宣言はいらない」のなら、いつプロトタイプ宣言が本当に必要になるのでしょうか。代表的なのが、2つの関数が互いを呼び合う相互再帰です。

IsEven(偶数判定)とIsOdd(奇数判定)を、お互いを呼び出す形で実装してみます。

#include <iostream>

// IsOddはまだ出てきていないので、先にプロトタイプ宣言しておく
bool IsOdd(int n);

bool IsEven(int n) {
    if (n == 0) return true;
    return IsOdd(n - 1); // ここでIsOddを呼ぶ時点では、まだIsOddは定義されていない
}

bool IsOdd(int n) {
    if (n == 0) return false;
    return IsEven(n - 1); // IsEvenは既に上で定義済みなのでOK
}

int main() {
    for (int i = 0; i <= 5; i++) {
        std::cout << i << " : IsEven=" << IsEven(i) << " IsOdd=" << IsOdd(i) << std::endl;
    }
    return 0;
}

実行結果です。

0 : IsEven=1 IsOdd=0
1 : IsEven=0 IsOdd=1
2 : IsEven=1 IsOdd=0
3 : IsEven=0 IsOdd=1
4 : IsEven=1 IsOdd=0
5 : IsEven=0 IsOdd=1

IsEvenIsOddは互いを呼び合う関係なので、どちらを先に書いても、もう片方はまだ定義されていない状態になります。この「お互い様」の関係を解決できるのが、プロトタイプ宣言です。IsOddを先に宣言だけしておくことで、IsEvenの中から呼べるようになります。

ヘッダファイルに宣言をまとめる

プロトタイプ宣言は、複数のソースファイルにまたがって関数を使うときにも欠かせません。関数の宣言をヘッダファイル(.h)にまとめておけば、他のファイルからは#includeするだけでその関数を呼べるようになります。

▼greet.h(宣言をまとめる)

#ifndef GREET_H
#define GREET_H

void Greet(); // プロトタイプ宣言をヘッダファイルにまとめる

#endif

▼greet.cpp(実際の定義)

#include <iostream>
#include "greet.h"

void Greet() {
    std::cout << "Hello from another file!" << std::endl;
}

▼main.cpp(呼び出し側)

#include "greet.h" // ヘッダをincludeするだけで、別ファイルのGreetを呼べる

int main() {
    Greet();
    return 0;
}

この3ファイルを一緒にコンパイルして実行した結果です。

Hello from another file!

main.cppgreet.cppの中身を直接読んでいませんが、greet.hのプロトタイプ宣言のおかげで「Greetという関数がどこかに存在する」ことをコンパイラに伝えられています。実際に呼び出せるかどうか(=リンクできるかどうか)は、最後にリンカがgreet.cpp側の定義と結び付けてくれます。

【重要】記事を書くこのデモで実際に見つけた危険なパターン

この記事用に実際のデモを書いていて、地味に時間を取られた失敗があります。プロトタイプ宣言と実際の定義で、引数の型をうっかり変えてしまうパターンです。

int Add(int a, int b); // プロトタイプ宣言:int, int を受け取ってintを返すはず…

int main() {
    std::cout << Add(2, 3) << std::endl;
    return 0;
}

// うっかり第2引数をdoubleにして定義してしまった
// -> これは「同じ関数の定義」ではなく「別のオーバーロード」として扱われる
double Add(int a, double b) {
    return a + b;
}

コンパイル自体は通ってしまい、リンクの段階で次のエラーになりました。

gotcha_signature_mismatch.obj : error LNK2019: 未解決の外部シンボル "int __cdecl Add(int,int)" (?Add@@YAHHH@Z) が関数 main で参照されました
  定義済みの一致する可能性があるシンボルに関するヒント:
    "double __cdecl Add(int,double)" (?Add@@YANHN@Z)
gotcha_signature_mismatch.exe : fatal error LNK1120: 1 件の未解決の外部参照

プロトタイプ宣言と定義は、名前だけでなく引数の型まで完全に一致していないと「同じ関数」として扱われません。1文字でも型が違えば、コンパイラ内部ではまったく別のシンボル(?Add@@YAHHH@Z?Add@@YANHN@Zのように、型の情報を含んだ別名)として扱われ、宣言した方の実体だけが永遠に見つからない、という状態になります。

厄介なのは、Addという関数自体はちゃんと存在しているように見えることです。実際にはオーバーロードとして「もう1つの別の関数」が生まれてしまっているだけなので、コンパイルエラーにはならず、リンクの段階で初めて気づくことになります。

プロトタイプ宣言のよくある失敗例と対処法

①宣言と定義で引数の型が食い違う

上で実演した通りです。「error LNK2019: 未解決の外部シンボル」というリンクエラーが出たら、まずプロトタイプ宣言と定義の引数の型・個数・順番が完全に一致しているか見比べましょう。

②宣言だけ書いて、定義を書き忘れる

プロトタイプ宣言はあくまで「後で出てきますよ」という予告なので、実際に定義(中身)を書かないままだと、こちらも同じLNK2019系のリンクエラーになります。宣言と定義はセットだと覚えておきましょう。

③ヘッダファイルにインクルードガードを付け忘れる

同じヘッダが複数の場所から#includeされると、プロトタイプ宣言が二重に読み込まれて再定義エラーになることがあります。#ifndef#define#endif(インクルードガード)、または#pragma onceを必ず付けましょう。

注意点

  • 関数の定義がmainよりにあるなら、プロトタイプ宣言は不要
  • プロトタイプ宣言と定義は、引数の型・個数・順番まで完全に一致させる
  • 複数ファイルで関数を共有するなら、宣言はヘッダファイルにまとめる
  • ヘッダファイルにはインクルードガードを必ず付ける

まとめ

  • プロトタイプ宣言は関数の存在を先にコンパイラへ教えるための仕組み
  • 定義がmainより下にある場合や、相互再帰複数ファイル分割では必須になる
  • 宣言なしで呼び出すとerror C3861(識別子が見つからない)になる
  • 宣言と定義で型が食い違うとLNK2019のリンクエラーになる(実際に踏んだ罠)

プロトタイプ宣言はシンプルな仕組みですが、「宣言と定義は完全に一致していないと別物として扱われる」という点だけは、実際にエラーを見ておくと記憶に残りやすいと思います。

ここまで読んでくださりありがとうございました。

関連記事