C++入門|5章の練習問題

抽象クラスは戦士たちの共通ルール。新しい流派の戦士を追加し、純粋仮想関数を実装して、ポリモーフィズムの動きを確認しよう。

5章では、C++のオブジェクト指向でとても重要な内容を学んできました。

継承では、親クラスの性質を子クラスへ受け継がせることができました。
オーバーライドでは、親クラスの関数を子クラス側で自分用に作り直せました。
virtual修飾子を使うと、親クラス型のポインタからでも、実際の子クラスに合った関数を呼び出せるようになりました。
さらに、純粋仮想関数を使うと、親クラスを抽象クラスにして、子クラスに必ず実装させる関数を決めることができました。

今回の練習問題では、これらの内容をまとめて確認します。

ドラゴンボール風の修行世界で考えると、親クラスBaseWarriorは「戦士なら必ず持つべき共通ルール」を表します。
ただし、BaseWarriorそのものは具体的な戦士ではありません。
そのため、BaseWarriorは抽象クラスとして作ります。

そして、具体的な戦士として、KameWarrior、KamiWarrior、KaiWarriorを作ります。

クラス親クラス役割
BaseWarriorなし戦士の共通ルールを表す抽象クラス
KameWarriorBaseWarrior亀仙流の戦士
KamiWarriorBaseWarrior神流の戦士
KaiWarriorBaseWarrior北の界王流の戦士

BaseWarriorには、純粋仮想関数shoutを用意します。

virtual void shout() = 0;

これは、「戦士なら必ず気を放つ動作を持ちなさい。ただし、具体的な内容は各流派の子クラスで決めなさい」という意味です。

また、BaseWarriorにはmove関数も用意します。
moveは通常のメンバ関数として作ります。

void move();

このmoveはvirtualを付けない関数として扱います。
そのため、BaseWarrior型のポインタからmoveを呼び出すと、子クラス側にmoveがあっても、BaseWarrior側のmoveが呼ばれます。

今回の練習問題では、この違いも大切な確認ポイントです。

関数種類BaseWarrior型ポインタから呼んだとき
shout純粋仮想関数実際の子クラスのshoutが呼ばれる
move通常のメンバ関数BaseWarriorのmoveが呼ばれる

この練習問題を通して、抽象クラスを使って新しい子クラスを追加する流れと、親クラス型ポインタから関数を呼び出したときの動作を確認していきましょう。

図:BaseWarriorに新しいKaiWarriorを追加する

この図が示していること

この図では、抽象クラスBaseWarriorを親クラスとして、KameWarrior、KamiWarrior、KaiWarriorという3つの子クラスを作る関係を表しています。

BaseWarriorは直接インスタンス化するクラスではなく、戦士に共通するルールを定めるクラスです。

KameWarrior、KamiWarrior、KaiWarriorは、そのルールを受け継ぎ、それぞれの流派に合わせてshoutを実装します。

問題5-1:北の界王流戦士クラスを追加する

問題文

次のようなクラス構成のプログラムがあります。

BaseWarriorクラスは、戦士の共通ルールを表す抽象クラスです。
KameWarriorクラスとKamiWarriorクラスは、BaseWarriorを継承した具体的な戦士クラスです。

ここに、新しくKaiWarriorクラスを追加しなさい。

KaiWarriorは、BaseWarriorを継承した北の界王流の戦士クラスとします。

KaiWarriorクラスでは、次の関数を定義します。

関数名処理内容
shout「界王流戦士が重力の気を高めます」と表示する
move「界王流戦士が重力空間を軽やかに移動します」と表示する

さらに、main.cppでKaiWarriorのインスタンスをnew演算子で生成し、BaseWarrior型のポインタ変数pWarrior3に代入しなさい。

その後、KameWarrior、KamiWarriorと同じようにmove関数とshout関数を呼び出し、最後にdelete演算子でインスタンスを消去しなさい。

期待される実行結果

基本戦士として修行場を移動します
基本戦士として修行場を移動します
基本戦士として修行場を移動します
亀仙流戦士が青白い気を放ちます
神流戦士が癒しの光を広げます
界王流戦士が重力の気を高めます

ここで注目したいのは、moveの実行結果です。

KaiWarriorクラスにもmoveを定義しますが、BaseWarriorのmoveにはvirtualを付けません。
そのため、BaseWarrior型のポインタからmoveを呼び出すと、BaseWarriorのmoveが実行されます。

