目次

Null安全の理解

Bob Nystrom著
2020年7月

Null安全は、Dart 2.0で元の健全でないオプショナル型システムを健全な静的型システムに置き換えて以来、Dartに加えた最大の変更です。Dartが最初に立ち上げられたとき、コンパイル時のNull安全は、長い導入を必要とするまれな機能でした。今日、Kotlin、Swift、Rust、その他の言語はすべて、非常によく知られた問題に対する独自の回答を持っています。次に例を示します。

dart
// Without null safety:
bool isEmpty(String string) => string.length == 0;

void main() {
  isEmpty(null);
}

Null安全なしでこのDartプログラムを実行すると、.lengthの呼び出しでNoSuchMethodError例外が発生します。 null値はNullクラスのインスタンスであり、Nullには「length」ゲッターがありません。実行時エラーは最悪です。これは、エンドユーザーのデバイスで実行するように設計されたDartのような言語では特に当てはまります。サーバーアプリケーションに障害が発生した場合、誰かが気付く前に再起動できることがよくあります。しかし、Flutterアプリがユーザーのスマートフォンでクラッシュすると、ユーザーは満足しません。ユーザーが満足していないと、あなたも満足できません。

開発者は、Dartのような静的に型付けされた言語が好きです。なぜなら、型チェッカーがコンパイル時にコードのミスを発見できるからです。通常はIDEで直接発見できます。バグを早く見つけるほど、早く修正できます。言語設計者が「Null参照エラーの修正」について話すとき、彼らは静的型チェッカーを強化して、nullの可能性のある値で.lengthを呼び出そうとする上記のようなミスを言語が検出できるようにすることを意味します。

この問題に対する唯一の真の解決策はありません。RustとKotlinはどちらも、それらの言語のコンテキストで意味のある独自のアプローチを持っています。このドキュメントでは、Dartに対する私たちの回答の詳細について説明します。静的型システムの変更と、Null安全なコードを書くだけでなく、そうすることを楽しむことができるようにするための、他の変更と新しい言語機能のスイートが含まれています。(ネタバレ注意:最終的には、現在Dartを書いている方法に驚くほど近くなります。)

このドキュメントは長いです。起動して実行するために知っておく必要があることだけを網羅した短いものが欲しい場合は、概要から始めてください。より深い理解が必要で、時間がある場合は、ここに戻ってきて、言語がnullどのように処理するか、なぜそのように設計したか、そして慣用的な、最新の、Null安全なDartをどのように書くかを理解してください。(ネタバレ注意:最終的には、現在Dartを書いている方法に驚くほど近くなります。)

言語がNull参照エラーに対処できるさまざまな方法には、それぞれ長所と短所があります。これらの原則は、私たちが行った選択を導きました

  • コードはデフォルトで安全であるべきです。 新しいDartコードを作成し、明示的に安全でない機能を使用しない場合、実行時にNull参照エラーは発生しません。考えられるすべてのNull参照エラーは静的にキャッチされます。柔軟性を高めるためにチェックの一部を実行時に延期したい場合は、コードでテキストとして表示される機能を使用することで選択できます。

    言い換えれば、私たちはあなたに救命胴衣を与えて、水に出るたびにそれを着ることを覚えておくのはあなた次第ではありません。代わりに、沈まないボートを提供します。船外に飛び出さない限り、濡れません。

  • Null安全なコードは簡単に書けるべきです。 既存のDartコードのほとんどは動的に正しく、Null参照エラーをスローしません。あなたは今のDartプログラムの外観が好きで、私たちはあなたがそのようにコードを書き続けることができるようにしたいと思っています。安全性のために、使いやすさを犠牲にする、型チェッカーへの償いを払う、または考え方を変える必要はありません。

  • 結果として得られるNull安全なコードは完全に健全であるべきです。 静的チェックのコンテキストにおける「健全性」は、人によって異なる意味を持ちます。私たちにとって、Null安全のコンテキストでは、式にnullを許可しない静的型がある場合、その式のいかなる実行もnullと評価されることはないことを意味します。言語は主に静的チェックを通じてこの保証を提供しますが、ランタイムチェックも含まれる場合があります。(ただし、最初の原則に注意してください。これらのランタイムチェックが発生する場所はあなたの選択になります。)

    健全性は、ユーザーの信頼にとって重要です。ほとんど afloatにとどまるボートは、外洋に挑戦することに熱心ではありません。しかし、それは私たちの勇敢なコンパイラハッカーにとっても重要です。言語がプログラムのセマンティックプロパティについて厳密に保証する場合、コンパイラはそれらのプロパティが真であると仮定して最適化を実行できることを意味します。nullに関しては、不要なnullチェックを排除するより小さなコードと、レシーバーがメソッドを呼び出す前に非nullであることを確認する必要がない高速コードを生成できることを意味します。

    1つの注意事項:完全にNull安全なDartプログラムでのみ健全性を保証します。Dartは、新しいNull安全コードと古いレガシーコードが混在するプログラムをサポートしています。これらの混合バージョンのプログラムでは、Null参照エラーが引き続き発生する可能性があります。混合バージョンのプログラムでは、Null安全な部分ではすべての静的安全上の利点が得られますが、アプリケーション全体がNull安全になるまで、完全なランタイム健全性は得られません。

nullの**排除**が目的ではないことに注意してください。nullに問題はありません。それどころか、値の**不在**を表すことができるのは非常に便利です。言語に特別な「不在」値のサポートを直接組み込むことで、不在を柔軟かつ使いやすく扱うことができます。これは、オプションパラメータ、便利な?. null認識演算子、およびデフォルト初期化の基礎となっています。悪いのはnullではなく、**予期しない場所に** nullが**入り込む**ことが問題を引き起こすのです。

したがって、null安全の目標は、nullがプログラムのどこを流れるかを**制御**し、**洞察**を与え、クラッシュを引き起こす場所に流れ込まないことを確実にすることです。

型システムにおけるNull許容性

#

null安全は静的型システムから始まります。なぜなら、他のすべてがそれに基づいているからです。Dartプログラムには、intStringのようなプリミティブ型、Listのようなコレクション型、そしてあなたやあなたが使用するパッケージが定義するすべてのクラスと型など、膨大な型の宇宙があります。null安全の前は、静的型システムは、値nullがこれらの型のいずれかの式の値になることを許していました。

型理論の専門用語では、Null型はすべての型のサブタイプとして扱われていました。

Null Safety Hierarchy Before

式で許可される操作のセット(ゲッター、セッター、メソッド、および演算子)はその型によって定義されます。型がListの場合、.add()または[]を呼び出すことができます。intの場合は、+を呼び出すことができます。しかし、null値はこれらのメソッドのいずれも定義していません。nullが他の型の式に流れ込むことを許可すると、これらの操作のいずれかが失敗する可能性があります。これがまさにnull参照エラーの核心です。すべてのエラーは、nullに存在しないメソッドまたはプロパティを検索しようとしたことから発生します。

Null非許容型とNull許容型

#

null安全は、型階層を変更することで、この問題を根本から排除します。Null型は依然として存在しますが、もはやすべての型のサブタイプではありません。代わりに、型階層は次のようになります。

Null Safety Hierarchy After

Nullはもはやサブタイプではないため、特別なNullクラスを除いて、どの型も値nullを許可しません。すべての型を**デフォルトでnull非許容**にしました。String型の変数がある場合、それは常に**文字列**を含みます。これで、すべてのnull参照エラーが修正されました。

