C++入門|多重継承とポリモーフィズム

複数の流派を受け継ぐ多重継承、同じ技名を使い分けるポリモーフィズム。C++では、クラスの力を組み合わせ、同じ呼び名にさまざまな動きを持たせることができます。

ここまで、C++の継承では、1つの親クラスをもとに子クラスを作る形を学んできました。

たとえば、基本戦士を表すBaseWarriorクラスがあり、その機能を受け継いでHealingWarriorクラスを作る、という流れです。

このように、親クラスが1つだけの継承を単一継承といいます。

しかし、C++では、1つのクラスが複数の親クラスを受け継ぐこともできます。
この仕組みを多重継承といいます。

ドラゴンボール風にたとえるなら、1人の戦士が亀仙流の修行法と神流の治癒術を同時に受け継ぐようなイメージです。
亀仙流からは気弾の修行を受け継ぎ、神流からは仲間を癒やす技を受け継ぐ。
その両方を持つ戦士を作れるのが、多重継承の考え方です。

ただし、多重継承は便利な反面、使いすぎるとクラス同士の関係が複雑になりやすいです。
そのため、基本的には親クラスを1つにする単一継承を中心に考え、多重継承は必要な場面だけ慎重に使うのが大切です。

また、ここではもう1つ大切な考え方として、ポリモーフィズムも学びます。

ポリモーフィズムは、同じ名前の関数を状況に応じて使い分ける仕組みです。
C++では、その表現方法として、オーバーロードオーバーライドがあります。

今回の記事では、まず多重継承の基本イメージを確認し、そのあとポリモーフィズムの代表例として、オーバーロードを使ったサンプルプログラムを見ていきます。

学習する内容概要ドラゴンボール風のイメージ
単一継承親クラスが1つ1つの流派を受け継ぐ
多重継承親クラスが複数複数の流派を受け継ぐ
ポリモーフィズム同じ名前の関数を使い分ける同じ技名でも状況で効果が変わる
オーバーロード引数の型や数で同名関数を使い分ける技名は同じでも、入力する気の量で処理が変わる
オーバーライド子クラスで親クラスの関数を上書きする親の技を子クラス流に発展させる

図:多重継承は複数の流派を受け継ぐイメージ

この図が示していること

この図では、1つの子クラスが複数の親クラスを受け継ぐ多重継承のイメージを表しています。

KameTrainingクラスからは気を高める修行や気弾の技を受け継ぎます。
KamiHealingクラスからは回復や防御の技を受け継ぎます。

HybridFighterクラスは、その両方を受け継ぐため、気弾と回復の両方を扱える戦士になります。

多重継承とは

多重継承とは、1つの子クラスが複数の親クラスを受け継ぐ仕組みです。

単一継承では、親クラスは1つです。

class 子クラス名 : public 親クラス名

一方、多重継承では、親クラスをコンマで区切って複数指定します。

class 子クラス名 : public 親クラスA, public 親クラスB

ドラゴンボール風にすると、次のようなイメージです。

class HybridFighter : public KameTraining, public KamiHealing

これは、HybridFighterクラスがKameTrainingクラスとKamiHealingクラスの両方を継承するという意味です。

継承の種類親クラスの数
単一継承1つclass KameFighter : public BaseFighter
多重継承複数class HybridFighter : public KameTraining, public KamiHealing

C++では、このように多重継承を書くことができます。

ただし、多重継承を使うと、どの親クラスの機能なのか分かりにくくなったり、同じ名前のメンバが複数の親クラスにあるとあいまいになったりすることがあります。

そのため、便利だからといって気軽に使いすぎるのはおすすめできません。

多重継承の書き方

多重継承の基本的な書き方は、次のとおりです。

class 子クラス名 : public 親クラスA, public 親クラスB {
    // 子クラスの内容
};

親クラスを複数書く場合は、コンマで区切ります。

class HybridFighter : public KameTraining, public KamiHealing {
public:
    void specialMove();
};

このように書くと、HybridFighterはKameTrainingとKamiHealingの両方のpublicメンバを利用できるようになります。

たとえば、次のような親クラスがあるとします。

class KameTraining {
public:
    void chargeKi();
};

class KamiHealing {
public:
    void healAlly();
};

HybridFighterがこの2つを継承すると、次のように両方の関数を使えるようになります。

HybridFighter fighter;
fighter.chargeKi();
fighter.healAlly();

これは、1人の戦士が亀仙流の気の修行と、神流の回復術の両方を扱えるようなイメージです。

