ホーム < ゲームつくろー! < C++踏み込み編 < 多重継承と仮想基本クラス


その8 多重継承と仮想基本クラス


 C++ではクラスの「多重継承」が認められています。多重継承とは1つのクラスが複数の親クラスを持つ事を言います。多重継承をすることにより、1つのクラスは2つのクラスの性質を同時に受け続く事になります。一般に多重継承はあまり多用するべきではないと言われていますが、その定義方法を知らないよりは知っておいた方が良いかと思います。ここでは多重継承の扱い方について簡単にまとめます。



@ どうして多重継承が必要か?

 多重継承というのは、1つのクラスに2つの親クラスのメソッドを引き継ぎたい時に使います。例えば、ゲームで使用するオブジェクトは大抵アップデート(状態の更新)する必要があります。また画面に表示させるには描画メソッドが必須でしょう。ゲーム内で使用されるクラスの多くは、このどちらか、もしくは両方の機能を持つことになるはずなので、その動作定義を親クラスでしておくと便利になります。ただ、親クラスでUpdateメソッドとDrawメソッド(共に純粋仮想関数)を定義してしまうと、描画の必要の無い派生クラスではDrawメソッドが不必要になってしまいます。必要無いメソッドを空実装しなければならないというのは、クラスの設計として間違っています。もちろんアップデートの必要が無い描画クラスについてもUpdateメソッドの空実装をしなければいけません。

 では、親クラスとして描画クラスと更新クラスを分けて作るとどうか?これは利便性が上がりますが、両方の機能が必要になった時に困ります。このように、単一継承のみでクラス設計をすると、複数のクラスに定義されている機能を取り込みたい時に困ってしまうんです。このような時に多重継承が大活躍してくれます。



A 多重継承の宣言

 多重継承の宣言はとても簡単で、子クラスを作るときに親クラスを複数指定します:

class CDrawer
{
public:
   virtual void Draw() = 0;   // 描画メソッド
};


class CUpdator
{
public:
   virtual void Update() = 0;   // 更新メソッド
}


class CGameObject : public CDrawer, public CUpdator
{
public:
   virtual void Draw();
   virtual void Update();
};

 これだけで描画と更新のメソッドが同時に引き継がれます。とっても簡単です。上の例では親クラスは純粋仮想関数になっていますが、メンバ変数や実装があってもかまいません。

 「何だこれだけか」と感じると思いますが、多重継承には単一継承には無いちょっとした落とし穴があるんです。そしてこの落とし穴が、意外と深いんです。



B ダイアモンド型多重継承の罠

 便利な多重継承は、「使い方を誤る」と困った問題を引き起こします。下の宣言をご覧下さい:

// エラー格納クラス
class CErrorInfo
{
protected:
   DWORD m_dwErrorID;   // エラーID

public:
  // エラー情報を取得
   virtual DWORD Geterror(){
      return m_dwErrorID;
   }
  // エラー情報を設定
  virtual void SetError( DWORD err){
      m_dwErrorID = err;
   }
};


// 描画クラス
class CDrawer : public CErrorInfo
{
public:
  // エラー情報を設定
  virtual void SetError( DWORD err){
      m_dwErrorID = err + 100;
   }
   virtual void Draw() = 0;   // 描画メソッド
};


// 更新クラス
class CUpdator : public CErrorInfo
{
public:
   // エラー情報を設定
  virtual void SetError( DWORD err){
      m_dwErrorID = err + 200;
   }
   virtual void Update() = 0;   // 更新メソッド
}


// ゲームオブジェクトクラス
class CGameObject : public CDrawer, public CUpdator
{
public:
   virtual void Draw();
   virtual void Update();
};

少し見辛いかもしれませんので説明致します。CErrorInfoクラスはエラー情報を設定し取得するメソッドを提供します。描画メソッドを提供するCDrawerクラスや更新メソッドを提供するCUpdatorクラスは、それぞれCErrorInfoクラスから派生させるとします。双方のクラスでは独自のSetErrorメソッドをオーバーライドしています。CGameObjectクラスはゲームのオブジェクトを表すクラスで、更新も描画も必要なので両方のクラスを多重継承しています。

 さてここで、

 CGameObject GameObj;
  GameObj.SetError( 100 );

