仮想関数 | Programming Place Plus C++編【言語解説】 第27章

トップページC++編

C++編で扱っている C++ は 2003年に登場した C++03 という、とても古いバージョンのものです。C++ はその後、C++11 -> C++14 -> C++17 -> C++20 -> C++23 と更新されています。
なかでも C++11 での更新は非常に大きなものであり、これから C++ の学習を始めるのなら、C++11 よりも古いバージョンを対象にするべきではありません。特に事情がないなら、新しい C++ を学んでください。 当サイトでは、C++14 をベースにした新C++編を作成中です。

この章の概要 🔗

この章の概要です。


仮想デストラクタ 🔗

前章では、場合によっては派生クラスのデストラクタが呼び出されなくなるため、new演算子を用いて、インスタンス化を行うことを避けていました。まずはこの問題を解消する話から始めましょう。

まず、問題を確認します。次のプログラムでは、派生クラスである Derivedクラスのデストラクタは呼び出されません。

#include <iostream>

class Base {
public:
    ~Base()
    {
        std::cout << "~Base()" << std::endl;
    }
};

class Derived : public Base {
public:
    ~Derived()
    {
        std::cout << "~Derived()" << std::endl;
    }
};

int main()
{
    Base* b = new Derived();

    delete b;
}

実行結果:

~Base()

このように、delete演算子の適用対象が Base型であるため、Baseクラスのデストラクタは呼び出しますが、派生クラスの存在までは考慮されません。

間違いがないように確認しておきますが、次のコードであれば、Derivedクラスのデストラクタと、Baseクラスのデストラクタが順に呼び出されます。

Derived* d = new Derived();

delete d;

この場合は、delete演算子の適用対象は Derived型ですから、まず Derivedクラスのデストラクタを呼び出します。Derivedクラスに基底クラスの Base があることは分かっていますから、そのまま Baseクラスのデストラクタも呼び出されます。

この流れなら何も問題はありませんが、公開継承の価値の1つは、Baseクラスも Derivedクラスも同じように扱えるという点にあるので、Base型の変数で扱えなければ、この価値を失ってしまいます。

基底クラス型のポインタを delete したときに、派生クラス側のデストラクタも呼ばせるためには、基底クラス側のデストラクタに、virtual指定子を付加します

#include <iostream>

class Base {
public:
    virtual ~Base()
    {
        std::cout << "~Base()" << std::endl;
    }
};

class Derived : public Base {
public:
    virtual ~Derived()
    {
        std::cout << "~Derived()" << std::endl;
    }
};

int main()
{
    Base* b = new Derived();

    delete b;
}

実行結果:

~Derived()
~Base()

virtual指定子が付加されたデストラクタは、仮想デストラクタと呼ばれます。また、「デストラクタを仮想にする」のように表現されることもあります。

なお、派生クラス側のデストラクタにも virtual を付けることができますが、基底クラス側に付いていれば、付けても付けなくても変わりはありません

派生クラス側のデストラクタに付ける virtual指定子については、付けなくても同じことなので、書く必要はないという意見と、基底クラスの定義を確認しにいかないと、付いているかどうか分からないので、書いた方がいいという意見とがあります。後者の意見については、C++03 の範疇では確かにそのとおりですが、C++11 には override指定子があるので、これを使えば解決できます。

仮想デストラクタ(または、次の項で解説する仮想関数)を定義すると、オブジェクトの大きさが少し増加することを一応知っておくと良いでしょう。これは、仮想関数テーブルという追加情報が、コンパイラによってこっそりと生成されるようになるからです。

仮想デストラクタによって増加する大きさは、ポインタ変数1個分に過ぎないので、せいぜい 8バイト程度ですが、まったくのノーペナルティで、仮想デストラクタにできるわけではないということです。

一般的によく、「継承するつもりがあるクラスのデストラクタは仮想にするべき」と言われます。大筋では正しいですが、もう少しいうと、「仮想関数があるクラスのデストラクタは仮想にするべき」となります。仮想関数については、この後の項で解説します。

前者の表現の場合は、前章のような公開継承の使い方であっても、仮想デストラクタにした方がいいということになります。安全性を重視するなら、この判断は適切ですが、前述したとおり、仮想デストラクタにすることがノーペナルティではないことを忘れないようにしましょう。

仮想関数、オーバーライド 🔗

virtual指定子は、静的でないメンバ関数にも付けられます。この場合、仮想関数(仮想メンバ関数)と呼ばれます。ちなみに、コンストラクタには付けられませんが、演算子オーバーロードに付けることはできます

