12月 282015
 

珍しくがっつりC++の話。

C++について勉強していて、未だにstatic_castとdynamic_castの挙動をよくわかってない感じだったので、実験して確認したことをまとめます。文章にするとめちゃ時間かかりますね・・・。

まず前提知識として、メモリレイアウトやらvtableやらRTTIやらの話をします。何故なら、おそらく多くの人がなんとなく以下の理解でいると思われ、その辺どうなっているかという話から始めないといけないためです。

  • dynamic_castは実行時に型チェックするから安全にダウンキャストできる。
  • static_castはコンパイル時に型チェックするが実行時に型チェックしないためダウンキャストが安全でない。

間違っちゃないんですが、もう少し突っ込んで理解して、正しい挙動を把握したいわけです。

とはいえ、私もオフィシャルなドキュメントを読んでいたりするわけではないので、ここに書くことも間違ってるかもという疑いを持って読んでください。間違ってたら指摘欲しいです。

特に断りがない限り、Visual Studio 2015でx86としてDebugビルドしてます。基本的にpublic継承の話しかしません。

メモリレイアウト

インスタンスのデータがどのようにメモリに配置されるかという話です。

非ポリモーフィックな(仮想関数がない)クラス・構造体は非staticメンバ変数が、定義順に配置されます。例えば、以下のクラスは、変数a、b、cが順にメモリ上に配置されます。

class Base
{
public:
    int a;
    short b;
    int c;
    char d;
};

ただし、注意しなければいけないこととして、アライメント(アラインメント)があります。アライメントとは、CPUの計算効率の良い単位でメモリ空間を区切って、各データを配置することを言い、上記データがメモリ上に隙間なく敷き詰められるわけではないということになります。

例えば、4byteのデータならば、4byte単位のアライメントをするため、メモリ領域を4byte単位で区切ったときに2つのブロックを跨がないように配置されます。2byteのデータなら2byte単位ですので、4byte領域に2個分配置できます。

上で示したBaseクラスならば、次のような配置になるわけです。アドレスは先頭アドレスからのオフセットで示してます。bとcの間に使われない2byte領域があることに注目です。

メンバ変数 アドレス データサイズ(byte)
a +0x00 4
b +0x04 2
c +0x08 4
d +0x0C 1

インスタンスのポインタは、そのインスタンスの先頭アドレスを示すので、これを利用して実際に各メンバ変数の配置を確認してみましょう。

#include <cstdio>

#define PRINT_OBJ(obj) \
    printf("%-10s: %08x, %-16s: %2d\n",\
        "&"#obj,\
        0,\
        "sizeof("#obj")",\
        sizeof(obj));

#define PRINT_FIELD(obj, field) \
    printf("%-10s: %08x, %-16s: %2d\n",\
        "&"#obj"."#field,\
        value(&obj.field) - value(&obj),\
        "sizeof("#obj"."#field")",\
        sizeof(obj.field));

namespace {
    class Base
    {
    public:
        int a;
        short b;
        float c;
        char d;
    };

    inline size_t value(void* p) { return reinterpret_cast<size_t>(p); }
}

int main(int argc, char* argv[])
{
    // 基底クラスのメモリ配置確認
    Base base;
    PRINT_OBJ(base);
    PRINT_FIELD(base, a);
    PRINT_FIELD(base, b);
    PRINT_FIELD(base, c);
    PRINT_FIELD(base, d);
    return 0;
}

出力は次のようになりました。上で示した表と一致し、アライメントされてるのが確認できると思います。オブジェクト全体のサイズも末尾に隙間があることがわかります。これも配列にするなど連続して配置するときのためのアライメントですね。

&base     : 00000000, sizeof(base)    : 16
&base.a   : 00000000, sizeof(base.a)  :  4
&base.b   : 00000004, sizeof(base.b)  :  2
&base.c   : 00000008, sizeof(base.c)  :  4
&base.d   : 0000000c, sizeof(base.d)  :  1

ちなみに、アライメントを意識した書き方(パディングとか)があるのですが、今回の話の本筋には関係ないので割愛します。

では、継承したらメモリ配置はどうなるでしょうか。単純に派生先のデータが、継承元のデータの後ろに加えられていきます。こうすることで、基底クラスのポインタとして見た時のメモリ配置(各メンバ変数へのオフセット)が維持されるため、基底クラスとして破綻なく扱うことができます。つまり、アップキャストができるわけです。

先程のBaseクラスを継承して、次のようなクラスを定義しましょう。

class Sub : public Base
{
public:
    char e;
    short f;
    float g;
};

Subクラスのメモリ配置は次のようになるでしょう。Baseクラス末尾の隙間を残しつつ、次のメモリ領域に追加分のデータが配置されることに注目です。

メンバ変数 アドレス データサイズ(byte)
a +0x00 4
b +0x04 2
c +0x08 4
d +0x0C 1
e +0x10 1
f +0x12 2
g +0x14 4

実際に確認してみます。

#include <cstdio>

#define PRINT_OBJ(obj) \
    printf("%-10s: %08x, %-16s: %2d\n",\
        "&"#obj,\
        0,\
        "sizeof("#obj")",\
        sizeof(obj));

#define PRINT_FIELD(obj, field) \
    printf("%-10s: %08x, %-16s: %2d\n",\
        "&"#obj"."#field,\
        value(&obj.field) - value(&obj),\
        "sizeof("#obj"."#field")",\
        sizeof(obj.field));

namespace {
    class Base
    {
    public:
        int a;
        short b;
        float c;
        char d;
    };

