仮想関数と多態


継承とポインタ

C++のクラスをポインタで扱う上で(そしてこの後で学ぶ仮想関数を使う上で)とても大切なルールがあります。それは、「基本クラスを指すポインタは、その派生クラスも指すことができる」というルールです。

たとえば、以下のように定義したBaseクラスとDerivedクラスがあったとしましょう。

class Base {
public:
    int m_1;
    int m_2;
    int m_3;
    void function() { ... }
};

class Derived : public Base {
public:
   int m_4;
   int m_5;
   void function() { ... } //Baseのメンバ関数を再定義
};

この時に、Derivedクラスのオブジェクトを指すのに、Baseクラスを指すポインタを使用しても良いということです。

Base* pb = new Base(); //OK

Derived* pd = new Derived(); //当然これもOK

Base* pb2 = new Derived();  //そしてこれもOK

Derived* pd2 = new Base();  //これは NG!

イメージを見てみましょう。上記のように定義したBaseクラスとDerivedクラスのメモリレイアウトは右の図のようになります。


BaseクラスのポインタはBaseクラスが持っているメンバの範囲までアクセスすることができます。Derivedを指すポインタは、さらにDerivedクラス固有のメンバまで指すことができるので、その範囲が広くなっていることが理解できると思います。

ここで、BaseクラスのポインタでDerivedクラスのオブジェクトを指すと、右の図のようなイメージになります。

Baseクラスのポインタからはすべてのメンバを指し示すことはできませんが、ポインタが指す範囲には、すべて有効なメンバが格納されています。


今度は逆に、Derived* pd2 = new Base(); のように派生クラスのポインタに基本クラスのアドレスを格納しようとするとどうなるでしょうか。

右の図のようにオブジェクトの範囲外まで不正なアクセスが可能になってしまいます。このようなことを避けるために、派生クラスのポインタで基本クラスを指すことはできなくなっています。


このようにC++では、継承関係にあるすべてのオブジェクトを、基本クラスのポインタを使用して管理できるようになっています。


基本クラスのポインタを使ったメンバ関数の呼び出し

では、ポインタを通じてメンバ関数を呼び出した場合はどうなるかを見てみましょう。

Base* pb1 = new Base();
Derived* pd = new Derived();

Base* pb2 = new Derived();

pb1->function();// Base::function()が呼ばれる 
pd->function();// Derived::function()が呼ばれる 


pb2->function();// Derivedクラスを指しているのに Base::function()が呼ばれる 

前ページの囲みで書きましたが、Baseを指すポインタのメンバ関数呼び出しに対して、コンパイラは単純にポインタの型を見て、Base::function() の呼び出しを生成します。ポインタの先に格納されているオブジェクトの型は実行時にならないとわからないことがある(むしろそういう使い方のほうが多い※)ので、コンパイル時に自動的に派生クラスのメンバ関数呼び出しは生成できないのです。

※補足 以下のコードのような例を考えてみてください。Baseクラスを基本クラスとした Derived1、Derived2クラスがあるとします。
void somefunc(Base* p)
{
        // この引数 p が実行時に指しているオブジェクトの型は?
        // 基本クラス? 派生クラス?
    
        p->function();  
 }
 
 void foo()
{
    Base* pArray[3];
    
    pArray[0] = new Base();
    pArray[1] = new Derived1();
    pArray[2] = new Derived2();
    
    for (int i = 0;i < 3;i++)
        somefunc(pArray[i]);// <- ここで上の somefunc を呼び出しています
}
 
 

このとき、somefunc 内で使われているポインタ P が指しているオブジェクトの型は、プログラムが実行されていざ somefunc関数が呼び出されるまでわかりません。

コンパイラが somefunc関数をコンパイルするときに与えられている情報は、そのポインタが Baseクラスを指しているということだけです。そのためコンパイラは、p->function()の文に対して Base::function関数の呼び出しを生成します。(function() が仮想関数でなかった場合)

仮想関数

