仮想関数

オーバーライド再び

仮想関数について説明する前に、少し継承に関して復習をしてみましょう。基本編の第7日目で学んだとおり、クラスは、継承によって、あるクラスの機能を受け継いだ、新しいクラスを作ることができました。

このとき、元になるクラスのことを、親クラスおよび、スーパークラス、その機能を受け継いだクラスのことを、子クラスおよび、サブクラスと呼びました。このとき、サブクラスの中に、スーパークラスと同一の名前、同一の引数、同一の戻り値を持つメンバ関数があった場合、これをオーバーライドと言い、サブクラスでは、そちらで定義された処理が優先されました。

仮想関数

しかし、ここに一つの問題点があります。それは、スーパークラスからサブクラスのメンバ関数を呼び出すことができないという点です。例えば、鳥というクラスがあったとします。その場合、それを継承したサブクラスとして考えられるのは、「にわとり」や「カラス」、「はと」といったものでしょう。

さらに、「鳥」というクラスには、「鳴く」というメソッドが存在したとします。その時、「にわとり」は「コケコッコー」と、「カラス」は「カァカァ」、といった具合に、鳴き方が異なります。ところが、プログラムによっては、その「鳥」の種類とは無関係に、「鳴く」というメソッドを呼び出したい場合があるのです。

そういった場合、従来のオーバーライドでは不可能です。そこで、「鳴く」というクラスのメソッドを呼び出したとき、その鳥の種類によって、柔軟に結果が変わるようにしたいものです。そういった場合、役に立つのが、仮想関数という概念です。

サンプルプログラム

では、仮想関数について説明する前に、実際に以下のサンプルプログラムを実行してみて下さい。

listex6-1:bird.h
#ifndef _BIRD_H_
#define _BIRD_H_

#include <iostream>
#include <string>

using namespace std;

class CBird{
public:
	//	「鳴く」関数(仮想関数)
	virtual void sing(){ cout << "鳥が鳴きます" << endl; }
	//	「飛ぶ」関数
	void fly(){ cout << "鳥が飛びます" << endl; }
};

#endif // _BIRD_H_
chicken.h
#ifndef _CHICKEN_H_
#define _CHICKEN_H_

#include "bird.h"

//	ニワトリクラス
class CChicken : public CBird{
public:
	//	「鳴く」関数(仮想関数)
	void sing(){ cout << "コケコッコー" << endl; }
	//	「飛ぶ」関数
	void fly(){ cout << "にわとりは飛べません" << endl; }
};

#endif // _CHICKEN_H_
crow.h
#ifndef _CROW_H_
#define _CROW_H_

#include "bird.h"

//	カラスクラス
class CCrow : public CBird{
public:
	//	「鳴く」関数(仮想関数)
	void sing(){ cout << "カーカー" << endl; }
	//	「飛ぶ」関数
	void fly(){ cout << "カラスが飛びます" << endl; }
};

#endif // _CROW_H_
main.cpp
#include <iostream>
#include <string>
#include "bird.h"
#include "chicken.h"
#include "crow.h"

using namespace std;

int main(){
	CBird* b1, *b2;
	b1 = new CCrow();
	b2 = new CChicken();
	b1->sing();
	b1->fly();
	b2->sing();
	b2->fly();
	return 0;
}
実行結果
カーカー
鳥が飛びます
コケコッコー
鳥が飛びます

virtual

CCrowおよび、CChickenクラスの共通の親クラスである、CBirdクラスは、sing()および、fly()>というメソッドがあり、それらは共にオーバーライドされています。

しかし、sing()メソッドには、先頭にvirtual(バーチャル)という修飾子がついています。これにより、実行結果に違いがでます。

virtualがついたメソッドは、サブクラスに実装された、メンバ関数が実行されています。したがって、同じCBirdクラスのポインタである、b1,b2で、同じメソッドを呼び出しても、実行結果が異なります。それに対し、virtualがついていない、fly()メソッドは、CBirdクラスのものが呼び出され、それぞれの子クラスに実装されているものは呼び出されません。

このように、virtualがついかメンバ関数のことを、仮想関数(かそうかんすう)と呼びます。(図6-1.)

図6-1.仮想関数の働き
仮想関数とvirtual

抽象クラスと完全仮想関数

抽象的なクラスとメソッド

前述の「鳥」の例について再び考えてみましょう。「鳥」をあらわすクラスとして、「CBird」というクラスを作りました。しかし、考えてみて下さい。「にわとり」という名の鳥、もしくは、「カラス」という鳥は実在しますが、「鳥」という名の鳥は存在するでしょうか?

もちろん、そういったものは実際には存在しません。そのため、CBirdクラスに、singというメソッドがあるのはわかりますが、そこに何らかの実装があるのは、なんだか変な感じがします。(図6-2.)