    class Sub : public Base
    {
    public:
        char e;
        short f;
        float g;
    };

    inline size_t value(void* p) { return reinterpret_cast<size_t>(p); }
}

int main(int argc, char* argv[])
{
    // 派生クラスのメモリ配置確認
    Sub sub;
    PRINT_OBJ(sub);
    PRINT_FIELD(sub, a);
    PRINT_FIELD(sub, b);
    PRINT_FIELD(sub, c);
    PRINT_FIELD(sub, d);
    PRINT_FIELD(sub, e);
    PRINT_FIELD(sub, f);
    PRINT_FIELD(sub, g);
    return 0;
}

出力は次のようになりました。先ほどの表と一致しますね。

&sub      : 00000000, sizeof(sub)     : 24
&sub.a    : 00000000, sizeof(sub.a)   :  4
&sub.b    : 00000004, sizeof(sub.b)   :  2
&sub.c    : 00000008, sizeof(sub.c)   :  4
&sub.d    : 0000000c, sizeof(sub.d)   :  1
&sub.e    : 00000010, sizeof(sub.e)   :  1
&sub.f    : 00000012, sizeof(sub.f)   :  2
&sub.g    : 00000014, sizeof(sub.g)   :  4

今回の話を理解するのに必要なメモリ配置について、一番基礎的なところはだいたいこんなところです。

補足しておくと、staticなメンバ変数については必要になった時にstatic用の領域に配置されるため、別の管理となります。プログラム起動時にすべて配置・初期化されるわけではないことに注意ですね。

さらに補足しておくと、非staticメンバ関数は、見えない第一引数としてthisポインタを与えるような関数として実装されるため、インスタンスごとに関数の実装コードが配置されるわけではありません。すっきりしてます。thisポインタと結びつけて関数オブジェクトとして扱いたい時はstd::bindを使う必要があるのも理解できますね。

ただ、関数呼び出しについて、ポリモーフィックなクラスは特殊なので、次に説明します。

メモリレイアウトの話は書籍「ゲームエンジン・アーキテクチャ」がわかりやすかったです。

仮想関数テーブル

ここでポリモーフィズム(多様性)とは、同じ名前で異なる振る舞いを実行できることだとします(厳密な定義は知りません)。C++ではインスタンスごとに非staticメンバ関数の振る舞いを変えるために仮想関数がありますね。

virtualキーワードとともに宣言したメンバ関数が仮想関数になります。派生クラスで、基底クラスの仮想関数と同じ名前・同じシグネチャ(戻り値や引数の型の組み合わせ)を持つ非staticメンバ関数にもvirtualは伝播して勝手に仮想関数になります。だいたい派生クラス側にも仮想関数であることを明示するためにvirtual指定すると思いますが、C++11ならoverrideキーワードも使いたいところです。

さて、非staticメンバ関数はインスタンスごとにメモリに配置されるわけではないことを説明しました。では、どのように実行する関数を判断するのでしょうか。

仮想関数を実現するには、やはり内部で関数ポインタが使われるのが実際です。クラスごとに自分が使用すべき関数のポインタを仮想関数テーブル(vtable)として持っていて、インスタンスは自分のクラスのvtableへのポインタ(vpointer、vptr)を先頭に持ちます。このようなクラスをポリモーフィックなクラスと呼びます。

早速、試してみましょう。

#include <cstdio>

#define PRINT_OBJ(obj) \
    printf("%-10s: %08x, %-16s: %2d\n",\
        "&"#obj,\
        0,\
        "sizeof("#obj")",\
        sizeof(obj));

#define PRINT_OBJ_AS(obj, type) \
    printf("%-10s: %08x, size: %2d (as "#type")\n",\
        "&"#obj,\
        value(static_cast<type*>(&obj)) - value(&obj),\
        sizeof(type));

#define PRINT_FIELD(obj, field) \
    printf("%-10s: %08x, %-16s: %2d\n",\
        "&"#obj"."#field,\
        value(&obj.field) - value(&obj),\
        "sizeof("#obj"."#field")",\
        sizeof(obj.field));

#define PRINT_VPOINTER(obj) \
    printf("%-10s: %08x, %-16s: %2d, vptr: %08x\n",\
        "&"#obj".vptr",\
        0,\
        "sizeof(vptr)",\
        sizeof(void*),\
        *reinterpret_cast<int*>(&obj));

namespace {
    class VirtualBase
    {
    public:
        int a;
        virtual void func1() {}
        virtual void func2() {}
    };

    class NonVirtualBase
    {
    public:
        int a;
    };

    class VirtualSub1 : public VirtualBase
    {
    public:
        virtual void func2() override {}
        virtual void func3() {}
    };

    class VirtualSub2 : public NonVirtualBase
    {
    public:
        virtual void func() {}
    };

    inline size_t value(void* p) { return reinterpret_cast<size_t>(p); }
}

int main(int argc, char* argv[])
{
    {   // ポリモーフィックな基底クラスのメモリ配置とvpointer
        VirtualBase base;
        PRINT_OBJ(base);
        PRINT_VPOINTER(base);
        PRINT_FIELD(base, a);
    }
    {   // ポリモーフィックな基底クラスを継承したクラスのメモリ配置とvpointer
        VirtualSub1 sub;
        PRINT_OBJ(sub);
        PRINT_VPOINTER(sub);
        PRINT_FIELD(sub, a);
    }
    {   // 非ポリモーフィックな基底クラスを継承したポリモーフィックなクラスのポインタ変換
        VirtualSub2 sub;
        PRINT_OBJ_AS(sub, VirtualSub2);
        PRINT_OBJ_AS(sub, NonVirtualBase);
    }
    return 0;
}