一方、shoutは純粋仮想関数として宣言されているため、各子クラスのshoutが実行されます。

プログラムを作成するヒント

この練習問題では、次のファイルを作成します。

ファイル内容
base_warrior.hBaseWarriorクラスの宣言
base_warrior.cppBaseWarriorクラスの共通処理
kame_warrior.hKameWarriorクラスの宣言
kame_warrior.cppKameWarriorクラスの処理
kami_warrior.hKamiWarriorクラスの宣言
kami_warrior.cppKamiWarriorクラスの処理
kai_warrior.hKaiWarriorクラスの宣言
kai_warrior.cppKaiWarriorクラスの処理
main.cpp3種類の戦士を生成して動作確認

BaseWarriorでは、shoutを純粋仮想関数として宣言します。

virtual void shout() = 0;

これにより、BaseWarriorは抽象クラスになります。
そのため、次のようにBaseWarriorを直接newすることはできません。

// BaseWarrior* p = new BaseWarrior();

KameWarrior、KamiWarrior、KaiWarriorは、BaseWarriorを継承します。

class KaiWarrior : public BaseWarrior

そして、KaiWarriorではshoutを必ず実装します。

void shout();

KaiWarriorではmoveも定義しますが、BaseWarrior側のmoveがvirtualではないため、BaseWarrior型のポインタから呼び出した場合はBaseWarriorのmoveが呼ばれます。

呼び出し実際に呼ばれる関数
pWarrior3->move();BaseWarrior::move()
pWarrior3->shout();KaiWarrior::shout()

この違いを確認することが、この練習問題の大切なポイントです。

図:moveは共通処理、shoutは流派ごとの処理

この図が示していること

この図では、BaseWarrior型のポインタからmoveとshoutを呼び出したときの違いを表しています。

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

一方、shoutは純粋仮想関数を子クラスで実装したものなので、実際のインスタンスに応じてKameWarrior、KamiWarrior、KaiWarriorのshoutが呼び出されます。

プログラムの解答例

BaseWarriorクラスの宣言

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

#ifndef _BASE_WARRIOR_H_
#define _BASE_WARRIOR_H_

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

    // 気を放つ関数(純粋仮想関数)
    virtual void shout() = 0;

    // 基本の移動関数
    void move();
};

#endif // _BASE_WARRIOR_H_

BaseWarriorクラスの定義

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

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

using namespace std;

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

// 基本の移動関数
void BaseWarrior::move() {
    cout << "基本戦士として修行場を移動します" << endl;
}

KameWarriorクラスの宣言

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

#ifndef _KAME_WARRIOR_H_
#define _KAME_WARRIOR_H_

#include "base_warrior.h"

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

    // 気を放つ関数
    void shout();

    // 移動する関数
    void move();
};

#endif // _KAME_WARRIOR_H_

KameWarriorクラスの定義

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

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

using namespace std;

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

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

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

KamiWarriorクラスの宣言

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

#ifndef _KAMI_WARRIOR_H_
#define _KAMI_WARRIOR_H_

#include "base_warrior.h"

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

    // 気を放つ関数
    void shout();

    // 移動する関数
    void move();
};

#endif // _KAMI_WARRIOR_H_

KamiWarriorクラスの定義

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

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

using namespace std;

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

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

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

KaiWarriorクラスの宣言

プロジェクト名/ファイル名: Prac5_1/kai_warrior.h

#ifndef _KAI_WARRIOR_H_
#define _KAI_WARRIOR_H_

#include "base_warrior.h"

// 北の界王流戦士クラス
class KaiWarrior : public BaseWarrior {
public:
    // デストラクタ
    virtual ~KaiWarrior();

    // 気を放つ関数
    void shout();

    // 移動する関数
    void move();
};

#endif // _KAI_WARRIOR_H_

KaiWarriorクラスの定義

プロジェクト名/ファイル名: Prac5_1/kai_warrior.cpp

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

using namespace std;

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

// 気を放つ関数
void KaiWarrior::shout() {
    cout << "界王流戦士が重力の気を高めます" << endl;
}

// 移動する関数
void KaiWarrior::move() {
    cout << "界王流戦士が重力空間を軽やかに移動します" << endl;}