nullがまったく役に立たないと考えていたら、ここで止めることができました。しかし、nullは便利なので、それを処理する方法が必要です。オプションパラメータは、良い例です。次のnull安全なDartコードを考えてみてください。

dart
// Using null safety:
void makeCoffee(String coffee, [String? dairy]) {
  if (dairy != null) {
    print('$coffee with $dairy');
  } else {
    print('Black $coffee');
  }
}

ここでは、dairyパラメータが任意の文字列または値nullを受け入れるようにしたいと考えていますが、それ以外は受け入れたくありません。これを表現するために、基になる基本型Stringの末尾に?を付けて、dairyに**null許容型**を与えます。内部的には、これは基本的に基になる型とNull型の共用体を定義しています。したがって、Dartに本格的な共用体型があれば、String?String|Nullの省略形になります。

Null許容型の使用

#

null許容型の式がある場合、結果に対して何ができるでしょうか?私たちの原則はデフォルトで安全であるため、答えはあまりありません。値がnullの場合、基になる型のメソッドを呼び出すことはできません。なぜなら、それらが失敗する可能性があるからです。

dart
// Hypothetical unsound null safety:
void bad(String? maybeString) {
  print(maybeString.length);
}

void main() {
  bad(null);
}

実行を許可した場合、これはクラッシュします。安全にアクセスできるメソッドとプロパティは、基になる型とNullクラスの両方で定義されているものだけです。それはtoString()==、およびhashCodeだけです。そのため、null許容型をマップキーとして使用したり、セットに格納したり、他の値と比較したり、文字列補間で使用したりできますが、それだけです。

null非許容型とはどのように相互作用するのでしょうか? **null非許容**型をnull許容型を期待するものに渡すことは常に安全です。関数がString?を受け入れる場合、Stringを渡すことは許可されます。なぜなら、問題が発生しないからです。すべてのnull許容型をその基になる型のスーパータイプにすることで、これをモデル化します。また、nullをnull許容型を期待するものに安全に渡すことができるため、Nullもすべてのnull許容型のサブタイプです。

Nullable

しかし、逆方向に進み、null許容型を基になるnull非許容型を期待するものに渡すことは安全ではありません。Stringを期待するコードは、値に対してStringメソッドを呼び出す可能性があります。String?を渡すと、nullが流れ込み、失敗する可能性があります。

dart
// Hypothetical unsound null safety:
void requireStringNotNull(String definitelyString) {
  print(definitelyString.length);
}

void main() {
  String? maybeString = null; // Or not!
  requireStringNotNull(maybeString);
}

このプログラムは安全ではなく、許可すべきではありません。ただし、Dartには、**暗黙的なダウンキャスト**と呼ばれるものがありました。たとえば、Object型の値をStringを期待する関数に渡すと、型チェッカーはそれを許可します。

dart
// Without null safety:
void requireStringNotObject(String definitelyString) {
  print(definitelyString.length);
}

void main() {
  Object maybeString = 'it is';
  requireStringNotObject(maybeString);
}

健全性を維持するために、コンパイラはrequireStringNotObject()の引数にas Stringキャストをサイレントに挿入します。そのキャストは失敗し、実行時に例外をスローする可能性がありますが、コンパイル時にはDartはこれが問題ないと判断します。null非許容型はnull許容型のサブタイプとしてモデル化されているため、暗黙的なダウンキャストでは、String?Stringを期待するものに渡すことができます。それを許可すると、デフォルトで安全であるという目標に違反します。そのため、null安全では、暗黙的なダウンキャストを完全に削除しています。

これにより、requireStringNotNull()の呼び出しでコンパイルエラーが発生します。これはまさにあなたが望むことです。しかし、それは**すべて**の暗黙的なダウンキャストがコンパイルエラーになることも意味します。requireStringNotObject()の呼び出しを含みます。明示的なダウンキャストを自分で追加する必要があります。

dart
// Using null safety:
void requireStringNotObject(String definitelyString) {
  print(definitelyString.length);
}

void main() {
  Object maybeString = 'it is';
  requireStringNotObject(maybeString as String);
}

これは全体的に良い変更だと思います。私たちの印象では、ほとんどのユーザーは暗黙的なダウンキャストが好きではありませんでした。特に、あなたは以前、これで痛い目に遭ったことがあるかもしれません。

dart
// Without null safety:
List<int> filterEvens(List<int> ints) {
  return ints.where((n) => n.isEven);
}

バグを見つけましたか? .where()メソッドは遅延であるため、ListではなくIterableを返します。このプログラムはコンパイルされますが、filterEvensが返すことを宣言しているList型にそのIterableをキャストしようとすると、実行時に例外をスローします。暗黙的なダウンキャストが削除されると、これはコンパイルエラーになります。

どこまで話しましたっけ?そうですね、プログラム内の型の宇宙を取り、それを2つの半分に分割したかのようです。

Nullable and Non-Nullable types

null非許容型の領域があります。これらの型は、すべての興味深いメソッドにアクセスできますが、nullを含むことはできません。そして、対応するすべてのnull許容型の並列ファミリがあります。これらはnullを許可しますが、あまり多くのことはできません。安全なので、値をnull非許容側からnull許容側に流すことは許可しますが、逆方向は許可しません。

null許容型は基本的に役に立たないように思えます。メソッドがなく、逃れることができません。心配しないでください。値をnull許容側から反対側に移動するのに役立つ機能が豊富に用意されています。すぐに説明します。

トップとボトム

#

このセクションは少し難解です。型システムに興味がない限り、最後の2つの箇条書きを除いて、ほとんどスキップできます。プログラム内のすべての型を、サブタイプとスーパータイプの関係にあるもの同士の間にエッジを持つと想像してください。このドキュメントの図のように描画すると、上部にObjectのようなスーパータイプ、下部に独自の型のようなリーフクラスを持つ巨大な有向グラフが形成されます。

その有向グラフが上部で、直接的または間接的にスーパータイプである単一の型がある点に達する場合、その型は**トップタイプ**と呼ばれます。同様に、下部に奇妙な型があり、それがすべての型のサブタイプである場合、**ボトムタイプ**があります。(この場合、有向グラフはラティスです。)

型システムにトップタイプとボトムタイプがあると便利です。なぜなら、最小上限のような型レベルの操作(型推論が2つのブランチの型に基づいて条件式の型を把握するために使用する)が常に型を生成できるからです. null安全の前は、ObjectはDartのトップタイプであり、Nullはボトムタイプでした。

Objectはnull非許容になったため、もはやトップタイプではありません。Nullはそれのサブタイプではありません。Dartには**名前付き**トップタイプがありません。トップタイプが必要な場合は、Object?を使用します。同様に、Nullはもはやボトムタイプではありません。もしそうなら、すべてがまだnull許容になります。代わりに、Neverという名前の新しいボトムタイプを追加しました。

Top and Bottom

実際には、これは次のことを意味します。

  • 任意の型の値を許可することを示す場合は、Objectの代わりにObject?を使用します。実際、Object型は「この奇妙に禁止されている値nullを除く、あらゆる可能な値になる可能性がある」ことを意味するため、Objectを使用することは非常にまれになります。

  • ボトムタイプが必要になるまれなケースでは、Nullの代わりにNeverを使用します。これは、関数が到達可能性分析を支援するために決して返らないことを示すのに特に便利です。ボトムタイプが必要かどうか分からない場合は、おそらく必要ありません。