出力は次のようになりました。

&base     : 00000000, sizeof(base)    :  8
&base.vptr: 00000000, sizeof(vptr)    :  4, vptr: 008b68bc
&base.a   : 00000004, sizeof(base.a)  :  4
&sub      : 00000000, sizeof(sub)     :  8
&sub.vptr : 00000000, sizeof(vptr)    :  4, vptr: 008b68cc
&sub.a    : 00000004, sizeof(sub.a)   :  4
&sub      : 00000000, size:  8 (as VirtualSub2)
&sub      : 00000004, size:  4 (as NonVirtualBase)

VirtualSub1では、仮想関数をメンバ変数より後に定義しましたが、定義順によらずvpointerが先頭にひとつだけ配置されていて、その値が異なるのがわかります。継承によってメンバ変数を増やしていないので、オブジェクトのサイズは変わりません。

VirtualSub2では、vpointerを持たない基底クラスを継承して、仮想関数を持たせたらポインタの扱いがどうなるか、という実験です。派生クラスでは先頭にvpointerが配置されているのがわかりますが、基底クラスのポインタへstatic_castでアップキャストしたとき、ポインタの示すアドレスが変わりました。つまり、ポインタのキャストを行う際に、メモリ配置のオフセット(差分)だけアドレスを適切にずらす、ということをしています。

ちなみに、ポインタのサイズは32bit環境では32bit(4byte)、64bit環境では64bit(8byte)となるため、vpointer持ちのインスタンスは32bitアプリケーションか64bitアプリケーションかによってサイズが変わることになります。x64でビルドし直した結果は次のようになりました。アライメントの影響もありますね。

&base     : 00000000, sizeof(base)    : 16
&base.vptr: 00000000, sizeof(vptr)    :  8, vptr: 8a13aec8
&base.a   : 00000008, sizeof(base.a)  :  4
&sub      : 00000000, sizeof(sub)     : 16
&sub.vptr : 00000000, sizeof(vptr)    :  8, vptr: 8a13aee0
&sub.a    : 00000008, sizeof(sub.a)   :  4
&sub      : 00000000, size: 16 (as VirtualSub2)
&sub      : 00000008, size:  4 (as NonVirtualBase)

さて、vpointerが参照するvtableが異なることでポリモーフィズムを実現できることはわかりましたが、参照先のvtableもせっかくなので確認したいところです。vtableがどのように配置されるかはコンパイラ依存のようですが、Visual Studioではデバッガを利用すると、メンバに__vfptrが見えます。ここからvtableの中身が見れます。適当にPRINT_FIELD(sub, a);あたりにブレーク張ってみました。

2015-12-28-01

VirtualBaseクラスのvtableとして表示されているため、VirtualSubで定義したfunc3関数のポインタが見えませんね。__vfptrの行を右クリックして「ウォッチ式を追加」を選びましょう。追加されたウォッチ式の末尾が「,nd」になってると思いますが、これを「,3」にしましょう。ウォッチ式のダブルクリックで編集できるはずです。これで先頭から3要素分表示することになります。

2015-12-28-02

確かにfunc3もvtableに格納されてることが確認できました。func2がオーバーライドされていることもわかりますね。

vtableを経由することでポリモーフィズムを実現するという仕組みがなんとなくわかったと思います。この仕組みのために仮想関数はインライン化ができず、参照コストによる僅かなオーバーヘッドがあることも理解できるでしょう。頻繁に呼び出されるメンバ関数は非virtualにしたいところです。また、ポリモーフィックなクラスはvpointerを各インスタンスの先頭に持つため、memsetとかすると恐ろしいことになるのでやめましょう。

今回ちゃんと検証してませんが、コンストラクタとデストラクタの中ではポリモーフィズムが働かず、仮想関数は派生クラスのものが使われません。大きな罠だと思います。継承関係に従って、決まった順に呼ばれる各コンストラクタ/デストラクタが、前処理としてそのクラスのvpointerを更新するという処理によります。派生クラスの仮想関数から基底クラスの未初期化変数にアクセスされると困るから、こういう仕様だそうです。

ちなみに、&演算子でメンバ関数へのポインタを得るとき、対象のメンバ関数が仮想関数であると、アドレスではなく仮想関数テーブル内のインデックスを示すデータとなることを知っておいてもいいでしょう。

以下、参考資料。

仮想デストラクタ

今回の話の本筋ではないんですが、一応、軽く触れておきます。

最も有名なC++の罠として、基底クラスのデストラクタはvirtualにしろというものがあります。これは派生クラスをアップキャストして基底クラスのポインタとして持ったとき、これに対するdeleteで派生クラスのデストラクタをvtable経由で呼べるようにするためです。

ただし、なんでもかんでもデストラクタをvirtualにしろという話ではなく、基底クラスでのdeleteを禁止するためにprotected継承する方法もあります。ポインタで持ってdeleteする使い方を禁止する、ということですね。

以下、参考資料。

RTTI

C++にはRTTI(実行時型情報)という機能があります。インスタンスが自身の型情報を持つことで、これを利用して安全なダウンキャストができるわけです。それがdynamic_castですね。一応、もう少し説明しておくと、「安全なダウンキャスト」とは、実行時型情報を見て不正なダウンキャストを失敗させる(失敗を検出できる)ものです。