図6-2.抽象クラスの考え方
抽象クラスの考え方

サンプルプログラム

そこで、C++言語では、そういった概念に対応できるような方法も用意されています。まずは、listex6-1の、bird.hを、以下のように変えてみてください。

listex6-1:bird.h(改)
#ifndef _BIRD_H_
#define _BIRD_H_

#include <iostream>
#include <string>

using namespace std;

class CBird{
public:
	//	「鳴く」関数(仮想関数)
	virtual void sing()=0;
	//	「飛ぶ」関数
	void fly(){ cout << "鳥が飛びます" << endl; }
};

#endif // _BIRD_H_

実行結果は、まったく変わらないはずです。

完全仮想関数

12行目の仮想関数に注目してみてください。後ろに「=0」がついており、同時に実装が省略されています。こういった仮想関数のことを、完全仮想関数(かんぜんかそうかんすう)と言います。

完全仮想関数
virtual void sing()=0;

完全仮想関数は、メソッドそのものは存在するけれども、実装がないクラスです。実装は、このクラスを継承した子クラスにされることが前提となっています。

抽象クラス

このような、完全仮想関数を一つでも持つクラスのことを、抽象クラス(ちゅうしょうくらす)と言います。「にわとり」や「カラス」と違い、「鳥」という概念が抽象的な概念であったのと同じことです。

抽象クラスの最大の特徴は、インスタンスを作ることが出来ないということです。ためしに、更にmain.cppを、以下のように変えてみてください。

listex6-1:main.cpp(改)
#include <iostream>
#include <string>
#include "bird.h"
#include "chicken.h"
#include "crow.h"

using namespace std;

int main(){
	CBird* b1, *b2, *b3;
	b1 = new CCrow();
	b2 = new CChicken();
	b3 = new CBird();
	b1->sing();
	b1->fly();
	b2->sing();
	b2->fly();
	return 0;
}

このプログラムは、13行目でエラーが発生するはずです。なぜなら、完全仮想関数を持つCBirdクラスは、抽象クラスとなるため、インスタンスを生成できないからです。

仮想デストラクタ

サンプルプログラム

基本編第4日目で、デストラクタには、virtualをつけるようにする必要がある、ということを説明しました。ここでは、改めてその理由を説明しましょう。まずは、以下のサンプルを実行してみてください。

listex6-2:sup1.h
#ifndef _SUP1_H_
#define _SUP1_H_

#include <iostream>

using namespace std;

class CSup1{
public:
	CSup1(){ cout << "CSup1のコンストラクタ" << endl; }
	~CSup1(){ cout << "CSup1のデストラクタ" << endl; }
};

#endif // _SUP1_H_
sup2.h
#ifndef _SUP2_H_
#define _SUP2_H_

#include <iostream>

using namespace std;

class CSup2{
public:
	CSup2(){ cout << "CSup2のコンストラクタ" << endl; }
	virtual ~CSup2(){ cout << "CSup2のデストラクタ" << endl; }
};

#endif // _SUP2_H_
sub1.h
#ifndef _SUB1_H_
#define _SUB1_H_

#include "sup1.h"

class CSub1 : public CSup1{
public:
	CSub1(){ cout << "CSub1のコンストラクタ" << endl; }
	~CSub1(){ cout << "CSub1のデストラクタ" << endl; }
};

#endif // _SUB1_H_
sub2.h
#ifndef _SUB2_H_
#define _SUB2_H_

#include "sup2.h"

class CSub2 : public CSup2{
public:
	CSub2(){ cout << "CSub2のコンストラクタ" << endl; }
	~CSub2(){ cout << "CSub2のデストラクタ" << endl; }
};

#endif // _SUB2_H_
main.cpp
#include <iostream>
#include "sup1.h"
#include "sub2.h"
#include "sub1.h"
#include "sub2.h"

using namespace std;

int main(){
	CSup1 *s1 = new CSub1();
	CSup2 *s2 = new CSub2();
	delete s1;
	delete s2;
	return 0;
}
実行結果
CSup1のコンストラクタ
CSub1のコンストラクタ
CSup2のコンストラクタ
CSub2のコンストラクタ
CSup1のデストラクタ
CSub2のデストラクタ
CSup2のデストラクタ

CSub1、CSub2は、それぞれCSup1、CSup2を親クラスとして持つ子クラスです。main.cppの10行目、11行目のような形で、インスタンスを生成し、消去した場合、CSub1はデストラクタが呼ばれないのに対し、CSub2はデストラクタが呼ばれています。

では、このような違いは何に由来するのでしょうか。それは、CSub2の親クラスである、CSup2のデストラクタには、virtualが付いていることに関係しています。

仮想デストラクタ

