概要
継承構造を持つクラスのコンストラクターの挙動と注意点の話を少々。
コンストラクターの実行順序
派生クラスのインスタンスが生成される際、 派生クラスのコンストラクターの前に、基底クラスのコンストラクターが呼び出されます。
class B
{
public B()
{
Console.Write("base\n");
}
}
class D : B
{
public D()
{
Console.Write("derived\n");
}
}
class Program
{
static void Main(string[] args)
{
D d = new D();
}
}
base derived
なので、派生クラスのコンストラクター内では、 基底クラスのメンバーはちゃんと初期化済みだと思って使えます。
class B
{
public double x;
public B()
{
this.x = 5;
}
}
class D : B
{
public double y;
public D()
{
// ↓ ちゃんと y == 25 になる。
this.y = this.x * this.x;
}
}
class Program
{
static void Main(string[] args)
{
D d = new D();
Console.Write(d.y);
}
}
25
仮想メソッド呼び出し
「派生クラスのコンストラクターの前に基底クラスのコンストラクターが呼ばれる」というルールは、 たいていどの言語でも同じルールです。 C++ でも Java でもそういうルールでコンストラクターを呼び出します。
でも、1つだけ注意すべき点があります。 コンストラクター中の仮想メソッド呼び出しの扱いに関して。 例えば、以下のような感じ。
class B
{
public B()
{
Console.Write(this.Name());
}
public virtual string Name()
{
return "base";
}
}
class D : B
{
public D()
{
}
public override string Name()
{
return "derived";
}
}
class Program
{
static void Main(string[] args)
{
D d = new D();
}
}
この挙動は C++ と C# で違います。 C++ で、このコードに相当するものを書いて実行すると、 base と表示されます。 派生クラス D のインスタンスを生成しているにもかかわらず、 基底クラス B の Name メソッドが呼ばれます。
base
一方、C# では、以下のように、派生クラスの Name メソッドが呼ばれます。
derived
仮想メソッド(あるいは、C++ では仮想関数と呼ぶ)の呼び出しは、 仮想メソッドテーブル(仮想関数テーブル)というものを通して行います。 Name というメソッドが呼ばれたときに、 実際にはどのメソッド(D.Name なのか B.Name なのか)を呼べばいいか、 テーブル中に参照情報が書かれていて、 それを見て実際のメソッド呼び出しが行われます。
で、C++ では、コンストラクターの頭で仮想関数テーブルが更新されます。 基底クラスのコンストラクター内では、まだ仮想関数テーブルが派生クラスのものに更新されていません。
一方、C# では、仮想メソッドテーブルの更新だけは先にして、 それから基底クラスのコンストラクター → 派生クラスのコンストラクターの順で処理が行われます。
余談: 実行順序に関して
「コンストラクター初期化子」で説明したように、 初期化の順序は、メンバー変数初期化子 → コンストラクター初期化子 → コンストラクター本体の順序になります。
基底クラスのコンストラクター呼び出しは、この2つ目、コンストラクター初期化子のところに入ります。 以下のようなコードを書くと実行順序がはっきりします。
class Member
{
public Member(string s)
{
Console.Write("Member {0}\n", s);
}
}
class Base
{
Member x = new Member("base");
public Base()
{
Console.Write("Base()\n");
}
}
class Derived : Base
{
Member x = new Member("derived");
public Derived()
{
Console.Write("Derived()\n");
}
}
class Program
{
static void Main()
{
new Derived();
}
}
Member derived Member base Base() Derived()
で、メンバー変数初期化子を使って値を設定した変数は、 基底クラスのコンストラクターが呼ばれた時点ですでにきちんと初期化済みな事が保証されます。 基底クラスから仮想メソッドを呼び出す場合、 このことに留意してコードを書くとトラブルになりにくいです。
コンストラクター中での仮想メソッド呼び出しの問題点
「それでいいじゃないか、仮想メソッドってのは動的な型に基づいて行われるんだから、 これが期待通りの動作じゃないの?」と思うかもしれません。
まあ、それはそうなんですが、1つ問題があります。 基底関数のコンストラクター内で仮想メソッドが呼ばれた時点では、 派生クラスのメンバー変数は初期化されていない(派生クラスのコンストラクターはまだ呼ばれてない)んですよね。 例えば、以下のコードを見てください。
class B
{
public B()
{
Console.Write(this.Name());
}
public virtual string Name()
{
return "anonymous";
}
}
class D : B
{
string name;
public D(string name)
{
this.name = name;
}
public override string Name()
{
return this.name;
}
}
class Program
{
static void Main(string[] args)
{
D d = new D("derived");
}
}
前節の内容と比べて何が違うかというと、D.Name メソッド内で派生クラスのメンバー変数である name の値を読み出しています。
B のコンストラクター内で Name メソッドが呼ばれた時点では、 まだ D のコンストラクターは実行されていません。 したがって、name 変数はまだ初期化されていない(null になっている)状態で、 結局、このコードの実行結果は何も出力されません。
まとめ
C# では、コンストラクター内での仮想メソッド呼び出しは、 動的な型に基づいて呼び出されます。
ただし、メンバー変数を読み出すような仮想メソッドをコンストラクター内から呼び出すと、 正しい値が読めないので注意が必要です。 (メンバー変数にアクセスしない場合や、値を書き込む方は OK。)