Menu

C++の仮想関数:ポリモーフィズムをわかりやすく解説

仮想関数を使うと、基底クラスのポインタから実行時に派生クラスのメソッドを呼び出せます。virtualoverride、抽象クラス、そして基底クラスのデストラクタが仮想でなければならない理由を学びましょう。

このページのコードはエディタで実行できます - 編集してすぐに結果を確認できます。

継承だけでは不十分な理由

前のページでは、クラス階層を構築しました。派生クラスは基底クラスからメンバを継承します。しかし、ここに落とし穴があります。基底クラスのポインタを通じてメソッドを呼び出すと、C++はオブジェクトの実際の型ではなく、ポインタの型に基づいて どの 関数を実行するかを決めます。つまり、実際には Dog を指している Animal* であっても、Animal 側のメソッドのバージョンが呼ばれてしまうのです。

これはほとんどの場合、望んだ動作ではありません。多くの場合、基底クラスのポインタのコレクションがあり、それぞれが異なる派生オブジェクトを指していて、各オブジェクトにそれ自身として振る舞ってほしいものです。仮想関数はそれを実現します。

オブジェクトは紛れもなく Dog ですが、それでも a->speak()Animal::speak() を実行しました。speak が仮想ではないため、コンパイラは静的型 Animal* からコンパイル時に関数を選んだのです。これこそ、仮想関数が解決するために存在するバグです。

関数を仮想にする

基底クラスのメソッドに virtual キーワードを追加します。これで呼び出しは、オブジェクトの実際の型に基づいて 実行時 に解決されるようになります。これが動的ディスパッチです。

Animal* に対する1つのループで、3つの異なる振る舞いが得られます。基底ポインタは実行時に実際の型を「知り」、それに応じてディスパッチします。この1つの仕組み、つまり1つのインターフェースに多数の実装という考え方こそが、C++における ポリモーフィズム の意味です。

virtual は基底クラスの宣言にだけ付ければよい点に注意してください。いったん関数が仮想になれば、すべての派生クラスで自動的に仮想のまま維持されます。派生クラスでもう一度書くのは任意であり、冗長です。

必ず override キーワードを使う

上記の例では、各派生メソッドに override が付いています。コードを動かすだけなら任意ですが、必須として扱うべきです。override(C++11)は、一致するシグネチャで基底クラスの仮想関数を本当にオーバーライドしているかどうかをコンパイラに検証させます。シグネチャをわずかに間違えても、静かなバグではなく明確なエラーが得られます。

struct Animal {
    virtual void speak() const { }   // 注意:const
};

struct Dog : Animal {
    void speak() { }            // constではない - これはオーバーライドではなく、新しい関数!
    void speak() override { }   // エラー:'speak' はオーバーライドしていない - すぐに教えてくれる
};

override がないと、最初の speak() は問題なくコンパイルされますが、シグネチャが基底クラスと異なる(const がない)ため、Animal* を通じて呼ばれることは決してありません。オーバーライドが何も効かない理由に悩んで、半日を費やすことになるでしょう。override を付ければ、コンパイラがその場で不一致を捕捉します。オーバーライドするすべての関数に付けましょう。

純粋仮想関数と抽象クラス

基底クラスに合理的なデフォルトがない場合もあります。汎用的な「Animal」はどんな音を出すのでしょうか?そのような場合は、= 0 を割り当てて関数を 純粋仮想 として宣言します。これにより関数は本体を持たなくなり、クラスはそれ単体ではインスタンス化できない抽象クラスになります。抽象クラスは、派生クラスが満たさなければならないインターフェースを定義するためだけに存在します。

具象的なサブクラスはすべて area() を実装し なければならず、さもなければそのサブクラスも抽象のままになります。これがC++における「インターフェース」の表現方法です。純粋仮想関数だけを持つ抽象クラスは、Javaのような言語のインターフェースに相当するC++の概念です。

仮想デストラクタのルール

