C++入門|抽象クラスと純粋仮想関数

抽象クラスは、まだ実体を持たない戦士の設計図。純粋仮想関数を使えば、子クラスに必ず技の実装を任せることができます。

C++の継承と仮想関数を学ぶと、親クラス型のポインタで子クラスのインスタンスを扱えることが分かってきます。

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

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

このようにすると、親クラス型で複数の子クラスをまとめて扱えるため、とても便利です。

ただし、ここで1つ考えたいことがあります。

BaseWarriorという「基本戦士」は、実際に存在する戦士なのでしょうか。

ドラゴンボール風の世界で考えると、亀仙流戦士や神流戦士のような具体的な戦士は存在します。
しかし、「基本戦士」という名前の戦士そのものは、あくまでも共通の性質をまとめた概念に近い存在です。

つまり、BaseWarriorは実体として戦うキャラクターというより、子クラスを作るための設計図です。

このように、直接インスタンスを生成するためではなく、子クラスに共通の型やルールを与えるためのクラスを抽象クラスといいます。

そして、抽象クラスを作るために使う大切な仕組みが、純粋仮想関数です。

純粋仮想関数は、親クラスの中で「この関数は必要だけれど、具体的な中身は子クラスで必ず作ってください」と宣言するための関数です。

virtual void shout() = 0;

このように、仮想関数の後ろに = 0 を付けると、純粋仮想関数になります。

用語意味ドラゴンボール風のイメージ
抽象クラス直接インスタンス化できないクラス実体ではなく、戦士の設計図
純粋仮想関数実装を子クラスに任せる仮想関数流派ごとに必ず完成させる技
= 0純粋仮想関数を表す記述この場では技の中身を決めない合図
子クラス抽象クラスを継承して実体を作るクラス亀仙流戦士、神流戦士など

今回の記事では、BaseWarriorを抽象クラスとして作り、KameWarriorとKamiWarriorで具体的な技を実装します。

BaseWarriorには、純粋仮想関数としてshoutを用意します。
KameWarriorとKamiWarriorは、それぞれ自分の流派らしいshoutを実装します。

図:抽象クラスは実体ではなく設計図

この図が示していること

この図では、BaseWarriorが直接インスタンス化するためのクラスではなく、子クラスに共通ルールを与える設計図であることを表しています。

BaseWarriorには、純粋仮想関数shoutがあります。
そのため、BaseWarrior自体はインスタンス化できません。

KameWarriorやKamiWarriorはBaseWarriorを継承し、shoutを自分の流派に合わせて実装します。

抽象クラスとは何か

抽象クラスとは、直接インスタンスを生成できないクラスです。

普通のクラスであれば、次のようにインスタンスを生成できます。

KameWarrior* pKame = new KameWarrior();

しかし、抽象クラスは次のように直接生成できません。

BaseWarrior* pBase = new BaseWarrior();

BaseWarriorが抽象クラスの場合、このコードはコンパイルエラーになります。

なぜなら、BaseWarriorは「具体的な戦士」ではなく、「戦士なら必ず持つべきルール」を定義したものだからです。

ドラゴンボール風に言えば、「亀仙流戦士」や「神流戦士」は実在する戦士です。
しかし、「基本戦士」という名前の戦士がそのまま修行場に現れるわけではありません。

BaseWarriorは、具体的な戦士を作るための土台です。

純粋仮想関数とは何か

純粋仮想関数は、実装を持たない仮想関数です。

通常の仮想関数は、親クラス側にも処理の中身を書くことができます。

virtual void shout();

しかし、純粋仮想関数は、次のように = 0 を付けて宣言します。

virtual void shout() = 0;

この書き方にすると、BaseWarriorの中ではshoutの中身を書きません。

つまり、BaseWarriorは「shoutという関数は必ず必要です」とだけ決めます。
具体的に何を表示するか、どんな技を出すかは、子クラスに任せます。

関数の種類書き方親クラス側の実装
通常の仮想関数virtual void shout();書ける
純粋仮想関数virtual void shout() = 0;基本的に子クラスへ任せる

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

今回のBaseWarriorは、shoutを純粋仮想関数にするため、抽象クラスになります。

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

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

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

BaseWarriorには、次の2つのメンバ関数を用意します。

関数種類内容
shout純粋仮想関数子クラスで必ず実装する
move通常のメンバ関数共通の移動処理

shoutは、戦士ごとに違う技を出すため、純粋仮想関数にします。

