目次

■ ポリモーフィズム

go back to frame topframe top

ポリモーフィズムと言う考え方について解説します。

0. 目次

  1. ポリモーフィズム概論
  2. 関数引数によるポリモーフィズム
  3. 継承によるポリモーフィズム
  4. 補足事項
  5. 演習問題

1. ポリモーフィズム概論

ポリモーフィズムとは日本語で多態性と言います。「C++ にはポリモーフィズムと言う機能がある」と言うよりは、「C++ にはポリモーフィズムな性格がある」と言う言い方の方が正しい感じがします。一つのモノに、場面に応じた複数の機能がある性格のことです。例を言えば、「凧が上がる」と「成績が上がる」は同じ「上がる」と表現するけど、ニュアンス的には「上向きに動く」で同じだが、内容的には意味が違う、と言うような話です。実際にどういうことを「ポリモーフィズムな性格」と呼ぶのか、見ていきましょう。

2. 関数引数によるポリモーフィズム

2.1. 基本事項

C++ 言語では名前の同じ関数であっても引数が違えば別物として扱ってくれます。以下に実装例を示します。

void function( int nData )
{
    cout << "function( int nData ) is called. argument is " << nData << endl;
}

void function( const char* pszData )
{
    cout << "function( const char* nData ) is called. argument is " << pszData << endl;
}

void function( int nData1, int nData2 )
{
    cout << "function( int nData1, int nData2 ) is called. arguments are "
        << nData1 << " and " << nData2 << endl;
}

int main()
{
    function( 10 );
    function( "文字列" );
    function( 888, 999 );

    return -1;
}

実行結果は以下の通り。

関数名は関数動作を示します。と言うか、示すように定義するべきですよね。これは割合暗黙の了解的なガイドラインなのですが、そのガイドラインから派生したポリモーフィズムです(と、説明している参考書は少ないのですが)。

2.2. メンバ関数として実装する

2.1. 基本事項で述べた動作は、メンバ関数としても同様に実装可能です。特にコンストラクタに用いることができるのは特筆するべき事項です。『継承』で改変した CBook に本の入荷数や売れた数を固定値で初期化するコンストラクタを追加してみましょう。

// 書籍クラス
class CBook
{
protected:

    const char* m_pszTitle;         // タイトル
    const int   m_nPrice;           // 値段(円)
    const int   m_nArrivedValue;    // 入荷した数
    int         m_nSelledValue;     // 売れた数

    …(省略)…

public:

    // -----------------------------------
    // Summary:
    //  コンストラクタ
    // Arguments:
    //  pszTitle:       タイトル
    //  nPrice:         値段
    //  nArrivedValue:  入荷した数
    //  nSelledValue:   売れた数
    CBook( const char* pszTitle,
        const int nPrice, int nArrivedValue, int nSelledValue )

    :m_pszTitle( pszTitle ),
        m_nPrice( nPrice ), m_nArrivedValue( nArrivedValue )
    {
        m_nSelledValue = nSelledValue;
    }

    // -----------------------------------
    // Summary:
    //  コンストラクタ。
    //  入荷した数=30、売れた数=0 として初期化
    // Arguments:
    //  pszTitle:       タイトル
    //  nPrice:         値段
    CBook( const char* pszTitle, const int nPrice )	
    :m_pszTitle( pszTitle ), m_nPrice( nPrice ), m_nArrivedValue( 30 )
    {
        m_nSelledValue = 0;
    }

    // -----------------------------------
    // Summary:
    //  デストラクタ
    virtual ~CBook(){}

    …(省略)…

};

オブジェクトの初期化の仕方は以下のような感じです。m_nArrivedValue、m_nSelledValue を固定値で初期化する場合は明示しなくて良くなります。

int main()
{

    CBook   HarryPotter( "ハリーポッター賢者の石", 1900 );
    HarryPotter.Disp();

    return -1;
}

他にも、以下のように『クラスとオブジェクト』で述べたデフォルト引数を用いる方法もあります。

// 書籍クラス
class CBook
{
protected:

    const char* m_pszTitle;         // タイトル
    const int   m_nPrice;           // 値段(円)
    const int   m_nArrivedValue;    // 入荷した数
    int         m_nSelledValue;     // 売れた数

    …(省略)…

public:

    // -----------------------------------
    // Summary:
    //  コンストラクタ
    // Arguments:
    //  pszTitle:       タイトル
    //  nPrice:         値段
    //  nArrivedValue:  入荷した数
    //  nSelledValue:   売れた数
    CBook( const char* pszTitle,
        const int nPrice, int nArrivedValue = 30, int nSelledValue = 0 )

    :m_pszTitle( pszTitle ),
        m_nPrice( nPrice ), m_nArrivedValue( nArrivedValue )
    {
        m_nSelledValue = nSelledValue;
    }