C++でRTTIと呼ばれるものはstd::type_infoですが、これはtypeid演算子で取得できます。ですが、type_infoとは一体どこから持ってくるのでしょうか?これはtypeid演算子に与えられた値がポリモーフィックなクラスなのかどうかによって変わります。非ポリモーフィックなクラスやアトミック型に対するtypeid演算は静的に決定されます。一方、ポリモーフィックなクラスに対しては実行時の情報を取得するため、これがRTTIと呼べるものとなります。

確認してみましょう。

#include <iostream>

using namespace std;

namespace {
    class Base {
    public:
        int a;
    };
    class Sub : public Base {};

    class VirtualBase {
    public:
        virtual void func() {}
    };
    class VirtualSub : public VirtualBase {
    public:
        virtual void func() override {}
    };
}

int main(int argc, char* argv[])
{
    {   // 非ポリモーフィック
        Sub sub;
        Base* p = &sub;
        cout << typeid(1).name() << endl;   // →int
        cout << typeid(1.28f).name() << endl;   // →float
        cout << typeid(Base).name() << endl;    // →Base
        cout << typeid(sub).name() << endl; // →Sub
        cout << typeid(*p).name() << endl;  // →Base
        p = nullptr;
        cout << typeid(*p).name() << endl;  // →Base
    }
    {   // ポリモーフィック
        VirtualSub sub;
        VirtualBase* p = &sub;
        cout << typeid(*p).name() << endl;  // →VirtualSub
        //p = nullptr;
        //cout << typeid(*p).name() << endl;    // これはできない
    }
    return 0;
}

実はRTTIもvtableを利用して取得しているのです。非ポリモーフィックなクラスからtype_infoは取得できても、コンパイル時に決定され、ポリモーフィックな挙動を示さないため、RTTIとは言えません。

つまり、dynamic_castでダウンキャストが適用できるのはポリモーフィックなクラスだけなのです。アップキャストはコンパイル時に判断できるので問題ないです。

先のvtableに関する実験では、vtable内にはRTTI取得関数のポインタっぽいものは見当たらなかったので、Visual StudioでどうやってRTTIを実装しているか詳細はわかりませんが、とにかくポリモーフィックなクラスでないとRTTIは使えないということが重要です。

ゲームプログラムなんかではdynamic_castは型チェックのコストが高くつくため、RTTIを無効にしてビルドすることが多いです。例外処理も同様。これら機能を使うかどうかは任意なのですが、使うかどうかによって出力するコードの形式が変わってしまうので、全体で設定を統一してコンパイルする必要はあります。

書籍「ゲームプログラマのためのC++」には独自のRTTIを実装する話も書いてあったりしますが、対応するクラスすべての宣言と定義にマクロ記述が必要だったり手間がかかるので、それをするなら独自RTTI対応コードを自動生成するのが理想的ですね。

以下、参考資料。

多重継承

いよいよめんどくさい話に入ります。これまでは単一継承の話だけしてきました。

C++は多重継承を許す言語です。一度に複数のクラスを継承可能ということですね。この時のメモリ配置は、基本的には継承順に基底クラスのデータを配置して、その後ろに派生クラスのデータを配置することになります。ポリモーフィックなクラスを継承すると逆転することがありますが、それは後述します。

では、多重継承した派生クラスを基底クラスのポインタとして持つときどうなるでしょうか?仮想関数テーブルのところでも説明したように、キャストした結果、適切にメモリ配置のオフセットが適用されることが期待されます。

確認してみましょう。

#include <cstdio>

#define PRINT_OBJ_AS(obj, type) \
    printf("%-12s: %08x, size: %2d (as "#type")\n",\
        "&"#obj,\
        value(static_cast<type*>(&obj)) - value(&obj),\
        sizeof(type));

#define PRINT_FIELD(obj, field) \
    printf("%-12s: %08x, size: %2d\n",\
        "&"#obj"."#field,\
        value(&obj.field) - value(&obj),\
        sizeof(obj.field));

namespace {
    inline size_t value(void* p) { return reinterpret_cast<size_t>(p); }

    class Base1
    {
    public:
        int a;
    };

    class Base2 {
    public:
        int b;
    };

    class MultipleInheritance : public Base1, public Base2 {
    public:
        int c;
    };
}

int main(int argc, char* argv[])
{
    // 多重継承
    MultipleInheritance multi;
    PRINT_OBJ_AS(multi, MultipleInheritance);
    PRINT_OBJ_AS(multi, Base1);
    PRINT_OBJ_AS(multi, Base2);
    PRINT_FIELD(multi, a);
    PRINT_FIELD(multi, b);
    PRINT_FIELD(multi, c);
    return 0;
}

出力は次のようになりました。

&multi      : 00000000, size: 12 (as MultipleInheritance)
&multi      : 00000000, size:  4 (as Base1)
&multi      : 00000004, size:  4 (as Base2)
&multi.a    : 00000000, size:  4
&multi.b    : 00000004, size:  4
&multi.c    : 00000008, size:  4

MultipleInheritanceのポインタが、2番目に継承したBase2のポインタにキャストした結果、ポインタの示すアドレス値が変化していることが確認できますね。メモリ配置も継承順になってます。ここまではわかりやすいでしょう。

では、ポリモーフィックなクラスを継承するとどうなるでしょうか?基底クラスのポインタとして扱うときに破綻せずにvtableを使うためには、すべてのポリモーフィックな基底クラスのデータの先頭にvpointerを持つしかありません。なので、復数のポリモーフィックなクラスを多重継承するとvpointerを複数持つことになります。

確認してみましょう。

#include <cstdio>