とゲームオブジェクトを作ってエラーコードを設定したとします。このSetErrorメソッドはCDrawerの物なのでしょうか?それともCUpdator側でしょうか?CGameObjectクラスの親クラスの両方でこのメソッドは再定義されているわけでして、どちらかが呼ばれないと困るわけです。実は、上の宣言のままだと次のような警告とエラーが発生してしまい、コンパイルできません:

多重継承の警告とエラー
error C2385: 'SetError' へのアクセスがあいまいです。
error C3861: 'SetError': 識別子が見つかりませんでした

 SetErrorメソッドを呼び出すときに、CDrawer::SetErrorメソッドなのかCUpdator::SetErrorメソッドなのかを解決できないためにこのコンパイルエラーが起こります。このように、多重継承では共通の親クラス(CErrorInfo)が持つ仮想関数の重複が非常に起こりやすくなってしまいます。これは「アクセスの曖昧さ」として全部エラーとなります。多重継承が敬遠されるのはこのわずらわしさと間違いやすさにあります。

 上記のコンパイルエラーを解決するには、呼び出し側を変更します。具体的には次のように実装します:

CGameObject GameObj;
GameObj.CUpdator::SetError(100);

見慣れない方には目が点になるかもしれませんが、このようにスコープ解決演算子でどのクラスのSetErrorメソッドであるかを明確に指定します。これでコンパイルエラーはなくなります。

 「スコープ解決演算子をいちいち使わなくてはいけないなんて面倒だ!」と思われるかもしれません。しかし、名前衝突を解決するにはこれしか方法がありません。まぁ単純にこれが関数の名前なんだと割り切れば、逆にメンバ関数の使い方を明確に記述しているぶん間違いがありません。



C 仮想基本クラス

 さて、上のクラス宣言で、今度はGetErrorメソッドを呼び出すとどうなるでしょうか?すると次のようなコンパイルエラーが出ます。

多重継承の警告とエラー
error C2385: 'GetError' へのアクセスがあいまいです。
    'GetError' (ベース 'CErrorInfo' 内) である可能性があります
    または、'GetError' (ベース 'CErrorInfo' 内) である可能性があります
error C3861: 'GetError': 識別子が見つかりませんでした

 これはGetErrorメソッドが少なくとも2つあるので、スコープ解決演算子で明確に選んで欲しいというエラーなんです。先ほどのSetErrorメソッドの時のエラーと同じです。しかしこれは何とも腑に落ちません。まずGetErrorメソッドはIErrorInfoクラスに定義されているのみでして、複数あるという感覚がありません。CGameObjectクラスではCErrorInfoクラスに実装されているGetErrorメソッドをそのまま使用したいと思っているわけでして、選べと言われる筋合いが無いわけです。

 そもそもどうしてこんな事を言われてしまうのか?実は、これには多重継承のあるカラクリが関係しています。

 多重継承をした場合、親クラスのメンバ変数や仮想関数テーブルは別々に定義されるという規則があります。多重継承をしたCGameObjectクラスの状態を図で示すと次のようになっています:

 緑色の枠がCGameObject全体です。1つのクラスの中に2つの完全に別の仮想関数テーブルが存在しています。これはそれぞれCDrawerクラスとCUpdatorクラスの物です。このため、GetErrorメソッドがたとえ同じCErrorInfo::GetErrorメソッドへのポインタを示していたとしても、スコープ解決演算子で「どちらの仮想テーブルのGetErrorメソッドなのか」を指定してあげなければ、エラーになってしまうというわけなんです。ついでに、上図の2つのCErrorInfo部分は完全に独立していますので、m_dwErrorIDというのは2つ存在する事になります。ですから、IDrawer::SetErrorメソッドで設定したエラーとIUpdator::SetErrorメソッドで設定したエラー値は個別に格納され、また個別に引き出すことできます(これはこれで面白いです)。

 使い方にもよりますが、一般にメンバ変数が複数あるというのは好ましくありません。また、先ほどのようにIErrorInfo::GetErrorメソッドを使いたいのに仮想関数テーブルを選択しなければいけないなどというのは、クラスの振る舞いとして間違っています。この諸悪の根源は、やはり仮想テーブルが2つあるという実態にあります。C++にはこれを解決する必殺技がちゃんと用意されています。それが「仮想基本クラス」です。

 C++の標準機能である「仮想基本クラス」を用いると、多重継承によってダブってしまった親クラスのメソッドを1つにできます。仮想基本クラスの実装自体は至極簡単でして、次のように親クラスを宣言します:

