T.TAO
ブログへ戻る
/16 min read/Programming

C++ #8 ポリモーフィズム

#C++#Programming#ComputerSystems
C++ #8 ポリモーフィズム

このノートでは C++ のオブジェクト指向におけるポリモーフィズムを扱います。ポリモーフィズムはオブジェクト指向の中核的な性質のひとつで、同じインターフェースに異なる実装を与えることを指します。C++ では、基底クラスのポインタや参照を通じて派生クラスの関数を呼べるようになります。

形態は二つあります。

  • 静的ポリモーフィズム(コンパイル時ポリモーフィズム)。関数オーバーロードとテンプレートによって実現します。
  • 動的ポリモーフィズム(実行時ポリモーフィズム)。仮想関数と継承によって実現します。

端的に言えば、ポリモーフィズムとはメッセージが複数の形を取れる能力のことです。

動的ポリモーフィズム

動的ポリモーフィズムは仮想関数と継承で実現します。仮想関数の概念は《C++ #4 関数》で既に紹介しましたが、ここで改めて復習し、仮想関数と vtable の働きをより深く見ていきます。

仮想関数

仮想関数とは、呼び出す関数をオブジェクトの実際の型に応じて実行時に決められるメンバ関数です。基底クラスで virtual を付け、派生クラスで override を付けて上書きすることで、動的束縛が得られます。

C++#include <iostream>

class Base {
public:
    virtual void func() {
        std::cout << "Base::func()" << std::endl;
    }
    virtual ~Base() = default;
};

class Derived : public Base {
public:
    void func() override {
        std::cout << "Derived::func()" << std::endl;
    }
};

Base では仮想関数 func() を定義し、派生クラス Derived では override を付けてそれを上書きしています。

これを main() で出力してみます。

C++int main() {
    Base* obj = new Derived();
    obj->func();      // Derived::func() が出力される
    delete obj;
    return 0;
}

obj は Base のポインタですが、生成のされ方から実際には Derived を指しているため、func() の呼び出しでは Derived 側の上書きが使われます。

要点は、仮想関数が動的束縛を実現していることです。呼ばれる関数はコンパイル時ではなく、実行時にオブジェクトの実際の型に応じて決まります。コンパイル時に決まる場合は静的束縛と呼ばれ、virtual の付かない関数がそれにあたります。

override についてもう一点。これは任意の飾りではありません。派生側のシグネチャを書き間違えた場合——const の付け忘れ、引数型の不一致——override がなければコンパイラは黙って新しい関数を定義したものとみなします。プログラムはそのままコンパイルされ、実行時には基底側が呼ばれます。override を付けていればそれはコンパイルエラーになります。上書きには必ず override を書いてください。

vtable

vtable(仮想関数テーブル)は、仮想関数を含むすべてのクラスに対して生成されるデータ構造で、そのクラスの仮想関数のアドレスを保持します。各オブジェクトは vptr と呼ばれる隠しポインタを通じて、自分の属するクラスの vtable を指します。仮想関数が呼ばれると、プログラムは vptr から vtable を引き、そこにある関数アドレスへ飛びます。

メモリ配置はおおよそ次のようになります。

Plain TextDerived オブジェクト         Derived の vtable(クラスごとに 1 つ。オブジェクト内にはない)
+------------------+       +---------------------------+
| vptr             | ----> | [0] &Derived::func        |
+------------------+       | [1] &Derived::~Derived    |
| Base のメンバ    |       +---------------------------+
+------------------+
| Derived のメンバ |
+------------------+

先ほどの例に沿って追ってみます。

  1. Derived のオブジェクトを作り、Base を指すポインタ obj で保持する。
  2. コンパイラは obj->func() が仮想関数の呼び出しだと判断し、直接呼び出しではなく「vptr を読み、表を引き、飛ぶ」コードを生成する。
  3. 実行時、obj の vptr は Derived の vtable を指している。
  4. その vtable から Derived::func() のアドレスを見つけ、派生側の実装を呼ぶ。