正確性の確保

#

型の宇宙をnull許容とnull非許容の半分に分割しました。健全性と、要求しない限り実行時にnull参照エラーが発生しないという原則を維持するために、null非許容側のどの型にもnullが表示されないようにする必要があります。

暗黙的なダウンキャストを取り除き、Nullをボトムタイプとして削除すると、代入や関数呼び出しのパラメータへの引数など、プログラム全体で型が流れる主要な場所がすべて網羅されます。nullが忍び込む可能性のある残りの主要な場所は、変数が最初に作成されたときと、関数を離れるときです。そのため、追加のコンパイルエラーがいくつかあります。

無効な戻り値

#

関数がnull非許容の戻り値の型を持つ場合、関数を通るすべてのパスは、値を返すreturnステートメントに到達する必要があります。null安全の前は、Dartは戻り値の欠落についてかなり寛容でした。例えば

dart
// Without null safety:
String missingReturn() {
  // No return.
}

これを分析すると、returnを忘れた可能性があるという穏やかな**ヒント**が得られましたが、そうでない場合は大したことではありません。これは、実行が関数本体の最後に達すると、Dartが暗黙的にnullを返すためです。すべての型はnull許容であるため、**技術的には**この関数は安全です。たとえそれがあなたが望むものではないとしてもです。

健全な非Null許容型を用いると、このプログラムは完全に間違っており、安全ではありません。Null安全の下では、非Null許容型の戻り値を持つ関数が確実に値を返さない場合、コンパイルエラーが発生します。「確実に」とは、言語が関数を通るすべての制御フローパスを分析することを意味します。すべてが何かを返す限り、それは満たされます。この分析は非常に賢いため、この関数でさえ問題ありません。

dart
// Using null safety:
String alwaysReturns(int n) {
  if (n == 0) {
    return 'zero';
  } else if (n < 0) {
    throw ArgumentError('Negative values not allowed.');
  } else {
    if (n > 1000) {
      return 'big';
    } else {
      return n.toString();
    }
  }
}

次のセクションでは、新しいフロー分析についてより深く掘り下げます。

初期化されていない変数

#

変数を宣言するときに、明示的な初期化子を与えない場合、Dartは変数をデフォルトでnullに初期化します。これは便利ですが、変数の型が非Null許容型の場合、明らかに全く安全ではありません。そのため、非Null許容型変数に対しては、厳格化する必要があります。

  • トップレベル変数と静的フィールドの宣言には、初期化子が必要です。 これらはプログラムのどこからでもアクセスおよび代入できるため、コンパイラは変数が使用される前に値が与えられたことを保証できません。唯一の安全な選択肢は、宣言自体に正しい型の値を生成する初期化式を持たせることです。

    dart
    // Using null safety:
    int topLevel = 0;
    
    class SomeClass {
      static int staticField = 0;
    }
  • インスタンスフィールドは、宣言時に初期化子を持つ、初期化形式を使用する、またはコンストラクタの初期化リストで初期化する必要があります。 これは多くの専門用語です。例を次に示します。

    dart
    // Using null safety:
    class SomeClass {
      int atDeclaration = 0;
      int initializingFormal;
      int initializationList;
    
      SomeClass(this.initializingFormal)
          : initializationList = 0;
    }

    言い換えれば、コンストラクタ本体に到達する前にフィールドに値があれば、問題ありません。

  • ローカル変数は最も柔軟なケースです。非Null許容型のローカル変数は、初期化子を持つ必要はありません。これは完全に問題ありません。

    dart
    // Using null safety:
    int tracingFibonacci(int n) {
      int result;
      if (n < 2) {
        result = n;
      } else {
        result = tracingFibonacci(n - 2) + tracingFibonacci(n - 1);
      }
    
      print(result);
      return result;
    }

    ルールは、ローカル変数は使用される前に確実に代入される必要があることです。 これについても、私が言及した新しいフロー分析に頼ることができます。変数の使用へのすべてのパスが最初にそれを初期化する限り、その使用は問題ありません。

  • オプションパラメータにはデフォルト値が必要です。 オプションの位置パラメータまたは名前付きパラメータに引数を渡さない場合、言語はデフォルト値でそれを埋めます。デフォルト値を指定しない場合、デフォルトのデフォルト値はnullであり、パラメータの型が非Null許容型の場合、これは機能しません。

    そのため、パラメータをオプションにする場合は、Null許容型にするか、有効な非nullデフォルト値を指定する必要があります。

これらの制限は面倒に聞こえますが、実際にはそれほど悪くありません。これらは、final変数に関する既存の制限と非常によく似ており、おそらく何年も気づかずにそれらを使用してきたでしょう。また、これらは非Null許容型変数にのみ適用されることを忘れないでください。型をNull許容型にすることで、デフォルトの初期化をnullにすることができます。

それでも、ルールは摩擦を引き起こします。幸いなことに、これらの新しい制限があなたを遅くする最も一般的なパターンを滑らかにする新しい言語機能のスイートがあります。しかし、まずはフロー分析について話す時が来ました。

フロー解析

#

制御フロー分析は、コンパイラで長年使用されてきました。これは主にユーザーから隠されており、コンパイラの最適化中に使用されますが、一部の新しい言語では、可視言語機能に同じ手法を使用し始めています。Dartには、すでに型昇格という形でフロー分析が少しあります。

dart
// With (or without) null safety:
bool isEmptyList(Object object) {
  if (object is List) {
    return object.isEmpty; // <-- OK!
  } else {
    return false;
  }
}

マークされた行で、objectに対してisEmptyを呼び出すことができることに注意してください。このメソッドは、Objectではなく、Listで定義されています。これは、型チェッカーがすべてのis式とプログラム内の制御フローパスを調べるためです。一部の制御フロー構造の本体が、変数に対する特定のis式がtrueの場合にのみ実行される場合、その本体内では、変数の型はテストされた型に「昇格」されます。

ここでの例では、if文のthenブランチは、objectが実際にリストを含む場合にのみ実行されます。したがって、Dartはobjectを、宣言された型Objectではなく、型Listに昇格させます。これは便利な機能ですが、かなり制限されています。Null安全以前は、次の機能的に同一のプログラムは機能しませんでした。

dart
// Without null safety:
bool isEmptyList(Object object) {
  if (object is! List) return false;
  return object.isEmpty; // <-- Error!
}

繰り返しますが、objectにリストが含まれている場合にのみ.isEmpty呼び出しに到達できるため、このプログラムは動的に正しいです。しかし、型昇格ルールは、return文が2番目の文に到達できるのはobjectがリストの場合のみであることを認識するほど賢くありませんでした。

Null安全のために、この限定的な分析を行い、いくつかの点で、はるかに強力にしました。

到達可能性分析

#

まず、型昇格が早期リターンやその他の到達不能なコードパスについて賢くないという長年の不満を修正しました。関数を分析するとき、returnbreakthrow、および関数の途中で実行が終了する可能性のあるその他の方法が考慮されるようになりました。Null安全の下では、この関数は

dart
// Using null safety:
bool isEmptyList(Object object) {
  if (object is! List) return false;
  return object.isEmpty;
}

今では完全に有効です。if文は、objectListではない場合に関数を終了するため、Dartは2番目の文でobjectListに昇格させます。これは本当に素晴らしい改善であり、Null可能性に関連しないものであっても、多くのDartコードに役立ちます。

