こんにちは。

オブジェクト指向プログラミングの3大特長は、「隠蔽」、「継承」、「動的なポリモーフィズム」です。前回は最後の1つ「動的なポリモーフィズム」について解説しました。今回は「動的ポリモーフィズムでは何故ポインタか参照を使わないといけないのか?」等の重要事項について補足説明をします。

1.動的ポリモーフィズムで使う基底クラスはポインタか参照で

前回のサンプル・ソースでは、派生クラスをnewで生成し、基底クラスへのポインタでポイントしました。適切なタイミングで派生クラスのデータをdeleteする必要があるので、AutoPointerクラスを導入しデストラクタでdeleteしました。

しかし、できればポインタではなく直接MultiMediaクラスの変数で管理したいです。それができればAutoPointerクラスやこれと同様の対策を導入する必要がありません。
つまり、AlbumクラスとMovieクラスを直接std::vector<MultiMedia>へ格納したいのです。なんとなくできそうな気もしますね。しかし、できません。 これについて解説します。

前回のmain()関数を次のように書いてみます。AutoPointerクラスで間接的にMultiMediaを管理するのではなく、直接MultiMediaを管理してみました。

int main()
{
    std::vector<MultiMedia>   aVector;
    aVector.emplace_back(Album("News Of The World", "Queen"));
    aVector.emplace_back(Movie("Star Trek", "Robert Wise", "William Shatner"));
    aVector.emplace_back(Album("Flash Gordon", "Queen"));
    aVector.emplace_back(Movie("Flash Gordon", "Mike Hodges", "Sam J. Jones"));

    for(unsigned i=0; i < aVector.size(); ++i)
    {
        std::cout << "(" << i << ") " << aVector[i].getPrimary() << "\n";
    }
}

例えば、ハイライトした4行目で何が起こるのか見てみます。

  1. Album("News Of The World", "Queen")
    この記述でAlbumクラスの一時オブジェクトが生成されます。

  2. emplace_back(Album("News Of The World", "Queen"))
    emplace_backはちょっと特殊な関数で、これはstd::vectorが管理するクラス(今回はMultiMedia)のコンストラクタへemplace_backの引数をそのまま引き渡します。
    従って、MultiMedia(Album(“News Of The World”, “Queen”));が実行されます。

  3. しかし、MultiMediaのコンストラクタにAlbumを受け取るものを定義していません
    ですので、本来ならこの時点で「引数が一致する関数なし」エラーでコンパイル出来ないはずなのですが、エラーになりません。それはコピー・コンストラクタと呼ばれるコンストラクタがコンパイラによって自動的に生成されるからです。(常に生成されるわけではないのですが、普通にプロクラムしていると生成されることがほとんどです。詳しくはまた後日解説します。)

  4. MultiMediaのコピー・コンストラクタはMultiMediaへの参照を受け取ります
    (厳密には「const参照」ですが今は気にしなくて良いです。constについて後日解説予定です。)
    Album(派生クラス)へのポインタはMultiMedia(基底クラス)へのポインタへ変換できます。同様にAlbumへの参照もMultiMediaへの参照へ変換できます。従って、MultiMediaのコピー・コンストラクタは、Albumを参照するMultiMediaへの参照を受け取ります。

  5. MultiMediaのコピー・コンストラクタは
    受け取ったAlbumクラスのデータを自分が管理する領域へコピーします。MultiMediaが管理する領域はstd::string mTitle;だけです。Albumクラスのstd::string mArtists;は管理していません。従ってmArtistsを無視します。つまり、Albumクラスの基底クラスMultiMediaにあるstd::string mTitle;だけを自分の領域へコピーします。

  6. vtableへのポインタはコピーされません
    MultiMediaのコピー・コンストラクタはAlbumクラスのデータ・メンバをコピーしません。その状態でAlbumクラスのgetPrimary()が呼ばれたら何が起こるか分かりません。AlbumクラスのgetPrimary()はmArtistsをアクセスするのですが、mArtistsを記録する領域がないからです。
    そのような問題をさけるため、MultiMediaのコピー・コンストラクタはAlbumクラスのvtableへのポインタもコピーしません。MultiMediaのvtableへのポインタのままです。

基底クラスへのコピー

従って、aVectorの各要素のgetPrimary()を呼び出しても、それは派生クラスのAlbumやMovieではなく基底クラスのMultiMediaクラスのものが呼ばれます。

(0) [News Of The World]
(1) [Star Trek]
(2) [Flash Gordon]
(3) [Flash Gordon]

Wandboxで試してみる

スライシング
上記の5.6.のような動作はスライシングと呼ばれています。
派生クラスの情報を「削ぎ落とす」ニュアンスです。