基底クラス側にある仮想関数と同じ名前、同じ引数のメンバ関数を、派生クラス側でも定義すると、基底クラス側の関数の実装を、オーバーライドできます。const や volatile の有無も一致している必要があるので、非constメンバ関数だった仮想関数を、派生クラス側で constメンバ関数にするといったことはできません。

また、通常は、戻り値の型も一致させる必要があります

【上級】公開継承の関係性にある B と D というクラスがあるとき、基底クラスで B* を返すように定義された仮想関数を、派生クラスで D* を返すようにオーバーライドすることが可能です。同様に、B& を D& に変更しても構いません。これは、共変性(コバリアンス)と呼ばれる性質です。

オーバーライドを行うことによる価値は、この後の項で取り上げることにして、まずは構文などのルールを確認しておきます。

class Base {
public:
    virtual void f() {}
};

class Derived : public Base {
public:
    virtual void f() {}  // オーバーライド
};

仮想デストラクタのときと同様に、基底クラス側のメンバ関数に virtual が付いていれば、派生クラス側の virtual はあってもなくても構いません

仮想デストラクタのところのコラムでも書いたように、意見は分かれるところですが、いずれにせよ、C++11 であれば、override指定子を使うのが良いです。

なお、基底クラス側に仮想関数があるからといって、派生クラス側で必ずオーバーライドしなければならないというわけではありません

また、オーバーライドする際、基底クラス側のアクセス指定子と、派生クラス側のアクセス指定子が違っても許されます。つまり、Base::fメンバ関数が「公開」であるとき、Derived::fメンバ関数を「非公開」としてもいいですし、その逆も可能です。しかしながら、このような変更はトラブルの元になることがあるので、避けた方が無難です


多態性 🔗

オーバーライドを使うと、多態性(ポリモルフィズム)を実現できます。

多態性と呼べるものはさまざまあるのですが、ここでは、ある特定のメンバ関数を呼び出すようにコードを書いても、そのオブジェクトの種類(=どのクラスからインスタンス化されたものであるか)を実行時に判断して、適切なオブジェクトのメンバ関数を呼び出せることをいいます。

【上級】もう少し正確にいえば、多態性は、ある関数やオブジェクトなどが、さまざまな型に属することができる性質のことです。たとえば、ある1つの関数が、渡されてきた引数の型に応じて実装を切り替えられるとすれば、その関数は多態性を持っているということになります。これを実現する方法の1つとして、ここで取り上げるオーバーライドがありますが、すでに解説済みのオーバーロードやテンプレートも、多態性を実現する手段であるといえます。

多態性の効果を、次のプログラムで確認してみましょう。

#include <iostream>

class Base {
public:
    virtual void f()
    {
        std::cout << "Base::f()" << std::endl;
    }
};

class D1 : public Base {
public:
    virtual void f()
    {
        std::cout << "D1::f()" << std::endl;
    }
};

class D2 : public Base {
public:
    virtual void f()
    {
        std::cout << "D2::f()" << std::endl;
    }
};

void call_f(Base* b)
{
    b->f();
}

int main()
{
    D1 d1;
    D2 d2;

    call_f(&d1);
    call_f(&d2);
}

実行結果:

D1::f()
D2::f()

call_f関数は、Baseクラスのポインタを通して、fメンバ関数を呼び出しています。一見すると、Base::fメンバ関数が呼び出されるように見えますが、実行結果を見ると、Base::fメンバ関数の処理は実行されず、派生クラス側の fメンバ関数が呼び出されます。これが、多態性の効力です。

このような結果を生むには2つの条件があります。

1つに、オブジェクトの実体ではなく、ポインタや参照を通じてメンバ関数を呼び出すことです。もし、call_f関数が次のように、オブジェクトの実体から仮想関数を呼び出していたとしたら、呼び出されるのは、必ずその型のとおりの関数(Base::fメンバ関数)になります

void call_f(Base b)
{
    b.f();  // 必ず Base::fメンバ関数を呼ぶ
}

なお、あるクラスのメンバ関数が、自身の持っている仮想関数を呼び出す場合、それは thisポインタ(第11章)を使用している訳ですから、ポインタを通じた呼び出しになります。よって、多態性の効果が発生します

もう1つの条件は、呼び出されるメンバ関数が仮想関数であることです。ポインタや参照を通じて仮想関数を呼び出すと、そのポインタや参照が本当はどのクラスのオブジェクトを指すものであるのかに応じて、派生クラス側でオーバーライドされた関数がないかどうか判断します。

