Null safety の理解
執筆者: Bob Nystrom
2020年7月
Null Safety は、Dart 2.0 で元のサウンドでないオプション型システムを サウンドな静的型システム に置き換えて以来、Dart に行われた最大の変更です。Dart が最初にリリースされたとき、コンパイル時 null Safety は長い導入を必要とする珍しい機能でした。今日、Kotlin、Swift、Rust、その他の言語は、非常によく知られた問題に対する独自の回答を持っています。ここに例があります。
// Without null safety:
bool isEmpty(String string) => string.length == 0;
void main() {
isEmpty(null);
}Null Safety なしでこの Dart プログラムを実行すると、.length の呼び出しで NoSuchMethodError 例外が発生します。null 値は Null クラスのインスタンスであり、Null には「length」ゲッターがありません。実行時エラーは最悪です。これは、エンドユーザーのデバイスで実行するように設計された Dart のような言語では特にそうです。サーバーアプリケーションが失敗した場合、誰にも気づかれる前に再起動できることがよくあります。しかし、Flutter アプリがユーザーの電話でクラッシュすると、ユーザーは満足しません。ユーザーが満足しないと、あなたも満足しません。
開発者は、Dart のような静的型付け言語を好みます。なぜなら、型チェッカーがコンパイル時にコードのミスを見つけられるようにするため、通常は IDE の中で直接見つけられるからです。バグを見つけるのが早ければ早いほど、修正するのも早くなります。「null 参照エラーの修正」について言語設計者が話すとき、それは、上記の null になる可能性のある値に対して .length を呼び出そうとするようなミスを言語が検出できるように、静的型チェッカーを拡張することを意味します。
この問題に対する唯一の真の解決策はありません。Rust と Kotlin はどちらも、それらの言語のコンテキストで意味のある独自のさまざまなアプローチを持っています。このドキュメントでは、Dart に対する私たちの回答の詳細をすべて説明します。静的型システムへの変更と、Null Safety コードを書けるだけでなく、それを楽しむことができるようにするためのその他のさまざまな変更や新言語機能が含まれています。
このドキュメントは長いです。すぐに使えるようになるために必要なことだけをカバーした短いものが欲しい場合は、概要から始めてください。より深い理解の準備ができ、時間があるときに、言語が null をどのように処理するか、なぜそのように設計したのか、そしてイディオム的でモダンな、null Safety Dart をどのように書くかを理解するために、ここに戻ってください。(ネタバレ注意: 結局、今日 Dart を書く方法と驚くほど近くなります。)
言語が null 参照エラーに対処できるさまざまな方法には、それぞれ長所と短所があります。これらの原則が、私たちが下した選択の指針となりました。
コードはデフォルトで安全でなければなりません。 新しい Dart コードを書き、明示的に安全でない機能を使用しない場合、実行時に null 参照エラーが発生することはありません。すべての null 参照エラーは静的に検出されます。より大きな柔軟性を得るために、そのチェックの一部をランタイムに延期したい場合は、それを行うことができますが、コードでテキスト表示される機能を使用してそれを選択する必要があります。
言い換えれば、ライフジャケットを渡して、水に出るときは毎回着ることを思い出してもらうだけではありません。代わりに、沈まないボートを提供します。飛び込まない限り、乾いたままです。
Null Safety コードは書きやすくする必要があります。 既存の Dart コードのほとんどは動的に正しく、null 参照エラーを発生させません。Dart プログラムは現在の見た目のままで気に入っており、そのようにコードを書き続けられるようにしたいと考えています。安全性は、使いやすさを犠牲にしたり、型チェッカーに償いを払ったり、考え方を大幅に変更したりする必要があるべきではありません。
結果として得られる null Safety コードは完全にサウンドでなければなりません。 静的チェックのコンテキストでの「サウンドネス」は、人によって意味が異なります。私たちにとって、Null Safety のコンテキストでは、式に
nullを許可しない静的型がある場合、その式の可能な実行は決してnullに評価されないことを意味します。言語はこの保証を主に静的チェックを通じて提供しますが、ランタイム チェックも含まれる場合があります。(ただし、最初の原則に注意してください: これらのランタイム チェックが発生する場所は、あなたの選択になります。)サウンドネスはユーザーの信頼にとって重要です。ほとんど浮いたままのボートは、外洋に繰り出すのに熱心になれるものではありません。しかし、それは私たちの勇敢なコンパイラハッカーにとっても重要です。言語がプログラムのセマンティックプロパティについて厳密な保証を行う場合、コンパイラはそのプロパティが真であると仮定して最適化を実行できることを意味します。
nullに関しては、不要なnullチェックを排除するより小さなコードを生成でき、メソッドを呼び出す前にレシーバーが non-nullであることを確認する必要のない、より高速なコードを生成できることを意味します。1 つの注意点: Null Safety なプログラムのみでサウンドネスを保証します。Dart は、新しい Null Safety コードと古いレガシー コードの混合を含むプログラムをサポートしています。これらの混合バージョンのプログラムでは、null 参照エラーが引き続き発生する可能性があります。混合バージョンのプログラムでは、Null Safety な部分で静的な安全性に関するすべてのメリットが得られますが、アプリケーション全体が Null Safety になるまで、完全なランタイム サウンドネスは得られません。
null を排除することが目標ではないことに注意してください。null には問題ありません。むしろ、値の*不在*を表現できることは非常に役立ちます。言語に直接「不在」値のサポートを組み込むことで、不在の処理が柔軟で使いやすくなります。これは、オプション引数、便利な ?. null 意識演算子、およびデフォルト初期化を支えています。悪いのは null ではなく、予期しない場所に null が入ることによって問題が発生することです。
したがって、Null Safety では、プログラム全体で null がどのように流れるかを制御し、洞察を得て、クラッシュを引き起こす可能性のある場所には流れないようにする確実性を提供することを目標としています。
型システムにおける null 許容性
#Null Safety は静的型システムから始まります。なぜなら、他のすべてはその上に成り立っているからです。Dart プログラムには、int や String のようなプリミティブ型、List のようなコレクション型、そしてあなたやあなたが使用するパッケージが定義するすべてのクラスや型など、型の全宇宙が含まれています。Null Safety の前は、静的型システムは null 値をこれらの型のいずれかの式に流れることを許可していました。
型理論の専門用語では、Null 型はすべての型のサブタイプとして扱われていました。