#define PRINT_OBJ_AS(obj, type) \
    printf("%-12s: %08x, size: %2d (as "#type")\n",\
        "&"#obj,\
        value(static_cast<type*>(&obj)) - value(&obj),\
        sizeof(type));

#define PRINT_FIELD(obj, field) \
    printf("%-12s: %08x, size: %2d\n",\
        "&"#obj"."#field,\
        value(&obj.field) - value(&obj),\
        sizeof(obj.field));

#define PRINT_VPOINTER_AS(obj, type) \
    printf("%-12s: %08x, size: %2d, vptr: %08x (as "#type")\n",\
        "&"#obj".vptr",\
        value(static_cast<type*>(&obj)) - value(&obj),\
        sizeof(void*),\
        *reinterpret_cast<int*>(static_cast<type*>(&obj)));

namespace {
    inline size_t value(void* p) { return reinterpret_cast<size_t>(p); }

    class VirtualBase1
    {
    public:
        virtual void func1() {}
        int a;
    };

    class VirtualBase2 {
    public:
        virtual void func2() {}
        int b;
    };

    class VirtualMultipleInheritance1 : public VirtualBase1, public VirtualBase2 {
    public:
        int c;
    };

    class VirtualMultipleInheritance2 : public Base1, public VirtualBase2 {
    public:
        int c;
    };
}

int main(int argc, char* argv[])
{
    {   // ポリモーフィックなクラス同士の多重継承
        VirtualMultipleInheritance1 multi1;
        PRINT_OBJ_AS(multi1, VirtualMultipleInheritance1);
        PRINT_OBJ_AS(multi1, VirtualBase1);
        PRINT_OBJ_AS(multi1, VirtualBase2);
        PRINT_VPOINTER_AS(multi1, VirtualMultipleInheritance1);
        PRINT_VPOINTER_AS(multi1, VirtualBase1);
        PRINT_VPOINTER_AS(multi1, VirtualBase2);
        PRINT_FIELD(multi1, a);
        PRINT_FIELD(multi1, b);
        PRINT_FIELD(multi1, c);
    }
    {   // 非ポリモーフィックなクラスとポリモーフィックなクラスの多重継承
        VirtualMultipleInheritance2 multi2;
        PRINT_OBJ_AS(multi2, VirtualMultipleInheritance2);
        PRINT_OBJ_AS(multi2, Base1);
        PRINT_OBJ_AS(multi2, VirtualBase2);
    }
    return 0;
}

出力は次のようになりました。

&multi1     : 00000000, size: 20 (as VirtualMultipleInheritance1)
&multi1     : 00000000, size:  8 (as VirtualBase1)
&multi1     : 00000008, size:  8 (as VirtualBase2)
&multi1.vptr: 00000000, size:  4, vptr: 00318b30 (as VirtualMultipleInheritance1)
&multi1.vptr: 00000000, size:  4, vptr: 00318b30 (as VirtualBase1)
&multi1.vptr: 00000008, size:  4, vptr: 00318b3c (as VirtualBase2)
&multi1.a   : 00000004, size:  4
&multi1.b   : 0000000c, size:  4
&multi1.c   : 00000010, size:  4
&multi2     : 00000000, size: 16 (as VirtualMultipleInheritance2)
&multi2     : 00000008, size:  4 (as Base1)
&multi2     : 00000000, size:  8 (as VirtualBase2)

ポリモーフィックなクラスであるVirtualBase1とVirtualBase2はvpointerを持つため、データサイズが少し増えています。VirtualMultipleInheritance1はVirtualBase1とvpointerを共有していますが、VirtualBase2のvpointerは別にあることがわかります。2つのvpointerは値が一致しませんが、各基底クラスから見て破綻しないようにするには、vtableをそれぞれ用意するしかないということでしょう。

もうひとつ実験として、非ポリモーフィックなBase1とポリモーフィックなVirtualBase2を多重継承したVirtualMultipleInheritance2も用意してみましたが、こちらはVirtualBase2が先に継承したBase1より先に配置されていることがわかります。ポリモーフィックなクラスを頭に持ってきてvpointerを共有することでメモリ節約しているようですね。

これで多重継承がどのように動作しているのか大体わかったと思います。

しかし、多重継承は様々な運用上の問題を引き起こすため、嫌われています。一切使用禁止とするか、限定された使い方(例えばトレイト)のみ許容するといったルールを設けることがよくあります。安全に効果的に使える場面でなければ避けるべきという考えですね。

菱形継承問題

多重継承が話をややこしくするひとつの理由は菱型継承問題があります。多重継承した複数のクラスがさらに共通する基底クラスを継承しているとき、これを菱型継承と言います。継承関係を図示すると菱型になるからですね。ここまでに説明してきた多重継承のメモリ配置を、菱型継承でもそのまま適用すると、同じクラスのデータを複数持つことになります。これをどうすべきか個別に考える必要があるため厄介です。

まずは菱型継承によって重複データができることを確認しましょうか。

#include <cstdio>

#define PRINT_OBJ_AS(obj, type) \
    printf("%-12s: %08x, size: %2d (as "#type")\n",\
        "&"#obj,\
        value(static_cast<type*>(&obj)) - value(&obj),\
        sizeof(type));

#define PRINT_FIELD(obj, field) \
    printf("%-12s: %08x, size: %2d\n",\
        "&"#obj"."#field,\
        value(&obj.field) - value(&obj),\
        sizeof(obj.field));

#define PRINT_FIELD_AS(obj, field, type) \
    printf("%-12s: %08x, size: %2d (as "#type"'s member)\n",\
        "&"#obj"."#field,\
        value(&obj.type::field) - value(&obj),\
        sizeof(obj.type::field));