call_f関数の例で言えば、見た目上の型は Base* ですが、渡されたポインタは本来、D1 や D2 からインスタンス化されたオブジェクトを指していますから、D1クラスや D2クラスで、fメンバ関数がオーバーライドされていないか確認され、オーバーライドされていれば、そちらを呼び出します。

ややこしいルールのようですが、次のようにセットになると考えれば、多少すっきりするかもしれません。

そして、こうなると、基底クラス型のポインタや参照を扱うことになるので、派生クラス側のデストラクタが確実に呼び出されるように、仮想デストラクタもセットで導入した方が良いということになります

多態性を利用すると、1つのコードを書くだけでも、オブジェクトに応じて実際に実行される処理を切り替えられます。少し専門的な言葉を使うと、「オブジェクトに応じて振る舞いを変えられる」ということです。この性質のおかげで、コードの共通化を図ることができます。

コードの共通化というと、共通の処理を1つの関数にまとめることと同じように思えるかもしれません。関数化による共通化は、呼び出される処理が1つにまとまる訳ですが、多態性の場合は、呼び出し元のコードが共通化されます。実際、先ほどの多態性のやり方をあらためて確認してもらえると分かりますが、処理は D1::fメンバ関数と D2::fメンバ関数とに分かれていますから、呼び出される処理が共通化されているわけではありません。一方、呼び出し元の処理は、「b->f();」という単一のコードにまとまっています。

また、クラスに仮想関数を持たせるということは、必然的に、「継承して使用するクラスである」と言っていることになります。同時に、その仮想関数の動作は、派生クラス側でオーバーライドして書き換えられることを許しているということでもあります。あなたの作るクラスが、他の誰かに継承され、オーバーライドされることになりますが、それは想定内でしょうか?

想定内なのであれば、終了処理に関するトラブルを防ぐために、必ず仮想デストラクタも持たせてください。想定外なのであれば、仮想関数を持たせるべきではありませんし、デストラクタを仮想にするべきでもありません。

仮想関数の呼び出しは、通常の関数呼び出しよりも、少しだけ遅くなります。オーバーライドされている可能性を考えると、実際にどの関数を呼び出さなくてはならないかは、動的にしか判断できないため、そのための速度低下ですが、ほとんどの場合、無視できる程度の差です。

ところで、呼び出される関数が動的にしか判断できないということは、基本的には、仮想関数をインライン関数(第10章)にできないことを意味しています。インライン関数は、コンパイル時に(つまり静的に)呼び出し元が決まるからこそ、コードをインライン展開できる訳ですから、動的な多態性の仕組みとの相性が悪いのです。

コンパイラは、インライン展開の指示を無視できるので、inline と virtual を同時に指定してもエラーになることはありませんが、普通は、inline の方は無視されます。

【上級】上の文章で、「基本的には~」としているのは、多態性を実現する仕組み上、インライン展開できませんが、多態性が発揮されないような使い方であれば、インライン化できるからです。たとえば、virtual は付いていても、「Base::f();」のような感じで、呼び出すべき関数を限定する呼び出し方をしている場合は、インライン化できます。

C++11 (override指定子) 🔗

C++11

C++11 では、オーバーライドすることを明示的に表現する override指定子が追加されています。仮想デストラクタに対しても使用できます。

class Base {
public:
    virtual ~Base() {}
    virtual void f() {}
};

class Derived : public Base {
public:
    ~Derived() override {}
    void f() override {}
};

従来の方法でのオーバーライドは、シグネチャが一致していなくても、構文的にはエラーにならないため、 気づきにくいバグにつながることがありました。

override指定子を使うと、基底クラス側に virtual指定子が付いていない場合には、コンパイルエラーになります。安全性を高められるシンプルな良い機能なので、つねに使用するようにしてください。

従来の方法と同様に、派生クラス側の virtual指定子の有無は自由です。override指定子を使うのなら、仮想関数であることは明白なので、派生クラス側での virtual は不要だと思います。

C++11 (final指定子) 🔗

C++11

C++11 で追加された final指定子を使うと、オーバーライドを禁止できます。final指定子は、前章でも、継承を禁止する指定子として紹介しています。

class Base {
public:
    virtual void f() final {}
};

class Derived : public Base {
public:
    void f() override {}  // エラー
};

ここでは、override指定子を使っていますが、使わずに従来の方法でオーバーライドしようとした場合でもエラーになります。


仮想関数とコンストラクタ・デストラクタ 🔗

仮想関数を含んだクラスを作成する際、コンストラクタとデストラクタの実装には注意しなければならない点があります。

