多態性の基本概念
オブジェクト指向プログラミングにおける多態性(Polymorphism)とは、同一のインターフェースに対して、オブジェクトの具体的な種類に応じて異なる挙動を実現する仕組みを指します。端的に言えば、同じ命令を送っても、対象となるオブジェクトの状態や種類によって実行される処理が変化することを意味します。
多態性の成立条件と実装
C++ において多態性を実現するには、継承関係に加え、以下の 2 つの要件を満たす必要があります。
- 基底クラスのポインタまたは参照を通じて、虚関数(virtual function)を呼び出すこと。
- 派生クラス側で、その虚関数をオーバーライド(再定義)していること。
虚関数とは、宣言時に virtual キーワードが付与されたメンバ関数のことです。
class Command {
public:
virtual void execute() {
std::cout << "Command: 標準実行" << std::endl;
}
};
虚関数のオーバーライド
派生クラスにおいて、基底クラスの虚関数と完全に同一のシグネチャ(関数名、引数リスト、戻り値の型)を持つ関数を定義することをオーバーライドと呼びます。
class ConcreteCommand : public Command {
public:
// virtual キーワードは省略可能だが、明示することが推奨される
void execute() override {
std::cout << "ConcreteCommand: 特殊実行" << std::endl;
}
};
派生クラス側の virtual は省略可能ですが、可読性の観点から記述するか、C++11 以降では override キーワードを使用するのが一般的です。
オーバーライドの例外事項
通常、オーバーライドには厳密なシグネチャの一致が必要ですが、以下の 2 つの例外が存在します。
1. 共変戻り値型(Covariant Return Types)
虚関数の戻り値が、基底クラスおよび派生クラスのオブジェクトへのポインタまたは参照である場合、派生クラスの戻り値型は基底クラスの戻り値型の派生型であっても構いません。
class Product { public: virtual ~Product() {} };
class Item : public Product { public: virtual ~Item() {} };
class Factory {
public:
virtual Product* create() { return new Product(); }
};
class ConcreteFactory : public Factory {
public:
// 戻り値が派生型であってもオーバーライドとして成立
Item* create() override { return new Item(); }
};
2. 析構関数のオーバーライド
基底クラスと派生クラスの析構関数は名称が異なります(クラス名に~を付けたもの)が、コンパイラ内部では統一された処理が行われるため、虚関数としてオーバーライド可能です。
基底クラスの析構関数を虚関数にしない場合、基底クラスのポインタを通じて派生クラスオブジェクトを削除する際、派生クラスの析構関数が呼ばれず、メモリリークの原因となります。したがって、継承を想定するクラスの析構関数は必ず virtual とするべきです。
オーバーロード、隠蔽、オーバーライドの比較
これらの用語は混同されやすいため、明確に区別する必要があります。
- オーバーロード: 同一スコープ内で、関数名が同じで引数リストが異なる。
- 隠蔽(Hiding): 派生クラスと基底クラスで関数名が同じ。虚関数かどうかは問わない。派生クラスから基底クラスの同名関数は参照できなくなる(
using宣言で解消可能)。 - オーバーライド: 派生クラスと基底クラスで、虚関数であり、シグネチャが一致する(共変戻り値型を除く)。
以下のコード例は、虚関数のデフォルト引数に関する重要な挙動を示しています。
class Base {
public:
virtual void process(int value = 10) {
std::cout << "Base: " << value << std::endl;
}
void run() { process(); }
};
class Derived : public Base {
public:
// デフォルト引数は静的バインドされるため、基底クラスの値が使われる
void process(int value = 20) override {
std::cout << "Derived: " << value << std::endl;
}
};
int main() {
Base* ptr = new Derived();
ptr->run(); // 出力は "Derived: 10" となる
delete ptr;
return 0;
}
この結果になる理由は、run() 内の process() 呼び出しにおいて、関数本体は動的バインド(派生クラス)されますが、デフォルト引数の値はコンパイル時に決定(静的バインド)されるため、基底クラスで定義された 10 が使用されるからです。
抽象クラス
虚関数の宣言末尾に = 0 を付与すると、それは純粋虚関数となります。純粋虚関数を少なくとも一つ含むクラスは抽象クラス(インターフェース)と呼ばれ、そのクラス自体のインスタンス化は禁止されます。
抽象クラスを継承した派生クラスは、すべての純粋虚関数を実装しなければインスタンス化できません。
class Device {
public:
virtual void start() = 0; // 純粋虚関数
};
class Printer : public Device {
public:
void start() override {
std::cout << "Printer 起動" << std::endl;
}
};
多態性の内部メカニズム
虚関数テーブル(vtable)
虚関数を持つクラスのオブジェクトは、通常のメンバ変数に加え、虚関数テーブルポインタ(vptr)を隠蔽的に保持します。このポインタは、クラスごとに生成される虚関数テーブル(関数アドレスの配列)を指します。
class Entity {
public:
virtual void actionA() {}
virtual void actionB() {}
void normalFunc() {}
private:
int m_id;
};
上記のようなクラスの場合、sizeof(Entity) は、int のサイズにポインタサイズを加えたものになります。これは vptr を保持するためです。
派生クラスが虚関数をオーバーライドすると、派生クラスの虚関数テーブル内の該当エントリが派生クラスの関数アドレスで書き換えられます。虚関数でないメンバ関数は虚関数テーブルには登録されません。
虚関数テーブル自体は通常、プログラムの常量領域に配置され、虚関数の実体コードはコード領域に存在します。オブジェクト内の vptr を経由してテーブルを参照し、実行時に適切な関数アドレスを取得することで多態性が実現されます。
なお、静的メンバ関数は this ポインタを持たないため、虚関数として定義することはできません。
静的バインドと動的バインド
- 静的バインド(早期バインド): コンパイル時に呼び出す関数が確定する。関数オーバーロードなどが該当。
- 動的バインド(後期バインド): 実行時にオブジェクトの実際の型を参照し、呼び出す関数を確定する。虚関数による多態性が該当。
コンストラクタ内での虚関数呼び出しには注意が必要です。以下の例では、構築途中のため多態性が機能しません。
class Component {
public:
Component() { init(); }
virtual void init() { std::cout << "Comp: 0" << std::endl; }
int m_val = 0;
};
class Widget : public Component {
public:
Widget() { init(); }
void init() override {
m_val++;
std::cout << "Widget: " << m_val << std::endl;
}
};
int main() {
Component* p = new Widget();
p->init();
delete p;
return 0;
}
このコードの実行順序は以下のようになります。
Widget構築開始。- まず基底クラス
Componentのコンストラクタが実行される。この段階ではvptrはComponentを指している。 Component構築中のinit()呼び出しは、Component::init()が実行される(多態性なし)。出力:0- 次に
Widgetのコンストラクタ本体が実行される。この時点でvptrはWidgetを指す。 Widget構築中のinit()呼び出しは、Widget::init()が実行される。出力:1main関数内のp->init()は、完全構築されたオブジェクト against であるため、Widget::init()が実行され、値がインクリメントされる。出力:2
結果として、コンソールには「0 1 2」が出力されます。これは、オブジェクトの構築中は動的型情報が完全には確定しないため、コンストラクタ内の虚関数呼び出しは静的バインドとして扱われることを示しています。