C++でのポリモーフィズムと継承と、コード保守性
結論から言うと「ポインタを使うしかない」ということなのですが,まずはいくつかの問題とパターンを見てみましょう.
初めは、C++で継承を使ったポリモーフィズム実現まで
まず例題として複数クラスが関連したものを想定します.今回は下記のような小説と本棚をクラスで書くこととします.
class Novel{ //小説クラス public: std::string title; //タイトル std::string author; //作者名 std::string illustrator; //挿絵作者名 //コンストラクタ Novel(); Novel(std::string t, std::string a, std::string i) : title(t), author(a), illustrator(i){}; virtual ~Novel(); //「==」演算子定義 bool operator==(Novel& ob){ return illustrator == ob.illustrator && author == ob.author; } //文字表現変換関数 std::string to_s(void){ return "[" + title + "]," + "作:" + author + "," + "絵:" + illustrator; } } class Shelf{ //本棚のクラス public: std::vector<Novel> list; //本棚の実体 //コンストラクタ Shelf(); virtual ~Shelf(); } int main(void){ //小説を作る Novel b = Novel("羅生門","芥川竜之介",""); //本棚を作る Shelf s = Shelf(); //小説を本棚に収納 s.list.push_back(b); }
さて,このようなコードを受け取ったとき,「あー、マンガ用にClass Comicを追加したいな」というときがあると思います.「管理対象のあるクラスと同じようなもので、新しい種類が必要となるとき」ですね.結構良くあると思います.今回のテーマはそういう場合、「コード保守性」・「ポリモーフィズム」・「インヘリタンス」を考えるとどうなるかということです.まあ、C++ってやっぱやっかいですよ……。
まずはComicクラスを作る
Comicのクラスを次のように作りました.
class Novel{ //小説クラス public: std::string title; //タイトル std::string author; //作者名 std::string illustrator; //挿絵作者名 Novel(); Novel(std::string t, std::string a, std::string i) : title(t), author(a), illustrator(i){}; virtual ~Novel(); bool operator==(Novel& ob){ return title == ob.title && author == ob.author && illustrator == ob.illustrator; } std::string to_s(void){ return "[" + title + "]," + "作:" + author + "," + "絵:" + illustrator; } } class Comic{ //マンガのクラス public: std::string title; //タイトル std::string author; //作者名 std::string magazin; //雑誌名←小説とここが違う //ほとんどNovelと同じ Comic(); Comic(std::string t, std::string a, std::string m) : title(t), author(a), magazin(m){}; virtual ~Comic(); bool operator==(Comic& ob){ return title == ob.title && author == ob.author && magazin == ob.magazin; } std::string to_s(void){ return "[" + title + "]," + "作:" + author + "," + "掲載:" + magazin; } } class Shelf{ public: std::vector<Novel> list; /*←NovelクラスのVector*/ Shelf(); virtual ~Shelf(); } int main(void){ Novel n = Novel("羅生門", "芥川竜之介",""); Comic c = Comic("ドラえもん", "藤子不二雄", "コロコロ"); Shelf s = Shelf(); s.list.push_back(n); s.list.push_back(c); //←Novelクラスではないので入らない }
こうしたとき,「クラスShelf」は上手く動きません.メンバlistがNovelのVectorクラスだからです.しかし、ShelfクラスでNovelとComicを管理したいです。そこで,上位クラス「Book」を作り,NovelとComicはBookを継承することにします.それに合わせて、Shelf::listをstd::vector
上位クラスの継承
class Book{ //共通部分をまとめたBookクラス std::string title; //タイトル std::string author; //作者名 Book(); Book(std::string t, std::string a) : title(t), author(a){}; virtual ~Book(); } class Novel : public Book{ //←Bookを継承 public: std::string illustrator; //←挿絵作者名だけ残す Novel(); Novel(std::string t, std::string a, std::string i) : Book(t,a), illustrator(i){};//←上位クラスのコンストラクタで初期化 /*その他省略*/ } class Comic : public Book{ //ComicもNovelとほぼ同様の修正 public: std::string magazin; //雑誌名 Comic(); Comic(std::string t, std::string a, std::string m) : Book(t,a), magazin(m){}; /*略*/ } class Shelf{ public: std::vector<Book> list; //←BookのVectorに Shelf(); virtual ~Shelf(); } int main(void){ Novel n = Novel("羅生門", "芥川竜之介",""); Comic c = Comic("ドラえもん", "藤子不二雄", "コロコロ"); Shelf s = Shelf(); s.list.push_back(n); s.list.push_back(c); //両方本棚に収納できるようになる }
上位クラスの変数には,下位クラスのインスタンスが代入できます。それにより、上記のコードのコンパイルが通るようになります.しかし次のように文字列表現で出力させたとします.
int main(void){ Novel n = Novel("羅生門", "芥川竜之介",""); Comic c = Comic("ドラえもん", "藤子不二雄", "コロコロ"); Shelf s = Shelf(); s.list.push_back(n); //←自動アップキャスト s.list.push_back(c); std::cout << s.list[0].to_s << std::endl; //←コンパイルエラー std::cout << s.list[1].to_s << std::endl; }
するとコンパイルエラーとなります.その理由は、
下位クラスのインスタンスを上位クラスの変数に代入すると,アップキャストされる。
ということです。
Novelクラスのインスタンスnも、Comicクラスのインスタンスcも、listに追加された時点で、Bookクラスになってしまいます。しかしBookクラスには「to_s」メソッドがありません。それでコンパイルエラーになるわけです。これを解決する方法は、上位クラスBookに、仮想関数として「to_s」メソッドを定義することです。
class Book{ /*略……*/ virtual std::string to_s(void){//←仮想関数でto_sを定義 return "作:" + author; } } class Novel : public Book{ public: /*略……*/ std::string to_s(void){ return Book::to_s() + "," + /*←上位クラスのto_sを使う*/ "絵:" + illustrator; } } class Comic{ public: /*略……*/ std::string to_s(void){ return Book::to_s() + "," + "掲載:" + magazin; } } class Shelf{ public: std::vector<Book> list; Shelf(); virtual ~Shelf(); } int main(void){ Novel n = Novel("羅生門", "芥川竜之介",""); Comic c = Comic("ドラえもん", "藤子不二雄", "コロコロ"); Shelf s = Shelf(); s.list.push_back(n); s.list.push_back(c); std::cout << s.list[0].to_s << std::endl; //←コンパイルは通るが、作者名しか表示されない std::cout << s.list[1].to_s << std::endl; }
コンパイルは通るようになりますが、作者名しか表示されません.理由は先ほどと同じで,アップキャストされているため、Book::to_s()が呼ばれてしまうわけです。これを解決する方法が、ポインタを使ったポリモーフィズムです。
ポリモーフィズムの実現
ポインタも通常のクラスインスタンスと同様に、上位クラスのポインタ変数には下位クラスのポインタが代入できます.
インスタンスを代入する場合との違いは、ポインタが指す実体はアップキャストされないことです。
つまり「ポインタ自体はアップキャストされるが、ポインタが指している先はアップキャストされない」ということでしょう.これを使う事でC++ではポリモーフィズムが実現できます。
しかし、正直なんかポインタを経由することで、実体のアップキャストを回避するというバッドノウハウでポリモーフィズムを実現しているという気がしなくもないです.
ではクラスShelfの管理変数 list をBook*型にします.ただし今回はスマートポインタであるboost::shared_ptrを使う事にします.このスマートポインタとは、動的に確保したメモリを不必要になった時点で自動で解放をしてくれるポインタのことです。メモリリークをなくすために積極的に使うべきです。
#include <boost/shared_ptr.hpp> /*略……*/ class Shelf{ public: //↓スマートポインタを使ったBookポインタのVector std::vector<boost::shared_ptr <Book> > list; Shelf(); virtual ~Shelf(); } int main(void){ Shelf s = Shelf(); //↓スマートポインタを使った実体の生成。上位クラスBookのポインタに入れる boost::shared_ptr<Book> np(new Novel("羅生門", "芥川竜之介","")); boost::shared_ptr<Book> cp(new Comic("ドラえもん", "藤子不二雄", "コロコロ")); //収納できる s.list.push_back(np); s.list.push_back(cp); //↓スマートポインタは普通のポインタとして使える std::cout << s.list[0]->to_s << std::endl; //←Novel::to_s()が呼ばれる std::cout << s.list[1]->to_s << std::endl; //←Comic::to_s()が呼ばれる }
このようにポインタ管理にすることによって、NovelとComicそれぞれのto_sが呼ばれて,ポリモーフィズムが実現できます。これがC++の一般的なポリモーフィズム実現方法ですね。
ですが、……これには相当色々問題があると思うのですよ。
ポインタを使ったポリモーフィズムの問題点いろいろ
ここでは色々「あり得る状況」を見て、問題とは何かを書いていきます。
下位クラスは普通固有メソッドを持っている
まずNovelクラスは次のようなものでした。
class Novel : public Book{ //Bookを継承したNovel public: std::string illustrator; //挿絵作者名 //コンストラクタ類 Novel(); Novel(std::string t, std::string a, std::string i) : Book(t, a), illustrator(i){}; virtual ~Novel(); //演算子 bool operator==(Novel& ob){ return title == ob.title && author == ob.author && illustrator == ob.illustrator && } //上位クラスと共通メソッド std::string to_s(void){ return Book::to_s() + "絵:" + illustrator; } }
そこでnovel_method()というあるメソッドを,Novelだけに実装したとします.
class Novel : public Book{ public: std::string illustrator; Novel(); Novel(std::string t, std::string a, std::string i) : Book(t, a), illustrator(i){}; virtual ~Novel(); bool operator==(Novel& ob){ return title == ob.title && author == ob.author && illustrator == ob.illustrator; } std::string to_s(void){ return Book::to_s() + "絵:" + illustrator; } /*Novel固有のメソッド*/ void novel_method(void){ std::cout << "Novel" << std::endl; } }
そして管理クラスがVectorクラスであるときは、普通次のようにイテレーターを使ってアクセスします。
int main(void){ Shelf s = Shelf(); boost::shared_ptr<Book> np(new Novel("羅生門", "芥川竜之介","")); boost::shared_ptr<Book> cp(new Comic("ドラえもん", "藤子不二雄", "コロコロ")); s.list.push_back(np); s.list.push_back(cp); //イテレータで要素にアクセス std::vector<boost::shared_ptr <Book> >::iterator it = s.list.begin(); for(;it != s.list.end();it++){ /* ここでNovel型の時だけ (*it)->novel_method(); というように固有メソッドを呼びたい…… */ /*上位クラスに定義されている共通メソッド*/ std::cout << (*it)->to_s() << std::endl; }
さてこの「novel_method」はどう呼べば良いのでしょうか。方法は次のようになります。
- 上位クラスにnovel_method()を仮想関数として定義しておく
- 実行時型情報 (RTTI: Real Time Type Identification)を利用し、ダウンキャストする
まず一番目の方法ですが,次のようにBookクラスにダミーとして書いておく方法です.
class Book{ /*略……*/ virtual void novel_method(void){//←仮想関数でダミー定義 return; } } int main(void){ Shelf s = Shelf(); boost::shared_ptr<Book> np(new Novel("羅生門", "芥川竜之介","")); boost::shared_ptr<Book> cp(new Comic("ドラえもん", "藤子不二雄", "コロコロ")); s.list.push_back(np); s.list.push_back(cp); //イテレータで要素にアクセス std::vector<boost::shared_ptr <Book> >::iterator it = s.list.begin(); for(;it != s.list.end();it++){ /*普通に呼べるようになる*/ (*it)->novel_method(); //←Novelの時はNovel::novel_method() /*上位クラスに定義されている共通メソッド*/ std::cout << (*it)->to_s() << std::endl; }
しかし以下の点で「コード保守性」が激しく落ちると思います。
- ある下位クラスにメソッドが追加される度に、上位クラスを修正する必要がある
- 他には何ら関係のないメソッドが上位クラス、他の下位クラスに定義されてしまう
下位クラスを上位クラスと関係無く拡張できるのが「インヘリタンス(継承)」の重要な利点だと思います。しかしこのように、下位クラスにメソッドを追加するために上位クラスを修正するというのは、コード保守性が激しく落ちます。
二番目の方法ですが、これはRTTIを使う方法です。C++にはtypeid関数というものが有ります。これは実体の型情報を持ってくることができる関数です。なので実行時に型を検査することで、次のように分岐させることができます。
int main(void){ Shelf s = Shelf(); boost::shared_ptr<Book> np(new Novel("羅生門", "芥川竜之介","")); boost::shared_ptr<Book> cp(new Comic("ドラえもん", "藤子不二雄", "コロコロ")); s.list.push_back(np); s.list.push_back(cp); std::vector<boost::shared_ptr <Book> >::iterator it = s.list.begin(); for(;it != s.list.end();it++){ if(typeid(**it) == typeid(Novel)){ //←実体がboost::shared_ptr<Novel>クラスの時 /*shared_ptr<Book>をshared_ptr<Novel>にダウンキャストして実行*/ (dynamic_pointer_cast<Novel>(*it))->novel_method(); } std::cout << (*it)->to_s() << std::endl; } }
この点には次の問題があると思います。
どういうクラスが入っているのか全て把握する必要があるということは、下位クラスが追加される度にこのような部分に関して修正しなければならない訳です。例えば、Novelクラスが必要がなかったのでBookクラスとComicクラスだけリンクした場合なんてあると思います。その場合、「typeid(Novel)」のようにチェックしている部分でコンパイルエラーになります。
またRTTIは実行速度が結構落ちます。コンパイル時に型が決まらないので当然なのですが、早さを求めてC++で実装することもあると思います。そうしたときポリモーフィズムという単なるプログラミングスタイルのせいで実行速度が落ちるのはあまりうれしくありません。
最後の煩雑さは見れば明らかです。ダウンキャストのコードは簡易とは言い難いです。
結局「ある上位クラスを継承した下位クラスにメソッドを追加した場合、透過的に下位クラスのメソッドを使う方法」は、「どの方法もにっちもさっちもいかない」ということです。
ポインタ以外の実体が存在する可能性
ポリモーフィズムでは「上位クラスのポインタに下位クラスポインタを入れる」といいました。このような場合、下位クラスは「ポインタでしか管理できないようにするべき」です。
しかしNovelクラスを見てみましょう。
class Novel : public Book{ public: std::string illustrator; Novel(); //←普通のコンストラクタ Novel(std::string t, std::string a, std::string i) : Book(t, a), illustrator(i){}; virtual ~Novel(); bool operator==(Novel& ob){ return title == ob.title && author == ob.author && illustrator == ob.illustrator && } std::string to_s(void){ return Book::to_s() + "絵:" + illustrator; } void novel_method(void){ std::cout << "Novel" << std::endl; } }
見れば分かりますが、Novelクラスは「通常のコンストラクタ」を持っています。つまり、「ポインタで管理するべきなのにポインタ以外のインスタンスを作る事ができる」のです。安全なクラス設計をするのであれば、「Novelインスタンスは必ずshared_ptr
そこで、コンストラクタをprivateにして外部から通常のインスタンスを作れなくします。そして、Staticメソッドとして独自コンストラクタcreateを作ります。それに合わせてポインタ型をtypedefで定義しておきましょう。
class Novel : public Book{ public: typedef boost::shared_ptf<Novel> NovelPtr; std::string illustrator; //ファクトリメソッド類 static NovelPtr create(){ return NovelPtr(new Novel()); } static NovelPtr create(std::string t, std::string a, std::string i){ return NovelPtr(new Novel(t, a, i)); } virtual ~Novel(); bool operator==(Novel& ob){ return title == ob.title && author == ob.author && illustrator == ob.illustrator && } std::string to_s(void){ return Book::to_s() + "絵:" + illustrator; } void novel_method(void){ std::cout << "Novel" << std::endl; } private: Novel(); }
Comicクラスも同様にすることで、以下のようになります.
class Book{ typedef boost::shared_ptr<Book> BPtr; /*略……*/ } int main(void){ Shelf s = Shelf(); Book::BPtr np = Novel::create("羅生門", "芥川竜之介",""); //正当な生成 Book::BPtr cp = Comic::create("ドラえもん", "藤子不二雄", "コロコロ"); s.list.push_back(np); s.list.push_back(cp); std::vector< BPtr >::iterator it = s.list.begin(); for(;it != s.list.end();it++){ std::cout << (*it)->to_s() << std::endl; } }
しかしこの方法は一般的なコンストラクタを使う方法から離れてしまう、という問題があると思います。できれば普通のコンストラクタで適切なポインタが帰ってきて欲しい所です。
ポインタ管理の弊害その2
Novelクラスが今このようになってるとします.
class Novel : public Book{ public: typedef boost::shared_ptf<Novel> NovelPtr; std::string illustrator; //ファクトリメソッド類 static NovelPtr create(){ return NovelPtr(new Novel()); } static NovelPtr create(std::string t, std::string a, std::string i){ return NovelPtr(new Novel(t, a, i)); } virtual ~Novel(); bool operator==(Novel& ob){ return title == ob.title && author == ob.author && illustrator == ob.illustrator && } std::string to_s(void){ return Book::to_s() + "絵:" + illustrator; } void novel_method(void){ std::cout << "Novel" << std::endl; } private: Novel(); }
気になるのは,operator==(Novel&)メソッドです.Novelクラスのインスタンスは,全てスマートポインタに制限するべきでした.しかしこの「==」演算子は引数にNovel&をとります.つまり,
Book::BPtr np1 = Novel::New_Novel("羅生門", "芥川竜之介",""); Book::BPtr np1 = Novel::New_Novel("羅生門", "芥川竜之介",""); np1 == np2; //shared_ptr<Book>::operator==()が呼ばれてしまう
こういう比較で小説を比べたいわけですが基本できません。np1,np2の型は、Book::BPtrというのがのしかかります。妥協して「*」演算子をつけるようにするしかないのですが、それでも中々上手く行きません。この問題は非常にやっかいだと思うのですよ。
まず「Bookクラスに「==」演算子をVirtualで定義すれば良い」と考えるでしょう。そこで,次のように「==」演算子を定義します.
class Book{ //共通部分をまとめたBookクラス typedef boost::shared_ptr<Book> BPtr; std::string title; //タイトル std::string author; //作者名 Book(); Book(std::string t, std::string a) : title(t), author(a){}; virtual ~Book(); virtual void novel_method(void){//仮想関数で定義 return; } virtual bool operator==(Book& ob){ return title == ob.title && author == ob.author; } } class Novel : public Book{ /*略……*/ bool operator==(Novel& ob){ //普通にオーバーライド return this->Book::operator(ob) && illustrator == ob.illustrator && } bool operator==(NovelPtr& ob){ //NovelPtr型の==を作ってみる return this->Book::operator(*ob) && illustrator == ob->illustrator; } }
そして試してみますが、上手く行かないか、悲惨なことになります。
Book::BPtr np1 = Novel::New_Novel("羅生門", "芥川竜之介",""); Book::BPtr np1 = Novel::New_Novel("羅生門", "芥川竜之介",""); np1 == np2; //shared_ptr::operator==()が呼ばれる *np1 == *np2 //「*」演算子をつけても、Book::operator==()が呼ばれる *np1 == np2 //左右の型がそろわないという悲惨な形に //↓Novel::operator==(Novel&)が呼ばれるが、ここまで書かないとダメ *dynamic_pointer_cast<Novel>(np1) == *dynamic_pointer_cast<Novel>(np2)
そこでこの比較を他に実現する方法は、かなり悲惨なことになります。
class Novel : public Book{ /*略……*/ bool operator==(Book& ob){ //←上位クラスを引数にとるようなオーバーライド if(typeid(ob) != typeid(Novel)) //自身のクラスの実体かチェック return false; bool temp = this->Book::operator(ob); //上位クラスで比較 //ダウンキャストして比較した結果と合わせてReturn return temp && illustrator == dynamic_cast<Novel>(ob).illustrator; } }
こうすると
Book::BPtr np1 = Novel::New_Novel("羅生門", "芥川竜之介",""); Book::BPtr np1 = Novel::New_Novel("羅生門", "芥川竜之介",""); *np1 == *np2 //うまくNovelの「==」が呼ばれる
これで上手く行く理由は次のようになります.
- スマートポインタで「*」はテンプレートで指定したクラス(Book)にアップキャストされたポインタ。つまり,operator==(Base&)が呼ばれる
- アップキャストされても、仮想関数をオーバーライドしていれば実体(下位クラス)のメソッドが呼ばれる
これは下位クラスで上位クラスを引数にとるメソッドをオーバーライドしなければ行けない点がコード保守性上悲惨です。下位クラスを作るのに、上位クラスを明示的に意識する必要がある訳ですから。さらに、その中身もかなり悲惨な内容です。
まとめ
C++でコード保守性をある程度犠牲にしつつも継承と多態性を使うなら、次のようになるかと思います。
- ポリモーフィズムに関連するクラスには必ず上位クラスを作る
- ポリモーフィズムに関連するクラスインスタンスは全てスマートポインタで管理する。必要があればコンストラクタも制限する。
- 下位クラスは、自身のクラスを対象とするようなメソッドが必要な場合、上位クラスで仮想関数として定義し、そのメソッドをオーバーライドする(演算子「==」等)。若しくは諦めてダウンキャストする。
- ある下位クラス固有のメソッドを呼び出す場合は、typeidメソッドを使い篩い分けをする
正直C++でポリモーフィズムは使うべきではないのかも知れないと思います。
C++11ではautoが導入されましたが、これは静的型推論機構なのでおそらく対処できないでしょう。