こんにちは。田原です。
オブジェクト指向プログラミングの3大特長は、「隠蔽」、「継承」、「動的なポリモーフィズム」です。今回は最後の1つ「動的なポリモーフィズム」について解説します。
これは一言で言うと、複数のクラスを1つのクラスにまとめて、同じ操作で異なるクラスを操作できる仕組みです。微妙に異なるものを1つのstd::vector等で管理したいような時、たいへん有用です。
1.仮想関数
動的ポリモーフィズムは仮想関数により、実現されます。といいますか、仮想関数は動的ポリモーフィズムを実現するための仕組みと言っても過言ではありません。
非staticなメンバ関数には「仮想関数」と「仮想関数でない非staticなメンバ関数」があります。
後者には特に名前が付いていませんが、これが最も良く使われるメンバ関数ですので「通常のメンバ関数」と呼ぶことにします。
まずは、仮想関数と通常のメンバ関数の違いを説明します
1-1.通常のメンバ関数
サンプル・ソースです。
#include <iostream> struct Base { void func() { std::cout << "Base::func()\n"; } }; struct Derived : public Base { void func() { std::cout << "Derived::func()\n"; } }; void foo(Base* iBase) { iBase->func(); } int main() { Base aBase; foo(&aBase); Derived aDerived; foo(&aDerived); }
第23回目で説明したように、派生クラス変数へのポインタを基底クラスへのポインタへ変換できます。
27行目でDerivedへのポインタをBase型へのポインタiBaseへ設定しています。
そして、21行目に注目して下さい。Base型へのポインタ経由でfunc()を呼び出してます。
出力は次のようになります。
Base::func() Base::func()
Base型変数を指しているポインタ経由の場合でも、Derived型変数を指しているポインタ経由の場合でも同じくBase型のfunc()が呼出されました。
後者の場合はiBaseの指す先はDerived型のクラスですが、iBaseに渡されるポインタがDerived型の変数を指しているとは限りません。上記の前者のように呼ばれることもありますし、他のコンパイル単位からBase型変数を指すポインタで呼ばれることも考えられます。
そのため、コンパイラが関数foo()をコンパイルしている時、iBaseが実際にどのクラスの変数を指しているのかコンパイラには分かりません。ですので、コンパイラに可能なことはBase型のfunc()を呼び出すコードを生成することなのです。
1-2.仮想関数
先程のソースを1箇所修正しました。5行目のBaseのfuncの宣言部にvirtualを書き加えています。
コンピュータ・プログラムの世界では、virtualは「仮想」と訳されることが多く、このようにvirtualが付けられたメンバ関数は「仮想メンバ関数」や「仮想関数」と呼ばれます。
仮想関数はメンバ関数の一種ですので「仮想メンバ関数」と呼ばれることもありますが「仮想関数」と呼ばれる方が多いです。
今度の出力は次のようになります。
Base::func() Derived::func()
後者は、Base型へのポインタ経由で呼び出したにも関わらず、Derived型のfunc()が呼出されました。
これはポインタの指す先がDerived型変数だからなのですが、コンパイラはDerived型であることを知らない筈なのにどのようにしているのでしょうか?
#include <iostream> struct Base { virtual void func() { std::cout << "Base::func()\n"; } }; struct Derived : public Base { void func() { std::cout << "Derived::func()\n"; } }; void foo(Base* iBase) { iBase->func(); } int main() { Base aBase; foo(&aBase); Derived aDerived; foo(&aDerived); }
Wandboxで試してみる(5行目のvirtualを消すと、Derived::func()
ではなくBase::func()
が出力されます。)
1-3.仮想関数の仕組み
Base型ポインタが指しているのがDerived型であることをコンパイラが知らないにも関わらず、Derived型のfunc()が呼び出される仕組みを説明します。
まず、どちらを呼べばよいかコンパイラは知らないため、コンパイラがBaseやDerivedのfunc()を呼ぶコードを直接生成できません。したがって、何か工夫してBaseとDerivedのfunc()を切り替えて呼ぶようなコードを生成している筈です。
試しに下のコードを1-1.のプログラムのmain()関数の最後に入れてみて下さい。
std::cout << "sizeof(Base) =" << sizeof(Base) << "\n"; std::cout << "sizeof(Derived)=" << sizeof(Derived) << "\n";
Base::func() sizeof(Base) =1 sizeof(Derived)=1
BaseもDerivedもメンバ変数を一切定義しておらず、仮想関数も持っていません。
このような時は記憶領域は不要なのでサイズは0バイトになるはずですが、それでは配列の獲得の時などで破綻するため、1バイト以上のサイズにするよう標準規格で決まっています。各処理系は、この規格を守りつつ、メモリの無駄を最小にするため最小サイズである1バイトにするようです。
Derived::func() sizeof(Base) =8 sizeof(Derived)=8
こちらもメンバ変数がないのでサイズは1でも良いはずですが、仮想関数を指定しただけで、8バイトになりました。64ビット・ビルドしたのでちょうどポインタのサイズと同じです。
つまり、仮想関数を定義したことで1つのポインタが確保されたようです。
これが実は仮想関数テーブル(vtable)ポインタです。
vtableポインタの配置を調べるため、各クラスにdouble型を1つずつ追加し、各変数のアドレスを出力してみました。
#include <iostream> struct Base { double mData; Base() : mData(12.3) { } virtual void func() { std::cout << "Base::func()\n"; std::cout << " mData =" << mData << "\n"; } }; 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"; } virtual void func2() { std::cout << "Derived::func2()\n"; } }; void foo(Base* iBase) { iBase->func(); } int main() { Base aBase; foo(&aBase); Derived aDerived; foo(&aDerived); std::cout << "sizeof(Base) =" << sizeof(Base) << "\n"; std::cout << "sizeof(Derived)=" << sizeof(Derived) << "\n"; std::cout << "&aBase =" << &aBase << "\n"; std::cout << "&aBase.mData =" << &aBase.mData << "\n"; std::cout << "&aDerived =" << &aDerived << "\n"; std::cout << "&aDerived.mData =" << &aDerived.mData << "\n"; std::cout << "&aDerived.mData2=" << &aDerived.mData2 << "\n"; }
wandboxでの結果は次の通りです。
Base::func() mData =12.3 Derived::func() mData =12.3 mData2=45.6 sizeof(Base) =16 sizeof(Derived)=24 &aBase =0x7fff83ac8ed0 &aBase.mData =0x7fff83ac8ed8 &aDerived =0x7fff83ac8eb0 &aDerived.mData =0x7fff83ac8eb8 &aDerived.mData2=0x7fff83ac8ec0
この様子を図にしてみました。
aBaseとaDerived変数はローカル変数ですので、スタック上に確保されます。
vtableは同じクラスの全ての変数で同じものです。
また、プログラム実行中に変更できませんのでプログラム領域にクラス1つに付き1つ生成されます。
そして、各変数側のvtableポインタはクラスの生成時に設定されます。
クラスを生成する時は当然ですが、どのクラスを生成するのか指定しますので、生成するクラスは分かっています。なので、それに対応するvtableの場所も解るのでそこへのポインタがコンストラクタで自動的に設定されます。
1-3.のサンプル・プログラムの重要なポイントは下記の2つです。
- 37行目のBase aBase;と40行目のDerived aDerived;
aBase, aDerive用のメモリがスタックに配置され、それぞれBase, Derivedのコンストラクタが呼ばれます。この時、コンストラクタが適切なvtableポインタを設定します。 -
32行目のiBase->func();
iBaseが指す先は、Base型かも知れないし、Derived型もしれません。
しかし、どちらの場合も先頭にvtableポインタがありますので、まずはvtableポインタを調べ、それが指す先にあるvtableを使ってfunc()に該当するvtableのエントリーからfunc()のアドレスを獲得し、func()を呼び出します。
これにより、iBaseがaBaseを指す時はBaseのfunc()が呼ばれ、iBaseがaDrivedを指す時はDerivedのfunc()が呼ばれます。
virtual指定の効果範囲
ここまでのサンプルでは、Baseクラスのfunc()だけにvirtualを指定し、Derivedでは省略しています。基底クラス側で一度でもvirtual指定されたメンバ関数は、派生クラス全てでvirtualになります。つまり、派生クラス側でvirtualは書いても書かなくても結果は同じです。
2.動的ポリモーフィズム
さて、動的ポリモーフィズムは、前章で説明したような仮想関数を使います。といいますか、そのものです。そして、派生クラスが複数ある時に有用な手法です。
基底クラスが複数のクラスへ派生して、更に複数のクラスへ派生することでツリー構造となるたいへん複雑な構造を作ることもあります。例えばllvmというC++コンパイラのAST解析結果を保持するクラス群は凄まじいほど複雑です。
サンプル・プログラムとして、保管しているCDやDVDを管理するプログラムを作る場合を考えてみます。(簡単のため、動的ポリモーフィズムを説明するのに最低限必要な部分に絞ります。)
以下の仕様としましょう。
- 音楽CDは、タイトルとアーティスト名を管理
要求により”Music : [タイトル] Artists:アーティスト名”を表示する。 -
映画DVDは、タイトル、監督名、主演俳優名を管理
要求により”Movie : [タイトル] Director:監督名; Principal actor:主演俳優名”を表示する。 -
std::vectorで音楽CDと映画DVDを管理
仕様の3.を実現するためにポリモーフィズムが有用になります。
なぜかといいますと、std::vectorもそうですが、一般にC++のような静的型付け言語で動的に数が増減する複数のデータを管理する時は、全て同じ型でないと容易には管理できません。静的型付け言語は、異なる型のものを1つの配列や1つのコンテナで管理することを許さないからです。
Basicのような「動的型付け言語」であれば、同じ変数に異なる型のデータを保存することができます。そのような言語であれば、1つの配列で異なる型のデータを管理できます。
たいへん便利なのですが、大きなプログラムを開発する時には却って障害になり得ます。
一般に不具合はできるだけ上流工程で見つけた方が手戻りが少ないため、開発期間の短縮に有効と言われています。動的型付け言語では、設計段階で見逃された不具合を次に発見し得るタイミングは運が良くてデバック工程、最悪テスト工程です。
これに対してC++のような静的型付け言語の場合、ビルド時に発見できます。(型の不一致エラーによりビルドできません。しかも、テスト・コードを書く必要さえありません。)
大きなプログラム開発に慣れた人はこの効果の有用性を知っているので、多少手間はかかりますが静的型付けを好みます。
2-1.サンプル・ソース
サンプルです。
- MultiMediaクラス
音楽CDや映画DVDを統一的に扱えるようにするための基底クラスです。これは両方に共通なタイトルを管理します。 -
Albumクラス
音楽(CD)を管理するクラスです。アーティスト名を管理します。 -
Movieクラス
映画(DVD)を管理するクラスです。監督名と主演俳優名を管理します。
#include <iostream> #include <string> #include <vector> class MultiMedia { std::string mTitle; public: MultiMedia(char const* iTitle) : mTitle(iTitle) { } virtual ~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; } }; int main() { 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"; } }
AutoPointerはAutoPointer変数が削除される時に、MultiMedia型へのポインタmMultiMediaの指す先をdeleteします。デストラクタでdeleteすることで実現しています。AutoPointerのデストラクタはAutoPointer変数が削除される際に呼出されますから、これでできるのです。簡単ですね。
動的ポリモーフィズムを使うためには、ポインタ(や参照)で管理する必要があるため、このようなクラスを導入しました。
スマート・ポインタ
実は、AutoPointer的な機能が既にスマート・ポインタとして標準ライブラリで提供されています。std::unique_ptrです。
std::unique_ptrの方が機能は豊富ですし、間違って使った時エラーを報告してくれる場面が多いです。また車輪の再発明をするのもよろしくないので本来はstd::unique_ptrを使うべきです。
ここではstd::unique_ptrを改めて解説するより、単純化したソースを見て頂いた方が早いので敢えてAutoPointerを書いてみました。
std::vectorは要素数を自動的に管理してくれる動的な配列です。プログラム動作中に要素数を増やしたり減らしたりできます。
AutoPointerクラスはMultiMediaクラスへのポインタを管理するクラスです。aVectorはそのAutoPointerクラスの動的配列ですので、MultiMediaクラスへのポインタを管理しています。
次のコードで、AlbumクラスやMovieクラスを生成し、MultiMediaクラスへのポインタへ自動的に変換して、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"; }
上記ソースでgetPrimary()を呼び出していますが、aVector[i]に記録されている「MultiMediaクラスへのポインタ」が指す領域がAlbumクラスの時はAlbumクラスのgetPrimary()を、Movieクラスの時はMovieクラスのgetPrimary()を全く同じ記述で呼び出します。
この事実はプログラムを拡張する時の効果が絶大です。例えば、ゲーム・メディアを管理する必要が発生した時、GameクラスをMultiMediaクラスから派生すればこのコードは変更不要なのです。
他にも、aVector[i]が指す型を深く気にせずにコードを書けますのでプログラミングが捗りますし、if文やswitch文によるバグの作り込みがありません。
よく似ているけど微妙にコードを変えないといけない多種類のデータを管理する時に非常に効果的なテクニックです。
(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
3.まとめ
今回は、動的ポリモーフィズムの仕組みと使い方を説明しました。これはたいへん強力なプログラミング・テクニックです。使いこなすには慣れが必要ですが、これを使えるようになるとプログラミングの幅が広がります。
ところで、サンプルに載せるタイトルを探している時にQueenのビデオを見つけました。良い曲ですのでリンクを載せてます。
あと少し動的ポリモーフィズム関連で解説が残ってしまいました。何故、動的ポリモーフィズムを使う時ポインタ(や参照)でないといけないか、仮想デストラクタの話などです。
それらについて、次回解説したいと思います。お楽しみに。