#define PRINT_VPOINTER_AS(obj, type) \
    printf("%-12s: %08x, size: %2d, vptr: %08x (as "#type")\n",\
        "&"#obj".vptr",\
        value(static_cast<type*>(&obj)) - value(&obj),\
        sizeof(void*),\
        *reinterpret_cast<int*>(static_cast<type*>(&obj)));

namespace {
    inline size_t value(void* p) { return reinterpret_cast<size_t>(p); }

    class CommonBase {
    public:
        int a;
    };

    class Inheritance1 : public CommonBase {
    public:
        int b;
    };

    class Inheritance2 : public CommonBase {
    public:
        int c;
        virtual void func() {}
    };

    class DiamondInheritance : public Inheritance1, public Inheritance2 {};
}

int main(int argc, char* argv[])
{
    // 菱形継承
    DiamondInheritance dia;
    PRINT_OBJ_AS(dia, DiamondInheritance);
    PRINT_OBJ_AS(dia, Inheritance1);
    PRINT_OBJ_AS(dia, Inheritance2);
    //PRINT_OBJ_AS(dia, CommonBase);    // Inheritance1とInheritance2のどちらか曖昧でエラー
    PRINT_VPOINTER_AS(dia, DiamondInheritance);
    PRINT_VPOINTER_AS(dia, Inheritance2);
    //PRINT_FIELD(diamond, a);  // Inheritance1とInheritance2のどちらか曖昧でエラー
    PRINT_FIELD_AS(dia, a, Inheritance1);
    PRINT_FIELD_AS(dia, a, Inheritance2);
    PRINT_FIELD(dia, b);
    PRINT_FIELD(dia, c);
    return 0;
}

出力は次のようになりました。