以上のようにスライシングが発生するため、動的ポリモーフィズムで使う基底クラスとして直接変数を用いることはできず、ポインタか参照で間接的に用いるしかありません。

2.デストラクタに関する重要な注意事項

動的ポリモーフィズムとしてクラス群を設計する時、基底クラスのデストラクタを必ず仮想関数として定義して下さい。
例えば、前回のサンプルは、MultiMediaクラスのデストラクタを仮想関数として定義しています。
これには強い理由があります。

2-1.aVectorが破棄される時、MultiMediaへのポインタがdeleteされる

aVectorが管理しているAutoPointerクラスはMultiMediaへのポインタを管理しており、AutoPointerのデストラクタでMultiMediaへのポインタをdeleteします。main()関数からreturnする時にaVectorが破棄されるのですが、その時にAutoPointerのデストラクタが呼ばれ、それはMultiMediaへのポインタをdeleteしています。

2-2.MutiMediaのデストラクタは仮想関数として定義している

基底クラスへのポインタ経由で派生クラスのメンバ関数を呼ぶための仕組みが仮想関数でした。
デストラクタでも同じです。デストラクタを仮想関数にすることで基底クラスのデストラクタを呼び出したら、派生クラスのデストラクタが呼ばれます。
つまり、MultiMediaへのポインタがdeleteした時に呼ばれるデストラクタは、派生クラスであるAlbumやMovieのデストラクタです。(もちろんvtableの仕組みにより正しい方が呼ばれます。)

2-3.しかし、もしデストラクタを仮想関数にしてなかったら何が起こる?

前回解説したように、基底クラスのポインタ経由で呼ばれたメンバ関数が通常のメンバ関数の場合、派生クラスのメンバ関数ではなく基底クラスのメンバ関数が呼ばれます。デストラクタの場合も同様です。
もし、デストラクタが仮想関数でなかった場合、「MultiMediaへのポインタをdelete」した時に呼ばれるデストラクタはMultiMediaクラスのデストラクタです。AlbumクラスやMovieクラスではありません。

2-4.例えばAlbumクラスのデストラクタが呼ばれないと何がおこる?

第21回目で解説しているように、もしAlbumクラスのデストラクタが呼ばれなかった場合、mArtistsのデストラクタも呼ばれません。つまり、mArtistsメンバ変数が管理していたメモリが開放されず、メモリ・リークとなります。

2-5.結論

基底クラスのデストラクタを通常関数として定義し、かつ、動的ポリモーフィズムとして用いた場合、基底クラスへのポインタで派生クラスのデータを破棄しようとしても派生クラスのデストラクタが呼ばれず、メモリ・リークします。(もし、派生クラスのデストラクタで開放するものがファイル・ハンドル等のリソースだった場合、そのリソースが開放されませんのでリークになります。)

従って、動的ポリモーフィズムで使用する場合、基底クラスのデストラクタは必ず仮想関数として定義して下さい。

2-6.おまけ(Visual C++で確認)

Visual C++は標準でメモリ・リークを検出する機能を提供しています。

それを使って、MultiMediaクラスのデストラクタを通常メンバ関数(virtualを削除)として、メモリ・リークが発生することを確認できるサンプルです。

#include <iostream>
#include <string>
#include <vector>

class MultiMedia
{
    std::string mTitle;
public:
    MultiMedia(char const* iTitle) : mTitle(iTitle)
    { }
    ~MultiMedia()
    { }
    virtual std::string getPrimary()
    {
        return "[" + mTitle + "]";
    }
};

class Album : public MultiMedia
{
    std::string mArtists;
public:
    Album(char const* iTitle, char const* iArtists) :
        MultiMedia(iTitle),
        mArtists(iArtists)
    { }

    std::string getPrimary()
    {
        return "Music : " + MultiMedia::getPrimary() + " Artists:" + mArtists;
    }
};

class Movie : public MultiMedia
{
    std::string mDirector;
    std::string mPrincipalActor;
public:
    Movie
    (
        char const* iTitle,
        char const* iDirector,
        char const* iPrincipalActor
    ) : MultiMedia(iTitle),
        mDirector(iDirector),
        mPrincipalActor(iPrincipalActor)
    { }

    std::string getPrimary()
    {
        return "Movie : " + MultiMedia::getPrimary()
            + " Director:" + mDirector
            + "; Principal actor:" + mPrincipalActor;
    }
};

class AutoPointer
{
    MultiMedia* mMultiMedia;
public:
    AutoPointer(MultiMedia*  iMultiMedia) : mMultiMedia(iMultiMedia)
    { }
    ~AutoPointer()
    {
        delete mMultiMedia;
    }

