アノテーションのコンパイル結果
null 許容参照型のアノテーションのコンパイル結果は、
NullableContext
とNullable
という2つの属性(いずれもSystem.Runtime.CompilerServices
名前空間)を使って表現されます。
2つの属性を使い分けるのはプログラムのサイズを小さくするためです。 属性は付けば付くだけ少しずつプログラムを大きくするため、ちょっとでも付く量を減らす工夫をしています。 例えば以下のようなメソッドを考えます。 引数が4つあって、非nullとnull許容がそれぞれ2つずつになっています。
public void M(string a, string? b, string c, string? d) { }
初期の案では Nullable
属性だけを使って、以下のようにコンパイルする予定でした。
public void M([Nullable(1)]string a, [Nullable(2)]string b, [Nullable(1)]string c, [Nullable(2)]string d) { }
これだとすべての引数に属性が付くことになります。
その後、少しでも属性の数を減らすために、NullableContext
属性が追加され、
以下のようにコンパイルされる仕様になりました。
[NullableContext(1)]
public void M(string a, [Nullable(2)]string b, string c, [Nullable(2)]string d) { }
NullableContext
は、クラス内やメソッド内で、Nullable
属性が付いていない引数・戻り値をどう扱うかを示しています。
(前述の「null 許容コンテキスト」とは微妙に違う意味で context (文脈)という単語を使ってしまっていますが、
まあどちらも「前後のコードの意味を変える」という意味で「文脈」です。)
この例でいうと、「メソッドに1と付いているので、引数 a
、c
は1扱い」ということになります。
メソッドに対する属性が1個増えた代わりに、引数に対する属性が2個減って、全体では属性の数が減りました。
ちなみに、属性の引数になっている1とか2とかの数値は以下の意味になります。
(Nullable
もNullableContext
も付いていない場合は0、すなわち oblivious 扱いになります。)
値 | 意味 |
---|---|
0 | oblivious |
1 | 非 null |
2 | null 許容 |
属性は、総数が極力少なくなるように付きます。 例えば以下のような2つのメソッドを考えます。
class A
{
// 非 null が2個、null 許容が1個
public void M1(string a, string b, string? c) { }
// 非 null が1個、null 許容が2個
public void M2(string a, string? b, string? c) { }
}
これは、以下のようなコードにコンパイルされます。 要するに、多い方が「context」になることで、属性が必要な引数が減ります。
class A
{
// 非 null が多いので NullableContext(1)
[NullableContext(1)]
public void M1(string a, string b, [Nullable(2)] string c) { }
// null 許容が多いので NullableContext(2)
[NullableContext(2)]
public void M2([Nullable(1)] string a, string b, string c) { }
}
(ちなみに、数が同じ場合は2よりも1を、1よりも0を優先するようです。)
型自体に NullableContext
が付く例も見てみましょう。
以下のような2つの型を考えます。
class A
{
public void M1(string a) { }
public void M2(string? a) { }
// 非 null なメソッドが多い
public void N1(string a, string b) { }
public void N2(string a, string b) { }
public void N3(string a, string b) { }
}
class B
{
// M1, M2 は A と同じ
public void M1(string a) { }
public void M2(string? a) { }
// null 許容なメソッドが多い
public void N1(string? a, string? b) { }
public void N2(string? a, string? b) { }
public void N3(string? a, string? b) { }
}
この場合、メソッドに付く属性が減るように、クラスに NullableContext
属性が付きます。
以下のようなコンパイル結果になります。
[NullableContext(1)]
class A
{
public void M1(string a) { }
[NullableContext(2)]
public void M2(string a) { }
public void N1(string a, string b) { }
public void N2(string a, string b) { }
public void N3(string a, string b) { }
}
[NullableContext(2)]
class B
{
[NullableContext(1)]
public void M1(string a) { }
public void M2(string a) { }
public void N1(string a, string b) { }
public void N2(string a, string b) { }
public void N3(string a, string b) { }
}
型引数に対するアノテーション
ジェネリクスが絡むともう少し複雑になります。
dynamic
型の場合と同じなんですが、
Nullable
属性の引数が配列になります。
例えば以下のようなメソッドを考えます。
public void M(
Dictionary<string, string?> a,
Dictionary<string, string?>? b,
(string, string, string?) c
) { }
Dictionary
型やタプルの型引数1個1個で null 許容性が違います。
また、「Dictionary
自体」と「Dictionary
の型引数」でも null 許容性が違っています。
こういう場合には、以下のような属性が付きます。
public void M(
[Nullable(new byte[] { 1, 1, 2 })]
Dictionary<string, string?> a,
[Nullable(new byte[] { 2, 1, 2 })]
Dictionary<string, string?>? b,
[Nullable(new byte[] { 0, 1, 1, 2 })]
(string, string, string?) c
) { }
配列の最初の要素が型自体で、2個目以降が型引数の null 許容性を表しています。
ちなみに、この他いくつか細かい条件を上げると以下のようなものがあります (公式ドキュメント: Nullable Metadata)。
- 非ジェネリックな値型には属性は付けない
- ジェネリックな値型の場合、0 に続けて型引数の値を並べる
- 型引数が値型のところはスキップ
- 配列中のすべて要素が同じ値のとき、配列ではなく1要素に置き換える
- タプルには元となる
ValueTuple
構造体に準じた属性を付ける
Nullable 属性とリフレクション
これで、プログラムのサイズはだいぶ小さくなっています。 しかし、すでに察している人もいるかもしれませんが、 その分、リフレクションで null 許容かどうかを取るのがだいぶ面倒になります。
例えば、前述のクラス A
、B
のメソッド M1
の引数を調べたい場合を考えます。
(M1
に関連する部分を抜粋して再掲します。)
[NullableContext(1)]
class A
{
public void M1(string a) { }
}
[NullableContext(2)]
class B
{
[NullableContext(1)]
public void M1(string a) { }
}
ここで、引数 a
が null 許容かどうか調べようとするとき、
- どちらも引数
a
自体には属性が付いていない - メソッドには
B
のM1
にだけ属性が付いている A
の場合は型までたどらないと引数a
の null 許容性がわからない
ということになります。