すでに説明したとおり、virtualをつけたメソッドは、仮想関数となることから、親クラスからメンバ関数が呼ばれた場合は、子クラスの処理が実行されます。実は、これはデストラクタについても同様です。

CSub1のデストラクタにvirtualがついていので、delete時にはCSup1クラスのデストラクタのみが実行されるため、CSub1のデストラクタは実行されません。

このような場合、もしも子クラスのデストラクタに、生成したメモリの開放などといった重要な処理がある場合、それが実行されず、システムに致命的な不具合が発生する可能性があります。それに対し、virtualをつければ、サブクラスのデストラクタが実行されたのちに、親クラスのコンストラクタが実行されるので、そのような心配はなくなります。

このように仮想関数にしたデストラクタのことを、仮想デストラクタと言います。継承して利用される可能性のあるクラスのデストラクタは、必ず仮想デストラクタにしましょう。(図6-3.)

図6-3.仮想デストラクタ
仮想デストラクタ

インターフェース

サンプルプログラム

クラスの中には、完全仮想関数のみからなるクラスが存在します。そういったクラスを、一般にインターフェースなどと呼ばれています。まずは、実際のにそれを利用した以下のサンプルを見てください。

listex6-3:IInf1.h
#ifndef _IINF1_H_
#define _IINF1_H_

//	インターフェースクラス1
class IInf1{
public:
	virtual void func1() = 0;
	virtual void func2() = 0;
};

#endif // _IINF1_H_
IInf2.h
#ifndef _IINF2_H_
#define _IINF2_H_

//	インターフェースクラス2
class IInf2{
public:
	virtual void func3() = 0;
	virtual void func4() = 0;
};

#endif // _IINF2_H_
Sample.h
#ifndef _SAMPLE_H_
#define _SAMPLE_H_

#include <iostream>
#include "IInf1.h"
#include "IInf2.h"

using namespace std;

class CSample : public IInf1, public IInf2{
public:
	void func1(){ cout << "func1" << endl; }
	void func2(){ cout << "func2" << endl; }
	void func3(){ cout << "func3" << endl; }
	void func4(){ cout << "func4" << endl; }
};

#endif // _SAMPLE_H_
main.cpp
#include <iostream>
#include "IInf1.h"
#include "IInf2.h"
#include "Sample.h"

//	IInf1のみが使える関数
void foo(IInf1*);
//	IInf2のみが使える関数
void bar(IInf2*);

int main(){
	CSample * s = new CSample();
	foo((IInf1*)s);
	bar((IInf2*)s);
	return 0;
}

//	IInf1のみが使える関数
void foo(IInf1* p)
{
	p->func1();
	p->func2();
	//p->func3();
	//p->func4();
}
//	IInf2のみが使える関数
void bar(IInf2* p)
{
	//p->func1();
	//p->func2();
	p->func3();
	p->func4();
}
		
実行結果
func1
func2
func3
func4

IInf1および、IInf2は、ともに完全仮想関数しか存在しないクラスで、それ自体には特に意味がありません。しかし、それらはともにCSampleクラスで多重継承されています。いったいなぜこのような面倒なことをしているのでしょうか。

インターフェース

このようなことをする効果として、main.cppに記述されている、foo()、bar()関数を見てください。前者は引数として、IInf1後者は、IInf2のポインタを引数としています。これにより、foo()内では、IInf1のメンバ関数、bar()内では、IInf2の関数しか利用できません。

ためしに、main.cppの23~24行目、および29~30行目のコメントをとってみてください。コンパイルエラーが出ます。もともと、は、すべての関数を備えているCSampleのインスタンスですが、13行目、および14行目でそれぞれのインターフェースでキャストすることにより、それぞれの完全仮想関数が記述されている関数しか利用できないようになっているのです。

このような完全仮想関数の使い方を、インターフェースなどと呼びます。インターフェースという用語は、言語としてのC++の概念ではありませんが、オブジェクト指向言語の重要な概念の一つです。(図6-4.)

図6-4.インターフェース
インターフェース

インターフェースの効能

インターフェースを利用することのメリットは、このように、ポインタを渡す相手のクラスに対して、機能を制限したい場合などに有効です。

基本編の第6日目の継承で述べたとおり、通常、多重継承を用いることはあまり好ましいこととはされていません。しかし、例外はこのインターフェースです。JavaやC#など、現在主流のオブジェクト指向言語は、多重継承を許していません。

しかし、独自にインターフェースの機能を持っており、このサンプルのように、ひとつのクラスに対し、多数のインターフェースをつけるような使い方をしています。そのような利用方法は、現在のオブジェクト指向プログラミングにおいて、主流の考え方になっています。

そのため、C++でも、このような利用方法の場合に限っては、多重継承を用いることは有効であるといえるでしょう。