main.cpp

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

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

using namespace std;

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

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

    // 通常の関数を呼び出す
    // BaseWarriorのmoveはvirtualではないため、BaseWarrior::moveが呼ばれる
    pWarrior1->move();
    pWarrior2->move();
    pWarrior3->move();

    // 純粋仮想関数を子クラスで実装した関数を呼び出す
    // 実際のインスタンスに合わせて各子クラスのshoutが呼ばれる
    pWarrior1->shout();
    pWarrior2->shout();
    pWarrior3->shout();

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

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

実行結果

基本戦士として修行場を移動します
基本戦士として修行場を移動します
基本戦士として修行場を移動します
亀仙流戦士が青白い気を放ちます
神流戦士が癒しの光を広げます
界王流戦士が重力の気を高めます

解説

この練習問題では、抽象クラスBaseWarriorに対して、新しい子クラスKaiWarriorを追加しました。

BaseWarriorには、純粋仮想関数shoutがあります。

virtual void shout() = 0;

この関数は、BaseWarrior側では中身を作りません。
その代わり、BaseWarriorを継承した子クラスで必ず実装します。

KameWarriorでは、次のように実装しています。

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

KamiWarriorでは、次のように実装しています。

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

今回追加したKaiWarriorでは、次のように実装しています。

void KaiWarrior::shout() {
    cout << "界王流戦士が重力の気を高めます" << endl;
}

つまり、BaseWarriorは「戦士ならshoutを持つ」というルールを作り、各子クラスが自分の流派に合わせて中身を作っています。

ドラゴンボール風に言えば、BaseWarriorは「戦士は必ず気を放つ技を持つ」という修行場の共通ルールです。
ただし、どんな気を放つかは流派によって違います。

亀仙流は青白い気を放ちます。
神流は癒しの光を広げます。
北の界王流は重力の気を高めます。

このように、同じshoutという関数名でも、子クラスごとに異なる動作を実現できます。

BaseWarriorは抽象クラス

BaseWarriorは、純粋仮想関数を持っています。

virtual void shout() = 0;

純粋仮想関数を1つでも持つクラスは、抽象クラスになります。

抽象クラスは直接インスタンス化できません。

そのため、次のようなコードは書けません。

// BaseWarrior* pBase = new BaseWarrior();

BaseWarriorは、具体的な戦士を作るためのクラスではなく、戦士たちに共通する型やルールを作るためのクラスです。

クラス抽象クラスかインスタンス化
BaseWarrior抽象クラスできない
KameWarrior具体クラスできる
KamiWarrior具体クラスできる
KaiWarrior具体クラスできる

KaiWarriorは、BaseWarriorの純粋仮想関数shoutを実装しているため、具体クラスとしてインスタンス化できます。

BaseWarrior型のポインタで子クラスを扱う

main.cppでは、次のようにBaseWarrior型のポインタを3つ用意しています。

BaseWarrior* pWarrior1;
BaseWarrior* pWarrior2;
BaseWarrior* pWarrior3;

そして、それぞれに子クラスのインスタンスを代入しています。

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

ポインタの型はすべてBaseWarrior*です。

しかし、実際に生成されているインスタンスは、それぞれ違います。

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

このように、親クラス型のポインタで複数の子クラスをまとめて扱えるのが、継承とポリモーフィズムの大きな利点です。

move関数の結果が同じになる理由

main.cppでは、まずmoveを呼び出しています。

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

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

基本戦士として修行場を移動します
基本戦士として修行場を移動します
基本戦士として修行場を移動します

KameWarrior、KamiWarrior、KaiWarriorには、それぞれmove関数を定義しています。

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

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

void KaiWarrior::move() {
    cout << "界王流戦士が重力空間を軽やかに移動します" << endl;}

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

void move();

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

void BaseWarrior::move() {
    cout << "基本戦士として修行場を移動します" << endl;
}

これが、moveの実行結果が3つとも同じになる理由です。

shout関数の結果が変わる理由

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

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

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

亀仙流戦士が青白い気を放ちます
神流戦士が癒しの光を広げます
界王流戦士が重力の気を高めます

shoutはBaseWarriorで純粋仮想関数として宣言されています。

virtual void shout() = 0;

そのため、BaseWarrior型のポインタから呼び出しても、実際のインスタンスに応じた子クラス側のshoutが呼び出されます。

