C#の0がenum値に暗黙キャストされてinvokeStaticで例外出て超ハマった

僕はついさっき知ったばかりなんですけど、C#の数値0ってあらゆるenum値に暗黙的キャストされるんですってね。要するに、0だけはキャスト不要で列挙型の値として変数に代入できちゃったりするということ。コードで示すと以下のようなかんじ。

using System;
 
public class Program
{
    enum Hoge { A, B }
    enum Piyo { C, D }
 
    public static void Main()
    {
        Hoge h1 = 0,   h2 = 0.0f;
        Piyo p1 = 0x0, p2 = 0.0m;
        Console.WriteLine($"{h1}, {h2}");
        Console.WriteLine($"{p1}, {p2}");
    }
}

このコードは何の警告もなくビルドが通り、以下の実行結果が得られる。

A, A
C, C

浮動小数点数やDecimalのゼロも同様の扱いっていうのが、なかなかのキモいポイント。C#のenumってC/C++のそれと違ってそれなりに厳密なので、自分的には結構衝撃的な仕様だった。まぁ、分かってしまえばどうってことはない。

で、ここからが本題。

そんな0のenumへの暗黙的型変換のおかげで、意図せぬメソッドのオーバーロード解決が行われ、小一時間ハマった。

以下のような処理メソッドAddと、それに対する単体テストAddTestを考える。対象メソッドはプライベートな静的メソッドなので、テスト呼び出しにはPrivateType.InvokeStaticメソッドを使っているが、至ってシンプルなコードである。

何の疑いもなく動くと思いきや、ケース3のテストでのみMissingMethodException例外が発生し、Addメソッドの呼び出しに失敗するのだ!

// 加算
private static decimal? Add(decimal? v1, decimal? v2)
{
    return v1 + v2;
}
 
// Addメソッドの単体テスト
void AddTest()
{
    var privateType = new PrivateType(typeof(AddClass));
 
    // ケース1
    var value = (decimal?)privateType.InvokeStatic("Add", 1.0m, 1.0m); // OK
    Assert.AreEqual(2, value);
 
    // ケース2    
    value = (decimal?)privateType.InvokeStatic("Add", 1.0m, 0.0m); // OK
    Assert.AreEqual(1, value);
 
    // ケース3
    value = (decimal?)privateType.InvokeStatic("Add", 0.0m, 1.0m); // MissingMethodException例外が発生!
    Assert.AreEqual(1, value);
}

MissingMethodExceptionは読んで字のごとく、メソッドが見つからなかった場合に投げられる例外だ。

ケース1~2は通っているのに見つからないとは一体…!?という感じなのだが、これまでの説明からお気づきであろう、ケース1~2と3では呼び出されるInvokeStaticのシグネチャが違うのだ。同メソッドは引数違いで10個ほどのオーバーロードが定義されており、それぞれ以下のシグネチャのものが呼び出される。

ケース3では、第二引数の0.0mがBindingFlagsに暗黙キャストされた結果、引数を1つ持つAddメソッドを呼び出そうとして例外を吐くというわけ。なんじゃそりゃー!分かるわけネェーッ!!意図通り動かすにはprivateType.InvokeStatic(“Add”, (decimal?)0.0m, 1.0m)のように、明示的にキャストし正しいオーバーロード解決を導いてあげればよい。

この暗黙型変換を禁止ないし警告出してくれるコンパイルオプションとかないのかしら…ハマった時に死ぬほどわかりづらいんですけど……