こんにちは。田原です。

オブジェクト指向プログラミングの3大特長は、「隠蔽」、「継承」、「動的なポリモーフィズム」です。今回は「継承」の残りのコンストラクト/デストラクトの順序、それに関数のオーバーロード/ハイディング/オーバーライドについて解説します。

1.継承時のコンストラクタとデストラクタの呼ばれる順序

基底クラスを継承した派生クラスを生成(コンストラクト)する時や破壊(デストラクト)する時、基底クラスのコンストラクタやデストラクタも呼ばれます。その順序について説明します。
と言っても、ルールは既に解説している通りです。意外に単純です。

1-1.基底クラスのコンストラクタの呼び出し

1-1-1.呼び出し順序

まず、第21回の「2-4.メンバ変数について」で説明したように、メンバ変数は定義した順序で初期化されます。
また、前回説明したように基底クラスは無名のメンバ変数と同じように振る舞います。
そして、基底クラスはメンバ変数より先に定義しますので、メンバ変数より先に定義した順序で初期化されます。
最後に、全ての基底クラスと全てのクラス型メンバのコンストラクタが呼ばれた後で、派生クラスのコンストラクタのボディ部分が実行されます。

1-1-2.基底クラスのコンストラクタの選択

次に、基底クラスは複数のコンストラクタを持っていることがあります。
呼び出されるコンストラクタは、初期化子リストで指定できます。(後述の関数のオーバーロードの仕組みにより選択されます。)

もし、初期化子リストで基底クラスのコンストラクタを明示的に指定しなければ、デフォルト・コンストラクタが呼ばれます。

1-2.基底クラスのデストラクタの呼び出し

第21回で説明したように、デストラクタはコンストラクトされたのと逆順で呼出されます。基底クラスも同様です。
つまり、派生クラスのデストラクタのポディ部分 → 各クラス型のメンバ変数のデストラクタ → 基底クラスのデストラクタの順序で呼出されます。

また、デストラクタは1つしかありませんので、コンストラクタのように「どのデストラクタを使うのか」を指定する機能はありません。

ところで、デストラクタの呼び出し順序がコンストラクタの呼び出し順序の逆であることに違和感を感じる方がいるかもしれませんね。
そのような方は、マトリョーシカ人形を仕舞う時のことを考えてみて下さい。マトリョーシカ人形を元通りに仕舞うためには、出した時の逆順で仕舞う必要があります。
コンピュータ・プログラムの場合も、ほとんどのケースでこれと同じになるので、この順序は使い勝手が良いのです。

なお、稀に例外はあります。例えば、初期化処理と同じ順序で終了処理するケースが多少あります。
そのような時は終了処理関数を普通のメンバ関数として作成し、明示的に呼び出すことで必要な順序で処理できます。

1-3.サンプル
#include <iostream>

class Base
{
public:
    char const* mName;
    Base() : mName("default")
    {
        std::cout << "Base(default)\n";
    }
    Base(char const* iName) : mName(iName)
    {
        std::cout << "Base(" << mName << ")\n";
    }
    ~Base()
    {
        std::cout << "~Base() : " << mName << "\n";
    }
};
 
class Derived : public Base
{
public:
    Base mBase;
    char const* mName;
    Derived() : mBase("default - mBase"), mName("default")
    {
        std::cout << "Derived(default)\n";
    }
    Derived(char const* iName) : Base("non-default"), mBase("non-default - mBase"), mName(iName)
    {
        std::cout << "Derived(" << mName << ")\n";
    }
    ~Derived()
    {
        std::cout << "~Derived() : " << mName << "\n";
    }
};
 
int main()
{
    std::cout << "--- 1 ---\n";
    Derived aDerive0;

    std::cout << "--- 2 ---\n";
    Derived aDerive1("aDerived1");

    std::cout << "--- 3 ---\n";
}

Wandboxで試してみる

CMakeLists.txt
project(derived)

if(MSVC)
    set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} /W4 /EHsc")
else()
    set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wall -std=c++11")
endif()

