アマゾンバナーリンク

ディスプレイ広告

スポンサーリンク

C#コンパイル時の型と実行時の型の違いを意識しよう【経験者向け】

こんにちは!ジェイです。普段C#でコンパイル時の型と実行時の型を意識してる人はあまりいないと思います。しかし、この辺りを曖昧にしていると思わぬアクシデントに出くわす可能性があるので、それらに付いて言及します。

記事内広告

クイズ

まず最初にあなたは、このコードの実行結果がどうなるかわかりますか?

object x = 1;
object y = 1;
Console.WriteLine(x == y ? "OK" : "NG");

実行結果は以下の通りです。

NG

なぜこうなるかあなたは説明できますか?

記載されていない変更があって、この結果になったわけではないです。

このコードのx == yの評価は、間違いなく偽になっていて、そしてそれは言語仕様通りなのです。

ヒント

以下の様に書き換えると結果が変わります。

object x = 1;
object y = 1;
Console.WriteLine(x.Equals(y) ? "OK" : "NG");  // OK

==の代わりにEquals()を使うと結果が変わります。

次の例はなかなか面白いです。

dynamic x = 1;
dynamic y = 1;
Console.WriteLine(x == y ? "OK" : "NG");  // OK

例2での違いは、変数の型がobjectではなく、dynamicになっているという点です。

なぜこの結果になったのか説明できますか?

この差は、型の解釈のタイミングで、C#ではコンパイルの型と実行時の型と2種類の解釈があるのです。

答え

最初に出力がNGになったケースではobjectクラスの==演算子が呼出されてるためです。特にC#ではstringの比較に==を使うのが一般的なので、以下のような共同になる場合も知っておきましょう。

object expected = "1";
object obj = 1.ToString();

// 注意が必要なパターン
Console.WriteLine(obj == expected);  // False

// こう書くとコンパイラの警告が出るので気が付く
Console.WriteLine(obj == "1");  // False

// 以下は大丈夫なケース
string str = 1.ToString();
Console.WriteLine(str == "1");  // True

このパターンは実務でもたまに遭遇するので、理解できてない人は覚えておくとよいでしょう。

これを解決するためには以下のようにobject.Equals()で比較しましょう。

object x = 1;
object y = 1;
Console.WriteLine(object.Equals(x, y));  // True     

自作クラスの演算時をオーバーロードしたときにも注意が必要です。つまり、どのクラスの演算子が呼び出されるかは、実行時の方ではなく、コンパイル時の型によって決定されています。

C#では、演算子のオーバーロードはできるが、オーバーライドはできない仕様になっています。つまり、「演算子のオーバーライド」という概念はなく、「メソッドのオーバーライド」と「演算子のオーバーロード」です。

オーバーライドとオーバーロードについて

オーバーロード

オーバーロードとは「多重定義」のことで、メソッド名が同じで異なる引数のメソッドを作ることです。

void Exec(string arg)
{
  ...
}

// 同じ名前で引数の異なるバージョンを多重に定義。これがオーバーロード
void Exec(int arg)
{
  Exec(arg.ToString());
}

この様に同じ名前のメソッドを多重に定義するのをオーバーロードといいます。似たような機能で引数だけ違うのに、違うメソッド名を付けなければならないのが適切でない場合に使います。

そして、オーバーロードされたメソッドのうちどれを呼び出すかは、コンパイル時に決定されます。ここが非常に重要なところです。

知ってる人にとっては当たり前だと思うでしょうが、これを意識するとしないとでは、大きな差が出てきます。こういう小さな積み重ねが確実に、プログラミングの技術を上げていきます。

オーバーライド

オーバーライドは基底クラスで定義したメソッドを派生クラスで上書きすることです。この時にメソッド名も引数も同じでなければなりません。

C#ではvirtualとoverrideを用います。

class ClassA
{
    public virtual void Exec(string arg)
    {
    }
}

// ClassA を継承したクラス
class ClassB : ClassA
{
    // 基底クラスのメソッドを上書き。これがオーバーライド
    public override void Exec(string arg)
    {
    }
}

