多重継承について

今回は多重継承の仕組みについて説明します。多重継承とは、あるクラスが二つ以上のクラスから継承を行うことを意味します。例えば「鉛筆」というクラスがあり、また、「消しゴム」というクラスがあるとして、この両方から同時に継承を行うことで「消しゴムつき鉛筆」とする、といったものです。これを単一継承で表現しようとした場合、消しゴムを鉛筆から派生させるか、その逆を行わなければなりません。どちらの方法でも元のコードに変更を加える必要があり、コードの再利用の観点から望ましくありません。また、別に「消しゴムつきボールペン」を作ることが困難になります。このように多重継承では、単一継承にはない多様な継承の形を実現することが可能です。次のコードは多重継承の一例です。

class cPencil // 鉛筆
{
public:
    int hardness;
    int weight;
    void Write( void );
};

class cEraser // 消しゴム
{
public:
    int weight;
    int color;
    void Erase( void );
};

// 消しゴムつき鉛筆
class cPeneraser : public cPencil, public cEraser
{
public:
    int length;
};

void func( void )
{
    cPeneraser pe;

    pe.hardness = 100; // ok
    pe.Erase(); // ok
}

多重継承を行った場合、その基本クラスのメンバをすべて継承することになります。ここで「 weight にアクセスしたらどうなるか」という疑問が生じます。

pe.weight = 100;

メンバがすべて継承されるわけですから、cPencil::weight も cEraser::weight も、両方とも継承されることになります。名前が同じだからといって、一つにまとめられることはありません。よって pe.weight とした場合、どちらの weight なのか判断ができないので、このままではコンパイルエラーになります。この「あいまいさ」を取り除くには、どちらのメンバであるかを次のように明示します。

pe.cPencil::weight = 100;

ちなみに単一継承でもメンバ名の重複は生じますが、単一継承の場合は継承の階層構造において下に位置するクラスのメンバ、すなわち、派生クラスのメンバが暗黙的に選択されます。もちろん、明示的に指定すれば基底クラスのメンバを参照することもできます。多重継承では、階層構造において上下ではなくて左右にクラスが並ぶため、コンパイラにはどちらを選択すればよいか判断できないわけです。

ここから、本題の「多重継承の仕組み」について説明します。単一継承の場合と同様に、仮想関数がない場合から始めます。多重継承でも、派生クラスのオブジェクトに対し、基底クラスのポインタを使ってアクセスすることが可能です。

void func( void )
{
    cPeneraser pe;

    cPencil *pPen = &pe;
    pPen->hardness = 100;

    cEraser *pErs = &pe;
    pErs->weight = 100;
}

ここで pErs != &pe となることが重要です。これは、cPeneraser のメモリレイアウトが次のようになっているからです。

[ hardness ][ weight ][ weight ][ color ][ length ]
|      cPencil       ||     cEraser     |
|                   cPeneraser                    |

単一継承の場合、すべての基底クラスが「左端」に位置しますが、ほとんどの多重継承ではそうなりません。図が示すとおり、cPeneraser オブジェクトのアドレスと、その中の cEraser オブジェクトのアドレスには、sizeof( cPencil ) バイトの「ズレ」が生じます。このため、コードの上では pErs = &pe となっている操作は、裏では、

pErs = ( cEraser* ) ( ( unsigned long ) &pe + sizeof( cPencil ) );

となっています。ズレを補正するためのこの「シフト」操作は、ポインタの型を変換するときにだけ行われます。cPeneraser オブジェクト、またはそのポインタから直接 cErasr のメンバにアクセスするときは、通常のメンバアクセスと同様の方法が用いられます。

では、これらのクラスに仮想関数を追加した場合について、説明します。各クラスを次のように書き換えます。

class cPencil
{
public:
    int hardness;
    int weight;
    void Write( void );
    virtual void vfpen( void );
};

class cEraser
{
public:
    int weight;
    int color;
    void Erase( void );
    virtual void vfers( void );
};

class cPeneraser : public cPencil, public cEraser
{
public:
    int length;
    virtual void vfpe( void );
};

メモリレイアウトは次のようになります。

[ vfptr1 ][ ha ][ we ][ vfptr2 ][ we ][ co ][ vfptr3 ][ le ]

vfptr1 -> [ vfpen ]
vfptr2 -> [ vfers ]
vfptr3 -> [ vfpe ]

素直に並べると上のようになりますが、メモリの節約等の理由で、次のようにレイアウトするほうが良いでしょう。

[ vfptr1 ][ ha ][ we ][ vfptr2 ][ we ][ co ][ le ]

vfptr1 -> [ vfpen ][ vfpe ]
vfptr2 -> [ vfers ]

後者を採用する場合、各仮想関数に対するアクセスは次のようになります。

void func( void )
{
    cPeneraser pe;

    pe.vfpen();
    // ( pe.vfptr1[ 0 ] )();

    pe.vfers();
    // ( pe.vfptr2[ 0 ] )();

    pe.vfpe();
    // ( pe.vfptr1[ 1 ] )();
}

これで無事、多重継承の場合でも仮想関数が呼び出されるように見えます。ところがこの実装方法には大きな問題があります。それは、次のようにした場合に発覚します。

void func( void )
{
    cPeneraser pe;
    cEraser *pErs = &pe;

    pErs->vfers();
}

問題となるのは vfers() に渡される this ポインタです。この関数は仮想関数であり、派生クラス cPeneraser でオーバーライドされているなら、そのオーバーライドした関数が呼び出されます。このとき、要求される this ポインタは cPeneraser* 型ですが、pErs から呼び出しているがために cEraser* 型の this ポインタ( == pErs )が渡されてしまい、悲惨な結果となります。これを解決するには、関数が呼び出される直前に、どうにかして this をこっそりシフトする必要があります。その実現方法は、関数を直接呼び出すのでなく、「 this を調整して関数にジャンプするコード」を呼び出すとか、最初から「ずれた this 」が渡されるものとして仮想関数をコンパイルするなど、色々な方法があります。

以上のように、多重継承は便利である一方、様々な問題を抱えています。次回はさらなる問題点について、説明します。