多重継承を使うときの注意

多重継承は強力ですが、使い方には注意が必要です。

親クラスが複数あると、クラス同士の関係が複雑になりやすくなります。

注意点内容
関係が複雑になるどの親クラスから何を受け継いだか追いにくい
同名メンバであいまいになる親クラス同士に同じ名前の関数があると混乱しやすい
設計が読みにくくなる子クラスの責任が大きくなりすぎる
保守が難しくなる変更時の影響範囲が広がりやすい

ドラゴンボール風に言えば、たくさんの流派を同時に受け継ぎすぎると、どの技がどの流派由来なのか分かりにくくなります。
結果として、戦士の設計が複雑になりすぎるわけです。

そのため、クラスを継承するときは、基本的には親クラスを1つにする単一継承を中心に考えると安全です。

多重継承は、どうしても複数の性質を受け継がせたいときだけ、慎重に使うようにします。

ポリモーフィズムとは

ポリモーフィズムとは、同じ名前の関数を、状況に応じて異なる動きとして使える仕組みです。

日本語では、多相性や多態性と呼ばれます。

C++では、ポリモーフィズムを表す方法として、主に次の2つがあります。

方法内容
オーバーロード同じクラス内で、引数の型や数が違う同名関数を複数定義する
オーバーライド親クラスの関数を子クラスで再定義する

今回のサンプルでは、まずオーバーロードを扱います。

オーバーロードは、同じ名前の関数を複数用意し、引数の違いによって呼び出される関数を自動的に切り替える仕組みです。

たとえば、気力を合成するcombineKiという関数を考えます。

combineKi();
combineKi(300, 400);

どちらも関数名はcombineKiです。

しかし、引数なしで呼び出した場合と、int型の引数を2つ渡した場合では、別の関数として扱われます。

ドラゴンボール風に言えば、同じ「気を合わせる」という技名でも、内部に保存された気を使う場合と、その場で渡された2つの気を合成する場合で、動きが変わるイメージです。

ポリモーフィズムを使うメリット

ポリモーフィズムを使うと、似た処理に同じ名前を付けられます。

たとえば、combineKiという名前を使えば、「気を合成する処理」だとすぐに分かります。

もし引数の違いごとに名前を変えると、次のように名前が増えてしまいます。

combineStoredKi();
combineGivenKi();
combineTwoKiValues();

このように関数名が増えると、覚える名前も増え、記述ミスも起きやすくなります。

オーバーロードを使えば、同じ役割の処理を同じ名前にまとめられます。

書き方メリット
combineKi()メンバ変数の値を使って気を合成する
combineKi(int a, int b)引数で受け取った値を使って気を合成する
同じ名前にする処理の意味に統一感が出る

このように、同じ意味を持つ処理を同じ名前にまとめることで、プログラムが読みやすくなります。

図:同じ関数名でも引数で呼び出される処理が変わる

この図が示していること

この図では、同じcombineKiという関数名でも、引数の違いによって呼び出される関数が変わることを表しています。

combineKi()は、メンバ変数に保存されている値を使います。
combineKi(300, 400)は、引数として渡された値を使います。

同じ名前でも、引数の型や数が違えば、C++はどの関数を呼び出すべきかを判断できます。

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

今回のサンプルでは、KiFusionCalcクラスを作ります。

KiFusionCalcは、2つの気の値を合成して、合計値を求めるクラスです。

このクラスには、次のようにコンストラクタとcombineKi関数を複数用意します。

メンバ種類内容
KiFusionCalc()デフォルトコンストラクタm_aとm_bを0で初期化する
KiFusionCalc(int a, int b)引数付きコンストラクタm_aとm_bに値を設定する
combineKi()オーバーロードされた関数m_aとm_bを合成する
combineKi(int a, int b)オーバーロードされた関数引数aとbを合成する
setValueメンバ関数m_aとm_bを設定する
getAメンバ関数m_aを取得する
getBメンバ関数m_bを取得する

このサンプルでは、同じcombineKiという名前でも、引数がある場合とない場合で別の処理が呼び出されることを確認します。

KiFusionCalcクラスの宣言

プロジェクト/ファイル名: Chap5_03/ki_fusion_calc.h

#ifndef _KI_FUSION_CALC_H_
#define _KI_FUSION_CALC_H_

class KiFusionCalc {
private:
    // 1つ目の気の値
    int m_a;

    // 2つ目の気の値
    int m_b;

public:
    // デフォルトコンストラクタ
    KiFusionCalc();

