C#の string? と int? は同じ「?」でも中身が違う:コンパイル時の注釈と実行時の型
はじめに
string? や int?、?. など、? の付く書き方をなんとなく「nullが入るときに付けるもの」としてまとめて理解していました。
一度整理してみると、同じ ? でも string? はコンパイラ向けの注釈、int? は実行時も別の型で、働くタイミングがまったく違うことが分かったので言語化してみようと思います。
前提として、.csproj で null許容参照型を有効にしています。
<Nullable>enable</Nullable>
string? は何をしているのか
コード
string name = null;
Console.WriteLine(name.Length);
問
コンパイルは通るか?実行するとどうなるか?
回答
警告付きでコンパイルは通る。
実行すると、1行目の代入ではなく、2行目の name.Length で NullReferenceException が出る。
nullを入れること自体は止められていなかった
当初は、? を付けていない string に null を入れるとコンパイルエラーになり、実行しても代入の時点でエラーになると考えていました。
実際は、コンパイラが出すのは警告で、代入そのものは実行時に何も止められていません。
例外が出るのは、nullの中身を使おうとした(.Length にアクセスした)タイミングです。
つまり、string と string? は実行中は同じ型で、違いはコンパイラが警告を出すかどうかだけです。
? は「この変数はnullになりうる」という意図をコンパイラに伝える注釈で、実行されるコードには何も残りません。
警告はどう判定されているのか
string? name = GetName();
if (name != null)
{
Console.WriteLine(name.Length); // 警告は出ない
}
if の中で name.Length と書いても警告は出ません。
コンパイラはメソッドの中を処理の流れに沿って読みながら、変数ごとに「nullではない」「nullかもしれない」という状態を持っています。
name != null のチェックを通ったブロックの中だけ状態が「nullではない」に切り替わるので、警告が出ないという仕組みです(フロー解析)。
ここでも、状態を持っているのはコンパイル中のコンパイラで、実行中のプログラムに印が付いているわけではありません。
! は何をしているのか
コード
string? name = GetName();
Console.WriteLine(name!.Length);
問
! は何をしているか?実行時にnullかどうかをチェックしてくれるか?
回答
! は「ここはnullではないので、警告を出さないで」とコンパイラに伝えて、警告を消すだけ。
実行時のチェックはなく、実際に null なら ! がないときと同じく NullReferenceException が出る。
警告を出すものだと思っていた
! は null免除演算子(null-forgiving operator)と呼ばれるもので、当初は「nullを使うと危ない」という警告を出す記号で、実行時にもチェックしてくれるものだと考えていました。
ただ、上で整理した「? は注釈で、実行されるコードには何も残らない」という結論から考えると、! も同じくコンパイラ向けの記号なので、実行時に何かをするはずがありません。
自分で出した結論を使えば予測できたはずでした。
警告を消すと、事前に気づく機会がなくなる
例えばUnityで、インスペクターから値を入れる前提のフィールドがあるとします。
[SerializeField] private Button button;
コード上ではどこでも代入されていないので、null許容参照型を有効にするとコンパイラは警告を出します。
開発者は「インスペクターで入れている」と知っているので ! で黙らせたくなりますが、インスペクターでの設定を忘れていた場合、事前の警告は消えたまま、実行時に使った場所で落ちます。
! はコンパイラが確かめられない約束を、開発者が代わりに引き受ける記号なので、使う場合は Awake でnullチェックしてログを出すなど、約束を別の方法で確かめる手段とセットにする必要があると感じました。
?. の結果は何型か
コード
string? s = null;
var a = s?.Length;
Console.WriteLine(a);
s = "abc";
var b = s?.Length;
Console.WriteLine(b);
問
それぞれ何が出力されるか?a の型は何か?
回答
s が null のときは、.Length は呼ばれず式全体が null になる(例外は出ない)。
"abc" のときは3が出力される。
a の型は int ではなく int?。
例外が出ると思っていた
当初は、s が null のときは例外が出て、型は int だと考えていました。
?. は例外を出さないための演算子で、左側が null なら右側を評価せずに null を返します。
null を返す可能性がある以上、結果の型はnullを入れられない int にはなれず、int? になります。
ここで1行の中に2種類の ? が出てきます。
s の型の string? は注釈で、a の型の int? は次の章で書く別の型です。
int? の中身
コード
int? x = null;
Console.WriteLine(x.HasValue);
Console.WriteLine(x ?? 0);
Console.WriteLine(x.Value);
問
それぞれ何が出力されるか?どこで例外が出るか?
回答
False、0 と出力され、3行目の x.Value で InvalidOperationException が出る。
int? は参照型になるのだと思っていた
string? が実行中は string と同じなら、int? も同じように注釈なのかと思いきや、ここが決定的に違いました。
参照型の変数はもともとnullを入れられる入れ物ですが、int は値型なのでnullを入れる場所がありません。
当初は、nullを入れるために int を参照型として扱い、ヒープに置いてその参照を持つようになるのだと考えていました。
実際は、int? は Nullable<int> という構造体の省略記法で、値型のままnullを表しています。
中身を簡略化すると、値があるかどうかの bool と値そのものの2つを持っているだけです。
// 説明のための簡略化(実際の定義とは異なる)
struct Nullable<T> where T : struct
{
bool hasValue; // 値が入っているか
T value; // 入っている値
}
int? x = null;
x : [ hasValue = false | value = 0 ] ← これを「null」として扱う
int? x = 5;
x : [ hasValue = true | value = 5 ]
-
x == nullは、コンパイラが!x.HasValueに置き換える -
x ?? 0も中でHasValueを見ているので例外は出ない -
x.Valueは値がないのに取り出そうとするとInvalidOperationExceptionを投げる
ちなみに、値型をヒープに置いて参照で扱う仕組みは「ボックス化」として別に存在していて、int を object に代入したときに起きます。int? はこれとは別の仕組みです。
まとめ
string? |
int? |
|
|---|---|---|
| 何か | コンパイラへの注釈 |
Nullable<int> という構造体 |
| 実行中の型 |
string と同じ |
int とは別の型 |
| nullのときに使うと | NullReferenceException |
.Value で InvalidOperationException
|
| 守ってくれるタイミング | コンパイル時の警告だけ | 実行時も型として区別される |
string? の ? はコンパイル時だけ働く注釈で、int? の ? は実行時も別の型でした。
同じ記号なので同じものとして扱っていましたが、働くタイミングを分けて考えると、! が実行時に何もしないことや、?. の結果が int? になることも説明がつくようになりました。