ここから実務的な帰結がいくつも導かれます。

  • 仮想関数にはコストがある。 呼び出しごとに間接参照が一段増え、さらに重要な点として、通常はインライン化できません。失われるインライン化のほうが、間接参照より高くつくことも多いです。
  • オブジェクトが大きくなる。 int ひとつだけのクラスでも、仮想関数が付くと sizeof は 4 から 16 になります(vptr 8 バイト、int 4 バイト、パディング 4 バイト)。パーティクルやコンポーネントのように密に詰めた配列では無視できません。
  • vtable はクラスごとに 1 つで、オブジェクトごとではない。 オブジェクトが余分に持つのはポインタ 1 つだけです。
  • 継承されない前提のクラスには final を付ける。 コンパイラが脱仮想化して直接呼び出しに戻せる余地が生まれます。

仮想デストラクタ

とくに注意が必要な点です。基底クラスのポインタ経由で派生オブジェクトを解放するなら、基底のデストラクタは仮想でなければなりません。さもないと資源が漏れます。

C++#include <iostream>

class Base {
public:
    virtual ~Base() {                 // virtual を外すと下の delete は未定義動作
        std::cout << "~Base()" << std::endl;
    }
};

class Derived : public Base {
public:
    Derived()  { data = new int[100]; }
    ~Derived() override {
        delete[] data;
        std::cout << "~Derived()" << std::endl;
    }
private:
    int* data;
};

int main() {
    Base* obj = new Derived();
    delete obj;
    return 0;
}

Base を継承した Derived は private な配列 data を持ち、コンストラクタで領域を確保し、デストラクタで解放しています。したがって main() において、Base のポインタである obj が delete されるとき、Derived のデストラクタへ正しく到達できなければなりません。だからこそ Base のデストラクタは仮想である必要があります。

繰り返します。仮想でなければなりません。そうでない場合、規格上これは未定義動作であり、実際には ~Derived() がまったく実行されず、100 個の int は永久に漏れます。

またデストラクタは通常の仮想関数とは振る舞いが異なります。仮想デストラクタを呼ぶと、まず派生クラスのデストラクタ、続いて基底クラスのデストラクタが実行されます。派生側だけが呼ばれるのではありません。これはオブジェクトを破棄する際に、資源が層ごとに正しく解放されることを保証するためです。

したがって上の main() の出力は次のようになります。

Plain Text~Derived()
~Base()

コンストラクタ

C++ ではコンストラクタを仮想にはできません。以前のノートでも触れたとおりです。

  • 構築順:まず基底のコンストラクタ、次に派生のコンストラクタ。
  • 基底の部分が完成していなければ、派生クラスはその上に拡張できないからです。

コンストラクタ内で仮想関数を呼んでも派生側の上書きには届かず、そのクラス自身で定義された版が呼ばれます。コンパイル時束縛が保たれるということです。

C++struct Base {
    Base() { init(); }                          // ここで呼ばれるのは Base::init
    virtual void init() { std::cout << "Base\n"; }
};

struct Derived : Base {
    void init() override { std::cout << "Derived\n"; }
};

Derived d;   // "Derived" ではなく "Base" が出力される

この振る舞いには理由があります。基底のコンストラクタが走っている時点では派生オブジェクトの構築はまだ完了しておらず、派生側のメンバは未初期化のメモリです。そこで派生の init() を呼べば、ごみを読む可能性が高い。vptr が派生の vtable へ書き換わるのは、基底のコンストラクタが終わり派生のコンストラクタに入ってからです。

デストラクタでも同じことが逆向きに起こります。基底のデストラクタが走る時点では派生部分は既に破棄されており、vptr は基底の vtable へ戻っています。

結論として、コンストラクタとデストラクタから仮想関数を呼んではいけません。 「構築が終わったあとに型固有の処理をしたい」場合は、独立した init() に分けて呼び出し側に実行させるか、ファクトリ関数で二段階をまとめてください。

静的ポリモーフィズム

静的ポリモーフィズムはコンパイル時に束縛されるため、vtable も間接参照もなく、インライン化も妨げません。よく使われる形は三つあります。

関数オーバーロード

同じ名前で引数リストが異なる関数を用意し、コンパイラが実引数の型からコンパイル時に選びます。

C++void draw(const Circle& c);
void draw(const Square& s);
void draw(const Circle& c, float scale);

draw(circle);          // 一つ目が選ばれる
draw(circle, 2.0f);    // 三つ目が選ばれる