式で許可される操作 (ゲッター、セッター、メソッド、演算子) のセットは、その型によって定義されます。型が List の場合、それに .add() または [] を呼び出すことができます。型が int の場合、+ を呼び出すことができます。しかし、null 値はこれらのメソッドのいずれも定義していません。null を他の型の式に流れることを許可すると、これらの操作のいずれかが失敗する可能性があります。これが null 参照エラーの核心です。すべての失敗は、null が持っていないメソッドまたはプロパティを null で検索しようとすることから生じます。
null 非許容型と null 許容型
#Null Safety は、型階層を変更することで、この問題を根本から排除します。Null 型は依然として存在しますが、もはやすべての型のサブタイプではありません。代わりに、型階層は次のようになります。

Null はもはやサブタイプではないため、特別な Null クラス以外のどの型も null 値を許可しません。すべての型をデフォルトで null 非許容にしました。String 型の変数がある場合、それは常に*文字列*を含みます。そこで、すべての null 参照エラーを修正しました。
null がまったく役に立たないと考えていれば、ここで終了できます。しかし、null は役立つため、それでもそれを処理する方法が必要です。オプション引数は良い例です。この null Safety 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 の場合に失敗する可能性があるためです。
// 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 許容型のサブタイプです。

しかし、逆方向に行って、null 許容型を基になる null 非許容型を期待するものに渡すのは安全ではありません。String を期待するコードは、値に対して String メソッドを呼び出す可能性があります。それを String? に渡すと、null が流れてきて失敗する可能性があります。
// Hypothetical unsound null safety:
void requireStringNotNull(String definitelyString) {
print(definitelyString.length);
}
void main() {
String? maybeString = null; // Or not!
requireStringNotNull(maybeString);
}このプログラムは安全ではなく、許可すべきではありません。しかし、Dart には暗黙的なダウンキャストと呼ばれるものが常にありました。たとえば、Object 型の値を String を期待する関数に渡すと、型チェッカーはそれを許可します。
// Without null safety:
void requireStringNotObject(String definitelyString) {
print(definitelyString.length);
}
void main() {
Object maybeString = 'it is';
requireStringNotObject(maybeString);
}サウンドネスを維持するために、コンパイラは requireStringNotObject() への引数にサイレントに as String キャストを挿入します。そのキャストは失敗して実行時に例外をスローする可能性がありますが、コンパイル時には Dart はこれを OK と言います。null 非許容型は null 許容型のサブタイプとしてモデル化されているため、暗黙的なダウンキャストは String? を String を期待するものに渡すことを許可します。それを許可することは、デフォルトで安全であるという私たちの目標に違反します。そのため、Null Safety では暗黙的なダウンキャストを完全に削除しています。
これにより、requireStringNotNull() への呼び出しはコンパイルエラーになります。これは望ましいことです。しかし、これはすべての暗黙的なダウンキャストがコンパイルエラーになることも意味します。これには requireStringNotObject() への呼び出しも含まれます。明示的なダウンキャストを自分で追加する必要があります。
// Using null safety:
void requireStringNotObject(String definitelyString) {
print(definitelyString.length);
}
void main() {
Object maybeString = 'it is';
requireStringNotObject(maybeString as String);
}全体として、これは良い変更だと思います。ほとんどのユーザーは暗黙的なダウンキャストを好んでいなかったという印象があります。特に、以前にこれに苦しんだことがあるかもしれません。
// Without null safety:
List<int> filterEvens(List<int> ints) {
return ints.where((n) => n.isEven);
}バグを見つけられますか? .where() メソッドは遅延実行されるため、List ではなく Iterable を返します。このプログラムはコンパイルされますが、filterEvens が返すと宣言している List 型にその Iterable をキャストしようとすると、実行時に例外が発生します。暗黙的なダウンキャストの削除により、これはコンパイルエラーになります。
どこまで話しましたか? そう、わかりました。それで、プログラム内の型の宇宙を取り出して、2つの半分に分割したかのようです。

