複雑な、単一継承 part 2

part 1 では仮想関数を使用しないと不便であることを示したので、今回は仮想関数の使用例と、その動作原理を説明します。まずは cName を再設計します。

// 関数の定義は省略。前回と同じ。
class cName
{
public:
    char first-name[ 128 ]; // 名前
    char last-name[ 128 ];  // 名字
    void SetName( 
        const char *lpszFirstName, 
        const char *lpszLastName );
    virtual void GetName( char *lpszBuffer );
};

前回の cName と比較してください。ほとんど違いはありませんが、GetName の前に virtual が追加されている点が唯一異なります。これで cName::GetName は仮想関数として宣言されたことになります。また、一度 virtual として宣言された関数は、その派生クラスにおいても自動的に virtual になるため、派生クラスの設計には全く変更を加える必要はありません。この状態で、前回と同様に GetName を呼び出すと、次のようになります。狙いどおり、「実際に指しているオブジェクトの型」に応じたメンバ関数が呼び出されるはずです。

void func( void )
{
    char lpsz[ 256 ];

    cName name;
    cJPNName j_name;
    cName *pName = NULL;

    pName = &name;
    pName->SetName( "Michael", "Anderson" );
    pName->GetName( lpsz ); // call cName::GetName

    pName = &j_name;
    pName->SetName( "信長", "織田" );
    pName->GetName( lpsz ); // call cJPNName::GetName
}

「オブジェクトの型に応じた関数」を呼び出せるこの仕組みですが、舞台裏ではいったいどのようにして関数を選んでいるのでしょうか。コンパイル時には、当然ながらオブジェクトの型はわかりません。となると、実行時にオブジェクトの型を調べる機構が必要になります。では関数を呼び出すたびに型を調べているのでしょうか。不可能ではありませんが、相当な手間がかかってしまいます。種明かしをすると、オブジェクトの型が何であるかを知らなくても、適切な関数を呼び出せるようになっているのです。このトリックは次のように仕組まれています。

/* C で表現した仮想関数 */

/* class cName */
struct sName
{
    void **vfptr;
    char first-name[ 128 ];
    char last-name[ 128 ];
};

/* cName::SetName */
void sName_SetName( 
    sName *this, 
    const char *lpszFirstName, 
    const char *lpszLastName ); // 定義は省略

/* virtual cName::GetName */
void sName_GetName( 
    sName *this, 
    char *lpszBuffer ); // 定義は省略

typedef void ( *LPFNSNAME_GETNAME )( struct sName*, char* );

/* class cJPNName : public cName */
struct sJPNName
{
    struct sName s; // 実際には無名構造体
};

/* cJPNName::GetName */
void sJPNName_GetName( 
    sJPNName *this, 
    char *lpszBuffer ); // 定義は省略

void *vftable_sName[] = { sName_GetName };
void *vftable_sJPNName[] = { sJPNName_GetName };

void sName_Constructor( sName *this )
{
    this->vfptr = vftable_sName;
}

void sJPNName_Constructor( sJPNName *this )
{
    this->s.vfptr = vftable_sJPNName;
}

void func( void )
{
    char lpsz[ 256 ];

    struct sName name;
    struct sJPNName j_name;
    struct sName *pName;

    sName_Constructor( &name );
    sJPNName_Constructor( &j_name );

    pName = &name;
    sName_SetName( pName, "Paul", "Smith" );
    ( ( LPFNSNAME_GETNAME ) ( pName->vfptr[ 0 ] ) )( pName, lpsz );

    pName = ( sName* ) &j_name;
    sName_SetName( pName, "秀吉", "豊臣" );
    ( ( LPFNSNAME_GETNAME ) ( pName->vfptr[ 0 ] ) )( pName, lpsz );
}

C で仮想関数を書いてみると以上のようになります。特に vfptr[ 0 ] == (実際のオブジェクトの型の)vftable[ 0 ] となることが重要です。コードの説明については割愛しますが、順番どおりに解釈していけば正しい関数が呼び出されることはご理解いただけるものと願っています。C だとこんなに複雑な仮想関数ですが、C++ では vftable の初期化、vfptr の初期化、関数呼び出し時のアドレスの取得などが自動的に行われるので、基底クラスに virtual の 7 文字を追加するだけで仮想関数を実装できます。ただし、これは表向きの話で、裏ではやはり先の C のコードと同じことが行われているため、普通のメンバ関数に比べて呼び出しに手間がかかります。次回は仮想関数の宣言と定義について、もう少し詳しく説明しようと思っています。

(注)仮想関数や継承の実装方法はコンパイラによって異なっているため、ここで説明している方法が実際に使われているとは限りません。また、呼び出し規約については、ここでは完全に無視しています。例えば this ポインタはスタックにプッシュせずにレジスタを使って渡す場合があります。