moveは、基本的な移動処理としてBaseWarrior側に実装します。

BaseWarriorクラスの宣言

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

プロジェクト/ファイル名: Chap5_07/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_

このヘッダーファイルで最も大切なのは、次の部分です。

virtual void shout() = 0;

virtualが付いているので、shoutは仮想関数です。
さらに、後ろに = 0 が付いているので、純粋仮想関数です。

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

つまり、BaseWarriorは次のように直接生成できません。

// BaseWarrior* pBase = new BaseWarrior();

BaseWarriorは、「戦士ならshoutを持っているべき」というルールを作るためのクラスです。

実際のshoutの中身は、KameWarriorやKamiWarriorで実装します。

BaseWarriorクラスの定義

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

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

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

using namespace std;

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

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

BaseWarriorでは、moveだけを定義しています。

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

shoutは純粋仮想関数なので、ここでは定義していません。

virtual void shout() = 0;

このshoutは、子クラスで必ず実装する必要があります。

ドラゴンボール風に言えば、BaseWarriorは「戦士なら必ず気を放つ技を持ちなさい」と決めているだけです。
実際にどんな気を放つかは、流派ごとの戦士が決めます。

図:純粋仮想関数は子クラスに実装を任せる

この図が示していること

この図では、BaseWarriorのshoutが純粋仮想関数であり、実装を子クラスへ任せていることを表しています。

BaseWarriorでは、shoutの存在だけを宣言します。
KameWarriorやKamiWarriorは、そのshoutを自分の流派に合わせて実装します。

このように、純粋仮想関数は「子クラスで必ず作るべき関数」を示す役割を持ちます。

KameWarriorクラスの宣言

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

KameWarriorは、BaseWarriorの純粋仮想関数shoutを実装する必要があります。

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

#ifndef _KAME_WARRIOR_H_
#define _KAME_WARRIOR_H_

#include "base_warrior.h"

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

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

#endif // _KAME_WARRIOR_H_

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

class KameWarrior : public BaseWarrior

BaseWarriorには、純粋仮想関数shoutがあります。
そのため、KameWarriorはshoutを実装する必要があります。

void shout();

これを実装することで、KameWarriorは抽象クラスではなく、実際にインスタンス化できるクラスになります。

KameWarriorクラスの定義

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

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

using namespace std;

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

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

KameWarriorでは、shoutの中身を具体的に書いています。

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

これで、KameWarriorはBaseWarriorのルールを満たしたことになります。

BaseWarriorが「shoutを必ず持ちなさい」と決め、KameWarriorが「亀仙流らしいshoutを実装します」と答えている形です。

KamiWarriorクラスの宣言

次に、もう1つの子クラスとしてKamiWarriorを作ります。

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

#ifndef _KAMI_WARRIOR_H_
#define _KAMI_WARRIOR_H_

#include "base_warrior.h"

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

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

#endif // _KAMI_WARRIOR_H_

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

class KamiWarrior : public BaseWarrior

そのため、KamiWarriorもshoutを実装する必要があります。

void shout();

KamiWarriorクラスの定義

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

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

using namespace std;

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

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

KamiWarriorでは、shoutの中身を神流らしい内容にしています。

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

これで、KamiWarriorもBaseWarriorの純粋仮想関数を実装したことになります。

KameWarriorとKamiWarriorは、どちらもBaseWarriorを継承していますが、shoutの中身は違います。

このように、抽象クラスは共通のルールを与え、子クラスはそれぞれの個性に合わせて処理を実装します。

main.cppで動作を確認する

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

ここでは、BaseWarrior型のポインタでKameWarriorとKamiWarriorを扱います。

プロジェクト/ファイル名: Chap5_07/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;
    BaseWarrior* pWarrior3;

    // 抽象クラス型のポインタに、具体クラスのインスタンスを代入する
    pWarrior1 = new KameWarrior();
    pWarrior2 = new KamiWarrior();

    // 抽象クラスは直接インスタンス化できない
    // pWarrior3 = new BaseWarrior();

    // それぞれの戦士が気を放つ
    pWarrior1->shout();
    pWarrior2->shout();

    // 親クラスで実装済みの共通処理を呼び出す
    pWarrior1->move();
    pWarrior2->move();

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

    // pWarrior3は使っていないので、何もしない
    return 0;
}

実行結果

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

実行結果を見ると、pWarrior1とpWarrior2はどちらもBaseWarrior型のポインタですが、shoutの結果は違います。