呼び出し実際のインスタンス呼ばれる関数
pWarrior1->shout();KameWarriorKameWarrior::shout
pWarrior2->shout();KamiWarriorKamiWarrior::shout
pWarrior3->shout();KaiWarriorKaiWarrior::shout

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

同じBaseWarrior*型のポインタから同じshoutを呼び出しているのに、実行結果が子クラスごとに変わります。

ドラゴンボール風に言えば、司令室からは全員に「気を放て」と同じ命令を出しています。
でも、実際に修行場にいる戦士の流派によって、放つ気の種類が変わるわけです。

図:BaseWarrior型でまとめて扱い、shoutは実体ごとに変わる

この図が示していること

この図では、BaseWarrior型のポインタで3種類の子クラスをまとめて扱っている様子を表しています。

ポインタの型は同じBaseWarrior*ですが、実際のインスタンスはKameWarrior、KamiWarrior、KaiWarriorです。

shoutは仮想関数として扱われるため、実際のインスタンスに合わせて、それぞれの子クラスのshoutが実行されます。

KaiWarriorを追加するときのポイント

今回の練習問題で追加したKaiWarriorは、BaseWarriorを継承しています。

class KaiWarrior : public BaseWarrior

BaseWarriorには純粋仮想関数shoutがあります。

そのため、KaiWarriorではshoutを必ず実装しなければなりません。

void shout();

実装は、kai_warrior.cppに書きます。

void KaiWarrior::shout() {
    cout << "界王流戦士が重力の気を高めます" << endl;
}

これで、KaiWarriorはBaseWarriorのルールを満たした具体クラスになります。

もしKaiWarriorでshoutを実装しなかった場合、KaiWarriorも抽象クラスのままになります。
その場合、次のようにnewできません。

// KaiWarrior* pKai = new KaiWarrior();

純粋仮想関数を持つ親クラスを継承したら、子クラス側でその関数を実装することが大切です。

delete時にvirtualデストラクタが必要な理由

BaseWarriorのデストラクタは、virtualにしています。

virtual ~BaseWarrior();

これは、BaseWarrior型のポインタで子クラスのインスタンスをdeleteするためです。

delete pWarrior1;
delete pWarrior2;
delete pWarrior3;

pWarrior1、pWarrior2、pWarrior3はBaseWarrior*型ですが、実際には子クラスのインスタンスを指しています。

このような場合、親クラスのデストラクタにvirtualを付けておくと、子クラスのデストラクタが正しく呼ばれ、そのあと親クラスのデストラクタが呼ばれます。

deleteするポインタ実際のインスタンスvirtualデストラクタの意味
pWarrior1KameWarrior亀仙流戦士側の片付けも行える
pWarrior2KamiWarrior神流戦士側の片付けも行える
pWarrior3KaiWarrior界王流戦士側の片付けも行える

継承を使い、親クラス型ポインタで子クラスを扱う場合は、親クラスのデストラクタをvirtualにするのが基本です。

この練習問題で確認したいポイント

この問題では、5章で学んだ大切な内容をまとめて確認しています。

確認ポイント内容
継承KaiWarriorがBaseWarriorを継承する
抽象クラスBaseWarriorは純粋仮想関数を持つため直接newできない
純粋仮想関数shoutは子クラスで必ず実装する
ポリモーフィズムBaseWarrior*からshoutを呼ぶと子クラス側が動く
通常関数moveはvirtualなしなのでBaseWarrior側が呼ばれる
仮想デストラクタBaseWarrior*でdeleteしても安全に片付けるために必要

今回の練習問題では、KaiWarriorという新しい流派の戦士を追加しました。

BaseWarriorという抽象クラスを親にしておくことで、新しい子クラスを追加するときのルールがはっきりします。

「戦士ならshoutを実装する」
「共通の移動処理はBaseWarriorに置く」
「親クラス型ポインタでまとめて扱う」

このように設計しておくと、新しい流派の戦士を増やしても、main.cpp側では同じように扱えます。

ドラゴンボール風に言えば、修行場の司令室は全員をBaseWarriorとして管理できます。
でも、実際に気を放つときは、亀仙流、神流、北の界王流、それぞれの個性が出ます。

これが、C++の抽象クラス・純粋仮想関数・ポリモーフィズムの力です。