これは誰もが少なくとも一度はハマる落とし穴です。基底クラスのポインタを通じてオブジェクトを delete すると、C++は見つけたデストラクタを呼び出します。そして、そのデストラクタが仮想でない場合、基底 クラスのデストラクタしか実行しません。派生部分は決して破棄されず、所有していたものがすべてリークします。標準ではこれを未定義動作と呼んでいます。

修正はたった1語です。基底クラスのデストラクタを virtual にします。そうすれば delete p は、本来あるべきとおりに、まず ~Derived を、次に ~Base を実行します。

struct Base {
    virtual ~Base() { cout << "~Base\n"; }   // 正しい
};
// これで:~Derived の次に ~Base

経験則: クラスに仮想関数が1つでもあれば、仮想デストラクタも与えましょう。クラスがポインタを通じて使われる基底クラスとして意図されているなら、そのデストラクタは仮想でなければなりません。

よくある間違いと落とし穴

仮想関数に慣れてきたら気をつけたい、さらにいくつかの罠を挙げます。

オブジェクトスライシング(object slicing)。 派生オブジェクトを 値で 基底クラスの変数に渡したり格納したりすると、派生部分が「切り落とされて」単なる基底オブジェクトが残ります。こうなると仮想ディスパッチはオーバーライドに届きません。ポリモーフィズムには必ずポインタ参照を使いましょう。

Dog d;
Animal a = d;   // スライスされた:a は今や単なる Animal で、Dog 部分は消えている
a.speak();      // 仮想であっても Animal::speak を実行する

Animal& ref = d;   // OK:参照は実際の型を保持する
ref.speak();       // Dog::speak を実行する

コンストラクタやデストラクタから仮想関数を呼ばない。 構築中は派生部分がまだ存在しないため、仮想呼び出しは派生のオーバーライドではなく 現在 のクラスのバージョンに解決されます。これはほとんどの場合、意図したものではありません。

仮想ディスパッチにはわずかなコストがある。 各仮想呼び出しは、関数ポインタの隠しテーブル(「vtable」)を経由し、呼び出しごとに1回の間接参照が発生します。安価ではありますが無料ではないので、本当にオーバーライドが必要でない限り関数を仮想にしないでください。

あえて基底クラスのバージョンを呼ぶ。 オーバーライドの内部からでも、Base::method() で基底クラスの実装を明示的に呼び出せます。派生の振る舞いが基底を 置き換える のではなく 拡張する 場合に便利です。

次へ:演算子のオーバーロード

仮想関数を使うと、オブジェクトは共有インターフェースを通じてその 振る舞い をカスタマイズできます。次のページでは、オブジェクトに作用する 演算子 をカスタマイズする方法を紹介します。演算子のオーバーロードを使えば、自作の型に +==<< などへの応答の仕方を教えられるので、Vector + Vectorcout << myObject が組み込み型と同じくらい自然に読めるようになります。

よくある質問

C++の仮想関数とは何ですか?

仮想関数とは、基底クラスで virtual キーワードを付けて宣言されたメンバ関数のことです。基底クラスのポインタや参照を通じて呼び出すと、C++は基底クラスのバージョンではなく派生クラスのオーバーライドを実行します。この実行時の選択は動的ディスパッチ(dynamic dispatch)と呼ばれ、ポリモーフィズムの基礎となります。

仮想関数と純粋仮想関数の違いは何ですか?

仮想関数には本体があり、オーバーライドできます。純粋仮想関数は = 0 を付けて宣言され、基底クラスでは本体を持ちません。これによって、具象的な派生クラスはすべて実装を提供することを強制されます。純粋仮想関数を1つでも持つクラスは抽象クラスとなり、インスタンス化できません。

C++で基底クラスに仮想デストラクタが必要なのはなぜですか?

基底クラスのポインタを通じて派生オブジェクトを delete したとき、基底クラスのデストラクタが仮想でない場合、基底クラスのデストラクタしか実行されません。派生部分は決してクリーンアップされず、リソースリークを引き起こし、未定義動作となります。ポリモーフィックに使うことを意図したクラスのデストラクタは必ず virtual にしましょう。

Coddy programming languages illustration

Coddyでコードを学ぼう

始める