    // 引数付きコンストラクタ
    KiFusionCalc(int a, int b);

    // 気の合成処理その1
    int combineKi();

    // 気の合成処理その2
    int combineKi(int a, int b);

    // 値を設定する
    void setValue(int a, int b);

    // 1つ目の気の値を取得する
    int getA();

    // 2つ目の気の値を取得する
    int getB();
};

#endif // _KI_FUSION_CALC_H_

このヘッダーファイルでは、KiFusionCalcクラスを宣言しています。

注目したいのは、コンストラクタが2つあることです。

KiFusionCalc();
KiFusionCalc(int a, int b);

1つ目は、引数を受け取らないデフォルトコンストラクタです。
2つ目は、int型の引数を2つ受け取るコンストラクタです。

同じKiFusionCalcという名前ですが、引数が違うため、オーバーロードできます。

また、combineKi関数も2つあります。

int combineKi();
int combineKi(int a, int b);

これも、引数なしと、int型の引数2つという違いがあるため、オーバーロードできます。

KiFusionCalcクラスの定義

プロジェクト/ファイル名: Chap5_03/ki_fusion_calc.cpp

#include "ki_fusion_calc.h"

// デフォルトコンストラクタ
KiFusionCalc::KiFusionCalc() : m_a(0), m_b(0) {
}

// 引数付きコンストラクタ
KiFusionCalc::KiFusionCalc(int a, int b) : m_a(a), m_b(b) {
}

// 気の合成処理その1
int KiFusionCalc::combineKi() {
    return m_a + m_b;
}

// 気の合成処理その2
int KiFusionCalc::combineKi(int a, int b) {
    return a + b;
}

// 値を設定する
void KiFusionCalc::setValue(int a, int b) {
    m_a = a;
    m_b = b;
}

// 1つ目の気の値を取得する
int KiFusionCalc::getA() {
    return m_a;
}

// 2つ目の気の値を取得する
int KiFusionCalc::getB() {
    return m_b;
}

このcppファイルでは、KiFusionCalcクラスの処理を書いています。

デフォルトコンストラクタでは、m_aとm_bを0で初期化しています。

KiFusionCalc::KiFusionCalc() : m_a(0), m_b(0) {
}

引数付きコンストラクタでは、受け取ったaとbをm_aとm_bに設定しています。

KiFusionCalc::KiFusionCalc(int a, int b) : m_a(a), m_b(b) {
}

combineKi()は、メンバ変数m_aとm_bを使って合計を返します。

int KiFusionCalc::combineKi() {
    return m_a + m_b;
}

combineKi(int a, int b)は、引数で受け取ったaとbを使って合計を返します。

int KiFusionCalc::combineKi(int a, int b) {
    return a + b;
}

同じ名前のcombineKiですが、引数の有無で処理が分かれています。
これがオーバーロードです。

main.cppでオーバーロードを確認する

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

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

using namespace std;

