水瓶座列車

どこまで行けるか、とりあえず発車します。

C++初心者のための継承と仮想関数の基本を解説

 

C++のプログラミングを行う上で、継承は、基本なので必ず理解しておかなければなりません。

 

そして、継承に関連するキーワードとして、

・protected属性(継承先クラスのみが使用できるメンバ)

・仮想関数(関数名の前に”virtual”が付いている関数)

・オーバーライド(親クラスの仮想関数を再定義すること)

・純粋仮想関数(関数名の前に”virtual”が付いており、最後尾に"=0"が付いている関数)

についても最低限、知っておく必要があります。

 

ここでは、C++の継承の種類や宣言のやり方、仮想関数など、基本的な事項を解説していきます。

 

 

 

 

 

1.C++の継承とは

 

継承とは、クラスとクラスを親子関係にして、関連付けることを言います。

 

継承されたクラスは、基底クラスや親クラス、またはスーパークラスと呼ばれます。

そして、継承するクラスは、派生クラスや子クラス、またはサブクラスと呼びます。

(ここでは、イメージしやすいように親クラス、子クラスで統一して記述していきます。)

 

継承のクラス図としては、下図のようになります。

継承関係を表す線は、クラスBからクラスAに向けて、矢印が白三角形の線を書きます。

この場合、クラスAが親クラス、クラスBが子クラスになります。

 

 

2.protected属性について

 

protected属性とは、public属性やprivate属性と同じアクセス指定子の一つで、

親クラスで、protected属性で宣言しているメンバ変数やメンバ関数を、

子クラスもアクセスがすることができます。

protected属性の宣言のやり方は、下記のようにします。

class ClassA
{
  public:
void testfunc();

private:
int num;

protected: // 子クラスのみアクセスできる。
int count, sum;
};

 

子クラスは、親クラスのpublic属性のメンバにもアクセスできますが、

親クラスは、protected属性でメンバを定義しておき、

子クラスに提供しているということを明示的に宣言しておく方が良いです。

 

 

3.C++の継承の種類と宣言のやり方

 

継承のやり方には、いろいろなパターンがあります。

よく使用する継承のやり方について解説していきます。

 

 

3.1. 階層型の継承について

 

基本の継承は、下図のように、1つのクラスが1つのクラスを継承します。

上図は、簡単なクラス図で、継承を示す白抜きの三角矢印を使用して、

子クラスであるクラスBから親クラスのクラスAの方向で指します。

プログラム上での継承のやり方は、下記のように、クラス定義名の後ろにコロンを付けて、

その後に、継承したいクラス名を記述します。

class ClassA
{
  ・・・
};

class ClassB: public ClassA  // ClassAを継承している
{
  ・・・
};

 

また、下図のように、複数のクラスが、同じクラスを継承することもできます。

 

3.2. 多段階の継承について

 

多段階の継承は、継承されたクラスが更に継承する形で、下図のようなイメージになります。

クラス宣言は、下記のようになります。

class ClassC
{
  ・・・
};

class ClassB: public ClassC  // ClassCを継承している
{
  ・・・
};

class ClassC: public ClassB  // ClassBを継承している
{
  ・・・
};

 

これは、鎖のようにクラスを継承していくやり方で、大規模プログラムになってくると、

頻繁に使用されます。

 

 

3.3. 多重継承について

 

下図のように、一つのクラスが複数のクラスを継承することを多重継承と言います。

 

また、2つ以上のクラスを継承する多重継承は下記のように記述します。

class ClassD: public ClassA, public ClassB, public ClassC 
{
  ・・・
}

 

多重継承をやりすぎると、後からバグの原因になることが多いです。

なので、多重継承をすることは、あまりしない方が良いですが、

多重継承することでプログラムが見やすくなったり、処理効率が上がる場合に使用すると良いです。

 

 

4.C++の仮想関数について

 

4.1. 仮想関数とは?

 

親クラスで、virtual属性の関数を定義しておくことで、

子クラスでは、再定義せずにそのままにしておくか、再定義して置き換えるかを決めることができます。

この親クラスでvirtual属性で定義している関数のことを仮想関数と言います。

 

仮想関数の宣言は、下記のように書きます。

class ClassA
{
    public:
        ClassA();
        virtual ~ClassA();

        virtual void testFunc(); // 仮想関数
};

class ClassB: public ClassA
{
    public:
        ClassB();
        ~ClassB();
};

 

親クラスで、宣言する仮想関数は、public属性かprotected属性にしておきます。

