LoginSignup
59
33

More than 5 years have passed since last update.

Null 非許容な参照型

Last updated at Posted at 2017-12-09

この記事について

この記事は2017年 C# Advent Calendar 12月9日の記事です。

C# 8.0に向けて検討中で、11/15にプレビュー版が公開された C# の新機能「nullable reference types」について説明するものです。

"nullable reference types" を直訳するなら「Null 許容参照型」になるかと思いますが、この機能の実態を一言で言うと、参照型がnullを 可能な限り受け入れない ようにするもの、と考えたほうがいいでしょう。そのあたりのことを説明していきます。

Disclaimer: この記事は11/15のプレビュー版を前提にしたものです。これはあくまで現時点での 仕様に関する議論 を形にしたプロトタイプでしかなく、今後の議論によって仕様も実装も大きく変わる可能性があると思っていたほうがいいでしょう。CTP (Community Technology Preview) と言われるものよりも実験的な色合いが強いと個人的には理解しています。

プレビュー版を試すための環境

リリースされたバイナリはC# コンパイラ (Roslyn) 拡張を置き換えてしまいますので、試用専用の環境を用意することを強く推奨します。

Visual Studio 15.5 (プレビュー4以降) が必要で、かつ、他のバージョンをインストールしないでください。複数バージョンのVisual Studioが共存していると、インストール用バッチファイルがエラーになります。

試用環境が準備できたら、GitHubの dotnet/csharplang リポジトリのWiki に行き、リンクされている Roslyn_Nullable_References_Preview.zip をダウンロードして展開した後、Visual Studioを閉じて install.batを実行してください。

どういうコードの書き方になるか:概要

注意:この記事では、型推論(varなど)を使っていません。これは、11/15のプレビュー版ではnull非許容参照型と型推論の組み合わせに一部問題があるからです。問題についてはこの記事では扱いませんので、 Wiki などを見てください。(TBD. 余力があったらフォローする。)

概要1. 基本

参照型は原則的にnullを値に持てなくなります。参照型の変数、フィールド、プロパティ、引数にnullを代入したり、戻り値が参照型のメソッドでnullを返したりすると、コンパイル時に警告されます。

string s0 = null; // NG (警告)
string s1 = default(string); // NG (警告)
string s2 = "abc"; // OK
string s3 = s2; // OK
static string NonNullableString()
{
    int dice = 1 + new Random().Next(6);
    if (dice == 6)
    {
        return null; // NG (警告)
    }
    else
    {
        return "ABC"; // OK
    }
}

null非許容参照型のフィールドを持つ型は、コンストラクターの中で、nullでない値で初期化する必要があります。初期化されないフィールドがあると、コンパイル時に警告されます。1

class C
{
    private string s0; // OK

    private string s1; // NG (警告)

    public C()
    {
        this.s0 = "ABC";
    }
}

(追記)構造体は、default(T) で初期化すると、すべてのフィールドがデフォルト値となります。フィールドにnull非許容な参照型を含んでいた場合は、そのフィールドの値が null となります。これは本来は警告すべきなのかもしれませんが、現実のコード上にあまりに多いため、あえて警告しない仕様としています。

struct V
{
    public string Field;
}
V value = default(V); // OK(警告なし)

nullを代入するかもしれない参照型は、参照型名の末尾に ? を付与します。

string? ns0 = null; // OK
string? ns1 = default(string); // OK
string? ns2 = "ABC"; // OK
string? ns3 = ns2; // OK
static string? NullableString()
{
    int dice = 1 + new Random().Next(6);
    if (dice == 6)
    {
        return null; // OK
    }
    else
    {
        return "ABC"; // OK
    }
}

参照型 A の値を null許容参照型 A? に使うことはできますが、その逆は原則的にできません。(ただし、フロー解析によってその制限は緩められます。そのことは後述します。)

string s0 = NonNullableString(); // 戻り値はstring型
string? ns0 = s0; // OK

string? ns1 = NullableString(); // 戻り値はstring?型
string s1 = ns1; // NG (警告)

概要2. デリファレンス(参照剥がし)とフロー解析

