2. C# 8のnull
C#は8より前のことは忘れてしまうと話は単純だ。値型も参照型も、値はnull
にはならない。
値がnull
になれる型はint?
やstring?
のようなnull許容の値型と参照型だけである。
しかし、案の定、いろいろな罠が待ち構えている。
null許容値型とnull許容参照型
C#には値型(value type)と参照型(reference type)があり、 そのどちらにもnull許容型とnull非許容型(non-nullable type)がある。
T
がnull非許容の値型の場合、
T?
はnull許容値型†1(nullable value type)である。
ただし、T?
の記法はシンタックスシュガーであり、
その実際の型はNullable<T>
(構造体)である。
したがって、T
とT?
はコンパイル時はもちろん、実行時も型として異なるものである。
T?
すなわちNullable<T>
はnull許容型である。
JavaのOptional<T>
とは異なり、
T?
もNullable<T>
もネストを禁止している。
C#コンパイラはNullable<T>
を特別扱いすることでそれを実現する。
なお、null許容値型のインスタンスは不変オブジェクトである。
†1 null許容値型はC# 2.0から追加された。
対照的に、T
がnull非許容の参照型の場合、
T?
はnull許容参照型†2(nullable reference type)である。
T
とT?
はコンパイラが区別するだけで、
実行時には同じもの(つまりT?
、C# 8以前は参照型と呼ばれていたもの)になる。
そして、もちろん、T?
はnull許容型である。
†2 C# 8からnull許容参照型が追加され、 従来の参照型はnull非許容参照型になった。 ただし、互換性を維持するための仕掛けも用意されているし、 デフォルトで互換性を維持する設定になっている。
まとめると次のようになる:
null非許容型 | null許容型 | |
---|---|---|
値型 | null非許容値型(例: int ) |
null許容値型(例: int? , Nullable<int> ) |
参照型 | null非許容参照型(例: string ) |
null許容参照型(例: string? ) |
参照型の式のnullチェック
参照型の式expr
がnull
であるかどうかを判定する方法を次の表にまとめた:
方式 | null である |
null ではない |
---|---|---|
比較演算子 | expr == null |
expr != null |
is パターンマッチング |
expr is null |
!(expr is null) |
プロパティパターン | !(expr is {}) |
expr is {} |
C# 9では
expr is not null
も使用できる。
コンパイル時と実行時のnull許容参照型の扱い
先ほどnull許容参照型とnull非許容参照型は「コンパイラが区別するだけ」と説明したが、 実際はそう簡単ではない。 コンパイラはコンテキストによって、それらを区別したり、同様に扱ったりする。 例えば、次のクラスはコンパイルエラーになる:
public class RaiseCS0111
{
public void M(string name)
{
}
public void M(string? name)
{
}
}
R
をnull非許容の参照型とするとき、
メソッドのパラメータの型をそのシグネチャとしてみるときは、
R
とR?
は同一のものとなる。
したがって、M(string)
とM(string?)
は同じシグネチャであるので、
コンパイルは失敗する。
一方で、シグネチャが同一でメソッドをオーバーライドするということになると、
今度はR
とR?
を区別して、
コンパイラは次のコードに警告を出す:
public class Base
{
public virtual void M(string? name)
{
}
}
public sealed class RaiseCS8610 : Base
{
public override void M(string name)
{
}
}
なお、逆に
M(string)
をM(string?)
でオーバーライドすると警告は出ない。 これはリスコフの置換原則(Liskov substitution principle)[1] が適用されるからだ。
大まかに言うと、コンパイラは次のような手順でコンパイルを行う:
R?
の?
を無視してコンパイル ➜ エラーがあれば出力- 次に
?
を考慮してnull許容性のデータフロー解析 ➜ 警告があれば出力
これが分かりにくいと思ったら、次のように考えてみると良い。
まず、R?
の?
は型ではなく、
コンパイルのときにだけ存在する属性(例えば、
[MaybeNull]
のようなもの)とみなしてみる。
つまり、
string? foo;
を次のようなコードと(脳内で)変換する:
[MaybeNull] string foo;
このようなケースでは、 型というよりも変数やパラメータにアノテーションを付加していると考えた方がよいだろう。
厄介なのは、実行時にR
とR?
の区別がないことである。
例として、次のようなコードを考えよう:
public static void Main() {
var array = new[]
{
"abc", null, "def",
};
var all = array.OfType<string?>()
.ToArray();
foreach (var s in all)
{
Console.WriteLine(s);
}
}
実行結果をみると、出力は2行だけであり、all
の中にnull
は含まれていない。
つまり、OfType<R>()
とOfType<R?>()
は同じ結果になる。
OfType<T>()
はそのリファレンス実装をみると、
is
パターンマッチングでT
にマッチする要素だけを返すようになっている。
そもそも、
is
パターンマッチングにおいて型にR?
を指定すると、
次のようにコンパイルエラーになる:
public class RaiseCS8650
{
public void M(object? o)
{
if (o is string?)
{
}
}
}
では、次のように型パラメータT
を用いてis
パターンマッチングを試してみよう:
public class C
{
public static void M<T>(object? o)
{
if (o is T)
{
Console.WriteLine("true");
}
else
{
Console.WriteLine("false");
}
}
public static void Main()
{
M<string?>("a");
M<string?>(null);
}
}
実行結果はtrue
、false
となる。
is
パターンマッチングで型パラメータT
に対してR?
を指定しても、
R
を指定したのと同じ結果になることがわかる。
is
パターンマッチングは実行時に型を判定する機能であり、
実行時に?
は型イレイジャ(type erasure)[2] により消失するので、
当然の結果である。
しかし、OfType<T>()
の例で示したように、
誤解しやすいケースもある。
もちろん、自分でメソッドを作成する場合は、
後述するように、
型制約でT
に対してR?
を指定できないようにすることも可能だ。
しかし、LINQなど、標準ライブラリのAPIについては、
T
が実行時に判定されるものかどうか、使う側がよく注意する必要がある。
デフォルト値
Javaのパートで考察したnullオブジェクトパターンを再びC#で考えてみよう:
...
public sealed class Program
{
private static readonly Action DoNothing = () => {};
private readonly Func<char, Action> toAction;
public Program()
{
var map = new Dictionary<char, Action>()
{
['h'] = MoveCursorLeft,
['j'] = MoveCursorDown,
['k'] = MoveCursorUp,
['l'] = MoveCursorRight,
};
toAction = c => map.TryGetValue(c, out var action)
? action
: DoNothing;
}
public void HandleKeyInput()
{
var c = GetPressedKey();
var action = toAction(c);
action();
}
...
Javaの例と同様に、null
は出てこない。素晴らしいが、気になることがある。
ここで、
Dictionary<TKey, TValue>
クラスのTryGetValue(TKey, out TValue)
メソッドがfalse
を返すとき、
action
はどのような値になっているのだろう。Action
はnull非許容型だから、
action
がnull
になることは無いはずでは、と考えるのが普通だ。
しかし、答えはnull
である。
APIリファレンスには次のような記述がある:
value
TValue
When this method returns, contains the value associated with the specified key, if the key is found; otherwise, the default value for the type of the value parameter. This parameter is passed uninitialized.
参照型のデフォルト値はnull
なので、仕様通りである。これを正当化する、というか、
コンパイラに教えるために、.NET Core 3.0ではTryGetValue
の定義に特別な属性を追加†3した。
具体的には、次のようにMaybeNullWhenAttribute
で第2パラメータをアノテートする:
public bool TryGetValue(TKey key, [MaybeNullWhen(false)] out TValue value)
†3 .NET Core SDK 3.0.100-preview9で確認した。
[MaybeNullWhen(false)]
は「メソッドの戻り値がfalse
のときは、
そのパラメータの型がnull非許容参照型だとしても、
パラメータがnull
になりえる」ことをコンパイラに伝える。
これにより、コンパイラはメソッドの戻り値がfalse
のパスで、
nullチェック無しにnull非許容参照型のパラメータにアクセスするコードに警告を出すことができる。
要するに、
null非許容参照型が生まれる前にデビューした標準ライブラリは、
参照型の値がnull
になりえることを前提として設計されているので、
null非許容参照型とは相性が悪いものがある、
ということなのだ。
そこでC#言語の開発者らは勇敢にも、
JavaのChecker Frameworkのアノテーションに相当する属性を標準ライブラリに取り込み、
null許容性を追跡する機能をコンパイラに追加した。
一見すると、次のようにシグニチャを変更してしまえば良い気がする:
public bool TryGetValue(TKey key, out TValue? value)
これがダメな理由はMicrosoftの開発者ブログの記事 Try out Nullable Reference Types [3] に詳しく説明されている。
V
、R
をそれぞれ、null非許容の値型、参照型として、
上記をまとめると次のようになる:
Types | Examples | Can be null ? |
default |
---|---|---|---|
V |
int , bool |
Never | 0 やfalse など |
V? , Nullable<V> |
int? , Nullable<int> |
Yes | null |
R |
string |
Yes | null |
R? |
string? |
Yes | null |
LINQ
続いて、配列の要素の中から条件に合う最初の要素を取得する操作を考えてみる。 次のコードをみてみよう:
var firstFavorite = new[] { "foo", "bar", "baz" }
.Where(matchesFavorite)
.FirstOrDefault();
// firstFavoriteはnullになることがある... ん!?
if (firstFavorite is {})
{
...
}
// または
if (firstFavorite is string s)
{
...
}
FirstOrDefault()
メソッドはthis
となるIEnumerable<T>
が空であればdefault(T)
を、
そうでなければ最初の要素を返す。
そして、その戻り値の型はT
である。
この例ではT
はnull非許容参照型のstring
なので、
空のIEnumerable<string>
に対してnull
を返す。
C# 8より前では普通のことだが、今や異常である。
なぜなら戻り値の型がstring?
ならその値はnull
になってもよいが、
string
ならnull
にならないことになっているからだ。
FirstOrDefault()
は次のように、
MaybeNullAttribute
で戻り値をアノテートし、
戻り値がnull
になることを許すはず‡である:
[return: MaybeNull]
public static TSource FirstOrDefault<TSource>(this IEnumerable<TSource> source)
[return: MaybeNull]
は「メソッドの戻り値は、
その型がnull非許容参照型だとしても、
null
になりえる」ことをコンパイラに伝える。
これにより、
メソッドの戻り値をnullチェック無しにアクセスするコードにコンパイラは警告を出すことができる。
‡ .NET Core SDK 3.0.100-rc1では、まだアノテートされていない。 masterブランチにマージされているのは確認したので、正式リリースまでには対応しているだろう。
FirstOrDefault()
の代わりにDefaultIfEmpty(defaultValue).First()
を使っても良い。
しかし、戻り値をnullオブジェクトパターン的に使えるのでなければ、
結局その戻り値とdefaultValue
の比較が必要になる。
再び、あえて次のように記述してみる:
var firstFavorite = new[] { "foo", "bar", "baz" }
.Where(matchesFavorite)
.Take(1);
foreach (var s in firstFavorite)
{
...
}
// あるいは...
var firstOrEmpty = new[] { "foo", "bar", "baz" }
.Where(matchesFavorite)
.Take(1)
.ToArray();
if (firstOrEmpty.Length > 0)
{
var s = firstOrEmpty[0];
...
}
null
の話題からは脱線するが、FirstOrDefault()
よりもTake(1)
が便利な場合がある。
それは要素の型T
が値型のときである。値型でdefault(T)
の値が扱いにくい場合は、
要素数が高々1個のIEnumerable<T>
として扱った方が都合が良いこともある
(Javaのパートで説明したときは理解を深めるための考え方でしかなかったが、
C#では実用的である)。
C#で高々1つ、0個または1個を扱うための選択肢は次のようになる:
インスタンスの個数 | null許容型/包括型 | null /その代わりに... |
---|---|---|
高々1個 | T? |
null |
0個以上 | T[] |
Array.Empty<T> |
0個以上 | IEnumerable<T> |
Enumerable.Empty<T> |
null許容値型の暗黙的/明示的型変換
null許容値型では、
T?
型の変数に対しては、T?
型の式とnull
だけではなく、
T
型の式を代入できる。
これは、Nullable<T>
型の「T?
からT
へ」のimplicit
演算子が適用されるためである。
T
がint
の場合の例を次に示す:
int? v1 = null;
int? v2 = 123;
右辺の値はNullable<int>
型に暗黙に変換
(「int
からint?
へ」のimplicit
演算子が適用)される。
つまり、次のコードと等価である:
var v1 = new Nullable<int>();
var v2 = new Nullable<int>(123);
反対に、T?
型の式をT
型の変数に代入する場合は、
明示的な型変換(T?
からT
へのexplicit
演算子)が必要である。
次の例はコンパイルエラーとなる:
int? maybeInt = 123;
int intValue = maybeInt;
次のような明示的な型変換(int
へのキャスト)を使用すればエラーにはならない:
int? maybeInt = 123;
var intValue = (int)maybeInt;
しかし、これは次のコードと等価である:
int? maybeInt = 123;
var intValue = maybeInt.Value;
Nullable<T>
のValue
プロパティは、オブジェクトが値をもつ場合、その値を返す。
そうでなければ、例外InvalidOperationException
をスローする。
したがって、T
型に変換するときに値が無ければ、
同様に例外をスローすることに気を付ける必要がある。
null許容値型の値の有無の判別
Nullable<T>
型の式に値があるかどうかは、
次のようにHasValue
プロパティで調べることができる:
int? maybeInt = ...
if (maybeInt.HasValue) {
var intValue = maybeInt.Value;
...
}
あるいは、次のようにT?
型の式をnull
と比較してもよい:
int? maybeInt = ...
// 次の行は if (maybeInt is {}) { や
// if (!(maybeInt is null)) { でも同じ
if (maybeInt != null) {
var intValue = maybeInt.Value;
...
}
さらにパターンマッチングを適用することもできる:
int? maybeInt = ...
// 次の行は if (maybeInt is int intValue) {
// でも同じ
if (maybeInt is {} intValue) {
...
}
なお、null許容参照型には、Value
プロパティやHasValue
プロパティは存在しない。
繰り返しになるが、null許容参照型の場合、T
とT?
は実際には同じ型なので、
それは当然である。
もちろん、null
との比較、パターンマッチングは同様に適用できる。
null許容値型のリフト演算子
null許容値型では、T
型の演算子をT?
型にそのまま適用できる。
T?
型に適用したT
型の演算子をリフト演算子(lifted operators)と呼ぶ。
演算結果は、
どちらか一方または両方に値がない(null
である)ときは値のないT?
型のオブジェクト、
両方に値があるときはそれらを演算した結果の値をもつT?
型のオブジェクトになる。
例えば、a
とb
がint?
型のオブジェクトのとき、次のコード:
var c = a + b;
は次のコードとほぼ等価である:
// &ではなく&&でもよい
var c = (a.HasValue & b.HasValue)
? new Nullable<int>(a.Value + b.Value)
: new Nullable<int>();
ただし、bool?
型の演算だけは、特殊なルールが適用される。
詳しくはnull許容論理型の論理演算子を参照してほしい。
null許容値型のボクシングとアンボクシング
null許容値型では、
T?
型のオブジェクトをボクシングすると、
オブジェクトに値があるときはそのT
型の値をボクシングしたオブジェクト、
そうでなければnull
となる。次に例を示す:
int? maybeInt = ...
object boxedInt = maybeInt;
これは次のコードと同様の意味である:
int? maybeInt = ...
var boxedInt = (maybeInt.HasValue)
? (object)maybeInt.Value
: null;
また、
T
型の値をボクシングしたオブジェクトを、T?
型にアンボクシングすることもできる。
次に例を示す:
int intValue = ...
object boxedInt = intValue;
int? maybeInt = (int?)boxedInt;
なお、null許容参照型は、参照型なので当然、ボクシングやアンボクシングは行われない。
?.
演算子と?[]
演算子
?.
演算子と?[]
演算子はnull条件演算子†4(null-conditional operator)である。
†4 Wikipedia [4] によると、 この演算子と同じ意味の演算子が他のプログラミング言語でも定義されているが、 今のところの呼び方は言語によって様々である。
expr
がnull許容型の式であるとき、
expr?.Member
は「expr
がnull
ではない場合に限って実行される、
expr
のメンバーMember
へのアクセス」である。
expr
がnull
である場合は、Member
へのアクセスは生じず、
Member
がvoid
型でなければ、式はnull
になる。
同様に、
expr?[index]
は「expr
が非nullの場合にだけ行われるexpr
のインデクサーthis[int]
へのアクセス」である。
expr
がnull
である場合はthis[int]
へのアクセスは生じずに式はnull
になる。
どちらもexpr
がnull非許容値型の場合はコンパイルエラーになる。
さらに細かいことだが、 コンパイル時に
Member
またはthis[int]
の型が、 参照型なのか値型なのか不明な場合(例えば、 ジェネリクスで型パラメータになっていて型制約がない場合、など)は、 コンパイルエラーになる。
これらをまとめると、次のようになる:
expr の型 |
expr の値 |
expr?.Member , expr?[int] の結果 |
---|---|---|
null許容型 または null非許容参照型 |
null |
nothing if the type is void ,
null otherwise |
not null |
expr.Member , expr[int] |
|
null非許容値型 | never null |
コンパイルエラー |
例えば、
Member
が「戻り値がvoid
型のメソッドやAction
のようなデリゲートの呼び出し」であれば、
次と同様な結果†5になる。
if (expr is {})
{
expr.Member();
}
そうではなく、Member
が値または参照を返す場合は、
expr
がnull
の時はnull
、
非null
の時はexpr.Member
になる。
R
、V
をそれぞれ、null非許容の参照型、値型とすると、次のようになる:
Member の型 |
expr?.Member と類似の結果†5 |
式の型 |
---|---|---|
R /R? |
(expr is null) ? null : expr.Member |
R? |
V |
(expr is null) ? (V?)null : expr.Member |
V? |
V? |
(expr is null) ? null : expr.Member |
V? |
インデクサーの場合は、expr
がnull
の時はnull
、
非null
の時はexpr[index]
になる。
同様にR
、V
を用いると、次のようになる:
this[int] の型 |
expr?[index] と類似の結果†5 |
式の型 |
---|---|---|
R /R? |
(expr is null) ? null : expr[index] |
R? |
V |
(expr is null) ? (V?)null : expr[index] |
V? |
V? |
(expr is null) ? null : expr[index] |
V? |
†5
expr?.Member
とexpr?[index]
では、 どちらもexpr
は一度しか評価されない。
ただし、Swiftと異なり、
次のように?.
と?[]
を左辺値に使用するとコンパイルエラーとなる:
public sealed class Program
{
public static void Main()
{
var m = new Program();
// MaybeFooがnullでなければSetBar(), SetChar()で値を設定
m.MaybeFoo?.SetBar("hello");
m.MaybeFoo?.SetChar(0, 'h');
// 上と同じになって欲しいけど、コンパイルエラー
m.MaybeFoo?.Bar = "hello";
m.MaybeFoo?[0] = 'h';
}
public Foo? MaybeFoo { get; set; }
public sealed class Foo
{
private char[] table = {};
public string Bar { get; set; } = "";
public char this[int k]
{
get => table[k];
set => table[k] = value;
}
public void SetBar(string newBar)
=> Bar = newBar;
public void SetChar(int k, char c)
=> this[k] = c;
}
}
特に、Member
の型、またはthis[int]
の型がnull非許容型のとき、
?.
演算子と?[]
演算子はnull
を上陸させる操作である。
つまり、それらの演算子により、
?
が感染し、もともとは存在しなかったR?
やV?
が新たに発生する可能性がある。
よって、これらはnull
の処分を先送りにする操作である。
それらはnullオブジェクトパターンの誤用、
NullReferenceException
(NRE)のキャッチなどに類似して、
妙なところからチェック漏れを引き起こしかねない。
それゆえ、安易に?.
と?[]
を(.
と[]
の代わりに)使用するべきではない。
??
演算子と??=
演算子
??
演算子、??=
演算子は、
それぞれ、
null合体演算子†6(null-coalescing operator)、
null合体代入演算子(null-coalescing assignment operator)である。
†6 null条件演算子と同様に、 他の言語でも同じ意味の演算子が定義されている。 Wikipedia [5] を参照。
expr1 ?? expr2
†7は次と同じ意味になる:
(expr1 is {}) ? expr1 : expr2
ただし、expr1 ?? expr2
の場合、expr1
は一度しか評価されない。
また、expr1
がnull許容値型の場合、
??
演算子はNullable<T>
のGetValueOrDefault(T)
メソッドと類似の操作であるが、
expr1
がnull
のときだけexpr2
が評価される点が異なる。
expr1 ?? throw new Exception()
†7は、expr1
がnull
のとき例外をスローし、
そうでなければexpr1
を返す式となる。
variable ??= expr
†7は次と同じ意味になる:
if (variable is null)
{
variable = expr;
}
†7
expr1
、variable
がnull非許容値型の場合、コンパイルエラーになる。
特にexpr2
がnull非許容型の場合、
??
演算子はnull
の上陸を阻止する操作である。
これにより、null
は処分される。
逆に、expr2
がnull許容型の場合、
?.
演算子と?[]
演算子と同様に、
??
演算子を安易に使うべきではない。
!
後置演算子
null許容演算子(null-forgiveness operator, null forgiving operator)はnull許容参照型の式(null
そのもの、
またはその可能性がある式)を、
null非許容参照型の式としてみなす演算子(実際は警告を出さないようにするコンパイラへの指令)である。
次のように、
null許容参照型の式に!
を後置して、
警告を抑制できる:
public sealed class Foo
{
public Foo()
{
// NonNullable = null; は警告
NonNullable = null!;
}
public string NonNullable { get; }
public void RaiseNoWarnings()
{
string? maybeNull = null;
// string t = maybeNull; は警告
string t = maybeNull!;
// var n = maybeNull.Length; は警告
var n = maybeNull!.Length;
}
}
念のために書いておくが、
コンパイラの警告を理解できずに、
ただそれを消したいだけで!
を後置してはならない。
そんなことをすれば、
自分があとでビックリするだけだろう。
null非許容型の式に対して
!
を後置しても、コンパイラは!
を無視する。 また、 null許容値型の式に対して!
を後置しても(Swiftの無条件アンラップとは異なり)、 単に警告を抑制するだけなので注意が必要である。Nullable<T>
から値を取り出す (値が無ければ例外をスローする)Value
プロパティのつもりで!
を後置しても、 値は取り出されず、単にコンパイルエラーになる。
ジェネリクスの型パラメータ制約
型パラメータは厄介だ。
Foo<T>
というクラスがあるとする。
このとき、
型パラメータT
はint
、string
のようなnull非許容型だけではなく、
int?
、string?
のようなnull許容型でもよい。
そのため、T?
型のパラメータや戻り値は破綻する。
次のように、型パラメータT
をもちながらT?
型を含む型は
コンパイルエラーとなる:
public sealed class Foo<T>
// 次のどちらかの行のコメントを解除すればエラーは無くなる
// where T : class
// または
// where T : struct
{
public T? Default { get; } = default;
public void DoSomething(T? t)
{
}
public void DoAnything()
{
T? bar;
}
}
型パラメータT
を非null許容の参照型(class
)か、
あるいは非null許容の値型(struct
)に型制約で限定することで、
エラーは解消する。
つまり、
T?
が実際のところ参照型T
と値型Nullable<T>
のどちらなのか、 をはっきりさせる必要がある。 これはC#の誓約とか呪いのようなものだ。
型パラメータT
が非null許容型であることを示すnotnull
制約もある。これは「class
またはstruct
」のようなものである。
T
がnotnull
制約である場合、T
は参照型なのか値型なのか不明なため、
同様にFoo<T>
はT?
型を含むことはできない。
そして、Foo<T>
のT
はnotnull
だと制限したら、
当然だがT
にnull許容型を指定することはできなくなる。
次のコードで確認してみよう:
public sealed class Foo<T>
where T : notnull
{
public Foo(T t)
{
}
}
public static class Foo
{
public static Foo<T> NewFoo<T>(T t)
where T : notnull
=> new Foo<T>(t);
public static void RaiseWarnings(string? maybeNull)
{
_ = NewFoo(1);
_ = NewFoo("a");
int? i = 1;
string? notNull = "a";
_ = NewFoo(notNull);
// 次の2行は警告
_ = NewFoo(i);
_ = NewFoo(maybeNull);
}
}
NewFoo(notNull)
に警告が出ないのは、null許容参照型の面白いところである。
string?
型の式でも、データフロー解析で値がnull
ではないことが判明していれば、
型推論ではstring
型として扱われる。一方、値型ではそのようなことは起きない。
最後に、ややこしいが、class?
制約もある。
T
がclass?
制約である場合、T
そのものがnull許容型であるため、
Foo<T>
はT?
型を含むことはできない。
しかし、T
に対してはnull許容、非許容に関わらず参照型を指定できる。
R
をnull非許容参照型、V
をnull非許容値型として、
上記をまとめると次のようになる:
T の制約 |
Foo<T> がT? を含む |
Foo<V> |
Foo<V?> |
Foo<R> |
Foo<R?> |
---|---|---|---|---|---|
なし | コンパイルエラー | ○ | ○ | ○ | ○ |
where T : class |
OK | ○ | |||
where T : class? |
コンパイルエラー | ○ | ○ | ||
where T : struct |
OK | ○ | |||
where T : notnull |
コンパイルエラー | ○ | ○ |
typeof
演算子
typeof
演算子はnull許容参照型には使用できない。
これはis
パターンマッチングに似た話だが、
例えばtypeof(string?)
はコンパイルエラーになる。
対照的に、typeof(int?)
はNullable<int>
を表すType
オブジェクトを返す。
object.GetType()
の結果も含めると、次のようになる:
// Console.WriteLine(typeof(string?));
// はコンパイルエラー
// System.Nullable`1[System.Int32]
Console.WriteLine(typeof(int?));
string? s = "a";
// System.String
Console.WriteLine(s.GetType());
int? i = 1;
// System.Int32
Console.WriteLine(i.GetType());
なお、
しつこいようだが、
型パラメータT
に対してR?
を指定してtypeof(T)
を評価しても、
その結果はtypeof(R)
になる。