null、一般名詞としては「無効なもの」とか「0個」とかの意味の単語です。 zero も語源をたどるとアラビア語とかサンスクリット語の「空っぽ (nothing)」にあたる単語から来ていて、実のところ一般名詞としては出自が違うだけで null = zero だったり。
一方、C# (とそれに類するプログラミング言語)では、 null
というキーワードを「無効なものを 0 を使って表す」という意味で使っていて、
一般名詞としての null が持つ2つの意味を同時に指していたりします。
とはいえ、別に null という英単語の意味を考慮して「無効なものを 0 を使って表す」にしたわけではなくて、 単に実装上「0 かどうかの判定は非常に高速なのでパフォーマンス的に都合がいい」という現実的な理由で 0 を使っています。
前置きが長くなりましたが、C# において null 判定をするというのは、内部的には単に 0 比較で、 大体の CPU 上で最速の部類に入る命令を使って実装できます。
x == null
null 判定というとまずどういうコードを思い浮かべるでしょうか?
「昔から書けた」という意味で、まず x == null
が真っ先に思い浮かぶ人が多いと思います。
bool M(A x) => x == null;
class A { }
これも、この状態であれば単なる 0 比較になります。 実際、コンパイル結果を覗いてみればわかるんですが、以下のコードと同じコードが生成されます。
bool M(int x) => x == 0;
ただ、ここで問題になるのが演算子オーバーロードでして、これをやっちゃってると「単なる 0 比較」ではなくなってしまいます。
特に以下のように、==
の中でそこそこ重たい処理をやっちゃっているときが問題になります。
bool M(A x) => x == null;
class A
{
public static bool operator ==(A x, A y) => そこそこ重たい処理;
public static bool operator !=(A x, A y) => そこそこ重たい処理;
}
==
を使っている側、この例でいうと メソッド M
の中身は最初にあげた「速い」コードと同じ見た目なのが罠で、「本当は 0 比較でいいはずなのにわざわざ重たい operator ==
が呼ばれてしまう」という状況が往々にして発生します。
過激派な意見としては「==
をオーバーロード(ユーザー裁量で中身を変更)可能にしてしまったことがよくなかった」という話もあるんですが、まあ、できるものは仕方がないとして。
本来の「無効かどうかの判定は単なる 0 比較で済む」という状態にしたければ、==
を避けた方がいいということが多々あります。
ReferenceEquals(x, null)
この罠にはまっちゃってるコードは案外世の中にあふれているというか、 .NET の標準ライブラリでも結構あったみたいです。
この問題は昔の C# でも簡単に解消する方法が1つあって、それが、ReferenceEquals
を使うという案。
bool M(object x) => ReferenceEquals(x, null);
これで、ユーザー定義の ==
オーバーロードは呼ばれることなく、常に 0 比較で null 判定が走ります。
めでたしめでたし。
となるわけはなく、見栄えが悪すぎる…
ということで、「ReferenceEquals
に書き換えて問題ないし、書き換えたら露骨にパフォーマンスがよくなるんだけど、この見栄えの悪さを許容するべき?」みたいな議題になっていました。
x is null
そこに来て、C# 7.0 でパターン マッチングという文法が入りました。
この頃には「== null
の罠」が周知の事実だったので、「is null
と書いたときにはユーザー定義の ==
を呼ばない。常に 0 比較にする」という判断が下りました。
bool M(object x) => x is null; // operator == は呼ばない。常に ReferenceEquals(x, null) と同じ。
class A
{
public static bool operator ==(A x, A y) => そこそこ重たい処理;
public static bool operator !=(A x, A y) => そこそこ重たい処理;
}
これに「見栄え的に ReferenceEquals
は NG」派が飛びつきました。
== null
から is null
への書き換えで救われたコードが結構あったみたいです。
(実際、僕が保守しているコードでもいくつかこの書き換えでパフォーマンス改善しています。)
めでたしめでたし?
非 null
めでたくなかった。
実際に多いのは以下のようなコードだったりします。
void M(A a)
{
var x = a.X; // プロパティ参照コストを避けるために変数に受ける。
if (x is null) return;
// x を使って何か処理をする。
}
class A
{
// virtual がついていたり、いくつかの場面では X プロパティの参照に多少コストがかかる。
public virtual object? X { get; set; }
}
これはいわゆる early return (先頭で検査して不適切なら即 return)な書き方ですが、 判定を逆転させて同じ結果になるコードを以下のように書きたいこともあります。
void M(A a)
{
var x = a.X;
if (!(x is null))
{
// x を使って何か処理をする。
}
}
何にしてもポイントが2つあって、
- 1度変数
x
で受けたい - 「null である」判定じゃなく、「null じゃない」判定をしたい
という要件があって、x is null
の導入だけだとまだちょっと面倒が残っている感じになっています。
x is object (非 null)
「null じゃない」判定に使える書き方はいくつかあるんですが、前半で話した x == null
の話と同様、x != null
はユーザー定義の演算子オーバーロードを呼ばれて遅くなることがあります。
そこで x is null
と同様、比較的新しめの文法であるパターン マッチングを使った「null じゃない」判定が欲しくなります。
C# の場合、「null は型を持っていない」という扱いになるので、すべての型の共通基底クラスである object
型にすらマッチしません。
なので、以下のように、is object
というパターンを書くと「null じゃない」という判定になります。
void M(A a)
{
if (a.X is object x)
{
// ここに来るのは a.X が null じゃなかった時だけ。
// x を使って何か処理をする。
}
}
注意: x is var (null 判定しない)
ここで注意すべきことが1点。結構な罠なんですが、上記のように is object
が「null じゃない」判定になるのに対して、is var
だと null / 非 null に関わらず常にマッチします(is var
単体だと常に true)。
void M(A a)
{
if (a.X is var x)
{
// ここは常に通る。
// if なしで var x = a.X; と書くのとほぼ同じ意味なので非推奨。
}
}
var
パターンは switch
-case
の default
句みたいなもので、「他のどの条件も満たさないときの最後の受け口」みたいに使うものです。
なので、今回の主題の null 判定に限らず、if
単体で使うものではありません。
x is { } (非 null)
もう1個、is { }
という書き方でも「null じゃない」判定ができます。
知らないと何が何だかわからない謎な書き方ですが、
文法的にいうとこれは「プロパティ パターン」というものになります。
void M(A a)
{
if (a.X is { } x)
{
// ここに来るのは a.X が null じゃなかった時だけ。
// 起こる結果は is object x と同じ。
// x を使って何か処理をする。
}
}
本来は以下のように、再帰的にプロパティの中身を確認できる「パターン」です。
void M(A a)
{
if (a is { X: object x })
{
// a の中身の X プロパティの中身をチェック。
// ちなみにこの場合、a 自体の null チェックもかかるので、a != null && a.X != null と似た処理。
}
}
ただ、{}
の中に何もなくても「null じゃない」判定だけはかかるので、その用途に流用できます。
ちょっと濫用・悪用気味ではありますが、「null じゃない」判定をしつつ変数で受ける手段としては一番短い書き方になります。
is not null
x is { }
は最も短く「null じゃない」判定を書ける手段ではあるんですが、
なにぶん濫用気味な書き方で、知らない人が見て理解しにくい、知っていても「null じゃない」という意図が伝わりにくいという問題があります。
そこで結局、!(x is null)
という書き方の方がいいんじゃないかという話にもなるんですが…
これはこれで、!()
も十分に見にくい(()
が邪魔だし、意味を真逆にする割には !
という記号は視認性が悪すぎて見逃す)という問題があります。
あと、以下のような「書き間違い」をする人が後を絶たないという問題も起こしました。
void M(A a)
{
if (a.X !is null) // is not のつもりで !is とか書く
{
// ちなみにこの ! は not の意味にならず、このコードは is null (意図と真逆)になる。
}
}
この !
はnull 判定の抑止、要するに、コンパイラーが正しくフロー解析できなさそうな微妙なコードで、コンパイラーの警告をもみ消すために使う演算子です。
フロー解析(あくまでコンパイラー内での処理)に使うだけであって、この !
の有無はコンパイル結果には全く影響を及ぼしません。
なので、x !is null
と x is null
が全く同じ意味。
一方、C# 9.0 では not
パターンというものが導入されて、今度こそ is not の意味のパターンが書けるようになりました。
void M(A a)
{
if (a.X is not null)
{
// ちゃんと null じゃないときだけここを通る。
// != null と違ってユーザー定義演算子は呼ばれず、単なる 0 比較。
}
}
is not { } (null の時に early return)
ここからは C# 9.0 のバグの話。
Visual Studio 16.8 (C# 9.0 の初期リリース。2020年11月リリース版)時点の C# には
is not { } x
という書き方にバグがあります(is not object x
でも同様にバグあり)。
not { }
は「null じゃない」をさらに否定しているので結局「null である」という判定になります。
単に「null である」判定をしたいだけなら is null
と書けばいい話なんですが、
「変数で受けつつ null である判定」という処理をしたいときに is not { } x
という書き方をします。
void M(A a)
{
if (a.X is not { } x) return; // null だったら early return。
// x を使って何か処理をする。
// ここでは x に非 null な値が入っているはず。
}
is not { } x
や is not object x
とい書き方はまさにこの「null のときに early return」のためにあって、null じゃなければその値が変数 x
に入った上で else
側に流れます。
ですが、バグで、時々その「null じゃない値を変数 x
で受ける」という処理が消えてしまうことがあるそうです。
上記コードはちゃんと動くんですが、例えば以下のコードだと x
が null のままになっていて実行時例外を起こします。
using System;
M("abc");
void M(string? s)
{
if (s is not { } x) return; // null だったら early return。
// x には s が代入されていないとおかしいはずなのに…
Console.WriteLine(x.Length); // ここでぬるぽ(バグ)。
}
バグです。 バグ報告済みというか、報告されて早々に修正・ merge 済みで、Visual Studio 16.9 では直る見込みです(16.8.1 とかにもこの修正が取り込まれるかは未定)。