今日は、?.とか??での、nullの判定方法について。

C# 6で導入されたnull条件演算子(?.)ですが、以下の2つの式がほぼ同じ意味になります。

x != null ? x.M() : null
x ?.M()

「ほぼ」であって「完全に同じ」と言えないのは、==演算子を呼ぶか呼ばないかが変わってしまうせいです。 前者(自分で==を呼んでいるやつ)はオーバーロードされた==を呼び出しますが、 後者(?.を利用)は呼びません(直接nullかどうか調べます)。

例えば、以下のように、本当はnullじゃないのにnullを自称する(x == nullがtrueになる)クラスを作ると、ちょっと変な挙動になります。

using static System.Console;

class NonDefault<T>
{
    public T Value { get; }
    public NonDefault(T value) { Value = value; }

    public override string ToString() => Value.ToString();

    // Value が既定値のときに null と同値扱いする
    // null でないものとの x == null が true になることがある
    public static bool operator ==(NonDefault<T> x, NonDefault<T> y) =>
        ReferenceEquals(x, null) ? ReferenceEquals(y, null) || Equals(y.Value, default(T)) :
        ReferenceEquals(y, null) ? ReferenceEquals(x, null) || Equals(x.Value, default(T)) :
        Equals(x.Value, y.Value);

    public static bool operator !=(NonDefault<T> x, NonDefault<T> y) => !(x == y);
}

class Program
{
    // null の時には "null" と表示する ToString
    static string A(NonDefault<int> x) => (x != null ? x.ToString() : null) ?? "null";
    // A とほぼ同じ意味に見えて…
    static string B(NonDefault<int> x) => x?.ToString() ?? "null";

    static void Main()
    {
        WriteLine(A(new NonDefault<int>(1))); // 1
        WriteLine(B(new NonDefault<int>(1))); // 1

        WriteLine(A(null));                   // null
        WriteLine(B(null));                   // null

        // == を呼ぶ呼ばないことによる差がここで出る
        WriteLine(A(new NonDefault<int>(0))); // null
        WriteLine(B(new NonDefault<int>(0))); // 0
    }
}

まあ、普通、こんな==演算子オーバーロードの仕方はしないんですが。 というか、参照型に対する==オーバーロード自体めったにしないんですが。 (通常、==演算子を使うのは、Dictionaryのキーにしたい不変なクラスくらいです。)

ちなみに、このメソッドABのコンパイル結果はそれぞれ以下のようになります。 比較のために表にして命令ごとに並べてみましょう。

A B
ldarg.0 ldarg.0
ldnull brtrue.s IL_0006
call NonDefault::op_Inequality  
brtrue.s IL_000c  
ldnull ldnull
br.s IL_0012 br.s IL_000c
ldarg.0 ldarg.0
callvirt Object::ToString callvirt Object::ToString
dup dup
brtrue.s IL_001b brtrue.s IL_0015
pop pop
ldstr "null" ldstr "null"
ret ret

nullの判定方法(2行目~4行目)だけが違って、残りは全く同じです。 ==演算子を呼ばずに直接nullを調べるならbrtrue命令1個でできます。

ちなみに、brtrueは"branch if true"の略で、 「直前の結果がtrueだったらジャンプする」という命令になります。 整数の0とか、参照型のnullとかはfalse扱い。

この挙動はnull合体演算子(??)でも同様です。

おまけ: throw null

話題は変わりますが、?.の中身をILレベルで覗いたついでと言ってはなんですが、ちょっとしたおまけ。

時々、「throw nullと書くと、throw new NullReferenceException()と同じ意味になる」的な誤解(?)を見かけたりします。 コンパイル結果的には当然、全然違うんですよね。

以下のように書いた場合、

static void X() { throw null; }
static void Y() { throw new NullReferenceException(); }

コンパイル結果は以下の通り。

.method private hidebysig static void  X() cil managed
{
  // コード サイズ       2 (0x2)
  .maxstack  8
  IL_0000:  ldnull
  IL_0001:  throw
} // end of method Program::X

.method private hidebysig static void  Y() cil managed
{
  // コード サイズ       6 (0x6)
  .maxstack  8
  IL_0000:  newobj     instance void [mscorlib]System.NullReferenceException::.ctor()
  IL_0005:  throw
} // end of method Program::Y

割かしそのまんまなILコードです。 nullをロード(ldnull)して、throw命令を実行。 要するに、前者は(throw命令の実行に失敗して)実行エンジンがNullReferenceExceptionを作って投げていて、 後者は自分自身で作ったNullReferenceExceptionを投げている。 まあ、結果的には同じような挙動をするんですが。