仮想継承について

前回、多重継承を説明した際に、メンバ名が重複することによるあいまいさの問題について触れましたが、さらに基底クラスそのものが重複する場合があることを説明します。次のクラス定義はその一例です。

class cA
{
public:
    int ia;
};

class cB1 : public cA
{
public:
    int ib1;
};

class cB2 : public cA
{
public:
    int ib2;
};

class cC : public cB1, public cB2
{
public:
    int ic;
};

/*
[ ia ][ ib1 ][ ia ][ ib2 ][ ic ]
| cA |       | cA |
|    cB1    ||    cB2    |
|              cC              |
*/

ia が二つあることに気付いたでしょうか。先頭にある ia は cB1::ia であり、次の ia は cB2::ia です。ここで問題となるのは、データの整合性です。cB1::ia と cB2::ia は別々に確保されているので、どちらか一方に対する操作がもう一方に反映されることはありません。仮に cC が何かの製品を表すクラスで、ia はその重量として扱うものとすると、cB1::ia と cB2::ia のどちらか一方だけを使用するように決めるか、またはどちらか一方の値を変更したら、もう片方も同じ値に変更するようにしなければ、どちらのメンバにアクセスするかによって異なった重量( ia )を得ることになります。さらに、この二つの方法を実際に導入するのは無理があります。例えば前者の方法において、cB1:ia だけを使用するように決めると、cB2 のメンバ関数が ia にアクセスしたときに無意味な値を得ることになります。this ポインタを cC* にダウンキャストすれば cB1::ia にアクセスすることも可能ではありますが、クラス全体を見直す必要があり、実現はかなり困難です。後者の方法においても、メンバ関数の中で ia が変更された場合はお手上げです。もう片方のクラスは、そのクラスの ia が変更されたことを知る術がありません(直接アクセスすればわかりますが、現実的ではありません)。

つまり、多重継承においては、同じクラスが二回以上継承されることがあり、その際には、以上のような「値の不整合」の問題を抱えることになるわけです。どちらか一方が変更されれば、もう一方も変更されるようにするには、どうしたらいいのでしょうか。それは、メンバを共有することです。つまり cB1::ia と cB2::ia が同じメモリ領域を共有するようにすればよいのです。そうすれば確実に整合性が保たれます。ところでそんなことが可能なのでしょうか。「仮想継承」という継承方法が、それを実現します。先に定義したクラスを次のように書き換えます。

class cA
{
public:
    int ia;
};

class cB1 : virtual public cA
{
public:
    int ib1;
};

class cB2 : virtual public cA
{
public:
    int ib2;
};

class cC : public cB1, public cB2
{
public:
    int ic;
};

/*
[ ib1 ][ ib2 ][ ic ][ ia ]
*/

cB1 と cB2 のところで virtual というキーワードが追加されていますが、これは cA が仮想継承されることを意味します。メモリレイアウトを見ると、仮想継承された cA のメンバが末尾に配置されています。そして一つしかありません。cB1::ia も cB2::ia も cC::ia も、すべて最後の cA にアクセスします。さて、これで値の整合性に関しては解決したものの、今度は「仮想継承されたクラスのメンバへの、this からのオフセットが不規則」だという問題が生じます。例えば cB1 のメンバ関数が ia にアクセスしようとしたとき、this から何バイト離れているかわかりません。cC オブジェクトへのポインタが渡されたなら 12 バイトですが、cC から派生したオブジェクトが渡されるかもしれませんし、そもそも、cB1 は自分が派生クラスにおいて、なにと多重継承されているのかも知りません。つまり、cB1 のメンバ関数に渡されるオブジェクトの型によって、ia へのオフセットが変わってしまうことになります。どうにかしてオフセットを知る必要がありますが、当然ながら、既に色々な方法が考案されています。例えば、次のようにオフセットを記録してあるテーブルを用意し、その値を参照する方式があります。

long vbtableB1inC[] = { B1toB1inC, B1toAinC };
long vbtableB2inC[] = { B2toB2inC, B2toAinC };

[ vbptr ][ ib1 ][ vbptr ][ ib2 ][ ic ][ ia ]

