概要
「仮想メソッド」というものがどういう仕組みで実現されているのかを説明します。
(C / C++ の知識がある程度必要なので、 その辺りが全く分からない場合には内容が高度すぎるので読み飛ばし推奨。)
「.NET Framework」 の 「IL」 は、 仮想メソッド呼び出し用の命令を持っていたりします。 ですが、一般的な PC に使われている CPU が「仮想メソッド呼出し命令」みたいなものを持っているわけではなく、 .NET Framework が適切な命令に置き換えて仮想メソッド呼び出しを実現してくれています。
(要するに、C# プログラマにとっては気にする必要のない部分です。 このページの内容は、「でも中身の分からないものを使うのはなんとなく不安」という人向けです。)
一般に、仮想メソッド呼び出し(C++ など、言語によっては仮想関数呼び出しという言い方をします)は、 仮想関数テーブル(virtual function table)というものを用いて実現されています。
ここでは、C++ の仮想関数呼び出しを、 それとほぼ透過な(非オブジェクト指向言語の) C 言語コードに置き換えることで、 仮想関数テーブルの実装方法を示します。 (C# と C だとちょっと「遠い親戚」過ぎるので、このページのサンプルには C++ を使います。)
↓サンプルプログラムのソース。
題材と元ソース
ここでは、「多態性」の演習問題と同じような題材 (Shape クラスを継承した Rectangle と Circle クラスを作る) で説明をします。
まず、元となる C++ のソースを示すと以下のような感じ。
#pragma once
class Shape
{
public:
virtual double GetArea() = 0;
virtual double GetPerimeter() = 0;
};
class Rectangle : public Shape
{
public:
Rectangle(double w, double h);
virtual double GetArea();
virtual double GetPerimeter();
private:
double width;
double height;
};
class Circle : public Shape
{
public:
Circle(double r);
virtual double GetArea();
virtual double GetPerimeter();
private:
double radius;
};
#include "ShapeCpp.h"
Rectangle::Rectangle(double w, double h)
{
this->width = w;
this->height = h;
}
double Rectangle::GetArea()
{
return this->width * this->height;
}
double Rectangle::GetPerimeter()
{
return 2 * (this->width + this->height);
}
Circle::Circle(double r)
{
this->radius = r;
}
double Circle::GetArea()
{
return 3.14159265358979 * this->radius * this->radius;
}
double Circle::GetPerimeter()
{
return 2 * 3.14159265358979 * this->radius;
}
要するに、Shape クラスは図形を表していて、面積と周囲を求めるメソッドを持っています。 Rectangle、Circle はそれぞれ、矩形・円を表すクラスです。
メンバー関数と仮想関数テーブル
まず、Shape クラスの宣言に相当する C 言語コードを作ってみます。
//----------------------------------------------------------------
// class Shape に相当
typedef struct TagShape
{
void** vftable;
} Shape;
#define VF_GetArea 1
#define VF_GetPerimeter 2
typedef double TypeGetArea(Shape* this);
typedef double TypeGetPerimeter(Shape* this);
extern void* ShapeVftable[];
void ShapeCtor(Shape* this);
void ShapeDtor(Shape* this);
まず、非 OOP 言語にはメンバー関数(メソッド)なんてものはありません。 C++ で、
class Person
{
public:
int GetAge();
};
Person p;
p.GetAge();
と言うように書いていたものは、 C 言語では、
typedef struct TagPerson
{
} Person;
int PersonGetAge(Person* p);
Person p;
PersonGetAge(&p);
と書く必要があります。 (typedef してるのは、C 言語と C++ の仕様の違いのためで、 このページの内容とはあまり関係ないので説明は割愛。)
で、ShapeVftable というのが、仮想関数を実現するためのキモである仮想関数テーブルというやつです。 実体は以下のようになっています。
void* ShapeVftable[] =
{
"class Shape",
0,
0
};
void* の配列になっていて、 配列の1番目はクラスの型情報、 2番目、3番目が GetArea, GetPerimeter メンバー関数の実体をさすポインターです。 今回の場合、Shape の GetArea, GetPerimeter は純粋仮想関数 (C# でいうところの「抽象メソッド」 )なので、 0(ヌルポインター、実体がないことを示す)になっています。
コンストラクタ
ShapeCtor, ShapeDtor はそれぞれコンストラクタ、デストラクタに相当する関数です。 (当然、Ctor, Dtor は Constructor, Destructor の略。) C++ とは違って、これらを自動的に読んでくれる仕組みは持っていないので、 自分で呼び出してやる必要があります。 例えば、C++ の、
// 実際には、Shape は抽象クラスなので new できないけども。
s = new Shape();
delete s;
と同じ事をしようと思うと、以下のような書き方が必要になります。
s = (Shape*)malloc(sizeof(Shape));
ShapeCtor(s);
ShapeDtor(s);
free(s);
ちなみに、元の Shape クラスがコンストラクタ・デストラクタで特に何もしていないので、 ShapeCtor, ShapeDtor の中身もほぼ空っぽになります。 ただし、ShapeCtor の中では1つだけやっておかないといけないことがあります。 前節で説明した仮想関数テーブルの実体 ShapeVftable を、 Shape の vftable メンバー変数に代入します。
void ShapeCtor(Shape* this)
{
this->vftable = ShapeVftable;
}
void ShapeDtor(Shape* this)
{
}
この vftable は、仮想関数呼び出しの際に利用します。
クラスの継承
続いて、Rectangle クラスの宣言に相当する C 言語コードを示します。
//----------------------------------------------------------------
// class Rectangle に相当
typedef struct TagRectangle
{
Shape base;
double width;
double height;
} Rectangle;
extern void* RectangleVftable[];
void RectangleCtor(Rectangle* this, double w, double h);
void RectangleDtor(Rectangle* this);
double RectangleGetArea(Rectangle* this);
double RectangleGetPerimeter(Rectangle* this);
Shape のときと同じく、 仮想関数テーブル RectangleVftable、 コンストラクタ RectangleCtor、 デストラクタ RectangleDtor を持っています。 それに加え、 GetArea, GetPerimiter メンバー関数に相当する、 RectangleGetArea, RectangleGetPerimeter があります。
クラスのメンバー変数(Rectangle の場合は width と height)は、 そのまま構造体のメンバー変数になります。
問題は「継承」なんですが、 これは、単に、親クラスを1つ目のメンバー変数として持つことによって実現します。 例えば、
Rectangle* r = (Rectangle*)malloc(sizeof(Rectangle));
Shape* s = (Shape*)r;
if (s->vftable == r->base.vftable)
printf("true");
というようなコードを書いた場合、 ちゃんと、 true という文字列が出力されるはずです。 (&r と &r->base が同じアドレスを表している。)
派生クラスの実装
Rectangle クラスの仮想関数テーブルの実体 RectangleVftable は以下のようになります。
void* RectangleVftable[] =
{
"class Rectangle",
RectangleGetArea,
RectangleGetPerimeter
};
Shape のときと同じく、 配列の1つ目がクラスに関する情報で、 2つ目、3つ目がそれぞれ GetArea, GetPerimeter に相当する関数へのポインターです。 Shape のときと違って、GetArea, GetPerimeter が実体を持っているので、 0 以外のちゃんとした値がセットされています。
コンストラクタに相当する関数 RectangleCtor で、 vftable メンバーに RectangleVftable を代入します。 ここで、1つ注意が必要なのは、 C++ と違って基底クラスのコンストラクタを自動的に呼んでくれるような機能はないので、 プログラマが明示的に ShapeCtor を呼び出す必要があります。 (デストラクタも同様。)
void RectangleCtor(Rectangle* this, double w, double h)
{
ShapeCtor(&this->base);
this->base.vftable = RectangleVftable;
this->width = w;
this->height = h;
}
void RectangleDtor(Rectangle* this)
{
ShapeDtor(&this->base);
}
ちなみに、RectangleGetArea, RectangleGetPerimeter の実体は以下のような感じ。 元の C++ の Rectangle クラスの GetArea, GetPerimeter とほぼ同じです。 (関数の引数に Rectangle* this が増えているだけ。)
double RectangleGetArea(Rectangle* this)
{
return this->width * this->height;
}
double RectangleGetPerimeter(Rectangle* this)
{
return 2 * (this->width + this->height);
}
仮想関数の呼び出し
Circle クラスの実装は Rectangle とほぼ同様なので説明は省略。 次は、仮想関数呼び出しの C 言語化を行います。
C++ で、以下のようなコードを考えます。
void print(Shape* s)
{
printf("%s\n%f\n%f\n\n",
typeid(*s).name(),
s->GetArea(),
s->GetPerimeter());
}
void TestCpp()
{
Shape* s;
s = new Rectangle(2, 3);
print(s);
delete s;
s = new Circle(1.41421356);
print(s);
delete s;
}
Rectangle, Circle のインスタンスそれぞれについて、 クラス名、面積、周囲を求めて表示しています。
これに相当する C 言語コードは以下のようになります。
void print(Shape* s)
{
printf("%s\n%f\n%f\n\n",
(char*)s->vftable[0],
((TypeGetArea*)s->vftable[VF_GetArea])(s),
((TypeGetPerimeter*)s->vftable[VF_GetPerimeter])(s));
}
void TestC(void)
{
Shape* s;
s = (Shape*)malloc(sizeof(Rectangle));
RectangleCtor((Rectangle*)s, 2, 3);
print(s);
RectangleDtor((Rectangle*)s);
free(s);
s = (Shape*)malloc(sizeof(Circle));
CircleCtor((Circle*)s, 1.41421356);
CircleDtor((Circle*)s);
print(s);
free(s);
}
これで、先ほどの C++ コードと同じ出力が得られます。
このままだと分かりにくいので、 仮想関数呼び出しと、型情報の取得の部分だけを取り出してみましょう。 まずは C++。
typeid(*s).name(),
s->GetArea(),
s->GetPerimeter());
続いて C 言語版。
(char*)s->vftable[0],
((TypeGetArea*)s->vftable[VF_GetArea])(s),
((TypeGetPerimeter*)s->vftable[VF_GetPerimeter])(s));
型情報の取得は簡単ですね。 仮想関数テーブルの先頭に型情報を入れたので、それを取り出すだけです。
仮想関数の呼び出しは少々面倒なんですが、 要するに、
-
仮想関数テーブルには関数ポインターが入っているので、それを取り出す。
-
その関数ポインターを介して、メンバー関数の実体を呼ぶ。
ということをしています。
vftable はコンストラクタに相当する関数 ShapeCtor, RectangleCtor, CircleCtor の中で、 それぞれ ShapeVftable, RectangleVftable, CircleVftable に初期化されています。 なので、
Shape* s = (Shape*)malloc(sizeof(Rectangle));
RectangleCtor((Rectangle*)s, 2, 3);
((TypeGetArea*)s->vftable[VF_GetArea])(s),
というコードでは、 s は Shape のポインター型の変数ですが、 正しく RectangleGetArea を呼び出すことができます。
仮想関数呼び出しのコスト
演算コスト
ここで、通常の関数呼び出しと仮想関数呼び出しの比較をしてみましょう。
もし、GetArea が仮想関数ではなかった場合、 (C 言語版の)RectangleGetArea の呼び出しは以下のようになります。
Shape* s = (Shape*)malloc(sizeof(Rectangle));
RectangleCtor((Rectangle*)s, 2, 3);
RectangleGetArea((Rectangle*)s);
一方、仮想関数呼び出しは以下のようになります。
Shape* s = (Shape*)malloc(sizeof(Rectangle));
RectangleCtor((Rectangle*)s, 2, 3);
((TypeGetArea*)s->vftable[VF_GetArea])(s),
その差は、仮想関数テーブル vftable の参照を行うかどうかということになります。 要するに、 「仮想関数呼び出しの演算コストはテーブルの参照1回分」 ということです。
このコストを小さいと見るか大きいと見るかは状況次第ですが、 当たり障りのない言い方をすると、 「微々たるコストだけども、避けれるなら避けたい」 といった所です。
ちなみに、メンバー関数に virtual キーワードが付いていても、 必ずしも仮想関数呼び出しになるわけではありません。 例えば、以下のようなコード(要するに、ポインターや参照を使っていない)では、 コンパイル時にどのメンバー関数を呼び出せばいいのかが確定するので、 通常のメンバー関数呼び出しになります。
Rectangle r(2, 3);
r.GetArea();
当然、仮想関数であることのメリットも一切受けないことになるので、 状況による使い分けが必要です。
メモリコスト
メモリの観点から見ると、 仮想関数を使うためには仮想関数テーブル分のメモリが必要になります。 具体的なメモリの量は、
-
仮想関数1つに付き、関数ポインター1つ分。
-
インスタンス1つに付き、vftable 分(これもポインター1つ分)。
となります。 ポインターのサイズは、処理系によりますが、 今だと大体は4バイトもしくは8バイトです。 クラス自体のサイズの大小に関係なく、常にこの4~8バイト分のサイズ増加があります。 なので、小さいクラスほど、相対的に vftable のコストが大きくなります。
C++ の仕様では、クラス中に1つでも仮想関数があると、 vftable が自動生成されます。 逆に、1つも仮想関数がなければ vftable は生成されません。 (したがって、型情報(typeid)も使えなくなります。) 「必要がなければ(特に小さいクラスでは)仮想関数は使うな」ということなんですが、 「すでに仮想関数が1つあるのに、2つ目の仮想関数の追加をためらう理由はそれほどない」 と言えます。
C# や Java では
C# や Java では、 型情報の取得のために、仮想メソッド(C++ でいうと仮想関数)が1つもない場合でも、 有無を言わせず vftable 相当の物が自動生成されます。 Java ではこのコストを避ける方法はありません。
C# の場合には、 1つも仮想関数が必要ないのなら、class ではなくて struct にすることで、 vftable 分のメモリを節約することができます。 (値型は継承不可、仮想メソッド定義不可。 したがって、仮想関数テーブル相当の物は必要ない。) (「1つも必要ない」というよりは、 「将来的にも絶対に1つも必要としない自信がある」場合に struct を使います。)
(struct を含む)値型を object 型の変数に代入すると、 ちゃんと ToString などの仮想メソッド呼び出しができるわけですが、 これは object への代入の際に仮想関数テーブルに相当する情報を追加する処理が行われるためです。 この処理を boxing と呼びます。