null 非許容型の領域があります。それらの型は、すべての興味深いメソッドにアクセスできますが、null を絶対に含めることはできません。そして、対応する null 許容型の並列ファミリーがあります。それらは null を許可しますが、それらでできることはあまりありません。非 null 側から null 許容側への値の流れを許可します。なぜなら、そうすることは安全だからです。しかし、逆方向ではありません。
それは、null 許容型が基本的に役に立たないように見えます。メソッドがなく、それらから逃れることもできません。心配しないでください。まもなく説明する、null 許容型から反対側へ値を移動させるのに役立つ機能がすべて揃っています。
トップ型とボトム型
#このセクションは少し専門的です。型システムのマニアでない限り、最後の 2 つの箇条書き以外はほとんどスキップできます。プログラム内のすべての型を、サブタイプとスーパータイプの関係があるエッジで結ばれていると考えてください。それらを、このドキュメントの図のように描いた場合、それは巨大な有向グラフを形成し、上部には Object のようなスーパータイプ、下部にはあなた自身の型のようなリーフクラスが配置されます。
その有向グラフが、直接的または間接的にスーパータイプである単一の型で上部で一点に収束する場合、その型はトップ型と呼ばれます。同様に、下部にすべての型のサブタイプである奇妙な型がある場合、*ボトム型*があります。(この場合、有向グラフは格子を形成します。)
型システムにトップ型とボトム型があると便利です。なぜなら、最小共通上位型 (型推論が 2 つのブランチの型の条件式 の型を決定するために使用する) のような型レベルの操作が常に型を生成できることを意味するからです。Null Safety の前は、Object が Dart のトップ型で、Null がボトム型でした。
Object はもはや null 非許容であるため、トップ型ではありません。Null はそのサブタイプではありません。Dart には名前付きトップ型はありません。トップ型が必要な場合は、Object? を使用したいでしょう。同様に、Null はもはやボトム型ではありません。そうであれば、すべてが null 許容のままになります。代わりに、Never という名前の新しいボトム型を追加しました。

