C++入門|virtual修飾子と仮想関数

virtualは、親クラスの指示を子クラスの本当の姿へ届ける合図。仮想関数を使えば、同じ呼び出しでも戦士ごとの技を正しく発動できます。

C++の継承では、親クラスのポインタを使って、子クラスのインスタンスを指すことができます。

たとえば、基本戦士を表すBaseWarriorクラスがあり、その子クラスとしてKameWarriorやKamiWarriorがあるとします。

このとき、次のように書くことができます。

BaseWarrior* pWarrior1 = new KameWarrior();
BaseWarrior* pWarrior2 = new KamiWarrior();

pWarrior1とpWarrior2の型は、どちらもBaseWarrior*です。
しかし、実際に生成されているインスタンスは、それぞれKameWarriorとKamiWarriorです。

ここが、ポリモーフィズムの大事な入口です。

ドラゴンボール風にたとえるなら、司令室のモニターには「基本戦士」として登録されているけれど、実際に修行場に立っているのは「亀仙流戦士」や「神流戦士」という状態です。

このとき、親クラスのポインタからメンバ関数を呼び出すと、少し注意が必要です。

通常のメンバ関数では、ポインタの型である親クラス側の関数が呼び出されます。
しかし、virtual修飾子を付けた仮想関数では、実際に指している子クラス側の関数が呼び出されます。

関数の種類呼び出される関数ドラゴンボール風のイメージ
通常のメンバ関数ポインタの型を基準に決まる基本戦士としての共通動作が出る
仮想関数実際のインスタンスを基準に決まる亀仙流・神流など本当の流派の技が出る

今回の記事では、BaseWarriorという親クラスを作り、そこからKameWarriorとKamiWarriorを継承します。

BaseWarriorには、通常のメンバ関数moveと、virtual付きの仮想関数shoutを用意します。

KameWarriorとKamiWarriorでは、moveとshoutをそれぞれ自分用に定義します。
そして、親クラス型のポインタから呼び出したときに、通常関数と仮想関数で結果がどう変わるのかを確認します。

図:親クラスのポインタが子クラスのインスタンスを指す

この図が示していること

この図では、BaseWarrior型のポインタが、KameWarriorやKamiWarriorのインスタンスを指している様子を表しています。

ポインタの型はBaseWarriorですが、実際のインスタンスは子クラスです。
virtual付きの仮想関数を呼び出すと、ポインタの型ではなく、実際のインスタンスに合わせた子クラス側の関数が実行されます。

仮想関数とは何か

仮想関数とは、親クラスでvirtualを付けて宣言されたメンバ関数です。

virtual void shout();

このようにvirtualを付けておくと、親クラス型のポインタから呼び出しても、実際に指しているインスタンスの型に合わせて、子クラス側の関数が実行されます。

たとえば、次のような関係を考えます。

クラス役割shoutの内容
BaseWarrior親クラス基本戦士が気合いを入れる
KameWarrior子クラス亀仙流のかけ声を出す
KamiWarrior子クラス神流の静かな気を放つ

親クラス型のポインタからshoutを呼び出しても、virtualが付いていれば、実体に合わせてKameWarriorやKamiWarriorのshoutが呼ばれます。

これによって、同じshoutという呼び出しでも、戦士の種類ごとに違う結果を出せます。

これが、C++でポリモーフィズムを実現するための大切な仕組みです。

通常の関数と仮想関数の違い

今回のサンプルでは、BaseWarriorに2つの関数を用意します。

1つ目は、通常のメンバ関数moveです。

void move();

2つ目は、virtual付きの仮想関数shoutです。

virtual void shout();

子クラス側でも、同じ名前のmoveとshoutを定義します。

ただし、親クラス型のポインタから呼び出したときの結果が変わります。

関数virtualの有無親クラス型ポインタから呼んだ結果
moveなしBaseWarriorのmoveが呼ばれる
shoutあり実際の子クラスのshoutが呼ばれる

この違いを理解すると、virtual修飾子の意味がかなり分かりやすくなります。

ドラゴンボール風に言えば、moveは司令室が「基本戦士としての移動」として処理してしまう行動です。
一方、shoutはvirtualが付いているため、実際に修行場にいる戦士の流派に合わせて、亀仙流や神流のかけ声が発動します。

今回作成するサンプルプログラム

