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許容値型†1nullable value type)である。 ただし、T?の記法はシンタックスシュガーであり、 その実際の型はNullable<T>(構造体)である。 したがって、TT?はコンパイル時はもちろん、実行時も型として異なるものである。 T?すなわちNullable<T>はnull許容型である。 JavaのOptional<T>とは異なり、 T?Nullable<T>もネストを禁止している。 C#コンパイラはNullable<T>を特別扱いすることでそれを実現する。 なお、null許容値型のインスタンスは不変オブジェクトである。

†1 null許容値型はC# 2.0から追加された。

対照的に、Tがnull非許容の参照型の場合、 T?null許容参照型†2nullable reference type)である。 TT?はコンパイラが区別するだけで、 実行時には同じもの(つまりT?、C# 8以前は参照型と呼ばれていたもの)になる。 そして、もちろん、T?はnull許容型である。

†2 C# 8からnull許容参照型が追加され、 従来の参照型はnull許容参照型になった。 ただし、互換性を維持するための仕掛けも用意されているし、 デフォルトで互換性を維持する設定になっている。

まとめると次のようになる:

null非許容型 null許容型
値型 null非許容値型(例: int null許容値型(例: int?, Nullable<int>
参照型 null非許容参照型(例: string null許容参照型(例: string?

参照型の式のnullチェック

参照型の式exprnullであるかどうかを判定する方法を次の表にまとめた:

方式 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非許容の参照型とするとき、 メソッドのパラメータの型をそのシグネチャとしてみるときは、 RR?は同一のものとなる。 したがって、M(string)M(string?)は同じシグネチャであるので、 コンパイルは失敗する。

一方で、シグネチャが同一でメソッドをオーバーライドするということになると、 今度はRR?を区別して、 コンパイラは次のコードに警告を出す:

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] が適用されるからだ。

大まかに言うと、コンパイラは次のような手順でコンパイルを行う:

  1. R??を無視してコンパイル ➜ エラーがあれば出力
  2. 次に?を考慮してnull許容性のデータフロー解析 ➜ 警告があれば出力

これが分かりにくいと思ったら、次のように考えてみると良い。 まず、R??型ではなく、 コンパイルのときにだけ存在する属性(例えば、 [MaybeNull]のようなもの)とみなしてみる。 つまり、

string? foo;

を次のようなコードと(脳内で)変換する:

[MaybeNull] string foo;

このようなケースでは、 型というよりも変数やパラメータにアノテーションを付加していると考えた方がよいだろう。

厄介なのは、実行時にRR?の区別がないことである。 例として、次のようなコードを考えよう:

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);
    }
}

実行結果

実行結果はtruefalseとなる。 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非許容型だから、 actionnullになることは無いはずでは、と考えるのが普通だ。 しかし、答えは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] に詳しく説明されている。

VRをそれぞれ、null非許容の値型、参照型として、 上記をまとめると次のようになる:

Types Examples Can be null? default
V int, bool Never 0falseなど
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演算子が適用されるためである。 Tintの場合の例を次に示す:

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許容参照型の場合、TT?は実際には同じ型なので、 それは当然である。 もちろん、nullとの比較、パターンマッチングは同様に適用できる。

null許容値型のリフト演算子

null許容値型では、T型の演算子をT?型にそのまま適用できる。 T?型に適用したT型の演算子をリフト演算子(lifted operators)と呼ぶ。

演算結果は、 どちらか一方または両方に値がない(nullである)ときは値のないT?型のオブジェクト、 両方に値があるときはそれらを演算した結果の値をもつT?型のオブジェクトになる。 例えば、abint?型のオブジェクトのとき、次のコード:

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条件演算子†4null-conditional operator)である。

†4 Wikipedia [4] によると、 この演算子と同じ意味の演算子が他のプログラミング言語でも定義されているが、 今のところの呼び方は言語によって様々である。

exprがnull許容型の式であるとき、 expr?.Memberは「exprnullではない場合に限って実行される、 exprのメンバーMemberへのアクセス」である。 exprnullである場合は、Memberへのアクセスは生じず、 Membervoid型でなければ、式はnullになる。

同様に、 expr?[index]は「exprが非nullの場合にだけ行われるexprのインデクサーthis[int]へのアクセス」である。 exprnullである場合は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が値または参照を返す場合は、 exprnullの時はnull、 非nullの時はexpr.Memberになる。 RVをそれぞれ、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?

インデクサーの場合は、exprnullの時はnull、 非nullの時はexpr[index]になる。 同様にRVを用いると、次のようになる:

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?.Memberexpr?[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合体演算子†6null-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)メソッドと類似の操作であるが、 expr1nullのときだけexpr2が評価される点が異なる。

expr1 ?? throw new Exception()†7は、expr1nullのとき例外をスローし、 そうでなければexpr1を返す式となる。

variable ??= expr†7は次と同じ意味になる:

if (variable is null)
{
    variable = expr;
}

†7 expr1variableが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>というクラスがあるとする。 このとき、 型パラメータTintstringのような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」のようなものである。 Tnotnull制約である場合、Tは参照型なのか値型なのか不明なため、 同様にFoo<T>T?型を含むことはできない。 そして、Foo<T>Tnotnullだと制限したら、 当然だが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?制約もある。 Tclass?制約である場合、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)になる。

References

[1] Wikipedia, Behavioral subtyping

[2] Wikipedia, Type erasure

[3] Microsoft Developer Blogs, Try out Nullable Reference Types

[4] Wikipedia, Safe navigation operator

[5] Wikipedia, Null coalescing operator