add_executable(derived derived.cpp)
--- 1 ---
Base(default)
Base(default - mBase)
Derived(default)
--- 2 ---
Base(non-default)
Base(non-default - mBase)
Derived(aDerived1)
--- 3 ---
~Derived() : aDerived1
~Base() : non-default - mBase
~Base() : non-default
~Derived() : default
~Base() : default - mBase
~Base() : default
1-2-1. "--- 1 ---""--- 2 ---"の間の処理と出力
順序 処理 出力
1 基底クラスBaseのデフォルト・コンストラタ Base(default)
2 メンバ変数mBaseのデフォルト・コンストラクタ Base(default – mBase)
3 メンバ変数mNameの初期化 無し
4 Derivedのデフォルト・コンストラクタのボディ Derived(default)
1-2-2. "--- 2 ---""--- 3 ---"の間の処理と出力
順序 処理 出力
1 基底クラスBaseのパラメータ付きコンストラタ Base(non-default)
2 メンバ変数mBaseのパラメータ付きコンストラクタ Base(non-default – mBase)
3 メンバ変数mNameの初期化 無し
4 Derivedのバラメータ付きコンストラクタのボディ Derived(aDerived1)
1-2-3. "--- 3 ---"の後、main()関数終了時のデストラクタ呼び出し順序

デストラクタは、コンストラクトが呼ばれた時の逆順で呼ばれますので、次のようになります。(今回例外的なデストラクタの呼び出し順序は該当しません。)

  1. aDerived1のデストラクタ
    順序 処理 出力
    1 Derivedのデストラクタのボディ ~Derived() : aDerived1
    2 メンバ変数mBaseのデストラクタ ~Base() : non-default – mBase
    3 基底クラスBaseのデストラクタ ~Base() : non-default
  2. aDerived0のデストラクタ

    順序 処理 出力
    1 Derivedのデストラクタのボディ ~Derived() : default
    2 メンバ変数mBaseのデストラクタ ~Base() : default – mBase
    3 基底クラスBaseのデストラクタ ~Base() : default

2.オーバーロードとハイディング、更にオーバーライド

同じ名前の関数を複数定義できる仕組みがあります。これをオーバーロード(多重定義)と呼びます。綴りはoverloadです。

第23回でもちょっと触れましたが、基底クラスのメンバと同じ名前のメンバを派生クラスで定義することで、基底クラスのメンバが隠され派生クラスのメンバで上書きされます。これをハイデング(隠蔽)と呼びます。綴りはhidingです。

そして、次回解説する仮想関数も、基底クラスの関数を派生クラスの関数で「上書き」する仕組みです。ハイディングと同じように「上書き」と呼びましたが、ハイディングより広い範囲で「上書き」してます。これをオーバーライド(上書き)と呼びます。綴りはoverrideです。

似ているような異なるような、微妙に絡み合う概念ですのでまとめて解説します。

2-1.関数のオーバーロードについて

C言語では、1つの名前の関数は1つしか定義できませんでした。
C++は驚くべきことに1つの名前に対して振る舞いが異なる複数の関数を定義することができます。
それを「関数のオーバーロード」と呼びます。
オーバーロードの綴りはoverloadです。「過積載」の意味です。荷物の積みすぎですね。
1つの名前には普通1つしか定義(積載)できないのに、複数定義(積載)するので「過積載」ということです。
日本語では多重定義と呼ばれることが多いです。

2-1-1.引数リストの型を変えて呼び分け

しかし、1つの名前で複数の関数を定義した時、それぞれをどうやって呼び分けるのでしょうか?
そのために仮引数リストを用います。仮引数1つ1つには「型」を決めますね。それら全ての型が一致するものが呼ばれるのです。

#include <iostream>

void Foo()
{
    std::cout << "Foo()\n";
}
void Foo(float iArg0)
{
    std::cout << "Foo(float)  " << iArg0 << "\n";
}

void Foo(double iArg0)
{
    std::cout << "Foo(double) " << iArg0 << "\n";
}

int main()
{
    Foo();
    Foo(1.0);
    Foo(1.0F);
}
2-1-2.呼び分けできない場合
  • 仮引数リストの型が同じで、仮引数名だけが異なる場合
    関数を呼び出す時、仮引数名を指定しないため、仮引数名が異なるだけですと、どちらを呼べば良いのかコンパイラは判断できません。なので多重定義エラーになります。

  • 仮引数リストの型が同じで、戻り値だけが異なる場合
    返却するべき型をコンパイラが判断できないケース(*1)が非常に多いからと思いますが、戻り値だけが異なる関数は多重定義できません。

  • 仮引数リストの型は異なるけど、呼び出しの時、複数の関数定義に合致する場合
    この場合もコンパイラはどちらを呼ぶべきか判断できないので「呼び出しが曖昧」エラーになります。

#include <iostream>

void Foo()
{
    std::cout << "Foo()\n";
}
void Foo(float iArg0)
{
    std::cout << "Foo(float)  " << iArg0 << "\n";
}

void Foo(double iArg0)
{
    std::cout << "Foo(double) " << iArg0 << "\n";
}

void Foo(double iOther) { }   // 仮引数名が違っても型が同じなので呼び分けできない