今回のプログラムは、次の7つのファイルで構成します。

クラス親クラスファイル
BaseWarriorなしbase_warrior.h、base_warrior.cpp
KameWarriorBaseWarriorkame_warrior.h、kame_warrior.cpp
KamiWarriorBaseWarriorkami_warrior.h、kami_warrior.cpp

main.cppでは、BaseWarrior型のポインタを2つ用意します。

そして、それぞれにKameWarriorとKamiWarriorのインスタンスを代入します。

BaseWarrior* pWarrior1;
BaseWarrior* pWarrior2;

pWarrior1 = new KameWarrior();
pWarrior2 = new KamiWarrior();

この状態でmoveとshoutを呼び出し、virtualが付いている場合と付いていない場合の違いを確認します。

BaseWarriorクラスの宣言

まず、親クラスとなるBaseWarriorを宣言します。

プロジェクト/ファイル名: Chap5_06/base_warrior.h

#ifndef _BASE_WARRIOR_H_
#define _BASE_WARRIOR_H_

class BaseWarrior {
public:
    // デストラクタ
    virtual ~BaseWarrior();

    // 通常の移動関数
    void move();

    // 気合いを入れる関数(仮想関数)
    virtual void shout();
};

#endif // _BASE_WARRIOR_H_

このヘッダーファイルで注目したいのは、shout関数です。

virtual void shout();

先頭にvirtualが付いています。

このため、BaseWarrior型のポインタからshoutを呼び出したとき、実際に指しているインスタンスに合わせて、子クラス側のshoutが呼び出されます。

一方、move関数にはvirtualが付いていません。

virtual void shout();

そのため、BaseWarrior型のポインタからmoveを呼び出すと、BaseWarriorのmoveが呼び出されます。

なお、親クラスのポインタで子クラスのインスタンスをdeleteするため、デストラクタにはvirtualを付けています。

virtual ~BaseWarrior();

これは、継承を使うクラスを安全に消去するための大切な書き方です。

BaseWarriorクラスの定義

次に、BaseWarriorクラスのメンバ関数を定義します。

プロジェクト/ファイル名: Chap5_06/base_warrior.cpp

#include "base_warrior.h"
#include <iostream>

using namespace std;

// デストラクタ
BaseWarrior::~BaseWarrior() {
}

// 通常の移動関数
void BaseWarrior::move() {
    cout << "基本戦士が地上を移動します" << endl;
}

// 気合いを入れる関数(仮想関数)
void BaseWarrior::shout() {
    cout << "基本戦士が気合いを入れます" << endl;
}

BaseWarriorのmoveは、基本戦士の移動を表しています。

void BaseWarrior::move() {
    cout << "基本戦士が地上を移動します" << endl;
}

BaseWarriorのshoutは、基本戦士の気合いを表しています。

void BaseWarrior::shout() {
    cout << "基本戦士が気合いを入れます" << endl;
}

ただし、shoutはヘッダーファイルでvirtual付きで宣言されています。
そのため、子クラスで同じ形のshoutを定義すると、親クラス型のポインタからでも子クラス側のshoutが呼ばれるようになります。

KameWarriorクラスの宣言

次に、BaseWarriorを継承したKameWarriorを作ります。

プロジェクト/ファイル名: Chap5_06/kame_warrior.h

#ifndef _KAME_WARRIOR_H_
#define _KAME_WARRIOR_H_

#include "base_warrior.h"

// 亀仙流戦士クラス
class KameWarrior : public BaseWarrior {
public:
    // デストラクタ
    virtual ~KameWarrior();

    // 通常の移動関数
    void move();

    // 気合いを入れる関数(仮想関数)
    void shout();
};

#endif // _KAME_WARRIOR_H_

KameWarriorはBaseWarriorを継承しています。

class KameWarrior : public BaseWarrior

KameWarriorでは、moveとshoutをどちらも定義しています。

void move();
void shout();

moveは、BaseWarriorにもあります。
shoutも、BaseWarriorにもあります。

ただし、BaseWarriorのshoutにはvirtualが付いています。
そのため、BaseWarrior型のポインタからshoutを呼んだ場合でも、実体がKameWarriorならKameWarriorのshoutが呼び出されます。

KameWarriorクラスの定義

プロジェクト/ファイル名: Chap5_06/kame_warrior.cpp