オーバーロード解決が見るのは引数だけで、戻り値型は見ません。したがって戻り値型だけが異なる二つの関数はオーバーロードになりません。暗黙変換による意図しないマッチにも注意が必要です。《C++ #5 オブジェクトとクラス》で引数一つのコンストラクタに explicit を勧めた理由のひとつがこれです。

テンプレート

テンプレートは静的ポリモーフィズムの主役です。「この操作群を満たす型ならここで使える」ことを表現し、それらの型が共通の基底クラスを持つ必要はありません。

C++template <typename T>
T maxOf(const T& a, const T& b) {
    return (a < b) ? b : a;
}

maxOf(3, 7);             // T = int
maxOf(3.5, 2.1);         // T = double
maxOf(std::string("a"), std::string("b"));

コンパイラは使われた T ごとにコードを 1 部ずつ生成します。つまり、

  • 実行時オーバーヘッドはゼロで、インライン化もできます。
  • 型のあいだに継承関係は不要で、使う操作(ここでは &lt;)さえ備わっていればよい。この暗黙のインターフェースが、C++ におけるダックタイピングの形です。
  • 代償はコード膨張(実体化ごとに 1 部)、コンパイル時間の増大、そして悪名高いテンプレートのエラーメッセージです。C++20 の concepts は主に最後の問題を解くために存在します。

標準ライブラリのアルゴリズムは、ほぼすべてこの上に築かれています。std::sort は要素が何らかの Comparable を継承していることを求めず、&lt; が使えることだけを求めます。

CRTP

奇妙に再帰したテンプレートパターン(Curiously Recurring Template Pattern)。派生クラスが自分自身をテンプレート引数として基底へ渡し、基底がコンパイル時に具体的な派生型を知れるようにします。

C++template <typename Derived>
class Shape {
public:
    void draw() {
        static_cast<Derived*>(this)->drawImpl();   // コンパイル時に解決、インライン化可能
    }
};

class Circle : public Shape<Circle> {
public:
    void drawImpl() { std::cout << "Circle\n"; }
};

これは仮想関数と同じ「基底が派生の実装を呼ぶ」を、vptr も vtable の参照もなしに実現します。代償は実行時の統一性を失うことです。Shape&lt;Circle>Shape&lt;Square>無関係な二つの型であり、同じ std::vector には入れられません。

どちらを選ぶか

動的(仮想関数)静的(テンプレート / CRTP)
束縛実行時コンパイル時
オーバーヘッドvptr と間接呼び出し。インライン化しにくいなし。インライン化可能
オブジェクトの大きさ1 オブジェクトにつきポインタ 1 つ増える変わらない
型の集合開いている。後から派生を追加できる閉じている。コンパイル時に確定
同じコンテナに入るか入る(基底ポインタとして)入らない
生成されるコード1 部実体化ごとに 1 部

判断基準は明快です。型の集合が実行時にしか分からないなら仮想関数——プラグイン、シーン中の雑多なオブジェクト、ステートマシン。型の集合がコンパイル時に分かっていて、しかもホットパス上のコードならテンプレート——数学ライブラリ、コンテナ、レンダラー内部の固定パイプライン。

ゲームエンジンでは通常その両方を使います。シーングラフの GameObject は実行時に任意の型が追加されるため仮想関数を使い、毎フレーム数万のパーティクルに対して行う計算はテンプレートにします。間接参照 1 回でも数万倍すれば、もはや小さな額ではないからです。

C++17 の std::variantstd::visit は第三の道を与えます。閉じた型集合、ヒープ確保なし、vtable なし、それでいてコンテナに入る。《C++ #11 C++17 の新機能》を参照してください。

このシリーズの記事

C++ プログラミング
  1. 01C++ #1 データとメモリ
  2. 02C++ #2 Struct と Union
  3. 03C++ #3 ポインタと配列
  4. 04C++ #4 関数
  5. 05C++ #5 オブジェクトとクラス
  6. 06C++ #6 継承
  7. 07C++ #7 コピー制御と演算子オーバーロード
  8. 08C++ #8 ポリモーフィズム
  9. 09C++ #9 C++11 の新機能
  10. 10C++ #10 C++14 の新機能
  11. 11C++ #11 C++17 の新機能