外部クラスからアクセスさせたい場合は、public属性にしておき、

子クラスのみアクセスさせたい場合は、protected属性で宣言しておく方が良いです。

また、上記宣言での動きとしては、

外部クラスからClassBのインスタンスでtestFunc()をコールした場合、

ClassAのtestFunc()がコールされます。

 

 

4.2. オーバーライドとは?

 

オーバーライドとは、親クラスの仮想関数名と同じ名前で、子クラスで宣言することを言います。

 

4.1項では、親クラスの仮想関数に対して、子クラスでは何もしませんでしたが、

下記のクラス宣言のように子クラスに、親クラスの仮想関数と同じ関数名で宣言します。

class ClassA {
public:
ClassA();
virtual ~ClassA();

virtual void testFunc(); // 仮想関数
}

class ClassB: public ClassA {
public: ClassB(); ~ClassB();

void testFunc(); // オーバーライドした関数
}

 

例えば、親クラスの仮想関数の処理から少し変更したいが、

機能的には同じ処理を行う場合などに、オーバーライドを行います。

動きとしては、外部クラスからClassBのインスタンスでtestFunc()をコールした場合、

実際には、ClassAのtestFunc()ではなく、ClassBのtestFunc()がコールされます。

 

外部クラスから子クラスのオーバーライドした関数をコールしても、

親クラスの同名の関数はコールされません。

 

補足で、似たような用語でオーバーロードがありますが、オーバーロードは、継承とは関係ありません。

オーバーロードとは、関数名が同じで、引数が異なった関数を定義することを言います。

 

 

4.3. 純粋仮想関数とは?

 

純粋仮想関数とは、先ほどの仮想関数とは違って、親クラスで定義している仮想関数を、

子クラスで必ずオーバーライドしなければならない関数です。

もし、オーバーライドしなかった場合は、コンパイルエラーになります。

 

親クラスでの純粋仮想関数の定義は、下記のようになります。

class ClassA {
public:
ClassA();
virtual ~ClassA();

virtual void testFunc() = 0; // 純粋仮想関数
}

class ClassB: public ClassA {
public:
ClassB(); ~ClassB();

void testFunc(); // オーバーライドした関数
}

 

純粋仮想関数の定義は、仮想関数の定義に「=0」を付けて宣言します。

そして、親クラスでの純粋仮想関数の処理は無く、

子クラスでオーバーライドした関数の処理のみになります。

 

純粋仮想関数は、子クラスで必ず定義実装しなければなりませんが、

もし関数として必要無ければ空関数にしても構いません。

 

 

5.継承の基本ルールまとめ

 

ここまで解説してきました内容を継承関係になった場合の基本ルールとして、

下表にまとめました。

継承の基本ルール
No 内容
子クラスのコンストラクタ実行前に、親クラスのコンストラクタも実行される。
子クラスのデストラクタ実行後に、親クラスのデストラクタも実行される。
(親クラスのデストラクタは、仮想関数にしておく。)
子クラスは、親クラスのpublic属性やprotected属性のメンバ関数やメンバ変数をインスタンス生成なしで直接使用することができる。
(親クラスのpublic属性のメンバも子クラスで使用できるが、子クラスのみが使用することを明示しておくために、protected属性で定義しておく方が良い。)
親クラスに仮想関数(virtual属性)が定義されている場合、
子クラスでは、必要であればその関数名で実装(オーバーライド)できる。
親クラスで、純粋仮想関数(virtual属性で=0で宣言している関数)を定義している場合、
子クラスでは、必ずその関数を実装(オーバーライド)する必要がある。

 

C++初心者やオブジェクト指向言語の初心者は、

上表のルールだけでも覚えておくと、C++プログラムの理解や作成がスムーズになります。

また、クラス設計の際には、上表の基本ルールを考慮して、

継承が必要かどうか?、親クラスの機能をどこまで使用するか?、

子クラスで個別の実装をする必要があるかどうか?などを検討します。

 

 

6.継承関係や仮想関数をプログラムで解説

 

継承の基本ルールである5項の表の内容を実際のプログラムを動かして、解説していきます。

 

6.1. 継承時のコンストラクタ/デストラクタのコール順序

 

継承関係において、どのような順序でコンストラクタ/デストラクタがコールされるかを、

多段階に継承したクラスのインスタンス作成/破棄をする下記のプログラムで確認します。

#include <iostream>