null許容参照型 A? の値は、文字通りnullかもしれないので、型 A に定義されているインスタンスメンバー(フィールドやメソッドやプロパティやイベントなど)にアクセスするコード、つまり実行時にNullReferenceExceptionが投げられる可能性があるコードはコンパイル時に警告されます。

string? ns0 = null;
string lower = ns0.ToLowerInvariant(); // NG (警告)

そのような状況では、null条件演算子 ?. を使うことができます。インスタンスメンバーの型(メソッド呼び出しの場合は戻り値の型)が型 R だった場合は、null条件演算子 ?. を通して呼び出すと R? 型となります。これは、型 R が値型だった場合でも参照型だった場合でも同様となります。

string? ns0 = null;
string? nlower = ns0?.ToLowerInvariant(); // OK

null条件演算子 ?. を使った上のコードは、下のように三項条件演算子 ? : で分岐したコードと等価です。

string? ns0 = null;
string? nlower = (ns0 != null) ? ns0.ToLowerInvariant() : null;

あるいは、 if 文で分岐しても同じです。

string? ns0 = null;
string? nlower;
if (ns0 != null)
{
    nlower = ns0.ToLowerInvariant();
}
else
{
    nlower = null;
}

この上の2つのコード片では string? 型のはずの ns0 に対して ns0.ToLowerInvariant(); のようにメンバーアクセスをしていますが、実はこの2つのコードも問題なくコンパイルできます。(警告が出ません)。それは、三項条件演算子 ? :if 文で、nullチェックの結果で分岐をしているからです。
実は、nullチェックの結果に基づいて分岐をした場合など、null許容参照型 A?値がnullではないことがコンパイラーにとって明確になっている実行パス では、その範囲に限り、null非許容の A 型として値を利用するコードを書くことができるのです。このように、制御フローの各実行パスにおいて、データの取りうる値をコンパイラーが追跡して認識することを、データフロー解析、または省略してフロー解析と呼びます。C# 8のnull非許容参照型を使ったコードでは、C#コンパイラがフロー解析を行い、値がnullにならない実行パスを見つけているのです。

フロー解析では、nullチェックの分岐だけではなく、null許容参照型 A? のローカル変数に null非許容の A 型の値を代入したときも追跡されます。その代入を起点として、nullそのもの、またはnullの可能性がある A? 型の値を代入するまで、その変数はnullにならない A 型として利用できます。

string s0 = "ABC";
string? ns1 = null;
string? ns2;

ns2 = s0; // ☆
// ☆から◆までの間、ns2はnullではない
string s1 = ns1.ToLowerInvariant(); // OK

ns2 = ns1; // ◆
// ここから、ns2はnull
string s2 = ns1.ToLowerInvariant(); // NG (警告)

ただし、フロー解析を厳密にかつプログラム全体に渡って行うことはコンパイラーにとって非常に難しいことです。解析を厳密にしたり、解析範囲を広くしたりすればするほど、コンパイル時間も長くなってしまいます。また、マルチスレッドモデルにおいては、ある変数の値を複数のスレッドが変更できる場合、静的な解析ではその変数がnullでないことを保証できなくなります。

そこで、C# 8のnull非許容参照型では、

  • フロー解析は1メソッドの範囲内に限定する(フロー解析の結果をメソッド外に引き継がない)
  • フロー解析の起点はローカル変数またはメソッド引数に限定する(フィールドやプロパティなどはフロー解析しない)

としています。

また、それ以外にも、リリースまでにはサポートしたいけれどもプレビュー版ではまだフロー解析が不十分なケースや、そもそもサポートするべきかどうかがまだ議論中のケースなどもあります。(TBD. 例示)

というわけで、プログラマーにはnullでないことが明らかでも、C#コンパイラーには判断ができない場合があります。そのような場合は、!演算子を使います。
!演算子は、null許容参照型を、警告を出さずに強制的にnull非許容参照型に変換します。! の優先順位は高いので、たとえば警告を出さずに強制的にメンバーアクセスしたければ、!.のように書くこともできます。

int dice = 1 + new Random().Next(6);
string? ns0 = (dice < 6) ? "ABC" : null;
string? ns1 = null;
if (ns0 != ns1)
{
    // ns0はnullではないが、C#コンパイラーはそのような判断をしないため、!演算子を使う
    string s0 = ns0!; // OK
    string s1 = s0 + ns0!.ToLowerInvariant(); // OK
    Console.WriteLine(s1);
}