int main(int argc, char** argv) {
    // 気力合成計算クラスのポインタを用意する
    KiFusionCalc* pCalc1;
    KiFusionCalc* pCalc2;

    // デフォルトコンストラクタを呼び出してインスタンスを生成する
    pCalc1 = new KiFusionCalc();

    // 引数付きコンストラクタを呼び出してインスタンスを生成する
    pCalc2 = new KiFusionCalc(100, 200);

    // 引数で渡した2つの気を合成する
    cout << 300 << " + " << 400 << " = " << pCalc1->combineKi(300, 400) << endl;

    // pCalc2のメンバ変数に保存されている気を合成する
    cout << pCalc2->getA() << " + "
         << pCalc2->getB() << " = " << pCalc2->combineKi() << endl;

    // インスタンスを消去する
    delete pCalc1;
    delete pCalc2;

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

実行結果

300 + 400 = 700
100 + 200 = 300

このプログラムでは、同じcombineKiという名前の関数を2種類呼び出しています。

1つ目は、引数を2つ渡して呼び出しています。

pCalc1->combineKi(300, 400)

この場合、呼び出されるのは次の関数です。

int combineKi(int a, int b);

そのため、300 + 400 が計算され、700が返されます。

2つ目は、引数なしで呼び出しています。

pCalc2->combineKi()

この場合、呼び出されるのは次の関数です。

int combineKi();

この関数は、メンバ変数m_aとm_bを使って計算します。

pCalc2は、次のように生成されています。

pCalc2 = new KiFusionCalc(100, 200);

そのため、m_aには100、m_bには200が入っています。
combineKi()を呼び出すと、100 + 200 が計算され、300が返されます。

オーバーロードの使い分け

オーバーロードされた関数は、引数の型や数によって使い分けられます。

今回のcombineKiでは、次のように判断されます。

呼び出し方呼び出される関数計算内容
combineKi(300, 400)int combineKi(int a, int b)引数300と400を合成
combineKi()int combineKi()メンバ変数m_aとm_bを合成

C++は、関数を呼び出すときに渡された引数を見て、どの関数を使うか判断します。

そのため、同じ名前でも、引数が違えば別の関数として定義できます。

ただし、戻り値の型だけが違う関数はオーバーロードできません。

たとえば、次のような書き方はできません。

int combineKi();
double combineKi();

引数が同じで戻り値だけが違うため、呼び出す側から見ると区別できないからです。

combineKi();

この呼び出しだけでは、int版を呼びたいのか、double版を呼びたいのか判断できません。

コンストラクタのオーバーロード

オーバーロードは、通常のメンバ関数だけでなく、コンストラクタにも使えます。

今回のKiFusionCalcには、2つのコンストラクタがあります。

KiFusionCalc();
KiFusionCalc(int a, int b);

引数なしでnewすると、デフォルトコンストラクタが呼び出されます。

pCalc1 = new KiFusionCalc();

この場合、m_aとm_bは0で初期化されます。

KiFusionCalc::KiFusionCalc() : m_a(0), m_b(0) {
}

一方、int型の値を2つ渡してnewすると、引数付きコンストラクタが呼び出されます。

pCalc2 = new KiFusionCalc(100, 200);

この場合、m_aに100、m_bに200が入ります。

KiFusionCalc::KiFusionCalc(int a, int b) : m_a(a), m_b(b) {
}

同じKiFusionCalcというコンストラクタ名でも、引数の有無によって呼び出される処理が変わります。

図:コンストラクタと関数のオーバーロード

この図が示していること

この図では、コンストラクタとcombineKi関数のオーバーロードを表しています。

new KiFusionCalc()では、引数なしのデフォルトコンストラクタが呼び出されます。
new KiFusionCalc(100, 200)では、引数付きコンストラクタが呼び出されます。

また、combineKi(300, 400)では引数付きのcombineKiが呼び出され、combineKi()では引数なしのcombineKiが呼び出されます。

どちらも、同じ名前を使いながら、引数の違いで処理を切り替えています。

オーバーロードとオーバーライドの違い

ポリモーフィズムを学ぶときに、オーバーロードとオーバーライドの違いも意識しておきましょう。

種類意味主な違い
オーバーロード同じ名前の関数を、引数の型や数を変えて複数定義する同じクラス内でも使える
オーバーライド親クラスの関数を子クラスで再定義する継承が関係する

今回のサンプルで扱ったのは、オーバーロードです。

combineKi()とcombineKi(int a, int b)のように、同じ名前で引数の違う関数を用意しました。

オーバーライドは、親クラスにある関数を、子クラスで別の動きに作り替えるときに使います。
これは、virtual関数や仮想関数と一緒に学ぶと理解しやすくなります。

多重継承とポリモーフィズムで押さえたいポイント

多重継承とポリモーフィズムでは、次のポイントを押さえておきましょう。

ポイント内容
単一継承親クラスが1つの継承
多重継承親クラスが複数ある継承
多重継承の書式class Sub : public SupA, public SupB
多重継承の注意使いすぎるとクラス関係が複雑になる
ポリモーフィズム同じ名前の関数を状況に応じて使い分ける考え方
オーバーロード引数の型や数で同名関数を使い分ける
コンストラクタのオーバーロード引数なし、引数ありで生成方法を変えられる
戻り値だけの違いオーバーロードにはできない

C++では、多重継承によって複数の親クラスの性質を1つの子クラスへ受け継がせることができます。

ただし、多重継承は便利な反面、設計が複雑になりやすいため、慎重に使うことが大切です。

一方、ポリモーフィズムを使うと、同じ名前の関数に複数の意味を持たせられます。

今回のKiFusionCalcでは、combineKiという同じ名前の関数を、引数なしと引数ありで使い分けました。

ドラゴンボール風に言えば、多重継承は複数の流派を受け継ぐこと、ポリモーフィズムは同じ技名でも状況によって発動内容が変わることです。

複数の力をどう受け継ぐか。
同じ名前の処理をどう使い分けるか。
この2つを理解すると、C++のオブジェクト指向がさらに柔軟に見えてきます。