LoginSignup
18
21

More than 3 years have passed since last update.

【C# 中級向け】「コンパイル時の型」と「実行時の型」を意識しよう

Last updated at Posted at 2019-05-03

はじめに

この記事では、C# における コンパイル時の型実行時の型 について説明する。また関連する概念としてオーバーライドとオーバーロードの違い、演算子とメソッドで呼び出し対象の決定タイミングの違いについて簡単に説明する。

対象とする読者層は、C#プログラミング歴1年以上、中級者向け。

特に SIer やユーザー企業に所属(もしくは常駐)し、特に複数人チームでの開発に携わる若手プログラマ(俗に言う"IT土方")に向けて、よくある ハマりポイント について分かりやすいように書いているつもり。

対象としている .NET のバージョンは 4.0 以降、もちろん .NET Core も対象だ。

クイズ

まず始めに、このコードの実行結果がどうなるか、考えてみてほしい。

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

出力結果は、以下の通り。(実際にIdeoneで試したい人は こちら

例1の出力結果
NG

なぜこうなるのか、あなたは正確に説明できるだろうか?

三項演算子がおかしいとか、記載されていないコードがあって変数が書き換えられているとか、そういう話ではない。

このコードの x == y の判定は、間違いなく偽になっており、そしてそれは言語仕様通りだ。

ヒント

以下のように書き換えると結果が変わる。

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

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

面白いのは、次の例だ。

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

例1と違うのは、変数の型が 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

実務でもたまに遭遇する ハマりポイント でもある1

もしちゃんと理解できていないと思うなら、この機会に是非マスターしてほしい。

ハマらないためには、以下のように object.Equals() を使って比較することだ。

これが正解
object x = 1;
object y = 1;
Console.WriteLine(object.Equals(x, y));  // True     

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

C# では、演算子のオーバーライドが出来ないようになっており、演算子をオーバーロードする際は static として宣言しなくてはならず、virtual 指定することも出来ない。従って演算子は実行時の型による動的ディスパッチ2が出来ない(ただし dynamic を使えば可能。後述)。

そもそも C# の世界に「演算子のオーバーライド」という概念はない。あるのは「メソッドの オーバーライド 」と「演算子の オーバーロード 」だ3

まずはその辺からキッチリ理解していこう。

オーバーライドとオーバーロードの違い

一応、オーバーライドとオーバーロードの違いをザックリおさらい。

オーバーロード

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

オーバーロードの例
void Exec(string arg)
{
  ...
}

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

このように同じ名前のメソッドを多重に定義することを「オーバーロード」と言う。
処理は同じだが引数のバリエーションを増やして使い勝手を良くするような場合に多用される。

オーバーロードされたメソッドのうちどれを呼び出すかは、コンパイル時に決定される。

大事なことなので2回言います。

オーバーロードされたメソッドのうちどれを呼び出すかは、コンパイル時に決定される。実行時に決定されるのではない。

当たり前のことを言っているように聞こえるかもしれないが、こういうことを意識しながらプログラミングすることがとても大切だ。これが出来るようになると、確実にレベルアップしていると言える。

オーバーライド

一方オーバーライド (override) は、クラス継承の際にメソッド名も引数リストも全く同じメソッドを定義し、基底クラスのメソッドを「 上書き 」することを指す。

C# では、virtualoverride キーワードを用いる必要がある。

オーバーライドの例
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 で再定義すると変数の型(コンパイル時の型)で呼び出すメソッドが決まる。

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 指定をしないと、実行時に動的ディスパッチするために必要な「仮想関数テーブル4」という内部データが作られないためだ。

演算子のオーバーロード

さてそろそろ本題の演算子のオーバーロードの話に入ろう。

まずは演算子のオーバーロードの例を見てみよう。

演算子のオーバーロード
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() をオーバーライドするのが適切だ。ここでは例示のための実験的なコードとして捉えておいてほしい5

ではこれを継承したクラスを作り、オーバーライド してみよう。

演算子のオーバーライド?
// 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 だし、virtual 指定ができないし、基底クラスの == とは引数リストも異なるので、全くオーバーライドになっていない。完全に異なる別々のメソッドになっている。Ideoneにコードを置いてあるので実際に試してみよう

「解説」で説明したとおり、C# では 演算子をオーバーライドすることは出来ない のだ。6

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

これまで見てきたように、C# ではコンパイル時の型と実行時の型を区別して認識する必要がある。
コンパイル時の型とは、言い換えると 変数の型 のことで、実行時の型とは、値の型 (実体)のことだ。

では、演算子は必ずコンパイル時の型で評価されるのかというと、実は実行時に評価させる方法がある。

ジェネリックを使う場合

クラスやメソッドにジェネリックを使って任意の型に適用出来るようにした場合、実行時の型で評価されるように期待するかもしれないが、 そうはならない 。基本的にはコンパイル時の型で解決されるが、where 制約を付けない限り、object クラスの == を呼ぶようコンパイルされる。

先程の演算子オーバロードの説明で用いたコードを使って、ジェネリックを試してみよう。

例4(ジェネリックを使う場合)
// コンパイルエラー。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 の演算子が呼ばれる7
  • where T: ClassA というように明示的に型制約をつけると、その型の演算子を呼ぶようになる。

このように、ジェネリック型に対して演算子を使う場合は注意が必要だ。

dynamic を使う場合

C# の dynamic は実行時に該当する型を判別して動的にコードを生成してくれる凄い仕組みだ。
ダックタイピングのような機能を提供する。

dynamic として宣言された変数はその名前のごとく動的(つまりコンパイル時ではなく実行時)に、型が解決される8。実行時というか、なので、例3の場合だと int 型で実行時にコード展開され、実行される。

デメリットとして、コンパイル時に型情報が一切ないので、インテリセンスも利かないし、何より静的型付け言語の最大のメリット「タイプセーフ」でなくなってしまう。
乱用は禁物だ9
ここではこれ以上の詳細な説明は省略するが、興味があれば調べてみると良いだろう。

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

例5(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

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

上記ジェネリックとdynamicのコードサンプルを Ideone に置いてあります

まとめ

「コンパイル時の型」と「実行時の型」という2つの解釈があることを認識しよう。

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

3つ目はエンタープライズ開発の現場ではほとんどお目にかかる事はないと思うので、例外的パターンぐらいの捉え方で十分と思う。

Java と比較すると、C# では文字列の比較にも == が使えるため、普段から Equal() メソッドを使うことはとても少ないと思われる10

しかしながら変数の型が object ならば、== は想定どおりに動かない、ということを知っておくべきだ11

特に Java から C# に来た人は、「string== で比較できるので直感的でいいな~」 なんて安易に捉えていると、昔の僕のようにハマることになります(笑)。


  1. ちなみに 1.ToString() としているのは、単純に "1" と書くと。 

  2. ディスパッチ: 複数の関数の中から一定の規則によって呼び出すべき関数を引き当てて呼び出すこと 

  3. もちろん「メソッドのオーバーロード」もある。ところで他のプログラミング言語では、演算子をオーバーライド可能なものもある。例えば Ruby では演算子は一般的なメソッド呼び出しの糖衣構文という言語仕様になっているため、オーバーライド可能だ。 

  4. 仮想関数テーブル (V-Table) とは C++ から導入された、コンパイル結果に含められる内部データのことで、クラスの継承ツリーをたどってオーバーライドされたメソッドを実行時に引き当てる仕組みのためのものだ。通常、プログラマが意識する必要はない。オーバライドされていようがされていまいが、メソッド呼び出し時には仮想関数テーブルの処理が行われるため、負荷が生じる。この負荷を避けるためにデフォルトでは仮想関数テーブルが作られず、オーバライドするためには virtual 指定が明示的に必要という言語仕様を採用している。これは C++ から C# にそのまま引き継がれた考え方だ。 

  5. ここでは本筋とはズレるので詳細説明は省略するが == をオーバーライドする場合は無限ループに陥りやすいので注意が必要だ。また Equals()GetHashCode() もオーバライドしておくべきだ。詳しくはMSDN Equals() と演算子 == のオーバーロードに関するガイドライン (C# プログラミング ガイド) および 方法: 型の値の等価性を定義する などを参照のこと。より実践的な実装例は ここ が一番分かりやすいと思います。 

  6. C# の static メソッドは、インスタンスに所属しておらず、そのクラスに名前空間的に配置される孤立したメソッドというような捉え方をすべきだ。演算子のオーバーロードを static で書くルールになっている以上、どうあがいてもオーバーライドすることは出来ない。 

  7. C++のテンプレートのように型ごとにメソッドが作られてコンパイルされる訳ではないため。 

  8. 実行時に型を解決する仕組みとして、実行時にコード生成してコンパイルして実行しているような仕組みになっている。コンパイル結果に動的コード生成のためのコードが含まれるようになるためバイナリサイズが大きくなるし、初回実行時にそれなりのオーバヘッドがかかる。 

  9. 例えば Dictionary<string, object> を使って文字列をキーにした連想配列で大きなデータ構造を扱うようなコードを書くぐらいなら、そもそもタイプセーフもクソもないので、ExpandoObject を使う方がよっぽどマシかもしれない。 

  10. 例えば Java では演算子のオーバーロードが言語仕様として存在せず、== は単にリファレンスの比較(ポインタの比較)となる。異なる型を == で比較しようとするとコンパイルエラーとなる。 

  11. 実際の開発現場でよくあるのは、データグリッドビューにセットしたオブジェクトと == で比較してしまって想定通りに動かない、というパターンだ。 

18
21
3

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
18
21