    // 以下は後日解説します
    AutoPointer(AutoPointer&& iAutoDelete) : mMultiMedia(iAutoDelete.mMultiMedia)
    {
        iAutoDelete.mMultiMedia=nullptr;
    }
    MultiMedia* operator->()
    {
        return mMultiMedia;
    }
};

#define _CRTDBG_MAP_ALLOC
#include <crtdbg.h>

int main()
{
    _CrtSetReportMode(_CRT_WARN,  _CRTDBG_MODE_FILE);
    _CrtSetReportMode(_CRT_ERROR, _CRTDBG_MODE_FILE);
    _CrtSetReportFile(_CRT_WARN,  _CRTDBG_FILE_STDERR);
    _CrtSetReportFile(_CRT_ERROR, _CRTDBG_FILE_STDERR);
    _CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF);

    std::vector<AutoPointer>   aVector;
    aVector.emplace_back(new Album("News Of The World", "Queen"));
    aVector.emplace_back(new Movie("Star Trek", "Robert Wise", "William Shatner"));
    aVector.emplace_back(new Album("Flash Gordon", "Queen"));
    aVector.emplace_back(new Movie("Flash Gordon", "Mike Hodges", "Sam J. Jones"));

    for(unsigned i=0; i < aVector.size(); ++i)
    {
        std::cout << "(" << i << ") " << aVector[i]->getPrimary() << "\n";
    }
}
project(virtual_destructor)

set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} /W4 /EHsc")

add_executable(virtual_destructor virtual_destructor.cpp)
(0) Music : [News Of The World] Artists:Queen
(1) Movie : [Star Trek] Director:Robert Wise; Principal actor:William Shatner
(2) Music : [Flash Gordon] Artists:Queen
(3) Movie : [Flash Gordon] Director:Mike Hodges; Principal actor:Sam J. Jones
Detected memory leaks!
Dumping objects ->
{169} normal block at 0x01591348, 8 bytes long.
 Data: <d Y     > 64 07 59 01 00 00 00 00
{168} normal block at 0x01591578, 8 bytes long.
 Data: <H Y     > 48 07 59 01 00 00 00 00
{164} normal block at 0x01591428, 8 bytes long.
 Data: <  X     > 00 9E 58 01 00 00 00 00
{160} normal block at 0x015916C8, 8 bytes long.
 Data: < ?X     > AC 3F 58 01 00 00 00 00
{159} normal block at 0x015915E8, 8 bytes long.
 Data: < ?X     > 90 3F 58 01 00 00 00 00
{155} normal block at 0x015914D0, 8 bytes long.
 Data: <  X     > 98 FC 58 01 00 00 00 00
Object dump complete.

3.抽象クラスと純粋仮想関数

3-1.純粋仮想関数とは

純粋仮想関数はvtableに設定されている関数へのポインタが「無効」とマークされている仮想関数です。(確認したわけではありませんが、恐らくvtableの関数実体へのポインタが 0 になっていると思います。)

例えば、前回のサンプル・ソースで次のように記述すると、Baseクラスのfunc()仮想関数は純粋仮想関数(pure virtual function)となります。

struct Base
{
    double mData;
    Base() : mData(12.3) { }
    virtual void func() = 0;
};

3-2.抽象クラスとは

そして、そのような仮想関数をメンバ関数として持つクラスを「抽象クラス(abstract class)」と呼びます。例えば3-1.のBaseクラスは純粋仮想関数を持つので抽象クラスです。

また、純粋仮想関数を継承したが、それをオーバーライドしていないクラスも純粋仮想関数を持ちます。(当該クラスのvtableの当該仮想関数へのポインタも無効のままです。)

例えば次のDerivedはBaseの純粋仮想関数func()を継承しますが、func()をオーバーライドしていないため、純粋仮想関数func()を継承したままとなります。
従って、純粋仮想関数を持つのでDeriveクラスも抽象クラスです。

struct Base
{
    double mData;
    Base() : mData(12.3) { }
    virtual void func() = 0;
};
struct Derived : public Base
{
    double mData2;
    Derived() : mData2(45.6) { }
};

3-3.抽象クラスは生成できない

例えば、次ソースのmain()関数にある、aBase, new Base, aDerived, new Derivedは全て、「抽象クラスなので生成できない」旨のエラーになり、生成できません。

struct Base
{
    double mData;
    Base() : mData(12.3) { }
    virtual void func() = 0;
};
struct Derived : public Base
{
    double mData2;
    Derived() : mData2(45.6) { }
};
int main()
{
    Base aBase;
    Base* aBasePtr=new Base;
    Derived aDerived;
    Derived* aDerivedPtr=new Derived;

    // disable warning
    aBase.func();
    aBasePtr->func();
    aDerived.func();
    aDerivedPtr->func();
}