理解の早い方はおわかりと思いますが、この ! 演算子はコンパイル時のnullチェックを回避して実行時のnullチェックに変えてしまうものです。誤った使い方をしても警告が出ないので、実行時にNullReferenceExceptionが投げられる可能性を排除できない機能といえます。この機能を使う時は危険性をきちんと理解した上で濫用しないようにしましょう。

概要3. 配列/ジェネリクス

配列の要素やジェネリックコレクションの型引数に参照型を指定するときも、デフォルトはnull非許容です。nullを許容させたい場合は ? を付与します。

string[] array0 = { "a", "b", "c" }; // OK
string[] array1 = { "a", "b", null }; // NG (警告)

string?[] narray0 = { "a", "b", null }; // OK
string?[] narray1 = new string?[] { "a", "b", null }; // OK

List<string> list0 = new List<string> { "a", "b", "c" }; // OK
List<string> list1 = new List<string> { "a", "b", null }; // NG (警告)

List<string?> nlist0 = new List<string?> { "a", "b", null }; // OK

(追記)配列は、初期化子なしの new で生成すると、すべての要素がデフォルト値となります。要素の型がnull非許容な参照型だった場合は、すべての要素が null となります。これは本来は警告すべきなのかもしれませんが、現実のコード上にあまりに多いため、あえて警告しない仕様としています。

string[] array = new string[10]; // OK(警告なし)

配列やジェネリックコレクション自体も参照型なので、デフォルトはnull禁止です。配列やジェネリックコレクション自体がnullを許容するようにするには、型全体の末尾に ? を置きます。

string[] array0 = null; // NG (警告)
string[]? n_array1 = null; // OK

string?[] narray2 = null; // NG (警告)
string?[]? n_narray3 = null; // OK

List<string> list0 = null; // NG (警告)
List<string>? n_list1 = null; // OK

List<string?> nlist2 = null; // NG (警告)
List<string?>? n_nlist3 = null; // OK

現在の仕様では、配列やジェネリックコレクションは「非変」扱い、つまり、参照型 A の配列やジェネリックコレクションの値を、null許容参照型 A? の配列やジェネリックコレクションに使うことはできません。(追記:配列の共変性についてはWikiにKnown Issues(既知の問題)として挙がっていました。今後変わる可能性があります。)

string[] array0 = { "a", "b", "c" };
string?[] narray1 = array0; // NG (警告)

List<string> list0 = new List<string> { "a", "b", "c" };
List<string?> nlist1 = list0; // NG (警告)

コンパイル結果がどうなるか

概要を説明しましたが、理解の早い方は「あれ?値型の場合とはいろいろ違うぞ?」と思われたかもしれません。その通りで、参照型に対するnull許容性は、値型に対するnull許容性と似て非なるものです。なぜなら、実現方式が全く異なるためです。一言でまとめると以下のようになります。

値型 V のnull許容性
V? は コード上も実行時も System.Nullable<V> と等価
参照型 R のnull許容性
R? はコンパイラーに対する注釈でしかなく、コンパイル後や実行時はただの型 R として扱われる、型消去(Type erasure)

というわけで、参照型のnull許容性では、System.Nullable<T>などの別の型でラップしているわけではないので、値型の時のように HasValue プロパティや Value プロパティを参照するコードを 書かなくていい というよりむしろ 書けない (その代わり、null許容性がフロー解析によって変化する) のでした。

では、コンパイル結果がどうなるかを、ILSpydnSpyで逆コンパイルしてみましょう。2
リストAは、ローカル変数にだけnull許容参照型を使った場合です。

リストA(元のコード)
static void M()
{
    int dice = 1 + new Random().Next(6);
    string? ns0 = (dice < 6) ? "ABC" : null;
    string? ns1 = ns0?.ToLowerInvariant();
    if (ns1 != null)
    {
        Console.WriteLine(ns1.Length);
    }
    string s2 = ns!.Replace('a', 'A');
    Console.WriteLine(s2);
}
リストA(コンパイル結果を逆コンパイルしたコード)
static void M()
{
    int dice = 1 + new Random().Next(6);
    string ns0 = (dice < 6) ? "ABC" : null;
    string ns = (ns0 != null) ? ns0.ToLowerInvariant() : null;
    bool flag = ns != null;
    if (flag)
    {
        Console.WriteLine(ns.Length);
    }
    string s2 = ns.Replace('a', 'A');
    Console.WriteLine(s2);
}