到達不能コードのためのNever

#

この到達可能性分析をプログラムすることもできます。新しいボトム型Neverには値がありません。(どのような値が同時にStringbool、およびintになりますか?)では、式が型Neverを持つということはどういう意味でしょうか?それは、その式が正常に評価を完了できないことを意味します。例外をスローするか、中止するか、そうでなければ式の結果を期待している周囲のコードが決して実行されないようにする必要があります。

実際、言語によると、throw式の静的型はNeverです。型Neverはコアライブラリで宣言されており、型注釈として使用できます。特定の種類の例外をスローしやすくするためのヘルパー関数があるかもしれません。

dart
// Using null safety:
Never wrongType(String type, Object value) {
  throw ArgumentError('Expected $type, but was ${value.runtimeType}.');
}

次のように使用できます。

dart
// Using null safety:
class Point {
  final double x, y;

  bool operator ==(Object other) {
    if (other is! Point) wrongType('Point', other);
    return x == other.x && y == other.y;
  }

  // Constructor and hashCode...
}

このプログラムはエラーなしで分析されます。==メソッドの最後の行は、other.x.yにアクセスすることに注意してください。関数がreturnまたはthrowを持っていない場合でも、Pointに昇格されています。制御フロー分析は、wrongType()の宣言された型がNeverであることを認識しており、これはif文のthenブランチが何らかの形で中止する必要があることを意味します。2番目の文はotherPointである場合にのみ到達できるため、Dartはそれを昇格させます。

言い換えれば、独自のAPIでNeverを使用すると、Dartの到達可能性分析を拡張できます。

確実な代入分析

#

ローカル変数について簡単に説明しました。Dartは、非Null許容型のローカル変数が読み取られる前に常に初期化されるようにする必要があります。できる限り柔軟に対応するために、確実な代入分析を使用します。言語は各関数本体を分析し、すべての制御フローパスを通じてローカル変数とパラメータへの代入を追跡します。変数の使用に到達するすべてのパスで変数が代入されている限り、変数は初期化されていると見なされます。これにより、変数が非Null許容型であっても、初期化子なしで変数を宣言し、その後、複雑な制御フローを使用して初期化できます。

final変数をより柔軟にするために、確実な代入分析も使用します。Null安全の前は、何らかの興味深い方法で初期化する必要がある場合、ローカル変数にfinalを使用するのが難しい場合があります。

dart
// Using null safety:
int tracingFibonacci(int n) {
  final int result;
  if (n < 2) {
    result = n;
  } else {
    result = tracingFibonacci(n - 2) + tracingFibonacci(n - 1);
  }

  print(result);
  return result;
}

result変数はfinalですが初期化子がないため、これはエラーになります。Null安全の下でのよりスマートなフロー分析により、このプログラムは正常です。分析では、resultがすべての制御フローパスで正確に1回確実に初期化されていることがわかるため、変数をfinalとマークするための制約が満たされます。

Nullチェックによる型昇格

#

よりスマートなフロー分析は、Null可能性に関連しないコードであっても、多くのDartコードに役立ちます。しかし、私たちが今これらの変更を行っているのは偶然ではありません。型をNull許容型と非Null許容型のセットに分割しました。Null許容型の値がある場合、実際にそれを使って何か役に立つことはできません。値がnullである場合、その制限は適切です。クラッシュを防ぎます。

しかし、値がnullでない場合は、メソッドを呼び出すことができるように、非Null許容型側に移動できると便利です。フロー分析は、ローカル変数とパラメータ(およびDart 3.2以降のプライベートfinalフィールド)に対してこれを行うための主要な方法の1つです。型昇格を拡張して、== nullおよび!= null式も調べるようにしました。

Null許容型のローカル変数をチェックしてNullかどうかを確認すると、Dartはその変数を基になる非Null許容型に昇格させます。

dart
// Using null safety:
String makeCommand(String executable, [List<String>? arguments]) {
  var result = executable;
  if (arguments != null) {
    result += ' ' + arguments.join(' ');
  }
  return result;
}

ここでは、argumentsはNull許容型です。通常、これは.join()を呼び出すことを禁止します。しかし、値がnullでないことを確認するためにチェックするif文でその呼び出しをガードしているので、DartはそれをList<String>?からList<String>に昇格させ、メソッドを呼び出したり、非Null許容型リストを期待する関数に渡したりできます。

これはかなり小さなことのように聞こえますが、Nullチェックでのこのフローベースの昇格により、ほとんどの既存のDartコードがNull安全の下で機能します。ほとんどのDartコードは動的に正しく、メソッドを呼び出す前にnullをチェックすることでNull参照エラーのスローを回避しています。Nullチェックの新しいフロー分析は、その動的な正確さを証明可能な静的な正確さへと変えます。

もちろん、到達可能性のために私たちが行うよりスマートな分析でも機能します。上記の関数は、次のように書くことができます。

dart
// Using null safety:
String makeCommand(String executable, [List<String>? arguments]) {
  var result = executable;
  if (arguments == null) return result;
  return result + ' ' + arguments.join(' ');
}

言語は、どのような種類の式が昇格を引き起こすかについてもより賢くなっています。明示的な== nullまたは!= nullはもちろん機能します。しかし、asを使用した明示的なキャスト、代入、または後置!演算子(後で説明します)も昇格を引き起こします。一般的な目標は、コードが動的に正しく、静的に把握するのが合理的であれば、分析はそのようにするのに十分賢くなければならないということです。

型昇格はもともとローカル変数でのみ機能し、Dart 3.2以降ではプライベートfinalフィールドでも機能することに注意してください。非ローカル変数の操作の詳細については、Null許容型フィールドの操作を参照してください。

不要なコードの警告

#

よりスマートな到達可能性分析を行い、nullがプログラムのどこを流れるかを知っていると、nullを処理するためのコードを追加するのに役立ちます。しかし、同じ分析を使用して、不要なコードを検出することもできます。Null安全の前に、次のようなものを書いた場合

dart
// Using null safety:
String checkList(List<Object> list) {
  if (list?.isEmpty ?? false) {
    return 'Got nothing';
  }
  return 'Got something';
}

Dartには、そのNull対応の?.演算子が有用かどうかを知る方法がありませんでした。知っている限り、関数にnullを渡すことができます。しかし、Null安全なDartでは、その関数を非Null許容型のList型でアノテーション付けした場合、listが決してnullにならないことがわかります。これは、?.が役に立たず、.を使用できる、そして使用するべきであることを意味します。

コードの簡略化を支援するため、静的解析がそれを検出できるほど正確になったため、不要なコードに対する警告を追加しました。null を認識する演算子、あるいは == null!= null のようなチェックを null 非許容型に使うと、警告として報告されます。

そしてもちろん、これは null 非許容型の昇格とも関連します。変数が null 非許容型に昇格されると、冗長に null チェックを行うと警告が表示されます。

dart
// Using null safety:
String checkList(List<Object>? list) {
  if (list == null) return 'No list';
  if (list?.isEmpty ?? false) {
    return 'Empty list';
  }
  return 'Got something';
}

ここで ?. に警告が表示されるのは、それが実行される時点で、listnull になることはないと既にわかっているからです。これらの警告の目的は、単に無意味なコードをクリーンアップすることだけではありません。不要な null チェックを削除することで、残りの意味のあるチェックが目立つようになります。コードを見て、null がどのように流れるかを理解できるようにしたいと考えています。

Null許容型の使用

#