基本クラスのポインタ経由で指された派生クラスがあったときに、その派生クラスで再定義したメンバ関数を正しく呼び出すようにした仕組みを仮想関数といいます。仮想関数を定義するには、virtual というキーワードを指定します。

仮想関数の呼び出し先はコンパイル時には決定しません。「ポインタが指している先のオブジェクトを調べて適切な関数を呼び出す」という手順のコードが出力されます。

class Base {
public:
    virtual void vfunc() { ... }  // 仮想関数を定義
    void func() { ... }  // これは普通のメンバ関数
};

class Derived1 : public Base {
public:
    virtual void vfunc() { ... }  // 派生クラスで再定義した関数は仮想関数になる 
};

class Derived2 : public Base {
public:
    void vfunc() { ... }// 派生クラスでは virtualと書かなくても仮想関数になる 
};

void main()
{
   Base* p = new Derived1();
   
   p->vfunc();  // Baseクラスのポインタ経由だが Derived1::vfunc() が呼び出される 
}

基本クラスを定義するときに、この仮想関数の仕組みを使いたいメンバ関数に virtual というキーワードを指定します。派生クラスでこの関数を上書きした場合には、すべて仮想関数となります。派生クラスの側では、キーワード virtual は書いても書かなくてもかまいません。(派生クラスの側で virtual と書こうが書くまいが、一度基本クラスで virtual と宣言されたメンバ関数は仮想関数となります。)

仮想関数の仕組みを使うと、同一の基本クラスのポインタを使用しながらも、オブジェクトの型に応じたメンバ関数が呼ばれます。呼び出す側ではオブジェクトの型を意識しないでも、実際にはオブジェクトの型に応じた様々な動作を実現できることを多態(ポリモーフィズム)と呼びます。また、仮想関数を派生クラスで定義して、基底クラスのメンバ関数を上書きすることオーバーライド(override)するといいます。

練習問題 6-1

次の仮想関数の動作確認サンプルをビルド・実行して動作を確認しなさい。プロジェクト名は p3k3_6、ソースファイル名は p3k3_6.cpp としなさい。(ヘッダ部を分割しなくてよい。)

  1. Base、Derivedクラスを構築・破棄したときのコンストラクタ・デストラクタの呼び出しを確認しなさい。 そこになにか問題はないかを考察しなさい。
  2. vFunc 関数(10行目)の virtual キーワードを削除すると、動作がどのように変化するかを確認しなさい。
  3. Baseクラス、Derivedクラスのサイズを確認し、なぜそうなったかを考察しなさい。

 

仮想関数のしくみ

仮想関数を実現する仕組みは処理系によりますが、 基本的な手法を示します。

仮想関数は、実行時に呼び出し先関数を決定する仕組みですが、そのために仮想関数テーブル(vtable)と呼ばれる関数ポインタの配列が生成されます。

仮想関数をもつオブジェクトはこの仮想関数テーブルを指すポインタ(vptr)を隠しメンバとして持ちます。仮想関数の呼び出しは、個々のオブジェクトがもつ vptr をもとにたどることにより適切な関数呼び出しを行うことができますが、そのコスト(手間)はそれなりにかかることを理解しましょう。

上記の演習問題で調べたオブジェクトのサイズの違いは、この vptr によるものです。

 

デストラクタの仮想化

練習問題6-1で見たように、派生クラスを基本クラスのポインタで管理した場合、そのポインタをdeleteしても基本クラスのデストラクタしか呼び出されません。この時に、派生クラスのデストラクタが正常に呼び出されるようにするためには、デストラクタも仮想関数にしておく必要があります。基本的に、仮想関数を持つクラス(基本クラスのポインタで管理される可能性のあるクラス)のデストラクタには virtual キーワードをつけて仮想関数にしておくようにしましょう

練習問題 6-2

練習問題6-1の Baseクラスのデストラクタを仮想関数(virtual)に修正し、デストラクタの呼び出しがどのように変化するかを確認しなさい。