C++の仮想関数を復習する
前回も登場した同僚のC#erと仮想関数の使い方について話していたとき、主にvirtualの意味についてこんがらがってきたので復習することにした。
このあたりはC++を勉強する初学者が最も躓きやすいところだよなぁとつくづく思う。
さて、これから女の子の名前を保持し、helloメソッドを呼ぶと自己紹介をしてくれるクラスを作ろうと思う。
メンバ変数は名前だけでいいよね。初期化はコンストラクタで行えばいいか。
あとは自己紹介するメソッドを作って、とやるとこんな感じになる。
class Girl { // 女の子の基底クラス protected: std::string name_; public: Girl(const std::string name) : name_(name) {} void hello(void) { // ごあいさつ std::cout << "私の名前は " + name_ + " だよ!" << std::endl; } };
うむうむ綺麗。
しかし、すべての女の子がこのような元気っ子とは限らない。
お嬢様だったらもっとおしとやかに挨拶するだろうし、幼女ならもっと舌足らずなはずだ。
しかし、だからと言ってこんなクラスを改めて作るのはナンセンスだ。
class Ojousama { // お嬢様クラス protected: std::string name_; public: Ojousama(const std::string name) : name_(name) {} void hello(void) { // ごあいさつ std::cout << "わたくしの名前は " + name_ + " ですのよ?" << std::endl; } };
確かにこれでも動作はするが、Girlクラスをコピーしてきてhelloメソッドの中を書き換えただけだ。
しかもGirlとOjousamaは、name_やその他今後増やしていく関数など共通して利用できるところがあるはずなのに互いに関連が無いため、拡張性にも乏しい。
そもそもプログラマーは可能な限りコーディングをサボりたいから、helloメソッドを再定義するだけでGirlクラスと同様の働きができるのならば嬉しいよねという考えに行き着く。
そこでオブジェクト指向プログラミングに用意されている継承を用いて、GirlクラスとOjousamaクラスをこのように書き換える。ついでにYoujoクラスも作ってあげよう。
class Girl { // 女の子の基底クラス protected: std::string name_; public: Girl(const std::string name) : name_(name) {} virtual void hello(void) { // ごあいさつ std::cout << "私の名前は " + name_ + " だよ!" << std::endl; } }; class Ojousama : public Girl { // お嬢様クラス public: Ojousama(const std::string name) : Girl(name) {} void hello(void) { // ごあいさつ std::cout << "わたくしの名前は " + name_ + " ですのよ?" << std::endl; } }; class Youjo : public Girl { // 幼女クラス public: Youjo(const std::string name) : Girl(name) {} void hello(void) { // ごあいさつ std::cout << "ふぇぇ...わたしのなまえは " + name_ + " だよぉ..." << std::endl; } };
変化したところは、Girl::helloにvirtualキーワードが追加されたことと、Ojousamaクラス及びYoujoクラスがGirlクラスを継承しコンストラクタもそれ用に書き換えたことのみ。
このようにvirtualをつけた基底クラスの関数を仮想関数と呼び、仮想関数を派生クラスで再定義することをオーバーライドと呼ぶ。
これを実行してみる。
Girl girl("夜ノ森小紅"); Ojousama ojousama("夜ノ森紅緒"); Youjo youjo("三峰真白"); girl.hello(); ojousama.hello(); youjo.hello();
ちゃんと自己紹介してくれた!!
これで、継承をうまく使ってGirlクラスを継承したお嬢様クラスと幼女クラスが出来ましたーちゃんちゃん。
……と、もちろんこれでおしまいではない。というか本題に入ってすらいない。
実はこれだけで終わるなら、Girlクラスに書いたvirtualキーワードは必要ない。
仮にvirtualがなかったとしても、さっきの結果と全く同じ結果が出力されるはずだ。
virtualキーワードをつけた仮想関数が真の力を発揮するのは、ポインタを介してhelloメソッドを呼び出したときにある。
しかもただポインタを使ったときではなく、基底クラス型のポインタを用いて派生クラスで再定義した関数を呼び出した際に効果が出る。
なんのこっちゃという感じだし、とりあえずその状況を再現したコードを見てみる。
int main(int argc, char const *argv[]) { Girl* girl[3]; girl[0] = new Girl("夜ノ森小紅"); girl[1] = new Ojousama("夜ノ森紅緒"); girl[2] = new Youjo("三峰真白"); for (int i = 0; i < 3; i++) { girl[i]->hello(); delete girl[i]; } return EXIT_SUCCESS; }
これを実行すると、さっきと同じ結果が出力される。
しかしプログラムをよく見ると、Girl型のポインタにOjousama型のインスタンスやYoujo型のインスタンスを代入していたり、Girl型であるポインタを使ってhelloメソッドにアクセスしているにも関わらず、出力結果が様々なクラスのhelloメソッドによるものになっていたりと、一見して不可解な点が多い。
今日はこれについて勉強してみる。
さて、このプログラムではGirl型のポインタを、配列を使って3つ用意している。
そのうち1つにはGirl型のインスタンスを、1つにはOjousama型のインスタンスを、そして最後の1つにはYoujo型のインスタンスを代入している。
これをはじめに見た時は大変奇妙に思えると思う。
感覚として逆なら理解出来そうだ。
つまり、Ojousama型のポインタにGirl型のインスタンスを代入するなら出来そうに思える。しかし現実としてそれはコンパイルエラーとなり不可能だ。
これの意味を感覚で理解するのは難しいと思う。
しかし、出来ることと出来ないことをはっきりさせると理解が進むのではと思う。
例えばGirl型のポインタは、Girlクラスで定義されている関数しか呼ぶことが出来ない。
なぜなら、中身はOjousamaクラスやYoujoクラスのインスタンスでも、見かけの型はGirl型であるため、Girl型である以上Girlクラスに無い関数についての知識を持ち得ないからだ。
だから仮にYoujoクラスにomorashi関数があったとしても、girl[2]はomorashi関数を呼ぶことは出来ない。
逆にGirl型で定義されているhelloメソッドにはもちろんアクセスできる。
しかし注意すべきは、helloメソッドの中身がポインタに代入されているオブジェクトの型によって変更されていることだ。
この時何が起きているかのイメージとしては、Girl型であるgirl[1]やはまずGirlクラスのhelloメソッドを呼ぼうとするが、Girl::helloについているvirtualキーワードは、この関数は継承先で再定義されているかもしれないから確認してね、再定義されているならそっちを呼んでねということを表しており、これを知ったgirl[1]は次に実際のクラスであるOjusamaクラスのメンバ関数にhelloが無いかを確認しに行き、そちらでhelloの再定義を確認したからそちらを呼んだ、と言った感じか。
だから、もしvirtualキーワードを付け忘れると、girl[1]やgirl[2]は特に何も考えずにGirlクラスのhelloを呼ぶため、
というように全部基底クラスのhelloを呼んでしまう。
まあこの辺は教科書の内容。
でもこれなにに使うの?ってところがよくわからないままだった。というか今でもよくわからない。
おそらく最大の特徴は、コンパイル時ではなく実行時にその振る舞いを変えられることだろう。
例えばこんな(糞)コード
Girl* girl[3]; for (int i = 0; i < 3; i++) { std::string name; int age; std::cout << "名前:"; std::cin >> name; std::cout << "年齢:"; std::cin >> age; if (age < 6) girl[i] = new Youjo(name); else girl[i] = new Girl(name); girl[i]->hello(); }
年齢と名前の入力に対し、年齢が6才未満であればYoujoクラスとして働き、それ以上であればGirlクラスとして働く。
同一の変数を用いつつ、実際の型や呼び出す関数を実行時に割り当てるなど、ここまで柔軟な振る舞いが可能であるところがすごいところなんだと思う。
というわけでvirtualについて復習してみた。
ちなみに実行時に呼び出す関数を変えるだけなら、関数ポインタ・関数オブジェクト・ラムダ式など他の手段はいくらでもある。
僕はこれまで主に関数ポインタで頑張ってたな。
どれが良いのかは人の価値観に依りそうだけど、役割によって厳密なクラス分割を行いたいのであれば、割りと重要な知識なのかなとも思って復習してみた。
なんか自分のなかでもきっちり整理できて、こういう基礎に立ち返るのもなかなか楽しい試みだ。