私たちは null を null 許容型の集合に閉じ込めました。フロー解析により、一部の非 null 値を安全に null 非許容型の側に移動させて使用することができます。これは大きな一歩ですが、ここで立ち止まると、結果として得られるシステムは依然として非常に制限的です。フロー解析は、ローカル変数、パラメータ、および private final フィールドのみに役立ちます。

Dart が null 安全になる前に持っていた柔軟性をできるだけ多く取り戻し、場合によってはそれを超えるために、他にもいくつかの新しい機能を用意しました。

よりスマートなNull対応メソッド

#

Dart の null 認識演算子 ?. は、null 安全よりもずっと前から存在します。実行時のセマンティクスでは、レシーバーが null の場合、右辺のプロパティアクセスはスキップされ、式は null と評価されます。

dart
// Without null safety:
String notAString = null;
print(notAString?.length);

例外をスローする代わりに、これは "null" を出力します。null 認識演算子は、Dart で null 許容型を使用可能にするための優れたツールです。null 許容型に対してメソッドを呼び出すことはできませんが、null 認識演算子を使用することはできます。null 安全後のバージョンのプログラムは次のとおりです。

dart
// Using null safety:
String? notAString = null;
print(notAString?.length);

これは以前のものと同様に動作します。

ただし、Dart で null 認識演算子を使用したことがある場合は、メソッドチェーンで使用するときに不便を感じたことがあるかもしれません。存在しない可能性のある文字列の長さが偶数かどうかを確認したいとします(特に現実的な問題ではありませんが、ここでは我慢してください)。

dart
// Using null safety:
String? notAString = null;
print(notAString?.length.isEven);

このプログラムは ?. を使用していますが、実行時に例外をスローします。問題は、.isEven 式のレシーバーが、左側の notAString?.length 式全体の結果であることです。その式は null と評価されるため、.isEven を呼び出そうとすると null 参照エラーが発生します。Dart で ?. を使用したことがある場合は、null 認識演算子を一度使用した後、チェーン内のすべてのプロパティまたはメソッドに適用する必要があることを苦労して学んだことでしょう。

dart
String? notAString = null;
print(notAString?.length?.isEven);

これは面倒ですが、さらに悪いことに、重要な情報をわかりにくくします。次の例を考えてみましょう。

dart
// Using null safety:
showGizmo(Thing? thing) {
  print(thing?.doohickey?.gizmo);
}

ここで質問です。Thingdoohickey ゲッターは null を返すことができますか?結果に ?. を使用しているため、できるように見えます。しかし、2 番目の ?. は、thingnull の場合のみを処理するためのものであり、doohickey の結果を処理するためのものではない可能性があります。判断できません。

これを解決するために、C# の同じ機能のデザインから賢いアイデアを借りました。メソッドチェーンで null 認識演算子を使用する場合、レシーバーが null と評価されると、メソッドチェーンの残りの部分はすべてショートサーキットされ、スキップされます。つまり、doohickey が null 非許容型の戻り値の型を持つ場合、次のように記述できます。

dart
// Using null safety:
void showGizmo(Thing? thing) {
  print(thing?.doohickey.gizmo);
}

実際、2 番目の ?. に不要なコード警告が表示されます。次のようなコードが表示された場合、

dart
// Using null safety:
void showGizmo(Thing? thing) {
  print(thing?.doohickey?.gizmo);
}

doohickey 自体が null 許容型の戻り値の型を持っていることを確実に知ることができます。それぞれの ?. は、null がメソッドチェーンに流れる可能性のある固有のパスに対応します。これにより、メソッドチェーン内の null 認識演算子は、より簡潔でより正確になります。

ついでに、他の 2 つの null 認識演算子を追加しました。

dart
// Using null safety:

// Null-aware cascade:
receiver?..method();

// Null-aware index operator:
receiver?[index];

null 認識関数呼び出し演算子はありませんが、次のように記述できます。

dart
// Allowed with or without null safety:
function?.call(arg1, arg2);

非Nullアサーション演算子

#

フロー解析を使用して null 許容型変数を null 非許容型の側に移動することの素晴らしい点は、そうすることが証明可能に安全であるということです。null 非許容型の安全性やパフォーマンスを損なうことなく、以前は null 許容型だった変数に対してメソッドを呼び出すことができます。

しかし、null 許容型の有効な使用方法の多くは、静的解析を満足させる方法で安全であると証明できません。たとえば、

dart
// Using null safety, incorrectly:
class HttpResponse {
  final int code;
  final String? error;

  HttpResponse.ok()
      : code = 200,
        error = null;
  HttpResponse.notFound()
      : code = 404,
        error = 'Not found';

  @override
  String toString() {
    if (code == 200) return 'OK';
    return 'ERROR $code ${error.toUpperCase()}';
  }
}

これを実行しようとすると、toUpperCase() の呼び出しでコンパイルエラーが発生します。error フィールドは、正常な応答では値を持たないため、null 許容型です。クラスを調べると、error メッセージが null の場合はアクセスしないことがわかります。しかし、そのためには、code の値と error の null 可能性の関係を理解する必要があります。型チェッカーはその関係を確認できません。

言い換えれば、コードの保守担当者である私たちは、エラーが使用される時点で null にならないことを知っており、それを表明する方法が必要です。通常、型は as キャストを使用してアサートしますが、ここでも同じことができます。

dart
// Using null safety:
String toString() {
  if (code == 200) return 'OK';
  return 'ERROR $code ${(error as String).toUpperCase()}';
}

error を null 非許容型の String 型にキャストすると、キャストが失敗した場合にランタイム例外がスローされます。そうでない場合は、メソッドを呼び出すことができる null 非許容型の文字列が得られます。

「null 可能性のキャスト」は、新しい省略表記法を用意するのに十分な頻度で発生します。後置の感嘆符(!)は、左側の式を取り、それを基になる null 非許容型にキャストします。したがって、上記の関数は次と同等です。

dart
// Using null safety:
String toString() {
  if (code == 200) return 'OK';
  return 'ERROR $code ${error!.toUpperCase()}';
}

この 1 文字の「bang 演算子」は、基になる型が冗長な場合に特に便利です。型から 1 つの ? をキャストするためだけに as Map<TransactionProviderFactory, List<Set<ResponseFilter>>> と書くのは本当に面倒です。

もちろん、他のキャストと同様に、! を使用すると静的安全性は失われます。健全性を維持するために、キャストは実行時にチェックする必要があり、失敗して例外がスローされる可能性があります。ただし、これらのキャストが挿入される場所を制御でき、コードを確認することでいつでも確認できます。

遅延変数

#

型チェッカーがコードの安全性を証明できない最も一般的な場所は、トップレベル変数とフィールドです。次に例を示します。

dart
// Using null safety, incorrectly:
class Coffee {
  String _temperature;

  void heat() { _temperature = 'hot'; }
  void chill() { _temperature = 'iced'; }

  String serve() => _temperature + ' coffee';
}

void main() {
  var coffee = Coffee();
  coffee.heat();
  coffee.serve();
}

ここでは、heat() メソッドが serve() の前に呼び出されます。つまり、_temperature は使用される前に null 以外の値に初期化されます。しかし、静的解析でそれを判断することは現実的ではありません。(この例のような簡単な例では可能かもしれませんが、クラスの各インスタンスの状態を追跡しようとする一般的なケースは扱いにくいものです。)

