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

C++ #6 継承

#C++#Programming#ComputerSystems

このノートでは C++ の継承と、それを使ったときにメモリ上で実際に何が起きるかを扱います。

継承とは埋め込みである

C++class Base {
    int a;
};

class Derived : public Base {
    int b;
};

Derived のレイアウトは、完全な Base サブオブジェクトが先にあり、そのあとに Derived 自身のメンバが続く形です。

Plain TextDerived オブジェクト
+----------------------+
| Base::a    (4 バイト)|   <- Base サブオブジェクト
+----------------------+
| Derived::b (4 バイト)|
+----------------------+

ここから二つのことが説明できます。第一に、Derived* が暗黙に Base* へ変換できること。Base サブオブジェクトはオブジェクトの先頭にあるので、ポインタ値を変える必要すらありません。第二に、Derived オブジェクトを値として Base 変数へ代入するとスライシングが起きること。Base の部分だけがコピーされ、b は黙って捨てられます。

C++Derived d;
Base b = d;    // スライス:d の Derived 部分は失われる
Base& r = d;   // 問題なし:参照はコピーしない

多態が必ずポインタか参照を経由するのは、値渡しがスライスするからにほかなりません。

三種類の継承アクセス

C++class D1 : public Base    { };   // public 継承
class D2 : protected Base { };   // protected 継承
class D3 : private Base   { };   // private 継承

これらは派生クラス内での基底メンバの可視性の上限を定めます。

基底メンバpublic 継承protected 継承private 継承
publicpublicprotectedprivate
protectedprotectedprotectedprivate
privateアクセス不可アクセス不可アクセス不可

public 継承は is-a 関係を表します。Base を受け取れる場所ならどこでも Derived を受け取れるべきである——リスコフの置換原則です。

private 継承は implemented-in-terms-of を表します。基底の実装を再利用したいが、その関係を外に見せたくない場合です。ただし多くの場面では、コンポジション(メンバ変数)のほうが private 継承より明快です。基底の仮想関数をオーバーライドする必要があるとき、あるいは空基底最適化が欲しいときにだけ、private 継承に出番があります。

protected 継承は実務でほぼ使いません。

コンストラクタとデストラクタの連鎖

構築はもっとも基底のクラスから始まり、破棄は逆順に進みます。

C++struct A { A() { puts("A()"); } ~A() { puts("~A()"); } };
struct B : A { B() { puts("B()"); } ~B() { puts("~B()"); } };

B b;
// 出力: A()  B()  ~B()  ~A()

派生クラスのコンストラクタは、自身の本体を実行する前に基底のコンストラクタを呼びます。明示しなければ基底のデフォルトコンストラクタが呼ばれ、引数を渡すには初期化リストを使う必要があります。

C++class Derived : public Base {
public:
    Derived(int x) : Base(x), y_(x * 2) {}
private:
    int y_;
};

コンストラクタから仮想関数を呼ばない

C++struct Base {
    Base() { init(); }                    // 危険
    virtual void init() { puts("Base"); }
};
struct Derived : Base {
    void init() override { puts("Derived"); }
};

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

Base のコンストラクタが走っている時点で Derived の部分はまだ存在せず、vtable ポインタは Base の vtable を指したままです。言語の規定としては正しい——そうでなければ未初期化メンバを読む関数が呼ばれてしまう——のですが、書いた本人の期待とはしばしば食い違います。デストラクタでも同様です。

基底クラスのデストラクタは仮想でなければならない

C++Base* p = new Derived();
delete p;   // ~Base が virtual でなければ ~Derived() は呼ばれない

これは未定義動作であり、実際には Derived が所有していた資源が漏れます。規則は単純です。基底ポインタ経由で delete される可能性があるクラスのデストラクタは virtual にする。 逆に継承されることを想定しないクラスは final を付けましょう。意図が伝わるうえ、コンパイラが脱仮想化できます。

名前の隠蔽

派生クラスの関数は、基底の同名オーバーロードをすべて隠蔽します。引数が違っていても同じです。

C++struct Base {
    void f(int);
    void f(double);
};
struct Derived : Base {
    void f(const char*);   // Base::f の両方を隠蔽する
};

Derived d;
d.f(42);   // コンパイルエラー:42 は const char* に変換できない

これはオーバーロード解決の問題ではありません。名前探索は Derived::f を見つけた時点で止まり、Base を見にいかないのです。基底のオーバーロードを呼び戻すには次のようにします。

C++struct Derived : Base {
    using Base::f;
    void f(const char*);
};

多重継承と菱形問題

C++ では複数の基底クラスから継承できます。そこで生じるのが菱形継承です。

C++struct Animal { int age; };
struct Bird : Animal {};
struct Fish : Animal {};
struct FlyingFish : Bird, Fish {};   // Animal を二つ含む

FlyingFish オブジェクトは独立した Animal サブオブジェクトを二つ持つため、ff.age は曖昧さのエラーになり、ff.Bird::age と書く必要があります。

答えは仮想継承です。

C++struct Bird : virtual Animal {};
struct Fish : virtual Animal {};
struct FlyingFish : Bird, Fish {};   // Animal は一つ

仮想継承はただではありません。共有サブオブジェクトの位置を求めるための仮想基底ポインタがオブジェクトに追加され、メンバアクセスは間接参照が一段増えます。さらに最派生クラスが仮想基底の構築責任を負うため、中間クラスに書いた Animal(...) の初期化は単に無視されます。これは繰り返し人を驚かせる点です。

実務的な助言としては、多重継承は実装基底を一つと純粋インターフェースを複数という形に限ること。純粋インターフェース(純粋仮想関数のみ、データメンバなし)は気にすべき菱形を作らないので、仮想継承の出番もありません。Java や C# が単一継承+複数インターフェースしか許さないのも、同じ理由です。

継承の実行時側面——仮想関数と vtable——は《C++ #8 ポリモーフィズム》で扱います。

このシリーズの記事

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 の新機能