null条件演算子 ?. を使った部分が三項条件演算子によるnullチェックになっている以外は、ほぼ元のコードから注釈を抜いただけのコードになっています。

次に、クラスメンバーにnull許容参照型を使った場合を見てみましょう。リストBはフィールドとプロパティ、リストCはメソッドの引数と戻り値にnull許容参照型を使った例です。

リストB(元のコード)
class B
{
    private string string1Field;

    private string? nullableString1Field;

    public string? NullableString1
    {
        get => nullableString1Field;
        set => nullableString1Field = value;
    }

    public event EventHandler<EventArgs?>? Foo;

    protected virtual void OnFoo()
    {
        Foo?.Invoke(this, EventArgs.Empty);
    }
}

リストB(コンパイル結果を逆コンパイルしたコード)
using System.Runtime.CompilerServices;

class B
{
    private string string1Field;

    [Nullable]
    private string nullableString1Field;

    [Nullable]
    public string NullableString1
    {
        [return: Nullable]
        get => nullableString1Field;
        [param: Nullable]
        set => nullableString1Field = value;
    }

    [Nullable(new bool[]{true, true})]
    public event EventHandler<EventArgs> Foo;

    protected virtual void OnFoo()
    {
        EventHandler<EventArgs> _foo = this.Foo;
        if (_foo != null)
        {
            _foo(this, EventArgs.Empty);
        }
    }
}
リストC(元のコード)
class C
{
    public string M0(string arg)
    {
        return arg;
    }

    public string? M1(string? arg)
    {
        return arg;
    }

    public string?[] M2(Dictionary<string, string?> table)
    {
        return table.Values.ToArray();
    }
}
リストC(コンパイル結果を逆コンパイルしたコード)
using System.Runtime.CompilerServices;

class C
{
    public string M0(string arg)
    {
        return arg;
    }

    [return: Nullable]
    public string M1([Nullable] string arg)
    {
        return arg;
    }

    [return: Nullable(new bool[]{false, true})]
    public string[] M2(
        [Nullable(new bool[]{false, false, true})] Dictionary<string, string> table)
    {
        return table.Values.ToArray();
    }
}

リストBとリストCのどちらも、型からは注釈が消えているのはリストAと同様です。ただし、どのような注釈が付けられていたのかをコンパイラが判断できるように、[Nullable] カスタム属性が付与されています。
この [Nullable] カスタム属性は、System.Runtime.CompilerServices.NullableAttribute クラスとして定義されるものです。そしてこのクラスは、.NET Frameworkの共通ライブラリ (FCL) に追加されたものではなく、null許容参照型を使ったコードをコンパイルするときにコンパイラが自動生成してアセンブリに追加したものになります。3
自動生成されたクラスの実装は次のようなものです。4

NullableAttributeクラス
using System;

namespace System.Runtime.CompilerServices
{
    [Microsoft.CodeAnalysis.Embedded]
    [CompilerGenerated]
    internal sealed class NullableAttribute : Attribute
    {
        public NullableAttribute()
        {
        }

        public NullableAttribute(bool[] transformFlags)
        {
        }
    }
}

この [Nullable]カスタム属性は、nullを許容する参照型 A? にはすべて付加されます。一方で、nullを許容しない参照型 A の方には何のカスタム属性も付加されません。
また、配列やジェネリック型の場合は、bool[]を受け取るコンストラクタが使われているのがわかると思います。これは、「配列やジェネリック型そのものがnullを許容するかどうか」と、「配列の要素、あるいは、ジェネリック型の型引数となっている参照型がnullを許容するかどうか」を区別するフラグとなっています。bool[]の先頭要素が、コンテナー型そのもののnull許容性を表し、それ以降が要素や型引数のnull許容性を順番に表しています。

既存コードとの相互運用性