コンパイラはvirtual指定されたメソッドの呼び出しがあると、コンパイル段階ではどのクラスのメソッドが呼び出されるかまでは決定できません。つまり、実行時に型を決定しなければならないと言うことです。

ClassA c = GetInstance();
c.Exec(arg);   // cはClassBのインスタンスかもしれない。コンパイル時には分からない。

オーバーライドのメソッドの呼び出しの決定は実行時にされる。

コンパイル時に決定されるのを「静的」、実行時に決定されることを「動的」と呼びます。

そして、実行時にメソッドが決定されることを「動的ディスパッチ」といいます。

virtual指定がないメソッドのオーバーライド

virtual指定がないメソッドに対して派生クラスで「上書き」しようとnew演算子でオブジェクトを確保すると、オーバーライドではなく再定義になります。

従って、変数の型でどのメソッドを呼び出すかが決定されます。

newで再定義すると変数の型(コンパイル時の型)で呼び出すメソットが決まる。

class ClassA
{
    public void Exec()
    {
        Console.WriteLine("A");
    }
}

// ClassA を継承したクラス
class ClassB : ClassA
{
    // 基底クラスのメソッドをオーバーライド?ではなく再定義になる
    public new void Exec()
    {
        Console.WriteLine("B");
    }
}

class Program
{
    static void Main(string[] args)
    {
        ClassB b = new ClassB();
        b.Exec();  // B

        ClassA c = b;
        c.Exec();  // A  <= 値の型は ClassB であるが、変数の型が ClassA であるため
    }
}

これを見るとわかりますが、C#ではvirtualを付けないとメソッドをオーバーライドすることは物理的に不可能です。なぜなら、virtual指定しないと、実行時に動的ディスパッチするために必要な「仮想関数テーブル」という内部データが作られないためです。

演算子のオーバーロード

まずは一つ例を見てみましょう。

class ClassA
{
    public string Value1 { get; protected set; }

    // コンストラクタ
    public ClassA(string val) { Value1 = val; }

    // == 演算子を定義
    public static bool operator==(ClassA self, ClassA other)
    {
        return self.Value1 == other.Value1;
    }
    // == を定義するためには != もペアで定義が必要
    public static bool operator!=(ClassA self, ClassA other)
    {
        return !(self == other);
    }
}

この例では、operator==()を1つしか定義してないが、それでも「演算子をオーバーロード」している事になります。

operator!=()の定義も必要なのはC#の仕様です。中身は==の否定を取るだけで、コンパイルが通りますが、実務で==をオーバーロードをする時には注意が必要です。

最初の例を思い出して見るとわかります。なので多くの場合は、==の代わりにEquals()をオーバーロードするのが、適切です。

ではこれを継承したクラスを作ってオーバーライドしてみます。

// ClassA を継承したクラス
class ClassB : ClassA
{
    public int Value2 { get; protected set; }

    public ClassB(string val1, int val2) : base(val1) { Value2 = val2; }

    // == をオーバーライド?(実は出来ていない)
    public static bool operator==(ClassB self, ClassB other)
    {
        return self.Value1 == other.Value1 && self.Value2 == other.Value2;
    }
    // == を定義するためには != もペアで定義が必要
    public static bool operator!=(ClassB self, ClassB other)
    {
        return !(self == other);
    }
}

以下は上記のコードを利用する例です。

class Program
{
    static void Main(string[] args)
    {
        // ClassA のインスタンスを比較
        var a1 = new ClassA("A");
        var a2 = new ClassA("A");

        Console.WriteLine(a1 == a2);  // True

        // ClassB のインスタンスを比較
        var b1 = new ClassB("1", 10);
        var b2 = new ClassB("1", 20);

        Console.WriteLine(b1 == b2);  // False  <= Value2 の値が異なるので False

        // ClassA 型の変数に代入(キャスト)
        ClassA c1 = b1;
        ClassA c2 = b2;

        Console.WriteLine(c1 == c2);  // True  <= ここで、ClassAの==演算子が呼ばれている

        Console.WriteLine($"{c1.GetType().Name}, {c2.GetType().Name}");  // ClassB, ClassB  <= 実体はどちらもClassB
    }
}