型チェッカーはフィールドとトップレベル変数の使用状況を分析できないため、null 非許容型フィールドは宣言時(またはインスタンスフィールドの場合はコンストラクタ初期化リスト)に初期化する必要があるという保守的なルールがあります。そのため、Dart はこのクラスでコンパイルエラーを報告します。

フィールドを null 許容型にして、null アサーション演算子を使用することで、エラーを修正できます。

dart
// Using null safety:
class Coffee {
  String? _temperature;

  void heat() { _temperature = 'hot'; }
  void chill() { _temperature = 'iced'; }

  String serve() => _temperature! + ' coffee';
}

これは正常に動作します。しかし、それはクラスの保守担当者に混乱したシグナルを送ります。_temperature を null 許容型としてマークすることにより、null がそのフィールドにとって有用で意味のある値であることを暗示しています。しかし、それは意図ではありません。_temperature フィールドは、null 状態では観測されるべきではありません。

遅延初期化による状態の一般的なパターンを処理するために、新しい修飾子 late を追加しました。次のように使用できます。

dart
// Using null safety:
class Coffee {
  late String _temperature;

  void heat() { _temperature = 'hot'; }
  void chill() { _temperature = 'iced'; }

  String serve() => _temperature + ' coffee';
}

_temperature フィールドは null 非許容型の型ですが、初期化されていません。また、使用時に明示的な null アサーションはありません。late のセマンティクスに適用できるモデルはいくつかありますが、私は次のように考えています。late 修飾子は、「この変数の制約をコンパイル時ではなく実行時に適用する」ことを意味します。「late」という言葉が、変数の保証をいつ適用するかを記述しているかのようです。

この場合、フィールドは確実に初期化されていないため、フィールドが読み取られるたびに、値が割り当てられていることを確認するためのランタイムチェックが挿入されます。割り当てられていない場合は、例外がスローされます。変数に String 型を指定することは、「文字列以外の値で私を見るべきではない」ことを意味し、late 修飾子は「実行時にそれを確認する」ことを意味します。

ある意味では、late 修飾子は ? を使用するよりも「魔法的」です。フィールドの使用は失敗する可能性がありますが、使用箇所でテキストとして表示されるものはありません。しかし、この動作を得るには、宣言に late を記述する必要があります。そして、私たちの信念は、そこで修飾子を見ることは、これが維持可能であるために十分に明示的であるということです。

その見返りとして、null 許容型を使用するよりも優れた静的安全性が得られます。フィールドの型は null 非許容型になったため、null または null 許容型の String をフィールドに割り当てようとすると、コンパイルエラーになります。late 修飾子を使用すると、初期化を延期できますが、null 許容型変数のように扱うことはできません。

遅延初期化

#

late 修飾子には、他にも特別な機能があります。逆説的に思えるかもしれませんが、初期化子を持つフィールドに late を使用できます。

dart
// Using null safety:
class Weather {
  late int _temperature = _readThermometer();
}

これを行うと、初期化子は遅延になります。インスタンスが構築されるとすぐに実行する代わりに、遅延され、フィールドが最初にアクセスされたときに遅延実行されます。言い換えれば、トップレベル変数または静的フィールドの初期化子とまったく同じように機能します。これは、初期化式のコストが高く、必要ない場合に役立ちます。

初期化子を遅延実行すると、インスタンスフィールドに late を使用した場合に追加のボーナスが得られます。通常、インスタンスフィールドの初期化子は this にアクセスできません。すべてのフィールドの初期化子が完了するまで、新しいオブジェクトにアクセスできないためです。ただし、late フィールドでは、これはもはや当てはまらないため、インスタンスで this にアクセスしたり、メソッドを呼び出したり、フィールドにアクセスしたりできます

遅延final変数

#

latefinal を組み合わせることもできます。

dart
// Using null safety:
class Coffee {
  late final String _temperature;

  void heat() { _temperature = 'hot'; }
  void chill() { _temperature = 'iced'; }

  String serve() => _temperature + ' coffee';
}

通常の final フィールドとは異なり、宣言またはコンストラクタ初期化リストでフィールドを初期化する必要はありません。後で実行時に割り当てることができます。ただし、割り当てることができるのは一度だけであり、その事実は実行時にチェックされます。複数回割り当てようとすると(ここで heat()chill() の両方を呼び出すなど)、2 回目の割り当ては例外をスローします。これは、最終的に初期化され、その後変更されない状態をモデル化するのに最適な方法です。

言い換えれば、Dart の他の変数修飾子と組み合わせた新しい late 修飾子は、Kotlin の lateinit と Swift の lazy の機能空間のほとんどをカバーしています。少しローカルな遅延評価が必要な場合は、ローカル変数にも使用できます。

必須名前付きパラメータ

#

null 非許容型の null パラメータが表示されないようにするために、型チェッカーでは、すべて省略可能なパラメータに null 許容型の型またはデフォルト値が必要です。null 非許容型の型でデフォルト値のない名前付きパラメータが必要な場合はどうでしょうか?これは、呼び出し側に常に渡すことを要求することを意味します。言い換えれば、名前付きであるが省略可能ではないパラメータが必要です。

この表を使用して、さまざまな種類の Dart パラメータを視覚化します。

             mandatory    optional
            +------------+------------+
positional  | f(int x)   | f([int x]) |
            +------------+------------+
named       | ???        | f({int x}) |
            +------------+------------+

理由は不明ですが、Dart は長い間この表の 3 つの角をサポートしてきましたが、名前付き + 必須の組み合わせは空のままにしてきました。null 安全により、それを埋めました。パラメータの前に required を配置することで、必須の名前付きパラメータを宣言します。

dart
// Using null safety:
function({int? a, required int? b, int? c, required int? d}) {}

ここでは、すべてのパラメータを名前で渡す必要があります。パラメータ ac は省略可能であり、省略できます。パラメータ bd は必須であり、渡す必要があります。必須性は null 可能性とは無関係であることに注意してください。null 許容型の必須の名前付きパラメータと、null 非許容型の省略可能な名前付きパラメータ(デフォルト値がある場合)を持つことができます。

これは、null安全に関係なく、Dartをより良いものにする機能の1つだと思います。この機能のおかげで、言語がより完全なものに感じられます。

抽象フィールド

#

Dartの優れた機能の1つは、型統一アクセス原則と呼ばれるものを支持していることです。簡単に言うと、フィールドはゲッターとセッターと区別できないことを意味します。Dartクラスの「プロパティ」が計算されるか、または格納されるかは実装の詳細です。このため、抽象クラスを使用してインターフェースを定義する場合、フィールド宣言を使用するのが一般的です。

dart
abstract class Cup {
  Beverage contents;
}

ユーザーはそのクラスを実装するだけで、拡張しないことを意図しています。フィールド構文は、ゲッター/セッターペアを記述するより短い方法です。

dart
abstract class Cup {
  Beverage get contents;
  set contents(Beverage);
}

しかし、Dartはこのクラスが具体的な型として使用されることはないことを*知りません*。contents宣言を実際のフィールドと見なします。そして残念ながら、そのフィールドはnull非許容であり、初期化子がありません。そのため、コンパイルエラーが発生します。

1つの解決策は、2番目の例のように、明示的な抽象ゲッター/セッター宣言を使用することです。しかし、それは少し冗長なので、null安全では、明示的な抽象フィールド宣言のサポートも追加しました。

dart
abstract class Cup {
  abstract Beverage contents;
}