#include "kame_warrior.h"
#include <iostream>

using namespace std;

// デストラクタ
KameWarrior::~KameWarrior() {
}

// 通常の移動関数
void KameWarrior::move() {
    cout << "亀仙流戦士が軽やかに跳んで移動します" << endl;
}

// 気合いを入れる関数(仮想関数)
void KameWarrior::shout() {
    cout << "亀仙流戦士が青白い気を放ちます" << endl;
}

KameWarriorのmoveでは、亀仙流戦士らしい移動を表示します。

void KameWarrior::move() {
    cout << "亀仙流戦士が軽やかに跳んで移動します" << endl;
}

KameWarriorのshoutでは、亀仙流戦士らしい気の放出を表示します。

void KameWarrior::shout() {
    cout << "亀仙流戦士が青白い気を放ちます" << endl;
}

ここで大切なのは、KameWarriorのshoutは、親クラスBaseWarriorの仮想関数shoutをオーバーライドしていることです。

KamiWarriorクラスの宣言

続いて、BaseWarriorを継承したKamiWarriorを作ります。

プロジェクト/ファイル名: Chap5_06/kami_warrior.h

#ifndef _KAMI_WARRIOR_H_
#define _KAMI_WARRIOR_H_

#include "base_warrior.h"

// 神流戦士クラス
class KamiWarrior : public BaseWarrior {
public:
    // デストラクタ
    virtual ~KamiWarrior();

    // 通常の移動関数
    void move();

    // 気合いを入れる関数(仮想関数)
    void shout();
};

#endif // _KAMI_WARRIOR_H_

KamiWarriorもBaseWarriorを継承しています。

こちらも、moveとshoutを定義しています。

void move();
void shout();

KameWarriorと同じように、shoutは仮想関数として扱われます。

KamiWarriorクラスの定義

プロジェクト/ファイル名: Chap5_06/kami_warrior.cpp

#include "kami_warrior.h"
#include <iostream>

using namespace std;

// デストラクタ
KamiWarrior::~KamiWarrior() {
}

// 通常の移動関数
void KamiWarrior::move() {
    cout << "神流戦士が静かに宙を移動します" << endl;
}

// 気合いを入れる関数(仮想関数)
void KamiWarrior::shout() {
    cout << "神流戦士が癒しの光を広げます" << endl;
}

KamiWarriorのmoveは、神流戦士らしい移動です。

void KamiWarrior::move() {
    cout << "神流戦士が静かに宙を移動します" << endl;
}

KamiWarriorのshoutは、神流戦士らしい気の出し方です。

void KamiWarrior::shout() {
    cout << "神流戦士が癒しの光を広げます" << endl;
}

このshoutも、BaseWarriorの仮想関数shoutをオーバーライドしています。

main.cppで仮想関数の動きを確認する

最後に、main.cppを作ります。

ここでは、BaseWarrior型のポインタで、KameWarriorとKamiWarriorのインスタンスを指します。

プロジェクト/ファイル名: Chap5_06/main.cpp

#include <iostream>
#include "base_warrior.h"
#include "kame_warrior.h"
#include "kami_warrior.h"

using namespace std;

int main(int argc, char** argv) {
    // 親クラス型のポインタ変数を用意する
    BaseWarrior* pWarrior1;
    BaseWarrior* pWarrior2;

    // 親クラス型のポインタに子クラスのインスタンスを代入する
    pWarrior1 = new KameWarrior();
    pWarrior2 = new KamiWarrior();

    // 通常の関数を呼び出す
    pWarrior1->move();
    pWarrior2->move();

    // 仮想関数を呼び出す
    pWarrior1->shout();
    pWarrior2->shout();

    // インスタンスを消去する
    delete pWarrior1;
    delete pWarrior2;

    // プログラムが正常に終わったことを示す
    return 0;
}

実行結果

基本戦士が地上を移動します
基本戦士が地上を移動します
亀仙流戦士が青白い気を放ちます
神流戦士が癒しの光を広げます

実行結果を見ると、moveとshoutで違いが出ています。

moveはvirtualが付いていない通常の関数です。
そのため、BaseWarrior型のポインタから呼び出すと、BaseWarriorのmoveが実行されます。

一方、shoutはvirtualが付いている仮想関数です。
そのため、BaseWarrior型のポインタから呼び出しても、実際のインスタンスに合わせて、KameWarriorやKamiWarriorのshoutが実行されます。