もちろんこれでは想定通りに動きません。staticだしvirtaul指定ができてないし、基底クラスの==と引数が事るので、オーバーライドになってなくて、完全に異なる別のメソッドになっています。

先程説明したとおり、C#では演算子をオーバーライドすることはできません。

コンパイル時の型と実行時の型

これまで説明してきたように、C#ではコンパイル時の型と実行時の型を区別して、認識する必要があります。

コンパイル時の型とは、言い換えると変数の型のことで、実行時の型とは、値の型(実体)のことです。

しかし、必ずしも演算子はコンパイル時の型で評価されるというわけではなく、実行時に評価される方法はあります。

ジェネリックを使う場合

クラスのメソッドにジェネリックを使って任意の方に適用できるようにした場合、実行時の方で評価されるように思うかもしれないですが、実はそうではありません。

基本的には、コンパイル時の方で解決されますが、where制約を付けなければ、objectクラスの==を呼ぶようにコンパイルされます。

先程の演算子のオーバーロードの説明のコードを使って、ジェネリックを使ってみましょう。

// コンパイルエラー。whereなしではジェネリック型に演算子を適用できない。
//static void f<T>(T x, T y) {
//  Console.WriteLine(x == y ? "OK" : "NG");
//}

// where で参照型だけに制限すれば演算子が使えるが、object.== が呼ばれる
static void f<T>(T x, T y) where T: class {
  Console.WriteLine(x == y ? "OK" : "NG");
}

// 明示的に ClassA に制約すれば、そのクラスの == が呼ばれる
static void g<T>(T x, T y) where T: ClassA {
  Console.WriteLine(x == y ? "OK" : "NG");
}

var x = new ClassA(1);
var y = new ClassA(1);

f(x, y);  // NG  <= objectクラスの == を呼ぶようコンパイルされるため

g(x, y);  // OK

特性をまとめると以下のようになります。

  • 基本的にはwhere T : classを付けないと演算子は使えない
  • 付けたとしても、コンパイル時のTのかだの演算子がよばれるわけではなく、objectの演算子が呼ばれる
  • where T : classAというように明示的に型制約をつけると、その方の演算子を呼ぶようになります

ジェネリック型で演算子を使う場合は注意が必要です。

dynamic を使う場合

C#のdynamicは実行時に該当する型を判別して動的にコードを生成してくれるすごい仕組みです。

dynamicとして、宣言された変数はその名前の通り動的(実行時)に型が解決されます。なので、例3の場合だとint型で実行時にコード展開されて、実行されます。

もちろんデメリットもあって、コンパイル時に型情報が一切ないので、インテリセンスも効かないし、静的付け言語の最大のメリットの「タイプセーフ」でなくなってしまいます。なので、乱用せず本当に必要な時のみ使うべきです。

先程のジェネリックの例をdynamicで書き換えると以下のようになります。

static void h(dynamic x, dynamic y) {
  Console.WriteLine(x == y ? "OK" : "NG");
}

object x = new ClassA(1);
object y = new ClassA(1);

h(x, y);  // OK

ただし、実務ではこのような使い方をしてはいけないです。正しくは「答え」で示したように、object.Equals()を使うべきです。

まとめ

C#には「コンパイル時の型」と「実行時の型」の2つの解釈があることを説明しました。

  • オーバーライドされたメソッドは、実行時の型でディスパッチされる
  • オーバーロードされたメソッドは(演算子に限らず全て)コンパイル時の型でディスパッチされる
  • ただしdynamicを使った場合は実行時にコンパイルされるため、オーバーロードされたメソッドでも実行時の型でディスパッチされる

以上の事を意識した上でコードを書くと、型に対する理解がより深まるので、覚えておきましょう。

参考文献

+2