pWarrior1はKameWarriorを指しているため、KameWarriorのshoutが呼び出されます。

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

pWarrior2はKamiWarriorを指しているため、KamiWarriorのshoutが呼び出されます。

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

一方、moveはBaseWarrior側で実装済みの共通処理です。

そのため、どちらの戦士から呼び出しても、次のように表示されます。

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

抽象クラスは直接インスタンス化できない

main.cppには、次のコメントがあります。

// pWarrior3 = new BaseWarrior();

このコメントを外して、次のように書くとコンパイルエラーになります。

pWarrior3 = new BaseWarrior();

BaseWarriorは、純粋仮想関数shoutを持つ抽象クラスです。

そのため、直接インスタンスを生成できません。

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

BaseWarriorは、あくまでも共通の型やルールを与えるクラスです。

実際にインスタンス化するのは、KameWarriorやKamiWarriorのように、純粋仮想関数を実装した子クラスです。

抽象クラスをインスタンス化できない理由

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

virtual void shout() = 0;

この関数には、BaseWarrior側で中身がありません。

もしBaseWarriorのインスタンスを直接作れてしまうと、次のような呼び出しができてしまいます。

BaseWarrior* pBase = new BaseWarrior();
pBase->shout();

しかし、BaseWarriorのshoutには中身がありません。
そのため、実行する処理が存在しないことになります。

このような不完全な状態のクラスを直接生成できないようにするため、C++では抽象クラスのインスタンス化を禁止しています。

ドラゴンボール風に言えば、BaseWarriorは「気を放つ技を必ず持て」という設計図です。
しかし、BaseWarrior自身には具体的な技の形がありません。
そのため、修行場に実体として出すことはできないわけです。

抽象クラスは型として使える

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

しかし、ポインタや参照の型として使うことはできます。

今回のmain.cppでも、次のようにBaseWarrior型のポインタを使っています。

BaseWarrior* pWarrior1;
BaseWarrior* pWarrior2;

そして、そこに具体クラスのインスタンスを代入しています。

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

これはとても大切です。

抽象クラスは、実体としては作れません。
しかし、複数の子クラスをまとめて扱うための共通の型として使えます。

使い方可能か理由
BaseWarriorを直接newする×抽象クラスだから
BaseWarrior*を宣言する型として使える
BaseWarrior*にKameWarriorを代入するKameWarriorはBaseWarriorの子クラス
BaseWarrior*にKamiWarriorを代入するKamiWarriorはBaseWarriorの子クラス

この仕組みによって、KameWarriorやKamiWarriorをBaseWarrior型としてまとめて扱えます。

図:抽象クラスは生成できないが型として使える

この図が示していること

この図では、抽象クラスBaseWarriorは直接newできない一方で、BaseWarrior*という型としては使えることを表しています。

BaseWarrior自体は抽象クラスなので、new BaseWarrior()はできません。

しかし、BaseWarrior*のポインタに、KameWarriorやKamiWarriorのインスタンスを代入することはできます。

これにより、複数の具体クラスを共通の型でまとめて扱えるようになります。

子クラスで実装しないとどうなるか

BaseWarriorを継承した子クラスは、純粋仮想関数shoutを実装する必要があります。

もし、子クラスでshoutを実装しなかった場合、その子クラスも抽象クラスのままになります。

たとえば、次のようなクラスを考えます。

class IncompleteWarrior : public BaseWarrior {
public:
    // shoutを実装していない
};

このIncompleteWarriorは、BaseWarriorの純粋仮想関数shoutを実装していません。

そのため、IncompleteWarriorも抽象クラスになります。
つまり、次のようにnewすることはできません。

// IncompleteWarrior* p = new IncompleteWarrior();

純粋仮想関数は、子クラスに「この関数を必ず作りなさい」と要求する仕組みです。

その要求を満たして初めて、実体としてインスタンス化できるクラスになります。

抽象クラスが存在する意味

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

では、なぜ抽象クラスが必要なのでしょうか。

大きな理由は、共通の型とルールを作れることです。

今回の例では、BaseWarriorは「戦士ならshoutできる」というルールを作っています。

KameWarriorもKamiWarriorも、BaseWarriorを継承するなら、必ずshoutを実装します。

その結果、main.cpp側では、どの戦士か細かく知らなくても、BaseWarrior型として扱い、shoutを呼び出せます。

BaseWarrior* warriors[2];

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

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