最初の vbptr には、オブジェクトの初期化時に vbtableB1inC が代入され、次の vbptr には vbtableB2inC が代入されます。B1toAinC は、cC の中での cB1 と cA の距離で、これは cC をコンパイルするときに決まります。B1toB1inC とは何かというと、cC の中での cB1 の vbptr と cB1 のベースアドレスの距離です。常にゼロになるように見えますが、クラスに vfptr が追加された場合は、これが -4 になります。vbptr の直前に vfptr が配置されるためです。同様に B2toAinC は、cC の中での cB2 と cA の距離です。そこで、ia に対するアクセスは次のようになります。

void func( void )
{
    cC c;
    cB1 *pb1 = &c;
    cB2 *pb2 = &c;
    cC  *pc = &c;

    pb1->ia = 10;
    /*
        offset = pb1->vbptr[ 1 ];
        *( ( int* )( pb1 + offset + 0 ) ) = 10;
    */

    pb2->ia = 20;
    /*
        offset = pb2->vbptr[ 1 ];
        *( ( int* )( pb2 + offset + 0 ) ) = 10;
    */

    pc->ia = 30;
    /*
        offset = pc->vbptr[ 1 ];
        *( ( int* )( pc + offset + 0 ) ) = 10;
    */
}

仮想関数のテーブル参照と似ていますが、違うのは、アドレスを取得するのでなくてオフセットを取得して、それを元のポインタに加算するという点です。+ 0 とは、cA のベースアドレスから ia へのオフセットです。この「基底クラスへのオフセット」「元のポインタ」「基底クラスでのメンバのオフセット」の三つを加算することにより、ようやく仮想継承されたクラスのメンバのアドレスを得ることができます。最後に、先ほど少し触れましたが、仮想関数を追加すると次のようになります。

class cA
{
public:
    int ia;
    virtual void vfa( void ){};
};

class cB1 : virtual public cA
{
public:
    int ib1;
    virtual void vfa( void ){};
    virtual void vfb1( void ){};
};

class cB2 : virtual public cA
{
public:
    int ib2;
    virtual void vfa( void ){};
    virtual void vfb2( void ){};
};

class cC : public cB1, public cB2
{
public:
    int ic;
    virtual void vfa( void ){};
    virtual void vfb1( void ){};
    virtual void vfb2( void ){};
    virtual void vfc( void ){};
};

void func( void )
{
    cC c;
    cC *pc = &c;

    pc->vfa();
    /*
        offset = pc->vbptr[ 1 ];
        a_in_c = pc + offset;
        func   = a_in_c->vfptr[ 0 ];
        func();
    */  
}

以上のように、仮想継承されたクラスのベースアドレス( == vfptr )を取得し、呼び出すべき関数のアドレスを取得して、(コードには書いていませんが)this ポインタを渡して、目的の関数を呼び出します。さらに、実際にはこの後で、this ポインタが調整されることになります。

仮想継承での仮想関数呼び出しにかかるコストは、単一継承の場合と比較してかなり大きなものになります。常に vbptr を参照しなければならないためです(ただし、ポインタを使わずに c.ia のようにした場合は、オフセットが完全に明らかなので vfptr を参照しない場合があります)。あるコンパイラでは、単一継承の場合での仮想関数の呼び出しに 4 命令、仮想継承の場合は 11 命令必要になります。この差をどう考えるべきかは、どういった状況でオブジェクトを使用するかによります。仮想関数を何度も繰り返し呼び出す場合で、呼び出しにかかる命令数が、処理の何割かを占めるような状況では、差は大きいと考えるべきでしょう。パフォーマンスを重視するならば、クラスすら使わずに、構造体と関数だけでコーディングするのが最もよいと思われますが(究極的にはアセンブリ言語ですが!)、構造体よりもクラスを使用したほうが便利であることはご存知の通りです。そしてクラスを使うとなれば、継承の仕組みを利用すると cool だということも、これまでの説明でご理解いただけたものと思います。一方で、それがどんなに(裏で)複雑なことをしているのかも、説明してきたとおりです。継承によってクラスを設計しようと思い立ったなら、そのコストについて、よく検証していただければと思います。