まず基本的なことを確認すると、前章でも触れたように、公開継承されている場合、コンストラクタは基底クラスのものが先、派生クラスのものが後で呼び出されます。デストラクタはその逆で、派生クラスのものが先、基底クラスのものが後で呼び出されます

問題は、基底クラスのコンストラクタとデストラクタの実装で、仮想関数を呼び出す場合に起こります。

まず、コンストラクタの場合、基底クラスのコンストラクタを実行している段階では、まだ派生クラスのコンストラクタを呼んでいませんから、派生クラス側の準備ができていません。もう少しいうと、まだ派生クラスのオブジェクトは生成されていません。この場合、仮想関数を普通の(仮想でない)関数とみなすように動作し、多態性の効力は発揮されず、基底クラス側の関数が呼びだれます

デストラクタの場合も同様です。派生クラスのデストラクタの呼び出しが終わり、続いて基底クラスのデストラクタが呼び出されるとき、その時点で、派生クラスのオブジェクトは破棄されており、もはや存在しません。そのため、基底クラスのデストラクタ内での仮想関数の呼び出しは、普通の関数と同じになり、基底クラス側の関数が呼び出されます

恐らく、これらの挙動は意図しないものであり、大抵は厄介なバグにつながります。そのため、ガイドラインとしては、「コンストラクタやデストラクタでは仮想関数を呼び出さない」ことを守るべきです。

限定公開 🔗

これまで、アクセス指定子は publicキーワードによる「公開」か、privateキーワードによる「非公開」のいずれかでした。

実は、アクセス指定子にはもう1種類あります。それは、protectedキーワードによる「限定公開」です。

「限定公開」されたメンバは、そのクラス自身と、その派生クラスからのアクセスを許可し、それ以外からのアクセスを不許可にします。フレンドに指定した相手からは、アクセスされます(第25章)。

「限定公開」は、派生クラスの存在を無視すると「非公開」と同じになるので、継承と密接に関連したアクセス指定であると言えます。そのため、継承して使用することを意図していないクラスでは「限定公開」は使わないようにするべきです。

また、メンバ変数を「限定公開」にすることは避けましょう。これは、「公開」のメンバ変数を作らず、それを操作するアクセサ関数を「公開」するべきだという以前からのガイドラインと同じ理由です(第12章)。

「限定公開」のメンバは、外部からのアクセスを不許可にするので一見して安全なようですが、公開継承してしまえば「公開」されているも同然なので、ほとんど安全ではありません。

外部からはアクセスしないが、派生クラスからはアクセスしたいメンバ変数がある場合は、メンバ変数は「非公開」として、アクセサ関数を「限定公開」で作れば良いです。

たとえば、前章で使った Penクラスを公開継承して、点線を描画する DotPenクラスを作成するとしましょう。線の色情報は、基底クラスである Penクラスが持っているので、派生クラスからもこの情報を使えるようにしなければなりません。こういうとき、色情報のメンバ変数を「限定公開」にするのではなく、getter関数を「限定公開」で提供すればいいのです。

class Pen {
public:
    // 色を表す型
    // RGB(赤・緑・青)をそれぞれ 16進数2桁 (0x00~0xff) で表し、
    // 0xff8000 のように指定する(この場合、R=0xff, G=0x80, B=0x00)
    typedef unsigned int Color_t;

public:
    explicit Pen(Color_t color) :
        mColor(color)
    {}

    virtual ~Pen()
    {}

    virtual void DrawLine(int x1, int y1, int x2, int y2)
    {
        // mColor の色を使って、(x1,y1) から (x2,y2) に向かって直線を描く
    }

protected:
    inline Color_t GetColor() const
    {
        return mColor;
    }

private:
    Color_t  mColor;
};

class DotPen : public Pen {
public:
    explicit DotPen(Color_t color) :
        Pen(color)
    {}

    virtual void DrawLine(int x1, int y1, int x2, int y2)
    {
        // GetColor() の色を使って、(x1,y1) から (x2,y2) に向かって点線を描く
    }
};

実のところ、仮想関数を「公開」することも、あまり良くないと言われることもあります。これはオーバーライドによって、処理内容を根底から変更できてしまうため、もはや、基底クラスの作者が想定していた処理の流れを保証できなくなるからです。

たとえば、使用しているグラフィックスシステムの都合で、描画処理の開始と終了をシステムへ伝える必要があるとしましょう。その場合、Pen::DrawLineメンバ関数の実装は次のようになるはずです。

void Pen::DrawLine(int x1, int y1, int x2, int y2)
{
    graphics::BeginDraw();
    graphics::DrawLine(mColor, x1, y1, x2, y2);
    graphics::EndDraw();
}