このように書けば、配列に入っている戦士がKameWarriorでもKamiWarriorでも、それぞれのshoutが呼び出されます。

抽象クラスは、子クラスをまとめて扱うための土台になります。

新しい子クラスを追加しやすい

抽象クラスがあると、新しい子クラスを追加しやすくなります。

たとえば、今後、次のような戦士を増やしたくなったとします。

新しい子クラス流派実装するshout
KaiWarrior北の界王流界王流の気を高める
PlanetWarrior惑星戦士流重力をまとった気を放つ
CraneWarrior鶴仙流鋭い気の波を放つ

これらのクラスもBaseWarriorを継承し、shoutを実装すれば、BaseWarrior型としてまとめて扱えます。

BaseWarrior* p = new KaiWarrior();
p->shout();

呼び出し側は、BaseWarrior型として扱うだけでよくなります。

具体的にどの流派の戦士なのかは、実際のインスタンスが決めます。

これが、抽象クラスと純粋仮想関数を使ったポリモーフィズムの強力なところです。

moveのような共通処理も持てる

抽象クラスには、純粋仮想関数だけでなく、実装済みの普通の関数も持たせることができます。

今回のBaseWarriorには、moveがあります。

void move();

これはbase_warrior.cppで実装しています。

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

つまり、BaseWarriorは抽象クラスですが、すべての関数が未実装というわけではありません。

関数実装場所役割
shout子クラスで実装流派ごとの個別技
moveBaseWarriorで実装共通の移動処理

このように、抽象クラスでは、共通処理は親クラスに書き、個別処理は純粋仮想関数として子クラスに任せることができます。

ドラゴンボール風に言えば、移動の基本動作は戦士共通です。
しかし、気を放つ技は流派によって違うので、子クラスで必ず実装させるわけです。

純粋仮想関数の書き方を整理する

純粋仮想関数の基本形は、次のとおりです。

virtual 戻り値の型 関数名(引数) = 0;

今回の例では、戻り値がvoid、引数なしのshoutを純粋仮想関数にしています。

virtual void shout() = 0;

この書き方には、次の意味があります。

部分意味
virtual仮想関数として扱う
void戻り値がない
shout()関数名と引数
= 0純粋仮想関数にする

= 0 は、「0を代入している」という意味ではありません。
この関数は純粋仮想関数であり、親クラス側では具体的な実装を持たない、という特別な書き方です。

抽象クラスと仮想関数の関係

仮想関数と純粋仮想関数は似ていますが、役割が少し違います。

種類書き方親クラス側の実装クラスは抽象クラスになるか
仮想関数virtual void shout();ありでもよいそれだけでは抽象クラスにならない
純粋仮想関数virtual void shout() = 0;子クラスに任せる抽象クラスになる

仮想関数は、親クラスにも標準の動作を用意できます。

一方、純粋仮想関数は、子クラスでの実装を前提にします。

今回のBaseWarriorでは、shoutの標準動作をあえて用意していません。
戦士の流派によって気の放ち方が違うため、子クラスに必ず実装させています。

抽象クラスで押さえたいポイント

抽象クラスと純粋仮想関数では、次のポイントを押さえておきましょう。

ポイント内容
純粋仮想関数virtual void 関数名() = 0; の形で宣言する
抽象クラス純粋仮想関数を1つでも持つクラス
直接newできない抽象クラスはインスタンス化できない
型として使える抽象クラス型のポインタや参照は使える
子クラスで実装する純粋仮想関数は子クラスが具体的に定義する
実装しない子クラスその子クラスも抽象クラスになる
共通処理も持てる抽象クラスでも普通のメンバ関数は実装できる

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

しかし、BaseWarrior*という型として、KameWarriorやKamiWarriorをまとめて扱えます。

KameWarriorは、亀仙流らしいshoutを実装します。
KamiWarriorは、神流らしいshoutを実装します。

抽象クラスは、「具体的な戦士」ではなく、「戦士なら守るべきルール」を表します。

ドラゴンボール風に言えば、BaseWarriorは流派を超えた戦士の設計図です。
その設計図には、shoutという必須技が書かれています。
でも、実際にどんな気を放つかは、亀仙流や神流のような具体的な子クラスが決めます。

この考え方を使うと、子クラスごとの個性を保ちながら、共通の型でまとめて扱えるようになります。
抽象クラスと純粋仮想関数は、C++のポリモーフィズムをより強く、より安全に使うための大切な仕組みです。