ここでは、「既存コード=C# 7系までのコンパイラーでコンパイルするコード、およびそのようにコンパイルしたアセンブリ」「新規コード=C# 8以降の、null許容参照型の機能を有効にしたコンパイラーでコンパイルするコード、およびそのようにコンパイルしたアセンブリ」とします。

相互運用性1: 新規コードから既存コードを利用する

既存コードには参照型に [Nullable]属性が付与されていないので、原則として すべての参照型がnull非許容 という扱いになります。すると、以下のような問題がおきます。

  • 既存コードから受け取る参照型は、型の上ではnull非許容に見えるが、実際にはnullが渡されてくるかもしれない
  • 既存コードに渡す参照型は、既存コードの実装ではnullを許容していたとしても、null許容参照型を渡すとコンパイル時に警告される

こうなるのは、「Kotlinとの比較」のところでも書きますが、 意図的な設計 であり、プレビュー版の不具合というわけではありません。5
この問題に新規コード側で対処するには、次のようになるでしょう。

  • 既存コードから参照型を受け取った場合は、プログラマーの判断で、nullチェックを追加する
  • 既存コードに参照型を渡す場合は、プログラマーの判断で、! を適用する

とはいえ、この「デフォルトでnull非許容とみなす」という仕様は、IEnumerable<T>.FirstOrDefault拡張メソッドや、Dictionary<TKey, TValue>.TryGetValueメソッドのように、default(T) つまり参照型では nullを返すことを前提とした既存APIとバッティングするものです。この問題はC#言語チームも認識しているようですが、今のところはうまい解決策はないようです。

相互運用性2: 既存コードから新規コードを利用する

新規コードで指定した型注釈は、カスタム属性として残るもの以外はすべて削除されるので、既存コード側は特別な考慮をしなくても正しくコンパイルできて動作するはずです。(「正しい」既存コードにはすでに適切なnullチェックが含まれているはずですよね?)

(12/12 aetosさんのコメントを受けて修正)
既存コードから新規コードの注釈を知るのは、リフレクションを使えば一応は可能です。
たとえば、メソッドの戻り値に付いたカスタム属性と、そのコンストラクター引数は次のようにして取得します。

リフレクションで[Nullable]カスタム属性を得る
// using System.Reflection;

MethodInfo mi = typeof(TargetClass)
    .GetMethod("TargetMethodName", BindingFlags.Public | BindingFlags.Instance);
ParameterInfo pi = mi.ReturnParameter();
CustomAttributeData data = pi.GetCustomAttributesData()
    .FirstOrDefault(x => x.AttributeType.FullName == "System.Runtime.CompilerServices.NullableAttribute");

if (data != null)
{
    if (data.ConstructorArguments.Count == 1)
    {
        CustomAttributeTypedArgument arg = data.ConstructorArguments[0];
        // 以下略
    }
}

つらつら書きましたが、他言語のコンパイラーを開発していて、C#のnull許容参照型との相互運用性をサポートしようとしているのでもなければ、この属性は無視していいと思います。

相互運用性3: オプトイン/アウト機構

たとえば、既存コードにnull注釈を追加しないままで、どうにか新しいコンパイラーでコンパイルしたい、などの状況で、警告を抑制したい、無視したい、ということもあるでしょう。そんな時のために、null許容性の不一致から発生する警告を部分的に抑制する機構が検討されています。

ただし、11/15のプレビュー版でいろいろ試した範囲では、警告の抑制はまだ実装されていない様子でした。あくまで、「このような方向で検討されている」という前提で読んで下さい。

オプトアウト1. 指定した要素(型、フィールド、プロパティ、メソッド、etc)によるnull許容性の警告を抑制する

System.Runtime.CompilerServices.NullableOptOutAttribute クラスを定義して、そのクラスを使って [NullableOptOut] カスタム属性を付与すると、その属性を付与した要素に関連した警告が抑制される、らしいです。

NullableOptOutAttributeクラス
using System;

namespace System.Runtime.CompilerServices
{
    [AttributeUsage(AttributeTargets.Module |
                    AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Delegate | AttributeTargets.Interface |
                    AttributeTargets.Event | AttributeTargets.Field | AttributeTargets.Method | AttributeTargets.Property)]
    internal class NullableOptOutAttribute : Attribute
    {
        public NullableOptOutAttribute(bool flag = true)
        {
        }
    }
}