これは、2番目の例とまったく同じように動作します。指定された名前と型の抽象ゲッターとセッターを宣言するだけです。

Null許容フィールドの操作

#

これらの新機能は、多くの一般的なパターンをカバーし、ほとんどの場合、nullの処理を非常に簡単に行えます。しかし、それでも、null許容フィールドは依然として難しい場合があります。フィールドをlateおよびnull非許容にできる場合は、問題ありません。しかし、多くの場合、フィールドに値があるかどうかを*確認*する必要があります。そのためには、nullを観察できるように、null許容にする必要があります。

プライベートでfinalのnull許容フィールドは、(特定の理由を除いて)型昇格できます。何らかの理由でフィールドをプライベートおよびfinalにできない場合は、回避策が必要です。

たとえば、これが機能することを期待するかもしれません。

dart
// Using null safety, incorrectly:
class Coffee {
  String? _temperature;

  void heat() { _temperature = 'hot'; }
  void chill() { _temperature = 'iced'; }

  void checkTemp() {
    if (_temperature != null) {
      print('Ready to serve ' + _temperature + '!');
    }
  }

  String serve() => _temperature! + ' coffee';
}

checkTemp()内では、_temperaturenullかどうかを確認します。そうでない場合は、それにアクセスし、最終的に+を呼び出します。残念ながら、これは許可されていません。

フローベースの型昇格は、*プライベートでfinalの両方*であるフィールドにのみ適用できます。そうでない場合、静的分析では、nullをチェックする時点と使用する時点の間でフィールドの値が変化しないことを*証明*できません。(病的なケースでは、フィールド自体が、2回目に呼び出されたときにnullを返すサブクラスのゲッターによってオーバーライドされる可能性があることを考慮してください。)

そのため、健全性を重視するため、パブリックまたは非finalフィールドは昇格せず、上記のメソッドはコンパイルされません。これは面倒です。ここでのような単純なケースでは、フィールドの使用に!を付けるのが最善の方法です。冗長に見えるかもしれませんが、それは多かれ少なかれ今日のDartの動作方法です。

役立つもう1つのパターンは、最初にフィールドをローカル変数にコピーしてから、代わりにそれを使用することです。

dart
// Using null safety:
void checkTemp() {
  var temperature = _temperature;
  if (temperature != null) {
    print('Ready to serve ' + temperature + '!');
  }
}

型昇格はローカルに適用されるため、これは正常に機能します。値を*変更*する必要がある場合は、ローカルだけでなく、フィールドにも保存することを忘れないでください。

これらおよびその他の型昇格の問題の処理の詳細については、型昇格の失敗の修正を参照してください。

Null許容性とジェネリクス

#

最新の静的型付け言語のほとんどと同様に、Dartにはジェネリッククラスとジェネリックメソッドがあります。これらは、直感に反するように見えるが、その意味をよく考えると理にかなっているいくつかの方法でnull可能性と相互作用します。まず、「この型はnull許容ですか?」という質問は、単純な「はい」または「いいえ」の質問ではなくなりました。以下を検討してください。

dart
// Using null safety:
class Box<T> {
  final T object;
  Box(this.object);
}

void main() {
  Box<String>('a string');
  Box<int?>(null);
}

Boxの定義では、Tはnull許容型ですか、それともnull非許容型ですか?ご覧のとおり、どちらの種類でもインスタンス化できます。答えは、Tは*潜在的にnull許容型*であるということです。ジェネリッククラスまたはメソッドの本体内では、潜在的にnull許容型には、null許容型*と*null非許容型の両方の制限があります。

前者は、Objectで定義されている少数のメソッドを除いて、メソッドを呼び出すことができないことを意味します。後者は、その型のフィールドまたは変数を使用する前に初期化する必要があることを意味します。これは、型パラメーターの扱いを非常に困難にする可能性があります。

実際には、いくつかのパターンが現れます。型パラメーターを任意の型でインスタンス化できるコレクションのようなクラスでは、制限に対処する必要があります。ここでの例のように、ほとんどの場合、型引数の型の値を操作する必要があるときはいつでも、その値にアクセスできることを確認する必要があります。幸いなことに、コレクションのようなクラスは、要素のメソッドを呼び出すことはめったにありません。

値にアクセスできない場合は、型パラメータの使用をnull許容にすることができます

dart
// Using null safety:
class Box<T> {
  T? object;
  Box.empty();
  Box.full(this.object);
}

objectの宣言の?に注意してください。これで、フィールドは明示的にnull許容型になったため、初期化されていないままにしておくことができます。

ここでT?のように型パラメーター型をnull許容にすると、null可能性をキャストする必要がある場合があります。正しい方法は、明示的なas Tキャストを使用することであり、!演算子*ではありません*。

dart
// Using null safety:
class Box<T> {
  T? object;
  Box.empty();
  Box.full(this.object);

  T unbox() => object as T;
}

!演算子は、値がnullの場合、*常に*スローします。ただし、型パラメーターがnull許容型でインスタンス化されている場合、nullTの完全に有効な値です。

dart
// Using null safety:
void main() {
  var box = Box<int?>.full(null);
  print(box.unbox());
}

このプログラムはエラーなしで実行されるはずです。as Tを使用すると、それが実現します。!を使用すると、例外がスローされます。

他のジェネリック型には、適用できる型引数の種類を制限する境界があります。

dart
// Using null safety:
class Interval<T extends num> {
  T min, max;

  Interval(this.min, this.max);

  bool get isEmpty => max <= min;
}

境界がnull非許容の場合、型パラメーターもnull非許容です。これは、null非許容型の制限があることを意味します。フィールドと変数を初期化されていないままにすることはできません。ここでの例のクラスには、フィールドを初期化するコンストラクターが必要です。

その制限と引き換えに、境界で宣言されている型パラメーター型の値に対して任意のメソッドを呼び出すことができます。ただし、null非許容の境界があると、ジェネリッククラスの*ユーザー*がnull許容の型引数でインスタンス化できなくなります。これは、ほとんどのクラスにとって妥当な制限でしょう。

null許容の*境界*を使用することもできます。

dart
// Using null safety:
class Interval<T extends num?> {
  T min, max;

  Interval(this.min, this.max);

  bool get isEmpty {
    var localMin = min;
    var localMax = max;

    // No min or max means an open-ended interval.
    if (localMin == null || localMax == null) return false;
    return localMax <= localMin;
  }
}

これは、クラスの本体では、型パラメーターをnull許容として扱う柔軟性があることを意味しますが、null可能性の制限もあります。null可能性に対処しない限り、その型の変数に対して何も呼び出すことはできません。ここでの例では、フィールドをローカル変数にコピーし、それらのローカルがnullかどうかを確認して、フロー分析で<=を使用する前にnull非許容型に昇格させます。

null許容の境界は、ユーザーがnull非許容型でクラスをインスタンス化することを妨げないことに注意してください。null許容の境界は、型引数がnull許容に*できる*ことを意味し、*なければならない*ことを意味するわけではありません。(実際、extends句を記述しない場合の型パラメーターのデフォルトの境界は、null許容の境界Object?です。)null許容の型引数を*要求*する方法はありません。型パラメーターの使用を確実にnull許容にし、暗黙的にnullに初期化する場合は、クラスの本体内でT?を使用できます。

コアライブラリの変更

#

言語には他にもいくつか調整がありますが、それらは軽微です。on句のないcatchのデフォルト型がdynamicではなくObjectになったなどです。switchステートメントのフォールスルー分析では、新しいフロー分析が使用されます。