Wandboxで試してみる

CMakeLists.txt
abstract.cpp(14): error C2259: 'Base': 抽象クラスをインスタンス化できません。 
abstract.cpp(15): error C2259: 'Base': 抽象クラスをインスタンス化できません。
abstract.cpp(16): error C2259: 'Derived': 抽象クラスをインスタンス化できません。
abstract.cpp(17): error C2259: 'Derived': 抽象クラスをインスタンス化できません。

3-4.派生してオーバーライドすると生成できるようになる

基底クラスから継承した全ての純粋仮想関数を純粋でない仮想関数でオーパーライドしたクラスは抽象クラスではないので、生成できます。(なお、元の基底クラスが生成できるようになるわけではありません。)

また、派生クラスへのポインタは基底クラスへのポインタへ変換できますが、基底クラスが抽象クラスの場合でも通常通り変換できます。

#include <iostream>

struct Base
{
    double mData;
    Base() : mData(12.3) { }
    virtual void func() = 0;
};
struct Derived : public Base
{
    double mData2;
    Derived() : mData2(45.6) { }
    void func()
    {
        std::cout << "Derived::func()\n";
        std::cout << "  mData =" << mData << "\n";
        std::cout << "  mData2=" << mData2 << "\n";
    }
};
int main()
{
//  Base aBase;
    Base* aBasePtr=new Derived;
    Derived aDerived;
    Derived* aDerivedPtr=new Derived;

    // disable warning
//  aBase.func();
    aBasePtr->func();
    aDerived.func();
    aDerivedPtr->func();
}

Wandboxで試してみる

3-5.抽象クラスと純粋仮想関数はどんな時に使うのか?

大きく2つのケースがあります。

3-5-1. オブジェクトを生成したくないようなクラスを抽象クラスにする

例えば、サンプル・ソースのMultiMediaクラスはタイトルだけ記録してますが、タイトルだけ表示しても役に立たないシステムも考えられます。そのような時、間違ってMultiMediaクラスを生成するようなコードを書けなくするために、MultiMediaクラスを抽象クラスにすることが考えられます。(要はフール・プルーフです。ミスは誰にでもありますから、可能な時はフール・プルーフを仕込んでおくと余計なデバッグに時間を取られにくくなります。)

3-5-2. メンバ関数の実装を強制したい時

JavaやC#にはインタフェースと言う仕組みが有ります。それは純粋仮想関数群だけを定義した一種のクラスです。インタフェースを継承したクラスは、それらの純粋仮想関数群を全てをオーバーライドしてないとオブジェクトを生成できません。
これにより、インタフェースを実装することを半ば強制します。

例えば、C#にはIEnumerableというインタフェースがあります。C++にあるstd::vector等にあたるC#の各種コンテナ・クラスは全てIEnumerableを継承しています。
そして、IEnumerableの各メンバ関数で継承先コンテナの各要素を列挙することができます。
ということは、IEnumerableを使える人は、IEnumerableを継承した全てのコンテナで要素を列挙するプログラムを追加調査なしに書けます。

C++で同様なことを実現したい場合、抽象クラスを用いれば良いです。

ところで、C++のSTLの場合
std::vector等のSTLの場合、IEnumerableの仕組みを導入していません。恐らく仮想関数の持つ僅かなオーバーヘッドを避けたかったのではないかと思います。標準ライブラリは実に様々な局面で使われます。そのため妥協を許さず常に最大の性能を発揮するよう列挙する際に仮想関数を使わなかったのだろうと思います。

そのデメリットとしては、例えば、std::vector aVector;の場合、aVector[i]でi番目の要素にアクセスできますが、std::list aList;の場合、aList[i]は使えません。

しかし、これはメリットの裏返しです。std::listの仕組み上、aList[i]はi=0の時とi=100の時でアクセスにかかる時間が異なります。性能が異なるのに以下にも同じ時間でアクセスできそうな記述で書くことを許さなかったのではないかと思います。
もし、どうしてもaList[i]として書きたい人は、第23回目で示したような考え方でstd::listを継承したクラスを作ることでも対応できます。

4.まとめ

今回は動的ポリモーフィズム周辺の細々した補足を解説しました。
この中で、動的ポリモーフィズムとして用いる場合、デストラクタを仮想化することは非常に重要です。これを守らないとメモリ・リークしますので、動的ポリモーフィズムとして用いるクラス群については、基底クラスにて必ず仮想デストラクタを定義するようにして下さい。

さて次回は、流れに組み込みにくくて後回ししていた「メンバのクラス外定義」や「staticメンバ」について解説する予定です。お楽しみに。