bool型のコンストラクター引数は、trueの時に警告を抑制し、falseの時に警告を出すというものです。たとえば、クラス全体は警告を抑制しつつ、特定のメソッドだけは警告を有効にする、といった時に使います。

オプトアウト2. 指定したアセンブリによるnull許容性の警告を抑制する

System.Runtime.CompilerServices.NullableOptOutForAssemblyAttribute クラスを定義して、そのクラスを使って [NullableOptOutForAssembly] カスタム属性をモジュールに付与すると、そのモジュール内のコードでは、コンストラクターに文字列で指定したアセンブリに関連した警告が抑制される、らしいです。

NullableOptOutForAssemblyAttributeクラス
using System;

namespace System.Runtime.CompilerServices
{
    [AttributeUsage(AttributeTargets.Module, AllowMultiple = true)]
    internal class NullableOptOutForAssemblyAttribute : Attribute
    {
        public NullableOptOutForAssemblyAttribute(string assemblyName)
        {
        }
    }
}

この属性はモジュールに対して指定するものなので、 [module: NullableOptOutForAssembly("Foo.Bar.Baz")] のように指定します。

Kotlinとの比較

参照型のnull許容性を明示できるようになるという機構は、C#のnull安全性を高めるものといえます。このnull安全性という考え方は、最近の新しいプログラミング言語では採用されることが多くなっています。どのような言語で取り入れられているかについては、Qiitaのnull安全でない言語は、もはやレガシー言語だという記事によくまとまっています。

C# 8.0で検討されている、null非許容な参照型は、この記事で言うならばKotlinのものに比較的似ています。似ている点を以下に挙げてみます。

  • (Nullable<T>型や代数的データ型のように)別の型でラップしない
  • コンパイルすると、カスタムのメタデータ(カスタム属性/アノテーション)が付与された状態になる
    • Kotlinでは 基本的にorg.jetbrains.annotationsパッケージの NullableおよびNotNullアノテーションが使われる
  • フロー解析によって変数や式の型が変化する
    • Kotlinでは「スマートキャスト」と呼ぶ
  • nullチェックせずに強制アクセスする記法がある
    • Kotlinでは !!!. のふたつがある

一方で、違っている点を以下に挙げてみます。

  • Kotlinでは、Javaの参照型はnull許容型とnull非許容型のどちらとも決められない「プラットフォーム型」として扱われ、コンパイル時にnull許容性のエラーが報告されない。一方C# 8では、既存コードの参照型はnull非許容型として扱われ、プラットフォーム型のような状態は存在しない。
  • Kotlinでnull非許容のコードをコンパイルすると NotNullアノテーションが付与されるが、C# 8にはそれに対応するカスタム属性はない。
  • Kotlinでは、null非許容型を使ったコードをコンパイルすると、実行時にnullチェックして、nullが入っていた場合は実行時例外をスローするコードが生成される。6 一方C# 8では、そのような実行時例外を投げるようなコードは生成されない。

1番目の「プラットフォーム型」については、MSDNブログのコメント欄でC#のプログラムマネージャであるMads Torgersenがコメントしています。いわく、「複雑になると思ったので入れていない。null許容性が不明な型を入れるとしても、(Kotlinのように)警告なしにするのか、逆にすべてで警告を出すのか、どちらにしたとしても議論になるだろう。」とのことです。
2番目の違いは、1番目の違いから来るものです。(デフォルトがnull非許容なので、NotNullを表明する必要がない)
3番目は、これは私の考えですが、C#の場合はあくまで既存言語のバージョンアップであって、null安全な別言語を作っているわけではないこと、C# は実行時性能をかなり意識していること、Contractクラスでも基本的には例外をスローしないこと、などを踏まえた判断ではないかと思います。

繰り返しになりますが、C#の場合はあくまで既存言語のバージョンアップであって、C# 本来の特性(書きやすさや実行性能など)と null安全性とのバランスを重視していると理解しています。なので、null安全性だけを見ると、他の言語と比べて弱い部分が残っていますが、それも含めた設計判断なのだと思います。

落とし穴:ジェネリクスとかNull許容値型とか