class CErrorInfo
{
protected:
   DWORD m_dwErrorID;   // エラーID

public:
  // エラー情報を取得
   virtual DWORD Geterror(){
      return m_dwErrorID;
   }
  // エラー情報を設定
  virtual void SetError( DWORD err){
      m_dwErrorID = err;
   }
};


class CDrawer : virtual public CErrorInfo
{
public:
  // エラー情報を設定
  virtual void SetError( DWORD err){
      m_dwErrorID = err + 100;
   }
   virtual void Draw() = 0;   // 描画メソッド
};


class CUpdator : virtual public CErrorInfo
{
public:
   // エラー情報を設定
  virtual void SetError( DWORD err){
      m_dwErrorID = err + 200;
   }
   virtual void Update() = 0;   // 更新メソッド
}


class CGameObject : public CDrawer, public CUpdator
{
public:
   virtual void SetError( DWORD err )   // エラー情報を設定
   virtual void Draw();
   virtual void Update();
};

 親クラスを示す時に「virtual」というキーワードをつけます。クラスの中で仮想メソッドを宣言する時にvirtualをつけるのと同じです。そして、ここが隠れたポイントなのですが、仮想関数テーブルが1つになってしまうので、CGameObjectクラス内でSetErrorメソッドをしっかりと再定義(オーバーライド)します。仮想基本クラスを親クラスに使用すると、CGameObjectの中身は次のようになります:

 先程までのCErrorInfoの重複が無くなって、親クラスで定義されている関数は1つの仮想関数テーブルにすっきりと収まります。もしSetErrorメソッドを再定義しなければ、「定義をちゃんと決めてください」というコンパイルエラーが出ます。再定義の手間が増えてはしまうものの、仮想基本クラスを用いる事で多くの場合多重継承の問題は解決するんです。



D 多重継承を使う時は使う意思をはっきりと

 多重継承はこれまでの説明にありますようにちょっとクセがあります。使うときのコツは、多重に継承するという意思をはっきりと決めることです。上の実装例にあるCDrawerクラス及びCUpdatorクラスを宣言する時、まず「同時に派生される事があるだろうな」と最初からはっきりと意識します。この意思があれば、これらクラスは共にCErrorInfoクラスを継承していますので、結果として「virtual public CErrorInfo」と仮想基本クラスにしなければならない実装が自ずと導けるわけです。

 一般に、仮想基本クラスを宣言すべきか否か判断しづらいものです。どうするべきか迷った時には、多重継承を使わない方が良いかもしれません。多重継承の対象となる親クラスには本当に親子関係が定義できないのか、そのあたりを検討すれば、この判断はつくようになります。判断が面倒だからと、何時でもどこでも仮想基本クラスとして宣言するのは避けるべきです。仮想基本クラスは色々とデリケートな面を持っていますので、手に負えないコンパイルエラーがわんさか出ることもあります。

 多重継承の機能があるにもかかわらず、意固地になって単一継承だけでクラス設計をするというのも、なんとも見識が狭い気がしてなりません。冒頭の例のように、クラスの役割を考えた時に単一継承では実現不可能なケースだってあるわけです。要は、多重継承が必要なケースに遭遇した時には、素直に多重継承を使えばいいんです。余談ですが、COMプログラムの経験がある方でであれば、COMプログラムが多重継承の塊であることは良くご存知だと思います。そんな超メジャーなフレームワークにも、多重継承はバリバリ使われているんです。多重継承は正しく使えば怖くありませんし、仮想基本クラスを用いればユーザ側からは多重継承であることをうまく隠蔽できるものなんです。

 あいまいさをなくして意思をビシッと固めて、多重継承とうまく付き合って行きましょう。