int main()
{
    Foo();
    Foo(1.0);
    Foo(1.0F);
    Foo(1);                   // 1 はint型。int型はdouble, floatどちらにも変換できるので「曖昧」
}

Wandboxで試してみる

(*1) 戻り値だけが異なる場合の補足
例えば、次の関数があったと仮定します。

float  Foo() { return 123.456; }
double Foo() { return 123.4567890123456; }

戻り値を受け取らない場合(Foo();)や、計算式の中で呼び出した(5*Foo()+10.5 など)場合、プログラマがfloat型で返したいのかdouble型で返したいのか、コンピュータは判断できないと思います。
何か複雑なルールを決めて判断できるようにすることも可能とは思いますが、単純明確でないルールは事実上使えません。なので現在の仕様になったのではないかと思います。

2-1-3.関数のオーバーロードはメンバ関数でも機能する

グローバル関数で例を示しましたが、オーバーロードはメンバ関数でも機能します。
もちろん、コンストラクタでも使えます。コンストラクタを複数定義するのもこのオーバーロードの役割です。
同じ型の引数リストのものは複数定義できませんし、呼び出しが曖昧になる場合もエラーになります。

ところで、実は、関数のオーバーロードは「継承」とはあまり関係ありません。しかし、後述するハイディング(隠蔽)、および、オーバーライドと絡むのでここで説明しています。

2-2.メンバのハイディングについて

「隠蔽」の意味です。基底クラスで定義されたメンバを「上書きして隠蔽」する仕組みです。

基底クラスを派生して、基底クラスの機能を拡張しますが、その時、関数についても同じ名前の関数を定義して その関数を「上書き」できます。その際、基底クラスの同じ名前の関数を呼び出したいこともあるかも知れません。基底クラス名::メンバ関数名で呼び出すことができます。

次のサンプルでは、基底クラスの関数printType()を上書き隠蔽し、mTypeの割当を拡張しています。

#include <iostream>

class Base
{
protected:
    unsigned mType;
public:
    Base(unsigned iType) : mType(iType) { }
    void printType()
    {
        switch(mType)
        {
            case 0:  std::cout << "Base     Type-Zero\n"; break;
            case 1:  std::cout << "Base     Type-One\n"; break;
            case 2:  std::cout << "Base     Type-Two\n"; break;
            default: std::cout << "Unkown   Type\n"; break;
        }
    }
};
 
class Derived : public Base
{
public:
    Derived(unsigned iType) : Base(iType) { }
    void printType()
    {
        switch(mType)
        {
            case 10: std::cout << "Extended Type-Zero\n"; break;
            case 11: std::cout << "Extended Type-One\n"; break;
            case 12: std::cout << "Extended Type-Two\n"; break;
            default: Base::printType(); break; // 基底クラスのprintType()呼び出し
        }
    }
};
 
int main()
{
    Derived aDerived0(1);  aDerived0.printType();
    Derived aDerived1(10); aDerived1.printType();
    Derived aDerived2(3);  aDerived2.printType();
}

Wandboxで試してみる

2-2-1.同じ名前のオーバーロード関数は1つ隠蔽すると全て隠蔽される

上記のサンプルでは、基底クラスのprintType()はオーバーロード(多重定義)されていませんでしたが、オーバーロードを追加してみました。そのオーバーロード関数は、他の同じ名前の関数がハイディング(隠蔽)されているため、まとめて隠蔽され派生クラスに引き継がれません。もし、派生クラスのメンバ関数のように使おうとするとコンパイル・エラーになります。

#include <iostream>

class Base
{
protected:
    unsigned mType;
public:
    Base(unsigned iType) : mType(iType) { }
    void printType()
    {
        switch(mType)
        {
            case 0:  std::cout << "Base     Type-Zero\n"; break;
            case 1:  std::cout << "Base     Type-One\n"; break;
            case 2:  std::cout << "Base     Type-Two\n"; break;
            default: std::cout << "Unkown   Type\n"; break;
        }
    }
    void printType(char const* iTitle)
    {
        std::cout << iTitle;
        printType();
    }
};
 
class Derived : public Base
{
public:
    Derived(unsigned iType) : Base(iType) { }
    void printType()
    {
        switch(mType)
        {
            case 10: std::cout << "Extended Type-Zero\n"; break;
            case 11: std::cout << "Extended Type-One\n"; break;
            case 12: std::cout << "Extended Type-Two\n"; break;
            default: Base::printType(); break; // 基底クラスのprintType()呼び出し
        }
    }
};
 
int main()
{
    Derived aDerived0(1);  aDerived0.printType();
    Derived aDerived1(10); aDerived1.printType();
    Base aBase(3);         aBase.printType("Title : ");
    Derived aDerived2(3);//aDerived2.printType("Title : ");  // 有効にするとコンパイル・エラー
                           aDerived2.Base::printType("Title : ");
}

