C# における仮想メソッドと非仮想メソッドのオーバーロード解決

Annotated C# Standard を読んでみたシリーズその 1。
オーバーロードの解決が一見すると謎な扱いになってしまうという話。

何はともあれサンプル

class A {
	public virtual void Method(int value) {
		System.Diagnostics.Debug.WriteLine("A.Method(int)");
	}
}
class B : A {
	public override void Method(int value) {
		System.Diagnostics.Debug.WriteLine("B.Method(int) : overrides A.Method");
	}
	public void Method(double value) {
		System.Diagnostics.Debug.WriteLine("B.Method(double)");
	}
}
class C : A {
	// void Method(int) を override していない。

	public void Method(double value) {
		System.Diagnostics.Debug.WriteLine("C.Method(double)");
	}
}
class D {
	public virtual void Method(int value) {
		System.Diagnostics.Debug.WriteLine("D.Method(int)");
	}
	public void Method(double value) {
		System.Diagnostics.Debug.WriteLine("D.Method(double)");
	}
}

public static void Main(string[] args) {
	var a = new A();
	var b = new B();
	var c = new C();
	var d = new D();

	a.Method(1);		// A.Method(int)
	b.Method((int)3);	// B.Method(double)   // int になっていない!
	b.Method(4.1);		// B.Method(double)
	c.Method((int)3);	// C.Method(double)   // int になっていない!
	d.Method((int)3);	// D.Method(int)
	d.Method(4.1);		// D.Method(double)
}

祖先クラスの仮想メソッドより、非仮想メソッドが優先される

上のサンプルでのポイントは、b.Method((int)3) にて

  • int 型の引数を渡している
  • B.Method(int) が存在する

にも関わらず、B.Method(double) が呼ばれてしまっているということである。

c についても同様で、C.Method(int) があるにも関わらず、c.Method((int)3) で c.Method(double) が呼ばれている。

これに対して d については、引数が int の時は D.Method(int) が呼ばれている。

こうなっている理由は、

  • 祖先クラス(上の例なら A)で定義された virtual メンバは、派生クラス(上の例なら B, C)で定義したメンバであると見なされない
  • C# コンパイラオーバーロード解決では、より派生側のクラスにあるメンバが必ず優先される

という二点である。

オーバーライド メソッドは、クラスで宣言されたものと見なされません。これらは、基本クラスで宣言されたメソッドの新しい実装です。

Override キーワードと New キーワードによるバージョン管理 (C# プログラミング ガイド)

C# コンパイラは、Derived の元のメソッドにメソッド呼び出しを一致させることができない場合に限り、名前が同じで互換のパラメータを持つ、オーバーライドされたメソッドに呼び出しを一致させようとします。

Override キーワードと New キーワードによるバージョン管理 (C# プログラミング ガイド)

どのようにして祖先の仮想メソッドより、派生側の非仮想メソッドが優先されたのか

これについて、C# 2.0 の仕様書や Annotated C# Standard にて根拠を探してみた。

上の仕様書によると、特定の型のメソッド呼び出しの解決は、以下のような流れになるそうだ。

  1. メソッドの集合(method group)を得る。 (Member lookup: §14.3)
    1. アクセス可能で名前が一致するメンバを列挙する。
    2. 型パラメタが一致しないメンバを除外する。型パラメタが指定されていないなら、何も除外しない。
    3. 他のメンバで隠蔽されているメンバを除外する。
      • 隠蔽されている基底クラス側のメンバは除外される
      • メソッドがある場合、名前が同じ&メソッドでない 同名のメンバは全て除外される
  2. メソッド呼び出しの候補になるメソッド(candidate methods)を選別する (§14.5.5.1)
    1. 要するに、型引数と引数が整合するメソッド以外を除外する
      • 型引数の推論も行う
  3. もっとも継承された型のメソッド以外を除外する (§14.5.5.1)
    • candidate methods のうち、一番派生した型で定義されたメソッド以外が消える
  4. overload resolution rules (§14.4.2) にしたがって、もっとも適合するメソッドが選出される
    • 要するに、暗黙の型変換がもっとも少ないメンバが選出される。

最初は overload resolution の方を延々と調べていて、override したメンバがオーバーロード解決で無視される理由が分からずに困ったものだ。が、そもそも method invocation の過程でもっとも継承された型のメソッド以外を除外しており、overload resolution の手順を適用する以前に候補から消されてしまっているのが根拠なのであった。

ちなみに、method group から candidate method を得る時点で、型引数や引数が整合するメンバ以外ははじかれているので、以下のコードは正常に動く。

class A {
	public virtual void Method(int value) {
		System.Diagnostics.Debug.WriteLine("A.Method(int)");
	}
}
class B : A {
	public override void Method(int value) {
		System.Diagnostics.Debug.WriteLine("B.Method(int) : overrides A.Method");
	}
	public void Method(string value) {
		System.Diagnostics.Debug.WriteLine("B.Method(string)");
	}
}

public static void Main(string[] args) {
	var a = new A();
	var b = new B();

	a.Method(1);		// A.Method(int)

	// このメソッド呼び出しの candidate method 集合には、A.Method(int) しか含まれていない。
	// Method(string) は candidate method から弾かれている。
	// ここで、candidate method の中でもっとも派生したクラスのメンバは A.Method(int) である。
	// なお、override した B.Method が呼ばれているのは CLI の働きであって、C# コンパイラによるものではない。
	b.Method((int)3);	// B.Method(int) : overrides A.Method
}

ともあれ、Annotated C# Standard の annotation でも言及されているが、同じメンバを派生先クラスと派生元クラスでばらばらに定義するのは、思わぬメソッドが呼ばれてしまう原因になるので、やめた方がよいだろう。