    // -----------------------------------
    // Summary:
    //  デストラクタ
    virtual ~CBook(){}

    …(省略)…

};

『クラスとオブジェクト 4.3. 定義ファイル、実装ファイル』で述べたクラス定義と実装を分ける場合、このデフォルト引数は定義ファイルでだけ定義します。実装側に書くとコンパイルエラーとなります。ちなみに今述べたで引数違いのコンストラクタとデフォルト引数のあるコンストラクタ、2つの実装を両方やってしまうと、CBook::CBook( pszTitle, nPrice ) はどちらのコンストラクタが呼ばれるか分からなくなってしまう(コンパイラ依存となります)ので、注意しましょう。

3. 継承によるポリモーフィズム

『クラスとオブジェクト 3.1. 動的に生成して、ポインタで使う』で述べた方法を用いると、派生後のクラスによるオブジェクトも一貫してその親クラスのリストとして扱うことができます。継承自体、ポリモーフィズムな性格を持っていますが、オブジェクトの扱いもポリモーフィズムに表現できます。

3.1. ポリモーフィズムの実践 1st ステップ

早速、本屋さんシステムを用いて実践してみましょう。以下のようなコードになると思います。

int main()
{
    // オブジェクトの生成

    CGeneralBook*   pHarryPotter =
        new CGeneralBook( "ハリーポッター賢者の石",
                    "J.K.ローリング",1900, 52, 0 );

    CGeneralBook*   pStarWars = 
        new CGeneralBook( "スターウォーズ暗黒の旅路 上巻",
                    "エレイン・カニンガム", 780, 12, 0 );

    CMagazine*  pGendai =
        new CMagazine( "月間現代",
                        780, 8, 0, CMagazine::Monthly, 10 );

    CMagazine*  pWeeklyASCII = 
        new CMagazine( "週刊アスキー",
                        350, 20, 0, CMagazine::Weekly, 28 );


    // オブジェクトへのポインタ列
    CBook*  BooksArray[4] = { pHarryPotter, pStarWars, pGendai, pWeeklyASCII };


    // ハリーポッター1冊売れた
    pHarryPotter->Selled();

    // スターウォーズ2冊売れた
    pStarWars->Selled();
    pStarWars->Selled();

    // 週刊現代1冊売れた
    pGendai->Selled();

    // 週刊ASCII2冊売れた
    pWeeklyASCII->Selled();
    pWeeklyASCII->Selled();

    // 一括表示
    for( int i = 0; i < 4; i++ ){

        BooksArray[i]->Disp();
    }

    // オブジェクトの破棄
    delete pHarryPotter;
    delete pStarWars;
    delete pGendai;
    delete pWeeklyASCII;

    return -1;
}

CBook オブジェクトへのポインタ列とすれば、子クラスの CGeneralBookCMagazine も一括してリストとして扱えます。しかも、BookArray[i]->Disp(); でそれぞれ CGeneralBook::Disp()、CMagazine::Disp() 呼び出しが出来れば実装はとても楽になれそうです。なお、CBook のオブジェクトそのものを配列リストとしてはいけません。なぜなら、CBook と CGeneralBook、CMagazine はそれぞれメンバ変数が違う分、各オブジェクトのサイズが違うからです。配列と言うのは同じサイズのものについてしかリストにできないのでした。ポインタの実態はアドレスですから、常にDWORDです。

3.1.1 予想外の結果

それでは実際に実行してみましょう。

おかしいことに気がつくでしょうか。BookArray[i]->Disp();が、CBook::Disp() が呼び出されてしまっていて、CGeneralBook::Disp()、CMagazine::Disp() が呼び出せていないのです。これは、配列宣言時、CBook のポインタとしているので、コンパイラが CBook::Disp() を割り当ててしまっているのです。

3.2. virtual キーワード

3.1.1. 予想外の結果を回避するためには、CBook::Disp() に virtual キーワードをつけて、仮想関数にします。

// 書籍クラス
class CBook
{
protected:

    const char* m_pszTitle;         // タイトル
    const int   m_nPrice;           // 値段(円)
    const int   m_nArrivedValue;    // 入荷した数
    int         m_nSelledValue;     // 売れた数

    // -----------------------------------
    // Summary:
    //  売り上げの算出
    // Returns:
    //  売り上げ額(円)
    int GetSells() const
    {
        return m_nSelledValue * m_nPrice;
    }

    // -----------------------------------
    // Summary:
    //  残数の算出
    // Returns:
    //  残数
    int GetRestValue() const
    {
        return m_nArrivedValue - m_nSelledValue;
    }

public:

    // -----------------------------------
    // Summary:
    //  コンストラクタ
    // Arguments:
    //  pszTitle:       タイトル
    //  nPrice:         値段
    //  nArrivedValue:  入荷した数
    //  nSelledValue:   売れた数
    CBook( const char* pszTitle,
        const int nPrice, int nArrivedValue, int nSelledValue )

    :m_pszTitle( pszTitle ),
        m_nPrice( nPrice ), m_nArrivedValue( nArrivedValue )
    {
        m_nSelledValue = nSelledValue;
    }

    // -----------------------------------
    // Summary:
    //  デストラクタ
    virtual ~CBook(){}

    // -----------------------------------
    // Summary:
    //  一冊売れた
    void Selled()
    {
        m_nSelledValue++;
    }

    // -----------------------------------
    // Summary:
    //  状態の表示
    virtual void Disp()
    {
        
        cout << m_pszTitle << "のデータ" << endl;
        cout << "--------------------------" << endl;
        cout << "値段:" << m_nPrice << endl;
        cout << "入荷した冊数:" << m_nArrivedValue << endl;
        cout << "販売した冊数:" << m_nSelledValue << endl;
        cout << "売り上げ:" << GetSells() << endl;
        cout << "残冊数:" << GetRestValue() << endl;
    }
};

virtual キーワードは基底クラスで定義すれば、派生クラスでも virtual になるので、今回は CBook についてのみ行います。一般にはどれが仮想関数か分からなくなってしまうので派生クラスにも virtual を付します。なお、『クラスとオブジェクト 4.3. 定義ファイル、実装ファイル』で述べたクラス定義と実装を分ける場合は、やはり定義ファイルにのみ定義を行います。実装ファイルで virtual キーワードを付けるとコンパイルエラーとなります。

クラス定義を変更すれば、main 関数はそのままで OK です。それでは実行してみましょう。

上手いこと、CGeneralBook::Disp() と CMagazine::Disp() が、BookArray[i]->Disp(); の一文で呼び出しオブジェクトのクラスに応じて呼び出されて、各々著者と発売時期が表示されていることが分かりますね。このような性質をポリモーフィズムと言い、そのためにはメンバ関数に virtual を付けなさいよ、と。そういうことです。

3.3. 仮想関数は何をしているのか?

virtual キーワードを付けてメンバ関数を仮想関数にした場合、コンパイラはクラスが持つそのメンバ関数へのポインタ(アドレス)を配列として持つようになります。それを vtable(多分 virtual table の略)と言います。その上でコンパイラは、呼び出し側の BookArray[i]->Disp(); を CBook::Disp() ではなく、まず オブジェクトが持っている vtable の該当関数ポインタを呼び出すようになるのです。

つまり、コンパイル時には呼び出されるメンバ関数は決まっておらず、実行して呼び出されたとき vtable から該当するメンバ関数が発掘されるわけです。このような動作を動的結合(レイト・バインディング)とか実行時結合などと言ったりします。反対に vtable を持たないメンバ関数を静的結合(アーリー・バインディング)と言います。

以上から、他の人に使ってもらえるよう設計されたクラスについて、少なくともインターフェースになる部分は virtual を宣言した方が良い、と言うわけです。ちなみに、仮想関数にすると、その動作から多少なりと実行時間にオーバーヘッドがあるため、デフォルトが静的結合になっているそうです(が、私個人的には逆の方が良かったような気がしてなりません)。ですが、最新の CPU の場合ほとんど影響しませんし、規模が大きくなればなるほど逆に実行効率は上がる、と言うのが一般的な見方です。virtual は積極的に活用しましょう。

4. 補足事項

4.1. デストラクタは「必ず」仮想関数にする

今回、個別に各オブジェクトを delete しましたが、実使用上は、以下のように一挙に削除したいケースが多々あります。

    // 一括 delete
    for( int i = 0; i < 4; i++ ){

        delete BooksArray[i];
        BooksArray[i] = NULL;
    }

恐るべきことに、デストラクタを仮想関数にしていない場合、CBook::~CBook() が呼ばれてしまい、CGeneralBook::~CGeneralBook() や CMagazine::~CMagazine は呼び出されないのです。これは継承によるポリモーフィズムで解説した静的結合の問題と同件です。今回はデストラクタで特殊なことは何もしていませんが、メンバ変数を動的に new してデストラクタで delete 処理をするような実装を行っている場合、メモリリークを引き起こし、深刻なバグの原因となります。実際、VisualStudio の自動生成クラスも全てデストラクタは virtual です。デストラクタは何はなくとも virtual を付けて仮想関数にしましょう

5. 演習問題

『継承 5. 演習問題』で作成したプログラムをポリモーフィズムを使って実現することを検討し、実装してください。

go back to frame topframe top