実際に重要な残りの変更点は、コアライブラリにあります。壮大なnull安全アドベンチャーに乗り出す前は、世界を大きく壊すことなくコアライブラリをnull安全にする方法がないことが判明するのではないかと心配していました。それほどひどいことではありませんでした。いくつかの重要な変更点は*ありますが*、ほとんどの場合、移行はスムーズに進みました。ほとんどのコアライブラリは、nullを受け入れず、自然にnull非許容型に移行するか、null許容型で正常に受け入れます。

ただし、いくつかの重要なコーナーがあります。

Mapインデックス演算子はNull許容

#

これは実際には変更ではなく、知っておくべきことです。Mapクラスのインデックス[]演算子は、キーが存在しない場合にnullを返します。これは、その演算子の戻り値の型がnull許容でなければならないことを意味します。VではなくV?です。

キーが存在しない場合に例外をスローし、使いやすくなったnull非許容の戻り値の型を与えるように、そのメソッドを変更することもできました。しかし、インデックス演算子を使用してキーが存在するかどうかをnullで確認するコードは非常に一般的であり、分析によると、すべての使用の約半分を占めています。そのコードをすべて壊すと、Dartエコシステムが炎上してしまいます。

代わりに、ランタイムの動作は同じであるため、戻り値の型はnull許容である必要があります。これは、一般的にマップ検索の結果をすぐに使用できないことを意味します。

dart
// Using null safety, incorrectly:
var map = {'key': 'value'};
print(map['key'].length); // Error.

これは、null許容文字列で.lengthを呼び出そうとすると、コンパイルエラーが発生します。キーが*存在することを知っている*場合は、!を使用して型チェッカーに教えることができます。

dart
// Using null safety:
var map = {'key': 'value'};
print(map['key']!.length); // OK.

キーを検索し、見つからない場合はスローし、そうでない場合はnull非許容の値を返す別のメソッドをMapに追加することを検討しました。しかし、何と呼ぶべきでしょうか?1文字の!よりも短い名前はなく、メソッド名はその場で組み込みのセマンティクスを持つ!を見るよりも明確ではありません。そのため、マップ内の既知の要素にアクセスする慣用的な方法は、[]!を使用することです。慣れるでしょう。

名前のないListコンストラクタなし

#

Listの無名コンストラクターは、指定されたサイズの新しいリストを作成しますが、要素は初期化しません。null非許容型のリストを作成してから要素にアクセスすると、健全性の保証に非常に大きな穴が開いてしまいます。

それを避けるために、コンストラクターを完全に削除しました。null許容型であっても、null安全なコードでList()を呼び出すとエラーになります。それは恐ろしいことのように聞こえますが、実際には、ほとんどのコードはリストリテラル、List.filled()List.generate()、または他のコレクションを変換した結果としてリストを作成します。ある型の空のリストを作成したいというエッジケースのために、新しいList.empty()コンストラクターを追加しました。

完全に初期化されていないリストを作成するパターンは、Dartでは常に場違いに感じられていましたが、今ではさらにそうです。これでコードが壊れている場合は、リストを作成する他の多くの方法のいずれかを使用することで、いつでも修正できます。

Null非許容リストに大きな長さを設定できない

#

これはあまり知られていませんが、Listlengthゲッターには、対応する*セッター*もあります。リストを切り詰めるために、lengthをより短い値に設定できます。また、リストを初期化されていない要素で埋め込むために、*より長い*lengthに設定することもできます。

null非許容型のリストでそれを行うと、後で書き込まれていない要素にアクセスしたときに健全性が損なわれます。それを防ぐために、リストにnull非許容の要素型があり、*かつ**より長い*lengthに設定した場合(そしてその場合のみ)、lengthセッターはランタイム例外をスローします。すべての型のリストを切り詰めることは問題なく、null許容型のリストを拡張することもできます。

ListBaseを拡張するか、ListMixinを適用する独自のリスト型を定義する場合、これには重要な結果があります。これらの両方の型は、以前はlengthを設定することで挿入された要素のためのスペースを確保していたinsert()の実装を提供しています。これはnull安全では失敗するため、代わりに、ListMixinListBaseが共有する)のinsert()の実装を、代わりにadd()を呼び出すように変更しました。継承されたinsert()メソッドを使用できるようにするには、カスタムリストクラスでadd()の定義を提供する必要があります。

反復の前後にIterator.currentにアクセスできない

#

Iteratorクラスは、Iterableを実装する型の要素をトラバースするために使用される可変の「カーソル」クラスです。最初の要素に進むには、要素にアクセスする前にmoveNext()を呼び出す必要があります。そのメソッドがfalseを返すと、終わりに達し、要素がなくなります。

以前は、moveNext() を最初に呼び出す前、または反復が終了した後に current を呼び出すと、null が返されていました。Null Safety では、current の戻り値の型を E ではなく E? にする必要があります。これは、すべての要素アクセスで実行時に null チェックが必要になることを意味します。

このようなチェックは、ほとんど誰もそのような誤った方法で現在の要素にアクセスしないことを考えると、無駄になります。代わりに、current の型を E にしました。反復の前または後にその型の値が利用できる*可能性がある*ため、想定されていないときに呼び出した場合のイテレータの動作は未定義のままにしています。Iterator のほとんどの実装では、StateError がスローされます。

まとめ

#

これは、Null Safety に関する言語とライブラリのすべての変更についての非常に詳細な説明です。多くの内容が含まれていますが、これは非常に大きな言語変更です。さらに重要なことは、Dart が依然としてまとまりがあり、使いやすく感じられるようにしたいと考えていました。そのためには、型システムだけでなく、その周りの多くの使いやすさに関する機能を変更する必要がありました。Null Safety が後付けのように感じられることを避けたいと考えました。

重要なポイントは次のとおりです。

  • 型はデフォルトでは null 非許容であり、? を追加することで null 許容になります。

  • オプションパラメータは、null 許容であるか、デフォルト値を持つ必要があります。required を使用すると、名前付きパラメータを必須にすることができます。null 非許容のトップレベル変数と静的フィールドには、初期化子が必要です。null 非許容のインスタンスフィールドは、コンストラクタ本体が開始される前に初期化する必要があります。

  • null 認識演算子の後のメソッドチェーンは、レシーバーが null の場合、ショートサーキットします。新しい null 認識カスケード(?..)演算子とインデックス(?[])演算子があります。後置 null アサーション「感嘆符」(!)演算子は、null 許容のオペランドを基になる null 非許容の型にキャストします。

  • フロー分析により、null 許容のローカル変数とパラメータ(およびDart 3.2以降ではプライベートな final フィールド)を安全に使用可能な null 非許容の変数に変換できます。新しいフロー分析では、型の昇格、戻り値の欠落、到達不能コード、変数の初期化に関するよりスマートなルールも備えています。

  • late 修飾子を使用すると、実行時チェックを犠牲にして、null 非許容の型と final を本来使用できない場所で使うことができます。また、遅延初期化フィールドも提供します。

  • List クラスは、初期化されていない要素を防ぐように変更されました。

最後に、これらすべてを理解し、コードを Null Safety の世界に移行すると、コンパイラが最適化できる、実行時エラーが発生する可能性のあるすべての場所がコードで明確になる、堅牢なプログラムが得られます。そこに到達するための努力は価値があると感じていただければ幸いです。