&dia        : 00000000, size: 20 (as DiamondInheritance)
&dia        : 0000000c, size:  8 (as Inheritance1)
&dia        : 00000000, size: 12 (as Inheritance2)
&dia.vptr   : 00000000, size:  4, vptr: 00988b60 (as DiamondInheritance)
&dia.vptr   : 00000000, size:  4, vptr: 00988b60 (as Inheritance2)
&dia.a      : 0000000c, size:  4 (as Inheritance1's member)
&dia.a      : 00000004, size:  4 (as Inheritance2's member)
&dia.b      : 00000010, size:  4
&dia.c      : 00000008, size:  4

CommonBaseのデータを復数持っているため、これにアクセスするとき、Inheritance1のCommonBaseなのかInheritance2のCommonBaseなのか明示する必要があります。

共通する基底クラスが別々のデータとして存在するのはなんだか気持ち悪いです。まったく同じデータとして共有しなければならないときがあるはずですが、これは仮想継承で実現できます。が、あまり簡単な話ではありません。

仮想継承

継承するときにvirtual指定すると仮想継承となり、仮想継承された基底クラス(仮想基底)は、多重継承により重複するときひとつにまとめられます。しかし、菱形継承となりうる基底クラス側にいちいちvirtualを書くというのは現実なかなか難しいと思います。

なお、メモリ配置が派生クラスによっては変わってしまうため、仮想継承された基底クラスのデータがどこにあるか解決するためにやはりvpointerのような追加データを使います。確認してみましょう。

#include <typeinfo>
#include <cstdio>

#define PRINT_OBJ_AS(obj, type) \
    printf("%-12s: %08x, size: %2d (as "#type")\n",\
        "&"#obj,\
        value(static_cast<type*>(&obj)) - value(&obj),\
        sizeof(type));

#define PRINT_FIELD(obj, field) \
    printf("%-12s: %08x, size: %2d\n",\
        "&"#obj"."#field,\
        value(&obj.field) - value(&obj),\
        sizeof(obj.field));

#define PRINT_FIELD_AS(obj, field, type) \
    printf("%-12s: %08x, size: %2d (as "#type"'s member)\n",\
        "&"#obj"."#field,\
        value(&obj.type::field) - value(&obj),\
        sizeof(obj.type::field));

#define PRINT_VPOINTER_AS(obj, type) \
    printf("%-12s: %08x, size: %2d, vptr: %08x (as "#type")\n",\
        "&"#obj".vptr",\
        value(static_cast<type*>(&obj)) - value(&obj),\
        sizeof(void*),\
        *reinterpret_cast<int*>(static_cast<type*>(&obj)));

namespace {
    inline size_t value(void* p) { return reinterpret_cast<size_t>(p); }

    class CommonBase {
    public:
        int a;
    };

    class VirtualInheritance1 : public virtual CommonBase {
    public:
        int b;
    };

    class VirtualInheritance2 : public virtual CommonBase {
    public:
        int c;
        virtual void func() {}
    };

    class VirtualDiamondInheritance : public VirtualInheritance1, public VirtualInheritance2 {};
}

int main(int argc, char* argv[])
{
    // 菱型継承+仮想継承
    VirtualDiamondInheritance dia;
    PRINT_OBJ_AS(dia, VirtualDiamondInheritance);
    PRINT_OBJ_AS(dia, VirtualInheritance1);
    PRINT_OBJ_AS(dia, VirtualInheritance2);
    PRINT_OBJ_AS(dia, CommonBase);
    PRINT_VPOINTER_AS(dia, VirtualDiamondInheritance);
    PRINT_VPOINTER_AS(dia, VirtualInheritance2);
    PRINT_FIELD(dia, a);
    PRINT_FIELD_AS(dia, a, VirtualInheritance1);
    PRINT_FIELD_AS(dia, a, VirtualInheritance2);
    PRINT_FIELD(dia, b);
    PRINT_FIELD(dia, c);
    return 0;
}

出力は次のようになりました。

&dia        : 00000000, size: 24 (as VirtualDiamondInheritance)
&dia        : 0000000c, size: 12 (as VirtualInheritance1)
&dia        : 00000000, size: 16 (as VirtualInheritance2)
&dia        : 00000014, size:  4 (as CommonBase)
&dia.vptr   : 00000000, size:  4, vptr: 00988b90 (as VirtualDiamondInheritance)
&dia.vptr   : 00000000, size:  4, vptr: 00988b90 (as VirtualInheritance2)
&dia.a      : 00000014, size:  4
&dia.a      : 00000014, size:  4 (as VirtualInheritance1's member)
&dia.a      : 00000014, size:  4 (as VirtualInheritance2's member)
&dia.b      : 00000010, size:  4
&dia.c      : 00000008, size:  4

仮想継承しているクラスVirtualInheritance1もVirtualInheritance2も、それぞれInheritance1とInheritance2より4byte大きなサイズを持つことがわかります。しかも、VirtualInheritance2を見るとこれがvpointerとは別に追加されたデータであることがわかります。おそらくCommonBaseのデータが置いてあるアドレスへのオフセットがあるんじゃないでしょうか。その具体的な値は重要じゃないので、そこまで確認してませんが。

それから、菱形継承したVirtualDiamondInheritance1のデータサイズに注目です。VirtualInheritance1とVirtualInheritance2を単純に足し合わせたより4byte少ないですね。仮想継承によってまとめられた結果、余分にCommonBaseのデータを持っていない証拠です。しかし、仮想継承しないサンプルで示したDiamondInheritanceより4byte多いです。これはVirtualInheritance1とVirtualInheritance2が持っているCommonBase位置データ分の差ですね。このように、仮想継承した結果としてデータサイズが大きくなる事があるわけですね。

わかりにくくなってきたので、実行結果から推測されるメモリ配置を表にします。

メンバ変数 アドレス データサイズ(byte)
VirtualDiamondInheritance::vptr
VirtualInheritance2::vptr
+0x00 4
VirtualDiamondInheritanceと
VirtualInheritance2からCommonBaseへのオフセット?
+0x04 4
VirtualInheritance2::c +0x08 4
VirtualInheritance1からCommonBaseへのオフセット? +0x0c 4
VirtualInheritance1::b +0x10 4
CommonBase::a +0x14 4

当然ですが、仮想継承を利用すると、仮想関数のように動的な参照解決に一手間かかるため、参照コストが増えます。なんでもかんでも仮想継承にするというわけにもいかないでしょう。

さらに、ここでは検証しませんが、仮想基底クラスのどのコンストラクタが呼ばれるかは、末端の派生クラスで指定する必要があります。それまでに継承されたクラスで記述された仮想基底クラスのコンストラクタ呼び出しは無視されます。とにかく末端の派生クラスが責任を持つというルールです。明示しなければデフォルトコンストラクタ呼び出しとなります。つまり、継承する度に仮想基底クラスのどのコンストラクタを呼ぶべきかしっかり面倒を見てやる必要があります。このあたりの挙動・仕様については下記ページを参照してください。

こういう仕組みなので、菱型継承問題を適切に扱うには、何が菱型継承となるか把握し、すべて適切に仮想継承を使い分けねばなりません。仮想継承の複雑なルールをきちんと理解して、破綻しないプログラムを保守するというのは人間には難易度が高いので、なるべく避けるのが吉かと思います。面倒なので使いたくないのが正直なところでもあります。

以下、参考資料。

static_castとdynamic_cast

ここまでの話を踏まえて、やっとstatic_castとdynamic_castの挙動を説明・検証できます。今回確認したかったのはこの話なので、長い道のりでした。と言っても、実はここまでにかなり検証してきましたので、あとは楽です。

それぞれのキャストは別々に説明しますが、検証コードはひとつにまとめたので先に貼っちゃいます。

#include <iostream>

using namespace std;

#define TEST(expr) (cout << ((expr) != nullptr ? "[Success]" : "[Failure]") << " " << (expr) << ": " #expr << endl)

namespace {
    class Base;
    class Sub;

    Base* getSubAsBase();
    void testForwardDeclaration()
    {
        Base* p = getSubAsBase();
        p = nullptr;
        //static_cast<Sub*>(p); // 継承関係が見えずコンパイルエラー
    }

    class Base {
    public:
        virtual void func() {}
    };
    class Sub : public Base {};

    Base* getSubAsBase()
    {
        static Sub sub;
        return &sub;
    }

    class Dummy {};

    class CommonBase {
    public:
        int a;
    };

    class VirtualInheritance1 : public virtual CommonBase {
    public:
        int b;
    };

    class VirtualInheritance2 : public virtual CommonBase {
    public:
        int c;
        virtual void func() {}
    };

    class VirtualDiamondInheritance : public VirtualInheritance1, public VirtualInheritance2 {};
}

int main(int argc, char* argv[])
{
    // 前方宣言クラスのポインタ変換テスト
    ::testForwardDeclaration();

    {   // アップキャストとダウンキャスト
        Sub sub;
        Base* p = &sub;

        TEST(dynamic_cast<Base*>(&sub));    // アップキャスト(成功)
        TEST(dynamic_cast<Sub*>(p));        // ダウンキャスト(成功)
        TEST(dynamic_cast<void*>(p));       // voidポインタへキャスト(成功)
        TEST(dynamic_cast<Dummy*>(p));      // 不正なキャスト(失敗)

        TEST(static_cast<Base*>(&sub));     // アップキャスト(成功)
        TEST(static_cast<Sub*>(p));         // ダウンキャスト(成功)
        TEST(static_cast<void*>(p));        // voidポインタへキャスト(成功)
        //TEST(static_cast<Dummy*>(p));     // 不正なキャスト(コンパイルエラー)
    }
    {   // クロスキャスト
        VirtualDiamondInheritance dia;
        VirtualInheritance2* p = &dia;
        TEST(dynamic_cast<VirtualInheritance1*>(p));    // 成功
        //TEST(static_cast<VirtualInheritance1*>(p));   // コンパイルエラー
    }
    {   // voidポインタのダウンキャスト
        VirtualDiamondInheritance dia;
        void* p = static_cast<VirtualInheritance2*>(&dia);
        TEST(static_cast<VirtualInheritance1*>(p)); // 不正だが成功
        TEST(static_cast<VirtualInheritance2*>(p)); // 成功
    }
    return 0;
}

出力は次のようになりました。

[Success] 007BEF3C: dynamic_cast<Base*>(&sub)
[Success] 007BEF3C: dynamic_cast<Sub*>(p)
[Success] 007BEF3C: dynamic_cast<void*>(p)
[Failure] 00000000: dynamic_cast<Dummy*>(p)
[Success] 007BEF3C: static_cast<Base*>(&sub)
[Success] 007BEF3C: static_cast<Sub*>(p)
[Success] 007BEF3C: static_cast<void*>(p)
[Success] 007BEF1C: dynamic_cast<VirtualInheritance1*>(p)
[Success] 007BEEE4: static_cast<VirtualInheritance1*>(p)
[Success] 007BEEE4: static_cast<VirtualInheritance2*>(p)

dynamic_cast

機能がわかりやすいのでdynamic_castから説明します。

dynamic_castは名前の通り、動的なキャストを実現します。RTTIを見てダウンキャストを適切に扱うことができるということです。

以下、機能のまとめです。

  • RTTIを利用するため、操作対象はポリモーフィックなクラスのポインタのみ。voidポインタには使えない。
  • 変換先はクラスのポインタあるいはvoidポインタであればよく、ポリモーフィックでなくていい。
  • ポインタに対してRTTIで型チェックした上で正当なキャストのみ実行し、不正なキャストではヌルポインタを返す。これによってダウンキャストが安全に行え、NULLチェックで失敗を判断できる。
  • 動的な型チェックを前提としているため、明らかに不正な変換でもコンパイルエラーにしない。
    • アップキャストはコンパイル時に判断できるのでstatic_cast推奨。
  • ポリモーフィックかつ菱型継承のとき、クロスキャストが可能。
    • 今回の検証コードでいうとDiamondInheritanceをInheritance2ポインタとして持ち、DiamondInheritanceポインタへのダウンキャストをすっ飛ばしてInheritance1ポインタへの変換が可能。

static_cast

static_castは名前の通り、静的なキャストを実現します。つまり、コンパイル時に変換コードを生成するため、型チェックによるオーバーヘッドはありませんが、ダウンキャストの安全性を保証しません。これまでの検証コードで、static_castによるポインタ変換が、メモリ配置のオフセットを考慮して変換してくれることを確認しました。しかし、これは決め打ちの変換処理になるので、ダウンキャストした結果が正しく動くかどうかはプログラマが保証してやらないといけません。結構怖いですね。

また、static_castはポインタだけでなくアトミック型やオブジェクトの変換もできることを忘れてはいけません。

以下、機能のまとめです。

  • 静的に変換コードを生成するため、コンパイル時に変換方法がわからないものについてはコンパイルエラーにする。
    • 継承関係にないクラスのポインタ変換など明らかに不正な変換をコンパイルエラーにしてくれるので、アップキャストに向いている。
  • 動的な型チェックをしないため、ダウンキャストの安全性はプログラマが保証する必要がある。
  • voidポインタからの変換はオフセット考慮しない。
    • 基底クラスと先頭アドレスが一致しないような派生クラスをvoidポインタで持つと危険。voidポインタに変換する前のポインタ型に一度戻さないといけない。
  • ポインタ以外の変換は基本的にコンストラクタ呼び出しorキャストのオーバーロード呼び出しをする。
    • static_cast<Class>(obj)Class(obj)に等しい。ポインタで同じことをしようとするとコンパイルエラー。

voidポインタ怖すぎ。検証コードで「不正だが成功」とコメントを書いた操作の出力を見れば、オフセット考慮が働かずに破綻した結果になっていることがわかります。

ちなみに、ポインタ自体はサイズが決まっているので、クラスの定義を知らなくても前方宣言があるだけでポインタを定義できます。しかし、それだけの情報では当然継承関係がわからないので、クラス定義まで見える位置でないとstatic_castによるポインタ変換はコンパイルエラーになります。

公式文書を読んだ人のまとめ記事を以下に張りますが、こちらにもっと正確なことが書いてあります(キャストのオーバーロードのことは触れられてないみたいですが)。

今回説明してきたことを理解できていれば、リンク先を見ても、どうしてそのような規格になっているのか大体わかるでしょう。private継承のときはアップキャストすらできないよとか、クラスメンバへのポインタ(超マニアック機能)の変換の話とかありますが、特に世話になることはないでしょう。

C++には4つのキャストが用意されていますが、static_castだけ用途が複数あって混乱のもとになっていると思うんです。

以下、参考資料。

結論

C++は闇。

コメントを残す

Top