実際には、これは意味します
あらゆる型の値を受け入れたい場合は、
Objectの代わりにObject?を使用してください。実際、Objectを使用することは非常に珍しくなります。なぜなら、その型は「この 1 つの奇妙に禁止された値null以外のあらゆる可能な値」を意味するからです。ボトム型が必要なまれな場合、
Nullの代わりにNeverを使用してください。これは、関数が決して戻らないことを示すのに特に役立ちます (到達可能性解析を支援するため)。ボトム型が必要かどうかわからない場合は、おそらく必要ありません。
正確性の保証
#型を null 許容と null 非許容の 2 つの半分に分割しました。サウンドネスと、要求しない限り実行時に null 参照エラーが発生しないという原則を維持するために、null 非許容側のどの型にも null が現れないことを保証する必要があります。
暗黙的なダウンキャストの削除と Null をボトム型として削除することは、代入や関数呼び出しの引数からパラメータへの型の流れの主な場所すべてをカバーします。null が入り込む可能性のある主な残りの場所は、変数が最初に作成されるときと、関数を終了するときです。そのため、いくつかの追加のコンパイル エラーがあります。
無効な戻り値
#関数が null 非許容の戻り値の型を持つ場合、関数内のすべてのパスが値を返す return ステートメントに到達する必要があります。Null Safety の前は、Dart は欠落している戻り値に対してかなり緩かったです。たとえば、
// Without null safety:
String missingReturn() {
// No return.
}これを分析すると、戻り忘れのヒントが得られましたが、そうでなければ問題ありませんでした。それは、実行が関数本体の末尾に到達した場合、Dart は暗黙的に null を返すためです。すべての型が null 許容であるため、この関数は技術的には安全ですが、おそらく望んでいるものではありません。
サウンドな null 非許容型では、このプログラムは完全に間違っていて安全ではありません。Null Safety では、非 null 許容の戻り値を持つ関数が確実に値を返さない場合、コンパイル エラーが発生します。「確実に」とは、言語が関数内のすべての制御フローパスを分析することを意味します。それらがすべて何かを返している限り、満足です。分析は非常にスマートなので、この関数でも問題ありません。
// 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 許容にするか、有効な non-
nullデフォルト値を指定する必要があります。
これらの制限は負担が大きいように聞こえるかもしれませんが、実際にはそれほど悪くありません。これらは、final 変数に関する既存の制限と非常によく似ており、おそらく長年ほとんど気づかずに作業してきたことでしょう。また、これらはnull 非許容変数にのみ適用されることを忘れないでください。いつでも型を null 許容にでき、デフォルトの初期化を null にすることができます。
それでも、ルールは摩擦を引き起こします。幸いなことに、これらの新しい制限が最も一般的なパターンで遅延させるのを円滑にするための新しい言語機能のセットがあります。しかし、まず、フロー解析について話す時間です。
フロー解析
#制御フロー解析は、コンパイラで長年使用されてきました。ほとんどのユーザーには隠されていますが、コンパイラ最適化で使用されますが、一部の新しい言語では、可視言語機能に同じ技術が使用され始めています。Dart には、*型昇格*という形で、すでにフロー解析が少し含まれています。
// With (or without) null safety:
bool isEmptyList(Object object) {
if (object is List) {
return object.isEmpty; // <-- OK!
} else {
return false;
}
}マークされた行で、object に対して isEmpty を呼び出せることに注意してください。そのメソッドは List に定義されており、Object には定義されていません。これは、型チェッカーがすべての is 式とプログラム内の制御フローパスを調べるためです。ある制御フロー構造の本体が、変数に対する特定の is 式が true の場合にのみ実行される場合、その本体内では変数の型がテストされた型に「昇格」されます。
ここでの例では、if ステートメントの then ブランチは、object が実際にリストを含んでいる場合にのみ実行されます。したがって、Dart は object を宣言された型 Object ではなく型 List に昇格します。これは便利な機能ですが、かなり限定的です。Null Safety の前は、機能的に同一の次のプログラムは機能しませんでした。
// Without null safety:
bool isEmptyList(Object object) {
if (object is! List) return false;
return object.isEmpty; // <-- Error!
}ここでも、object がリストである場合にのみ .isEmpty 呼び出しに到達するため、このプログラムは動的に正しいです。しかし、型昇格ルールは、return ステートメントが object がリストである場合にのみ到達できることを認識するほど賢くはありませんでした。
Null Safety のために、この限定的な分析をいくつかの点で大幅に強力にしました。
到達可能性解析
#まず、型昇格が早期リターンやその他の到達不能コードパスについて賢くないという、長年の不満を解消しました。関数を分析するとき、return、break、throw、および実行が関数で早期に終了する可能性のあるその他の方法が考慮されるようになりました。Null Safety の下では、この関数は
// Using null safety:
bool isEmptyList(Object object) {
if (object is! List) return false;
return object.isEmpty;
}これで完全に有効になりました。if ステートメントは object が List で*ない*場合に関数を終了するため、Dart は 2 番目のステートメントで object を List に昇格させます。これは null 許容性に関係のない多くの Dart コードにも役立つ非常に良い改善です。
到達不能コードのための Never
#この到達可能性解析をプログラムすることもできます。新しいボトム型 Never には値がありません。(String、bool、そして int のすべてである値とは何でしょうか?) では、式が Never 型を持つとはどういう意味でしょうか? それは、その式が評価を正常に完了できないことを意味します。例外をスローするか、中止するか、または式の結果を期待する周囲のコードが実行されないようにする必要があります。
実際、言語によれば、throw 式の静的型は Never です。Never 型はコアライブラリに宣言されており、型注釈として使用できます。特定の種類の例外をスローするのを容易にするヘルパー関数があるかもしれません。
// Using null safety:
Never wrongType(String type, Object value) {
throw ArgumentError('Expected $type, but was ${value.runtimeType}.');
}次のように使用するかもしれません。
// Using null safety:
class Point {
final int x, y;
Point(this.x, this.y);
Point operator +(Object other) {
if (other is int) return Point(x + other, y + other);
if (other is! Point) wrongType('int | Point', other);
print('Adding two Point instances together: $this + $other');
return Point(x + other.x, y + other.y);
}
// toString, hashCode, and other implementations...
}このプログラムはエラーなしで分析されます。+ メソッドの最後の行が other の .x と .y にアクセスしていることに注意してください。関数に return または throw がないにもかかわらず、Point に昇格されています。制御フロー解析は、wrongType() の宣言された型が Never であることを知っており、これは if ステートメントの then ブランチが何らかの方法で*中止*されなければならないことを意味します。最後のステートメントは other が Point である場合にのみ到達できるため、Dart はそれを昇格させます。
つまり、独自の API で Never を使用すると、Dart の到達可能性解析を拡張できます。
確定代入解析
#ローカル変数について少し触れました。Dart は、null 非許容のローカル変数が読み取られる前に常に初期化されていることを確認する必要があります。可能な限り柔軟にするために、*確定代入解析*を使用します。言語は各関数本体を分析し、すべての制御フローパスを通じてローカル変数と引数への代入を追跡します。変数の使用箇所に到達するすべてのパスで変数が代入されている限り、変数は初期化されたと見なされます。これにより、初期化子なしで変数を宣言し、複雑な制御フローを使用して後で初期化できます。変数が null 非許容型であってもです。
確定代入解析は、*final* 変数をより柔軟にするためにも使用します。Null Safety の前は、興味深い方法で初期化する必要がある場合、ローカル変数に final を使用するのは難しい場合があります。
// 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 Safety 下のよりスマートなフロー解析により、このプログラムは問題ありません。解析は、result がすべての制御フローパスで正確に 1 回確定的に初期化されていることを伝えることができるため、変数を final としてマークするための制約が満たされます。
null チェック時の型昇格
#よりスマートなフロー解析は、Null Safety に関係のないコードを含む、多くの Dart コードに役立ちます。しかし、これらの変更を今行っているのは偶然ではありません。型を null 許容と null 非許容のセットに分割しました。null 許容型の値がある場合、それを使用して実際にできることはほとんどありません。値が null である場合、その制限は良いことです。クラッシュを防いでいます。
しかし、値が null でない場合、それを null 非許容側に移動させてメソッドを呼び出せるようにすると便利です。フロー解析は、ローカル変数と引数 (および Dart 3.2 以降のプライベート final フィールド) に対してこれを行うための主な方法の 1 つです。null チェックが == null および != null 式も調べるように型昇格を拡張しました。
null 許容型のローカル変数を null でないか確認すると、Dart は変数を基になる null 非許容型に昇格させます。
// 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 Safety で機能するようにしている理由です。ほとんどの Dart コードは動的に正しく、メソッドを呼び出す前に null をチェックすることで null 参照エラーを回避しています。null チェックでの新しいフロー解析は、その動的な正しさを証明可能な静的な正しさに変えます。
もちろん、到達可能性に関するよりスマートな分析とも連携します。上記の関数は、次のように書くこともできます。
// 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 Safety の前は、次のようなものを書いた場合
// Using null safety:
String checkList(List<Object> list) {
if (list?.isEmpty ?? false) {
return 'Got nothing';
}
return 'Got something';
}Dart には、その null 意識的な ?. 演算子が便利かどうかを知る方法がありませんでした。知っている限り、関数に null を渡すこともできました。しかし、null Safety Dart では、関数に非 null 許容の List 型を注釈付けした場合、list が決して null にならないことを認識します。これは ?. が役立つことは決してないことを意味し、単に . を使用できるはずです。
コードの簡略化を支援するために、静的解析がそれを検出できるほど正確になったため、このような不要なコードの警告を追加しました。null 許容型で null 意識演算子または == null や != null のようなチェックを使用すると、警告として報告されます。
もちろん、これは null 非許容型昇格とも連携します。変数が null 非許容型に昇格されると、null について再度チェックすると冗長なチェックの警告が表示されます。
// Using null safety:
String checkList(List<Object>? list) {
if (list == null) return 'No list';
if (list?.isEmpty ?? false) {
return 'Empty list';
}
return 'Got something';
}ここでは ?. の警告が表示されます。これは、実行される時点では list が null になることはないことがすでにわかっているためです。これらの警告の目標は、単に無意味なコードをクリーンアップすることだけではありません。不要な null チェックを削除することで、残りの意味のあるチェックが際立つようになります。コードを見て、null がどこに流れるか見えるようにしたいのです。
null 許容型との連携
#これで、null を null 許容型のセットに隔離しました。フロー解析により、一部の non-null 値を安全にフェンスを越えて null 非許容側に移動させ、使用できるようにします。これは大きな一歩ですが、ここで停止すると、結果のシステムは依然として非常に制限的になります。フロー解析は、ローカル変数、引数、およびプライベート final フィールドにのみ役立ちます。
Dart が Null Safety の前に持っていた柔軟性をできるだけ多く取り戻し、いくつかの場所ではそれを超えるために、さらにいくつかの新しい機能があります。
よりスマートな null 意識メソッド
#Dart の null 意識演算子 ?. は、Null Safety よりもずっと古いです。ランタイムセマンティクスは、レシーバーが null の場合、右側のプロパティアクセスはスキップされ、式は null と評価されると述べています。
// Without null safety:
String notAString = null;
print(notAString?.length);例外をスローする代わりに、これは "null" と表示されます。Null 意識演算子は、Dart で null 許容型を使用可能にするための優れたツールです。null 許容型にメソッドを呼び出すことはできませんが、それらに対して null 意識演算子を使用することはできます。プログラムのポスト Null Safety バージョンは次のとおりです。
// Using null safety:
String? notAString = null;
print(notAString?.length);以前と同じように機能します。
しかし、Dart で null 意識演算子を使用したことがあるなら、メソッドチェーンで使用するときに問題に遭遇したことがあるでしょう。ここでは、存在しない可能性のある文字列の長さが偶数であるかどうかを確認したいとします (あまり現実的な問題ではありませんが、私に付き合ってください)。
// Using null safety:
String? notAString = null;
print(notAString?.length.isEven);このプログラムは ?. を使用していますが、実行時に例外が発生します。問題は、.isEven 式のレシーバーが、左側の notAString?.length 式全体の結果であることです。その式は null と評価されるため、.isEven を呼び出そうとすると null 参照エラーが発生します。Dart で ?. を使用したことがあるなら、たぶん、一度使用したら、チェーンの*すべての*プロパティまたはメソッドに null 意識演算子を適用しなければならないことに苦労して学んだでしょう。
String? notAString = null;
print(notAString?.length?.isEven);これは迷惑ですが、さらに悪いことに、重要な情報を不明瞭にします。次を検討してください。
// Using null safety:
showGizmo(Thing? thing) {
print(thing?.doohickey?.gizmo);
}ここで質問です: Thing の doohickey ゲッターは null を返すことができますか? 結果に対して ?. を使用しているように見えるため、*できる*ように見えます。しかし、2 番目の ?. は、thing が null の場合のみであり、doohickey の結果ではない可能性があります。わかりません。
これを解決するために、C# での同じ機能の設計から賢いアイデアを借用しました。メソッドチェーンで null 意識演算子を使用すると、レシーバーが null と評価された場合、*残りのメソッドチェーン全体がショートサーキットされ、スキップされます*。これは、doohickey が null 非許容の戻り値の型を持つ場合、次のように書くことができることを意味します。
// Using null safety:
void showGizmo(Thing? thing) {
print(thing?.doohickey.gizmo);
}実際、そうしないと不要なコードの警告が表示されます。次のようなコードを見た場合
// Using null safety:
void showGizmo(Thing? thing) {
print(thing?.doohickey?.gizmo);
}これは、doohickey 自体が null 許容の戻り値の型を持つことを意味すると確信できます。各 ?. は、メソッドチェーンに null が流れる可能性のある*一意の*パスに対応します。これにより、メソッドチェーンの null 意識演算子は、より簡潔でより正確になります。
ついでに、他の 2 つの null 意識演算子を追加しました。
// Using null safety:
// Null-aware cascade:
receiver?..method();
// Null-aware index operator:
receiver?[index];null 意識的な関数呼び出し演算子はありませんが、次のように書くことができます。
// Allowed with or without null safety:
function?.call(arg1, arg2);
null 非断言演算子
#フロー解析を使用して null 許容変数を null 非許容側の世界に移動させることの素晴らしい点は、そのプロセスが証明可能に安全であることです。以前の null 許容変数のメソッドを、安全性や null 非許容型のパフォーマンスを犠牲にすることなく呼び出すことができます。
しかし、null 許容型の多くの有効な用途は、静的解析を満足させる方法で安全であることが証明できません。たとえば、
// 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 許容です。クラスを検査すると、code の値と error の null 許容性との関係を理解できます。型チェッカーは、その接続を見ることはできません。
言い換えれば、コードの人間としての保守者は、使用時点では error が null にならない*ことを知って*おり、それをアサートする方法が必要です。通常、as キャストを使用して型をアサートし、ここで同じことを行うことができます。
// Using null safety:
String toString() {
if (code == 200) return 'OK';
return 'ERROR $code ${(error as String).toUpperCase()}';
}error を null 非許容の String 型にキャストすると、キャストが失敗した場合に実行時例外が発生します。それ以外の場合、メソッドを呼び出すことができる null 非許容文字列が得られます。
「Null 許容性をキャストで排除する」ことは非常によく起こるため、新しい短縮構文があります。後置感嘆符 (!) は、左側の式を取得し、それを基になる null 非許容型にキャストします。したがって、上記の関数は次のようになります。
// Using null safety:
String toString() {
if (code == 200) return 'OK';
return 'ERROR $code ${error!.toUpperCase()}';
}この 1 文字の「バング演算子」は、基になる型が冗長な場合に特に便利です。Map<TransactionProviderFactory, List<Set<ResponseFilter>>> を ? の 1 つだけキャストするために書くのは非常に面倒です。
もちろん、どのキャストと同様に、! を使用すると静的安全性の一部が失われます。サウンドネスを維持するために、キャストは実行時にチェックされ、失敗して例外が発生する可能性があります。しかし、これらのキャストが挿入される場所を制御でき、コードを調べることでいつでもそれらを確認できます。
late 変数
#型チェッカーがコードの安全性を証明できない最も一般的な場所は、トップレベル変数とフィールドの周りです。例を挙げます。
// 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 非断言演算子を使用することで、エラーを修正できます。
// 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 を追加しました。次のように使用できます。
// 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 を使用できます。
// Using null safety:
class Weather {
late int _temperature = _readThermometer();
}このようにすると、初期化子は*遅延*されます。インスタンスが構築されるとすぐに実行されるのではなく、フィールドに最初にアクセスされたときに遅延実行されます。つまり、トップレベル変数または静的フィールドの初期化子のように機能します。初期化式が高コストで、必要ない可能性がある場合に便利です。
初期化子を遅延実行すると、インスタンスフィールドに late を使用した場合にさらにボーナスが得られます。通常、インスタンスフィールドの初期化子は this にアクセスできません。なぜなら、すべてのフィールド初期化子が完了するまで新しいオブジェクトにアクセスできないからです。しかし、late フィールドでは、それはもはや真実ではないため、インスタンスの this にアクセスしたり、メソッドを呼び出したり、フィールドにアクセスしたりできます。
late final 変数
#late を final と組み合わせることもできます。
// Using null safety:
class Coffee {
late final String _temperature;
void heat() { _temperature = 'hot'; }
void chill() { _temperature = 'iced'; }
String serve() => _temperature + ' coffee';
}通常の final フィールドとは異なり、宣言またはコンストラクタ初期化リストでフィールドを初期化する必要はありません。後で実行時に代入できます。ただし、*1 回しか*代入できません。そして、その事実は実行時にチェックされます。複数回代入しようとすると (たとえば、heat() と chill() の両方を呼び出す場合)、2 回目の代入で例外が発生します。これは、最終的に初期化され、その後不変になる状態をモデル化するのに最適な方法です。
言い換えれば、新しい late 修飾子は、Dart の他の変数修飾子と組み合わせて、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 Safety では、それを埋めました。required を引数の前に配置して、必須の命名引数を宣言します。
// Using null safety:
function({int? a, required int? b, int? c, required int? d}) {}ここでは、すべての引数を名前で渡す必要があります。引数 a と c はオプションであり、省略できます。引数 b と d は必須であり、渡す必要があります。必須性は null 許容性とは独立していることに注意してください。null 許容型の必須命名引数や、null 非許容型のオプション命名引数 (デフォルト値がある場合) を持つことができます。
これは、Null Safety に関係なく Dart をより良くする機能の 1 つだと思います。単に言語がより完全になったように感じます。
抽象フィールド
#Dart の興味深い機能の 1 つは、均一アクセス原則と呼ばれるものを維持していることです。人間が理解できる言葉で言えば、フィールドはゲッターとセッターと区別がつかないということです。Dart クラスの「プロパティ」が計算されているか格納されているかは実装の詳細です。このため、抽象クラスを使用してインターフェイスを定義する場合、フィールド宣言を使用するのが一般的です。
abstract class Cup {
Beverage contents;
}意図は、ユーザーがそのクラスを実装するだけで、拡張しないということです。フィールド構文は、ゲッター/セッターペアの短い書き方です。
abstract class Cup {
Beverage get contents;
set contents(Beverage);
}しかし、Dart はこのクラスが決して具体的な型として使用されないことを知りません。contents 宣言を実際のフィールドと見なします。そして残念ながら、そのフィールドは null 非許容で初期化子がないため、コンパイル エラーが発生します。
1 つの修正は、2 番目の例のように明示的な抽象ゲッター/セッター宣言を使用することです。しかし、それは少し冗長なので、Null Safety では明示的な抽象フィールド宣言のサポートも追加しました。
abstract class Cup {
abstract Beverage contents;
}これは 2 番目の例とまったく同じように動作します。単に指定された名前と型を持つ抽象ゲッターとセッターを宣言します。
null 許容フィールドとの連携
#これらの新しい機能は多くの一般的なパターンをカバーしており、ほとんどの場合 null との連携はかなり容易です。しかし、それでも、null 許容フィールドは依然として難しいという経験があります。フィールドを late にして null 非許容にできる場合は、問題ありません。しかし、多くの場合はフィールドに値があるかどうかをチェックする必要があります。そのためには、null を観測できるように null 許容にする必要があります。
プライベートで final な null 許容フィールドは、(いくつかの特定の理由を除いて) 型昇格できます。何らかの理由でフィールドをプライベートで final にできない場合は、回避策が必要になります。
たとえば、次のように機能すると期待するかもしれません。
// 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() の内部では、_temperature が null かどうかをチェックします。そうでない場合、それにアクセスし、最終的に + を呼び出します。残念ながら、これは許可されていません。
フローベースの型昇格は、プライベートで final なフィールドにのみ適用できます。それ以外の場合、静的解析は、チェック時点から使用時点までのフィールドの値が変わらないことを*証明*できません。(病的なケースでは、フィールド自体がサブクラスのゲッターによってオーバーライドされ、2 回目に呼び出されたときに null を返す可能性があることを検討してください。)
そのため、サウンドネスを重視しているため、パブリック および/または non-final フィールドは昇格せず、上記のメソッドはコンパイルされません。これは迷惑です。ここでは単純なケースでは、フィールドの使用箇所に ! を付けるのが最善です。冗長に見えますが、Dart が今日機能するのとほぼ同じです。
別の役立つパターンは、フィールドをローカル変数にコピーしてから、それを使用することです。
// Using null safety:
void checkTemp() {
var temperature = _temperature;
if (temperature != null) {
print('Ready to serve ' + temperature + '!');
}
}型昇格はローカル変数に適用されるため、これは問題なく機能します。値を*変更*する必要がある場合は、ローカル変数だけでなくフィールドに値を保存することを忘れないでください。
これらの問題やその他の型昇格問題の処理の詳細については、型昇格の失敗の修正を参照してください。
Null 許容性とジェネリクス
#ほとんどのモダンな静的型付け言語と同様に、Dart にはジェネリクス クラスとジェネリクス メソッドがあります。これらは null 許容性といくつかの直観に反するように見える方法で対話しますが、その影響を考えれば意味があります。まず、「この型は null 許容か?」という単純な yes/no の質問ではなくなりました。次を検討してください。
// 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 許容にできます。
// Using null safety:
class Box<T> {
T? object;
Box.empty();
Box.full(this.object);
}object の宣言に ? が付いていることに注意してください。これでフィールドは明示的に null 許容型になり、初期化せずにそのままにしておくことができます。
型パラメータ型を T? のように null 許容にした場合、 null 許容性をキャストで削除する必要がある場合があります。その正しい方法は、! 演算子ではなく、明示的な as T キャストを使用することです。
// Using null safety:
class Box<T> {
T? object;
Box.empty();
Box.full(this.object);
T unbox() => object as T;
}! 演算子は、値が null の場合、常に例外をスローします。しかし、型パラメータが null 許容型でインスタンス化されている場合、null は T の有効な値です。
// Using null safety:
void main() {
var box = Box<int?>.full(null);
print(box.unbox());
}このプログラムはエラーなしで実行されるはずです。as T を使用するとそれが達成されます。! を使用すると例外が発生します。
他のジェネリクス型には、適用できる型引数の種類を制限する何らかの境界があります。
// 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 許容の*境界*を使用することもできます。
// 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 許容*になる可能性がある*ことを意味しますが、*必須*ではありません。(実際、extends 句を記述しない場合、型パラメータのデフォルトの境界は null 許容の境界 Object? です。) null 許容型引数を*要求*する方法はありません。型パラメータの使用が確実に null 許容であり、暗黙的に null で初期化されるようにしたい場合は、クラスの本体内で T? を使用できます。
コアライブラリの変更
#言語には他にもいくつかの調整がありますが、それらはマイナーです。たとえば、on 節のない catch のデフォルト型は dynamic ではなく Object になりました。switch ステートメントのフォールスルー解析は、新しいフロー解析を使用します。
残りの本当に重要な変更は、コア ライブラリにあります。Grand Null Safety Adventure に着手する前は、コア ライブラリを世界を大規模に破壊することなく null Safety にすることはできないのではないかと心配していました。それほどひどい結果にはなりませんでした。いくつかの重要な変更は*ありますが*、ほとんどの場合、移行はスムーズに進みました。ほとんどのコア ライブラリは null を受け入れず、自然に null 非許容型に移行したか、null 許容型で受け入れていました。
ただし、いくつか重要なコーナーがあります。
Map のインデックス演算子は null 許容
#これは実際には変更ではなく、知っておくべきことです。Map クラスのインデックス [] 演算子は、キーが存在しない場合は null を返します。これは、その演算子の戻り値の型が V ではなく V? になる必要があることを意味します。
キーが存在しない場合に例外をスローするメソッドに変更し、より使いやすい null 非許容の戻り値の型を与えることもできたでしょう。しかし、キーが存在するかどうかを null でチェックするためにインデックス演算子を使用するコードは非常に一般的であり、分析によるとその使用の約半分を占めています。そのすべてのコードを壊すことは、Dart エコシステムを炎上させるでしょう。
代わりに、ランタイムの動作は同じであり、したがって戻り値の型は null 許容である必要があります。これは、通常、マップルックアップの結果をすぐに使用できないことを意味します。
// Using null safety, incorrectly:
var map = {'key': 'value'};
print(map['key'].length); // Error.これにより、null 許容文字列に対して .length を呼び出そうとするとコンパイル エラーが発生します。キーが存在することを*知っている*場合は、! を使用して型チェッカーを教えることができます。
// Using null safety:
var map = {'key': 'value'};
print(map['key']!.length); // OK.キーを検索し、見つからない場合はスローし、それ以外の場合は null 非許容値を返すマップの新しいメソッドを追加することを検討しました。しかし、それに名前を付けるとしたら? 単一文字の ! よりも短い名前はなく、呼び出し箇所で組み込みセマンティクスを持つ ! を見るよりも明確なメソッド名はありません。したがって、マップ内の既知の要素にアクセスするためのイディオムは []! を使用することです。慣れます。
名前のない List コンストラクタはありません
#List の名前のないコンストラクタは、指定されたサイズの新しいリストを作成しますが、要素を初期化しません。これは、null 非許容型のリストを作成してから要素にアクセスした場合、サウンドネス保証に非常に大きな穴を開けることになります。
それを避けるために、コンストラクタを完全に削除しました。null Safety コードで List() を呼び出すことは、null 許容型であってもエラーになります。それは恐ろしく聞こえますが、実際にはほとんどのコードはリスト リテラル、List.filled()、List.generate()、または他のコレクションを変換した結果としてリストを作成します。一部の型の空のリストを作成したいエッジケースのために、新しい List.empty() コンストラクタを追加しました。
完全に初期化されていないリストを作成するパターンは、常に Dart では場違いなように感じられていましたが、今ではさらにそうです。これによりコードが壊れた場合は、リストを生成する他の多くの方法のいずれかを使用して修正できます。
null 非許容リストにそれより大きい長さを設定できません
#これはあまり知られていませんが、List の length ゲッターには対応する*セッター*もあります。長さを短い値に設定してリストを切り捨てることができます。また、*より長い*長さに設定して、初期化されていない要素でリストを埋めることもできます。
null 非許容型のリストでこれを行った場合、後でこれらの書き込まれていない要素にアクセスするときにサウンドネスを侵害することになります。それを防ぐために、length セッターは、リストが null 非許容要素型*であり*、かつ*より長い*長さに設定した場合にのみ、実行時例外をスローします。すべての型のリストを切り捨てることは依然として問題なく、null 許容型のリストを成長させることはできます。
ListBase を拡張したり ListMixin を適用したりする独自のリスト型を定義する場合、これには重要な結果があります。これらの両方の型は insert() の実装を提供しており、以前は長さを設定することで挿入された要素のスペースを確保していました。これは Null Safety では失敗するため、代わりに ListMixin (ListBase と共有される) の 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 の世界に入れると、コンパイラが最適化できるサウンドなプログラムが得られ、実行時エラーが発生する可能性のあるすべての場所がコードに表示されます。それがそこに到達するための努力に見合う価値があると感じていただければ幸いです。