この記事はプレビュー版を前提に書いていることもあり、制限事項もいろいろあります。多くの制限事項はWikiにKnown Issues(既知の問題)としてまとまっているのですが、ここではジェネリクスに関係することを少しだけ取り上げたいと思います。

参照型に制約されたジェネリック型を見てみます。

class Foo<T>
    where T : class
{
    public string FullName(T obj)
    {
        return obj.GetType().FullName;
    }
}

こんな感じで書けるのですが、実はこのTに、null許容参照型を渡せます。(コンパイルエラーCS0453ならない

Foo<string?> f = new Foo<string?>(); // コンパイルが通ってしまう
Console.WriteLine(f.FullName(null)); // 実行時例外になる

これはWikiにはまだ書かれていないのですが、おそらくプレビュー版のみの挙動だと思われます。なぜなら、言語仕様提案のドキュメントにはジェネリクスの制約にnull許容性を明示的に指定する可能性が言及されているからです。
とはいえ、記法として、where T is nonnullable みたいな書き方になるかどうかも含めて、まだ確定はしていません。

次に、参照型にも値型にも制約されていないジェネリック型を見てみます。

class Bar<T>
{
    public T? M()
    {
        return default(T);
    }

    public void N(T? obj)
    {
        if (obj == null)
        {
            Console.WriteLine("obj is null");
        }
        else
        {
            Console.WriteLine($"obj == {obj}");
        }
    }
}

T 型は参照型にも値型にも制約されていないのですが、これは合法で、コンパイルが通ります。ただし、コンパイル結果を見ると、null許容参照型のように、null許容性が消去されたバイナリになっていました。

こんな型を使うと、ちょっと予想と違う動きをします。

  • Bar<T>の実装コード中で、T? 型に null を入れられない。(コンパイルエラーCS0453になる)
  • Bar<T> の型引数にnull許容値型、たとえば int? を指定してもエラーにならない。(intを指定したのと同じ扱いになる)
  • Bar<T> の型引数にたとえば int を指定したとき、メソッド N の引数 obj の型は int? に見えるが、Nullable<int>の値を渡すとコンパイルエラーになる

T 型が参照型にも値型にも制約されていないときにT?型がエラーにならずに使えるのはKnown Issues(既知の問題)となっているので、このあたりの挙動はいずれ変わるのでしょうけど、参照型にも値型にも制約されていない型引数自体はおそらく合法のまま残ると思われます。

T 型が参照型にも値型にも制約されていないときにT?型を使わなければそれでいいかというと、まだ微妙なやつがあります。

class Baz<T>
{
    public T M()
    {
        return default(T); // コンパイルが通ってしまう
    }
}

このように、型 T に対して default(T) を与えるコードは、Tが参照型に制約されている場合はエラーになるのですが、上の例のように、T 型が参照型にも値型にも制約されていないときはコンパイルが通ってしまいます。そしてもちろん、型引数 T にnull非許容参照型を指定した場合、このメソッド M() からは null が返ってきます。
これもWikiにはまだ書かれていないですが、いずれ変更になると思われます。

最後に

さて、説明はあくまで概要のみで、プレビュー版の実装など、詳細についてはほとんど触れていないつもりなのですが、それでもそれなりのボリュームになってしまいました。

現在の仕様に対して意見がある人は英語での議論に参加するといいと思いますが、とはいえこれまでの議論は踏まえる必要があるでしょうね。


  1. this()の呼び出しがあるなど、警告が出ないケースがあることがKnown Issues(既知の問題)として挙がっています。 

  2. 実際には逆コンパイラーの出力を一部修正しています。 

  3. ちなみに、この System.Runtime.CompilerServices.NullableAttribute クラスは、ソースコード中に自分で定義しておくと、そちらが参照され、同名のクラスが別途自動生成されることはありません。 

  4. Microsoft.CodeAnalysis.EmbeddedAttribute クラスも同時に生成されていますが、それは省略します。 

  5. デフォルトがnull非許容になっていることはWikiにFAQとして上がっています。 

  6. kotlin.jvm.internal.Intrinsics#checkExpressionValueIsNotNull()メソッドによってIllegalStateExceptionが投げられる。ぬるぽではない。 

59
33
6

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
59
33