class ClassA {
  public:
    ClassA() { std::cout << "A コンストラクタ A\n"; }; 
virtual ~ClassA() { std::cout << "A デストラクタ A\n"; }; }; class ClassB: public ClassA { public: ClassB() { std::cout << "B コンストラクタ B\n"; };
virtual ~ClassB() { std::cout << "B デストラクタ B\n"; }; }; class ClassC: public ClassB { public: ClassC() { std::cout << "C コンストラクタ C\n"; }; ~ClassC() { std::cout << "C デストラクタ C\n"; }; }; int main() { ClassC* pClassC = new ClassC(); delete pClassC;
return 0; }

 

このプログラム実行結果は、下図のようになります。

コンストラクタは、一番上の親クラスであるClassAクラスから順にコールされ、

デストラクタは、コンストラクタの逆順でコールされることがわかります。

デストラクタが逆順でコールされることに注意が必要です。

 

 

6.2. protected属性メンバにアクセス

 

protected属性のメンバのアクセスを、下記プログラムで確認します。

 

#include <iostream>

class ClassA {
    public:
        ClassA() { std::cout << "A コンストラクタ A\n"; }; 
virtual ~ClassA() { std::cout << "A デストラクタ A\n"; };

protected:
void testFunc();
int num = 10;
};

void ClassA::testFunc()
{
std::cout << "A ClassA testFunc A\n";
}

class ClassB: public ClassA { public: ClassB() { std::cout << "B コンストラクタ B\n"; };
~ClassB() { std::cout << "B デストラクタ B\n"; };

void test();
};

void ClassB::test()
{
testFunc(); // ClassAのtestFunc()関数をコール
std::cout << "B ClassA num=" << num << " B\n"; // ClassAのメンバ変数値を表示
};

int main() { ClassB* pClass = new ClassB();
pClass->test(); // ClassBのtest()関数をコールする。
delete pClass;
return 0;
}

 

このプログラム実行結果は、下図のようになります。

ClassBでは、上記のようにClassAのprotected属性のメンバ変数やメンバ関数を、

自分のクラスに存在しているかのようにアクセスすることができます。

 

 

6.3. 仮想関数のコール

 

親クラスの仮想関数を子クラスのインスタンスからコールすることを、

下記プログラムで確認します。

 

#include <iostream>

class ClassA
{ public: ClassA() { std::cout << "A コンストラクタ A\n"; }; virtual ~ClassA() { std::cout << "A デストラクタ A\n"; }; virtual void testFunc() // 仮想関数
{ std::cout << "A ClassA Function A \n"; }; }; class ClassB: public ClassA { public: ClassB() { std::cout << "B コンストラクタ B\n"; }; ~ClassB() { std::cout << "B デストラクタ B\n"; }; }; int main() { ClassB* pClass = new ClassB(); pClass->testFunc(); delete pClass;

return 0;
}

 

このプログラム実行結果は、下図のようになります。

このプログラムでは、ClassBクラスにtestFunc()関数をオーバーライドしていないので、

ClassAのtestFunc()関数がコールされます。

 

 

6.4. オーバーライドのコール順序

 

先程のプログラムでClassAクラスの仮想関数をClassBクラスでオーバーライドして、

関数コールする確認のプログラムです。

 

#include <iostream>

class ClassA { public: ClassA() { std::cout << "A コンストラクタ A\n"; }; virtual ~ClassA() { std::cout << "A デストラクタ A\n"; }; virtual void testFunc() // 仮想関数
{ std::cout << "A testFunc A \n"; }; }; class ClassB: public ClassA { public: ClassB() { std::cout << "B コンストラクタ B\n"; }; ~ClassB() { std::cout << "B デストラクタ B\n"; };

void testFunc() // オーバーライドした関数
{ std::cout << "B testFunc B\n"; }; }; int main() { ClassB* pClass = new ClassB(); pClass->testFunc(); delete pClass;

return 0;
}

 

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

ClassBクラスのインスタンスをClassAクラスの型で宣言していて、

そのクラスポインタからClassFunc()関数をコールすると、

ClassAクラスではなくClassBクラスのClassFunc()関数がコールされていることがわかります。

 

 

6.5. 純粋仮想関数のコール

 

純粋仮想関数のコールについて、下記プログラムで確認します。

 

#include <iostream>