図:通常関数moveと仮想関数shoutの違い

この図が示していること

この図では、virtualが付いていないmoveと、virtualが付いているshoutの呼び出し結果の違いを表しています。

moveは通常の関数なので、BaseWarrior型のポインタから呼ぶとBaseWarriorのmoveが実行されます。

shoutは仮想関数なので、BaseWarrior型のポインタから呼んでも、実際に指しているKameWarriorやKamiWarriorのshoutが実行されます。

親クラス型のポインタで子クラスを指す

main.cppの次の部分を見てみましょう。

BaseWarrior* pWarrior1;
BaseWarrior* pWarrior2;

pWarrior1 = new KameWarrior();
pWarrior2 = new KamiWarrior();

pWarrior1とpWarrior2は、どちらもBaseWarrior型のポインタです。

しかし、実際に代入しているのは、子クラスのインスタンスです。

ポインタ変数ポインタの型実際のインスタンス
pWarrior1BaseWarrior*KameWarrior
pWarrior2BaseWarrior*KamiWarrior

これは、子クラスは親クラスの一種として扱えるためです。

KameWarriorはBaseWarriorを継承しています。
KamiWarriorもBaseWarriorを継承しています。

そのため、BaseWarrior型のポインタで、KameWarriorやKamiWarriorを指すことができます。

この仕組みによって、複数の子クラスを親クラス型としてまとめて扱えるようになります。

通常関数moveの呼び出し

次の行では、moveを呼び出しています。

pWarrior1->move();
pWarrior2->move();

pWarrior1の実体はKameWarriorです。
pWarrior2の実体はKamiWarriorです。

しかし、moveにはvirtualが付いていません。

そのため、BaseWarrior型のポインタから呼び出すと、ポインタの型であるBaseWarriorのmoveが実行されます。

実行結果は次のようになります。

基本戦士が地上を移動します
基本戦士が地上を移動します

KameWarriorにもKamiWarriorにもmoveは定義されています。
それでも、BaseWarrior型のポインタから呼び出しているため、通常関数ではBaseWarriorのmoveが使われます。

ここが、virtualのない関数の注意点です。

仮想関数shoutの呼び出し

次に、shoutを呼び出しています。

pWarrior1->shout();
pWarrior2->shout();

shoutは、BaseWarriorでvirtual付きで宣言されています。

virtual void shout();

そのため、BaseWarrior型のポインタから呼び出しても、実際のインスタンスに合わせて関数が選ばれます。

pWarrior1の実体はKameWarriorなので、KameWarrior::shoutが呼び出されます。

亀仙流戦士が青白い気を放ちます

pWarrior2の実体はKamiWarriorなので、KamiWarrior::shoutが呼び出されます。

神流戦士が癒しの光を広げます

このように、同じBaseWarrior型のポインタから同じshoutを呼び出しているのに、実行される処理が変わります。

これが仮想関数の重要な働きです。

virtual修飾子の役割

virtual修飾子は、親クラスのメンバ関数に付けます。

virtual void shout();

このvirtualが付いていると、その関数は仮想関数になります。

仮想関数は、親クラス型のポインタや参照から呼び出したときでも、実際のインスタンスの型に応じて、子クラス側のオーバーライド関数を呼び出せます。

virtualの有無呼び出しの基準
virtualなしポインタの型
virtualあり実際のインスタンスの型

今回の場合、pWarrior1とpWarrior2のポインタ型はBaseWarrior*です。

しかし、shoutはvirtual付きなので、実際のインスタンスであるKameWarriorやKamiWarriorのshoutが呼び出されます。

ドラゴンボール風に言えば、virtualは「司令室の基本戦士モニターから指示しても、現場の本当の流派に合わせて技を出せ」という合図です。

仮想関数とポリモーフィズム

仮想関数を使うと、ポリモーフィズムを実現できます。

ポリモーフィズムとは、同じ呼び出し方でも、実際のオブジェクトによって違う動作をさせる仕組みです。

今回のサンプルでは、どちらも次のように呼び出しています。

pWarrior1->shout();
pWarrior2->shout();

呼び出し方は同じです。
ポインタの型もどちらもBaseWarrior*です。

しかし、実行結果は違います。