graphics は、想像上のグラフィックスシステムの名前空間名だと思ってください。

派生クラスの DrawLineメンバ関数でも同じことをするのは可能ですが、オーバーライドする際はできるだけ、変化がある場所だけを再実装できるようにした方が良いでしょう。

そこで、Pen::DrawLineメンバ関数を、仮想でない通常のメンバ関数に変更し、派生クラスで書き換える部分だけを仮想関数とするように修正します。

class Pen {
public:
    // 色を表す型
    // RGB(赤・緑・青)をそれぞれ 16進数2桁 (0x00~0xff) で表し、
    // 0xff8000 のように指定する(この場合、R=0xff, G=0x80, B=0x00)
    typedef unsigned int Color_t;

public:
    explicit Pen(Color_t color) :
        mColor(color)
    {}

    virtual ~Pen()
    {}

    void DrawLine(int x1, int y1, int x2, int y2)
    {
        graphics::BeginDraw();
        DrawLineInner(x1, y1, x2, y2);
        graphics::EndDraw();
    }

protected:
    virtual void DrawLineInner(int x1, int y1, int x2, int y2)
    {
        // mColor の色を使って、(x1,y1) から (x2,y2) に向かって直線を描く
    }

    inline Color_t GetColor() const
    {
        return mColor;
    }

private:
    Color_t  mColor;
};

class DotPen : public Pen {
public:
    explicit DotPen(Color_t color) :
        Pen(color)
    {}

protected:
    virtual void DrawLineInner(int x1, int y1, int x2, int y2)
    {
        // GetColor() の色を使って、(x1,y1) から (x2,y2) に向かって点線を描く
    }
};

このように、仮想関数を「公開」しないような設計は、NVI(Non Virtual Interface。非仮想インターフェース)と呼ばれ、一般的に、良い設計であるとされています。

仮想関数(仮想デストラクタは除く)も、メンバ変数の場合と同様に、特に理由が無ければ「非公開」としておくべきですが、派生クラス側から呼び出したい理由があれば「限定公開」とすることにも価値があります。

たとえば、オーバーライドによって、基底クラスの仮想関数の処理に、少し追加処理を加えたいという状況では、派生クラスから、基底クラスの仮想関数を呼び出したいでしょう。この場合は、「限定公開」にすると良いです。

なお、派生クラスでオーバーライドした仮想関数から、オーバーライド元になった基底クラス側の仮想関数を呼び出す場合は、無限再帰にならないように、基底クラス名で修飾してください

void Derived::f()
{
    Base::f();
}


練習問題 🔗

問題① 仮想関数が定義されることで、オブジェクトの大きさが大きくなることを確認してください。

問題② コンストラクタやデストラクタを「限定公開」にすることには、どのような意味がありますか?

問題③ 以下のプログラムがしていることを説明してください。Pen と DotPen は、この章のサンプルプログラムと同じものとします。

int main()
{
    typedef std::vector<Pen*> PenContainer;

    PenContainer pens;
    pens.push_back(new Pen(0xffffff));
    pens.push_back(new DotPen(0x000000));
    pens.push_back(new DotPen(0xff0000));
    pens.push_back(new Pen(0x00ff00));
    pens.push_back(new Pen(0x0000ff));

    int x1 = 0;
    int y1 = 0;
    int x2 = 0;
    int y2 = 100;

    const PenContainer::iterator itEnd = pens.end();
    for (PenContainer::iterator it = pens.begin(); it != itEnd; ++it) {
        (*it)->DrawLine(x1, y1, x2, y2);
        x1 += 20;
        x2 += 20;
    }
}


解答ページはこちら

参考リンク 🔗


更新履歴 🔗

 virtual、override、final をそれぞれ指定子と表記するように修正。

 「サイズ」という表記について表現を統一。 型のサイズ(バイト数)を表しているところは「大きさ」、要素数を表しているところは「要素数」。

 文章中の表記を統一(bit、Byte -> ビット、バイト)

 コンパイラの対応状況について、対応している場合は明記しない方針にした。

 clang 3.7 (Xcode 7.3) を、Xcode 8.3.3 に置き換え。

≪さらに古い更新履歴を展開する≫



前の章へ (第26章 派生クラス)

次の章へ (第28章 継承と合成)

C++編のトップページへ

Programming Place Plus のトップページへ



はてなブックマーク に保存 Pocket に保存 Facebook でシェア
X で ポストフォロー LINE で送る noteで書く
rss1.0 取得ボタン RSS 管理者情報 プライバシーポリシー
先頭へ戻る