class ClassA { public: ClassA() { std::cout << "A コンストラクタ A\n"; }; virtual ~ClassA() { std::cout << "A デストラクタ A\n"; }; virtual void testFunc() = 0; // 純粋仮想関数 }; class ClassB: public ClassA { public: ClassB() { std::cout << "B コンストラクタ B\n"; }; ~ClassB() { std::cout << "B デストラクタ B\n"; };

void testFunc(void) // オーバーライドした関数
{ std::cout << "B testFunc B\n"; };
}; int main() { ClassB* pClass = new ClassB(); pClass->testFunc(); delete pClass;

return 0;
}

 

このプログラム実行結果は、下図のようになります。

関数コールとしては、6.4項のオーバーライドと同じになります。

 

 

7.継承を使用する時の考え方

 

アプリケーションのクラス設計をする際に、複数のクラスに同じような処理が必要な場合があります。

同じような処理が複数クラスにあると、コード量も多くなり、修正する際も手間がかかりますので、

同じような処理を親クラスに実装して、その差分的な処理を子クラスで実装するようにすれば、

コード量も少なくなり、プログラムも読みやすくなります。

 

クラス機能設計時に、共通処理があるかどうか、将来的に共通処理を使用するクラスを作成するか、

を検討して本当に継承が必要かどうか見極めます。

 

例えば、Qtでは、QButtonクラスなどウィジェットクラスのほとんどが、

QWidgetクラスを継承しています。

これは、背景色設定や文字色設定など、どのウィジェットでも使用される機能なので、

末端の機能クラスが実装するのではなく、共通処理としてQWidgetクラスで実装されています。

 

 

8.継承で注意すること

 

継承で、いくつか注意しておくことがあります。

 

8.1. 純粋仮想関数を持つ親クラスは、インスタンス化できない。

 

純粋仮想関数を持つ親クラスは、抽象クラスになり、インスタンス化することができません。

プログラム内で純粋仮想関数を定義しているにもかかわらず、

子クラスで実装していない場合は、コンパイルの時点でエラーになります。

なので、親クラスで純粋仮想関数が定義されている場合は、必ず子クラスで実装します。

 

 

8.2. 親クラスのデストラクタは仮想関数にしておく。

 

継承関係になると、以下のようなインスタンスの作成も行うことができます。

 

ClassA pClass = new ClassB();

 

ClassBでインスタンスを作成して、その戻り値の型が親クラスであるClassAになっています。

ClassAのデストラクタが仮想関数でない場合、インスタンス破棄の際に、

ClassBのデストラクタがコールされないという現象が起こります。

理由は、ポリモーフィズムに関係するのですが、この状態のままだと、delete演算子からみて、

子クラスのデストラクタと親クラスのデストラクタに関連性が無いので、

クラスポインタの型であるClassAのデストラクタのみのコールになるからです。

 

この状態からClassBのデストラクタがコールされるためには、

親クラスのデストラクタを仮想関数にしておきます。

 

なので、継承関係にある親クラスのデストラクタは、必ず仮想関数にしておきます。

 

 

8.3. protected属性について

 

子クラスは、親クラスのpublic属性とprotected属性で定義されているメンバ変数に、

アクセスすることができますが、極力public属性のメンバにアクセスしないように設計する方が良いです。

何故なら、public属性メンバは、外部クラスからも触ることでき、子クラスも触っていると、

不具合解析やプログラムの変更が難しくなる場合があります。

なので、子クラスが親クラスのメンバを触る場合は、protected属性で宣言して、

外部から使用される関数と子クラスで使用する関数に、分けておく方が良いです。

 

 

9.最後に

 

アプリケーションのような大規模プログラムになると、継承関係が複雑になり、

プログラムを読んでいるときに、自クラスで定義していない関数が突然使用されている場合があります。

そのような場合に、継承を理解していると簡単に実処理にたどり着くことができます。

継承関連の知識としては、ここで書いた内容を最低限理解していれば、

実際の開発業務でも十分に役に立つと思います。

 

 

<関連・おすすめ記事>

C++のクラス構造の解説とプログラミングの始め方 - 水瓶座列車

C++のvector<>コンテナの使い方と応用について解説 - 水瓶座列車

Qt5とQtCreatorをUbuntu20.04にインストールする手順と使い方を解説 - 水瓶座列車

Linux勉強用の中古パソコンおすすめショップランキング - 水瓶座列車

Qtで入力した文字を取り込むやり方 - 水瓶座列車

QTableWidgetクラスの使い方をプログラムをもとに解説 - 水瓶座列車

vimエディタの使い方とインストール手順を解説 - 水瓶座列車