亀仙流戦士が青白い気を放ちます
神流戦士が癒しの光を広げます

これは、pWarrior1がKameWarriorを指していて、pWarrior2がKamiWarriorを指しているからです。

同じshoutという呼び出しで、それぞれの子クラスに合った動きが実行されています。

これが、仮想関数によるポリモーフィズムです。

virtualがないとどうなるのか

もしBaseWarriorのshoutにvirtualが付いていなかったら、BaseWarrior型のポインタからshoutを呼び出したとき、BaseWarriorのshoutが呼ばれます。

つまり、次のような結果になります。

基本戦士が気合いを入れます
基本戦士が気合いを入れます

実際にはKameWarriorやKamiWarriorを指していても、ポインタの型がBaseWarrior*であるため、BaseWarrior側の関数が使われます。

それでは、戦士ごとの個性が出ません。

仮想関数にすることで、親クラス型のポインタを使いながら、子クラスごとの動きを正しく呼び出せるようになります。

図:virtualは実体に合わせて関数を選ぶ

この図が示していること

この図では、virtualがある場合とない場合で、呼び出される関数の決まり方が変わることを表しています。

virtualなしでは、ポインタの型であるBaseWarrior側の関数が呼ばれます。

virtualありでは、実際に指しているインスタンスを確認し、KameWarriorやKamiWarriorの関数が呼ばれます。

この違いによって、同じBaseWarrior型のポインタでも、子クラスごとの動きを実現できます。

仮想関数を使う場面

仮想関数は、親クラス型で複数の子クラスをまとめて扱いたいときに便利です。

たとえば、たくさんの戦士を配列でまとめる場面を考えます。

BaseWarrior* warriors[2];

warriors[0] = new KameWarrior();
warriors[1] = new KamiWarrior();

このようにすれば、どちらもBaseWarrior*として扱えます。

そして、shoutが仮想関数なら、ループで同じように呼び出しても、それぞれの戦士らしい技が出ます。

for (int i = 0; i < 2; i++) {
    warriors[i]->shout();
}

KameWarriorなら亀仙流の気を放ちます。
KamiWarriorなら神流の癒しの光を広げます。

呼び出し側は、戦士の具体的な種類を細かく判定しなくてもよくなります。

これが仮想関数の大きなメリットです。

通常関数と仮想関数の比較

今回の内容を整理すると、次のようになります。

項目通常関数仮想関数
宣言void move();virtual void shout();
呼び出し基準ポインタの型実際のインスタンスの型
親クラス型ポインタからの呼び出し親クラス側が呼ばれる子クラス側が呼ばれる
ポリモーフィズム実現しにくい実現できる
今回の出力基本戦士が地上を移動します亀仙流戦士、神流戦士ごとの出力

通常関数は、ポインタの型を基準に呼び出されます。

仮想関数は、実際のインスタンスを基準に呼び出されます。

この違いが、C++の継承とポリモーフィズムを理解するうえでとても大切です。

仮想関数で押さえたいポイント

virtual修飾子と仮想関数では、次のポイントを押さえておきましょう。

ポイント内容
virtual親クラスのメンバ関数に付ける修飾子
仮想関数virtualが付いたメンバ関数
目的親クラス型ポインタから子クラス側の関数を呼び出す
通常関数ポインタの型に基づいて呼び出される
仮想関数実際のインスタンスに基づいて呼び出される
ポリモーフィズム同じ呼び出しで、実体ごとに違う動作をさせる仕組み
virtualデストラクタ親クラス型ポインタでdeleteする場合に重要

C++では、親クラス型のポインタで子クラスのインスタンスを扱うことができます。

そのとき、通常の関数では、ポインタの型である親クラス側の関数が呼ばれます。

しかし、virtualを付けた仮想関数では、実際に指している子クラス側の関数が呼ばれます。

ドラゴンボール風に言えば、BaseWarrior*という共通の司令系統から指示しても、KameWarriorなら亀仙流の気を放ち、KamiWarriorなら神流の癒しの光を広げます。

同じshoutという呼び出しでも、実際の戦士に合わせて結果が変わる。
これがvirtual修飾子と仮想関数の大きな力です。

仮想関数を理解すると、C++のポリモーフィズムがぐっと見えやすくなります。
親クラス型でまとめて扱いながら、子クラスごとの個性を正しく発動できるようになるからです。