Wandboxで試してみる

基底クラスのprintType(char const* iType)は派生クラスで拡張した分類をサポートしていません。
このように派生クラス側でprintType()を上書きして隠蔽したということは、他のオーバーロード関数も隠蔽する必要がある場合が多いです。しかし、派生クラスでは必要ないため隠蔽しないこともあります。そのようなケースで使えてしまうのは好ましくありません。
そのため、オーバーロード関数が1つでも隠蔽されると同じ名前のオーバーロード関数全てが使えなくなるようになっています。

しかし、サンプルの48行目のように基底クラス名::メンバ関数名で明示的に基底クラスの関数であることを指定すると呼び出せます。この構文であれば、派生クラスの拡張が成されていないことが明らかですので、バグになることは少ないです。

2-2-2.usingは隠蔽されたオーバーロード関数を使えるようにする

第23回の「2.手動で引き継ぎ(using)」で(少しフライング気味に)解説したusingを使うと隠蔽されたオーバーロード関数群を使えるようにできます。

#include <iostream>

class Base
{
protected:
    unsigned mType;
public:
    Base(unsigned iType) : mType(iType) { }
    void printType()
    {
        switch(mType)
        {
            case 0:  std::cout << "Base     Type-Zero\n"; break;
            case 1:  std::cout << "Base     Type-One\n"; break;
            case 2:  std::cout << "Base     Type-Two\n"; break;
            default: std::cout << "Unkown   Type\n"; break;
        }
    }
    void printType(char const* iTitle)
    {
        std::cout << iTitle;
        printType();
    }
};
 
class Derived : public Base
{
public:
    Derived(unsigned iType) : Base(iType) { }
    void printType()
    {
        switch(mType)
        {
            case 10: std::cout << "Extended Type-Zero\n"; break;
            case 11: std::cout << "Extended Type-One\n"; break;
            case 12: std::cout << "Extended Type-Two\n"; break;
            default: Base::printType(); break; // 基底クラスのprintType()呼び出し
        }
    }
    using Base::printType;
};
 
int main()
{
    Derived aDerived0(1);  aDerived0.printType();
    Derived aDerived1(10); aDerived1.printType();
    Base aBase(3);         aBase.printType("Title : ");
    Derived aDerived2(3);  aDerived2.printType("Title : ");  // usingしたので使える
                           aDerived2.Base::printType("Title : ");
}

Wandboxで試してみる

2-2-3.メンバ変数のハイディング(隠蔽)について

ところで、あまり使うことは無いと思いますが、メンバ変数も隠蔽できます。

#include <iostream>

struct Base
{
    int mData;
    Base() : mData(123) { }
};

struct Derived : public Base
{
    short mData;
    Derived() : mData(456) { }
};

int main()
{
    Derived aDerived;
    std::cout << aDerived.mData << "\n";
    std::cout << aDerived.Base::mData << "\n";
}

基底クラスと派生クラスの両方に別々に変数が確保されます。
メンバ変数のハイディング(隠蔽)

ですので、アクセスする時、どちらにアクセスしているのか常に意識しておかないと、思っていたのと違う方の変数を書き換えてしまい、「何故だ? 変数の値が変わらないぞ!!」と悩むバグになりがちです。
どうしても必要な場合以外、変数のハイディング(隠蔽)は避けることをお勧めします。

2-3.オーバーライドについて

基底クラスで定義されている仮想関数と同じ関数(名前と引数リストが同じもの)を派生クラスで定義することで基底クラスの関数をオーバーライドできます。

通常は、基底クラスへのポインタや参照経由で基底クラスの関数を呼び出した場合、当然基底クラスで定義されている関数が呼ばれます。しかし、呼び出した関数がオーバーライドされていた場合、派生クラスの関数が呼び出されます。実に頭の良い方法で実現されています。詳しくは次回解説します

そして、オーバーライドは、基底クラスのメンバと同じ名前のメンバを定義しますから、派生クラスの関数で基底クラスの関数をハイディング(隠蔽)します。ということは、基底クラスの関数がオーバーロードされたいた場合、それらのオーバーロードされている関数群も全て隠蔽されます。

4.まとめ

今回で「継承」機能の解説を一通り終わりました。当初思っていたより量が多くなりました。やはりC++は奥が深いです。

さて、来週から「継承」を更に拡張した「動的ポリモーフィズム」について解説します。この機能を使いこなせるようになると、オブジェクト指向プログラミングのプロと言っても過言ではありません。頑張りましょう。