2020/11/10 Understanding null safetyの訳パート1

 

null-safetyは、 Dart 2.0で元の不健全なオプションの型システムを健全な静的型システムに置き換えて以来、Dartに加えた最大の変更です。

Dartが最初にリリースされたとき、コンパイル時のnullの安全性は、長い導入を必要とするまれな機能でした。今日、Kotlin、Swift、Rust、およびその他の言語はすべて、非常によく知られている問題に対する独自の答えを持っています次に例を示します。

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

main() {
  isEmpty(null);
}

conditional property access

上記のコードをnull-safety無しで実行すると、.lengthにおいてNoSuchMethodError exception(例外)がthrowされます。

null値はNullクラスのインスタンスです。そしてNullクラスには”lenght”ゲッターは定義されていません。実行時エラーは最悪の事態です。これは、エンドユーザーのデバイスで実行するように設計されたDartのような言語に特に当てはまります。サーバーアプリケーションに障害が発生した場合、誰かが気付く前にサーバーアプリケーションを再起動できることがよくあります。

しかし、Flutterアプリがユーザーの電話でクラッシュした場合、ユーザーは悲しいです。ユーザーが悲しいとき、あなたも悲しいはずです。

多くの開発者がDartのような静的に型付けされた言語が好む理由は、型チェッカーがコンパイル時に、通常はIDEでコードの間違いを見つけることができるためです。バグを見つけるのが早ければ早いほど、それを修正することができます。言語設計者が「null参照エラーの修正」について話すとき、それは静的型チェッカーを強化して、言語が上記のようなnull(の可能性がある)変数に対して.lengthを呼び出そうとするような間違いを検出できるようにすることを意味します。

この問題に対する真の解決策はありません。RustとKotlinはどちらも、これらの言語のコンテキストで意味のある独自のアプローチを持っています。このドキュメントでは、Dartに対する回答のすべての詳細について説明します。これには、静的型システムへの変更、その他の一連の変更、およびnullセーフコードを記述できるだけでなく、それを楽しむことができる新しい言語機能が含まれています。

この文書は長いです。起動して実行するために知っておく必要があることだけをカバーする短いものが必要な場合は、概要から始めてください。より深く理解する準備ができて時間があれば、ここに戻って、Dart言語がどのようにnullを処理するなぜそのように設計されたのか、慣用的でモダンなnull-safe Dartの書き方を理解してください。(ネタバレ注意:現時点でのDartの書き方に驚くほど近いです。)

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

  • コードはデフォルトで安全である必要があります。新しいDartコードを記述し、明示的に安全でない機能を使用しない場合、実行時にnull参照エラーがスローされることはありません。考えられるすべてのnull参照エラーは静的にキャッチされます。柔軟性を高めるためにそのチェックの一部をランタイムに延期したい場合は可能ですが、コードにテキストで表示される機能を使用して選択する必要があります。
  • Null safeコードは簡単に使えるものであるべきです。ほとんどの既存のDartコードは動的に正しく、null参照エラーをスローしません。あなたは今のDartプログラムが好きで、そのようにコードを書き続けることができるようにしたいと思っています。安全性は、使いやすさを犠牲にしたり、タイプチェッカーに罰金を払ったり、考え方を大幅に変えたりする必要はありません。

結果として得られるnullセーフコードは完全に健全であるべきです。静的チェックのコンテキストでの「健全性」が意味することは、人によって異なります。

私たちにとって、nullの安全性のコンテキストでは、これは、「式がnullを許可しない静的型である場合、その式の実行の結果がnullと評価される可能性が無いこと」です。主に静的チェックを通じてこの保証を提供しますが、実行時チェックも含まれる場合があります。(ただし、最初の原則を重要視してください。)

工事中🏗

言語がプログラムのセマンティックプロパティについて厳しい保証をする場合、それはコンパイラがそれらのプロパティが真であると仮定して最適化を実行できることを意味します。nullに関して言えば、不要なnullチェックを排除することにより小さなコードを生成でき、メソッドを呼び出す前に受け取る引数を検証する必要がないより高速なコードを生成できることを意味します。

注意点:完全にヌルセーフなDartプログラムの健全性のみを保証します。Dartは、新しいnullセーフコードと古いレガシーコードが混在するプログラムをサポートしています。これらの「混合モード」プログラムでは、null参照エラーが引き続き発生する可能性があります。混合モードプログラムでは、nullセーフである部分ではすべての静的安全性の利点が得られますが、アプリケーション全体がnullセーフになるまで、完全な実行時の健全性は得られません。

nullを排除しようとしているわけでは無いことに注意してください。null自体が悪いわけではありません。それどころか、値がないことを表現できると非常に便利です。

値が存在しないケースをDart言語に対して直接的にサポートすることで、便利で柔軟性のある「値の不存在」の取り扱いが可能になります。

これはオプションパラメータ(引数)やnull-aware演算子( .?)

デフォルト初期化を使います。

問題はnull自体にあるのではなく、nullが予期しないタイミングで現れた時にそれを適切に処理できないことが問題なのです。

私たちの目指すゴールは、nullが現れる場所をわかりやすくして、そしてアプリがクラッシュする場所にnullが現れないことを確実にすることです。


Nullability in the type system

全てのものが静的型システムの上に成り立っているので、Null-safetyもそこから始まります。

私たちのDartプログラムには様々な型が存在します。intやStringなどのプリミティブ型、Listなどのコレクション型、そして我々が独自に定義した型などです。

null-safetyが無いと、静的型システムは全ての型の式にnullがセットされる可能性があります。

これまでの型理論では、Null型は全ての全ての型のサブタイプ(サブクラス)として扱われていました。

一部の式において許可される一連のオペレーション(ゲッター、セッター、メソッド、および演算子)は、その型(クラス)によって定義されます。

List型であれば、その変数に対して.add()メソッドや[]演算子を呼び出すことができます。

int型であれば、+演算子を呼び出すことができます。

しかしnull値にはこれらの演算子は定義されていません。

あらゆる型の式にnullがセットされ得る、ということは上記の演算子、メソッドなどの呼び出しが失敗する、ということが起こり得る、ということを意味します。

これは実際にはnull参照エラーの核心です。すべての失敗は、nullが持っていないメソッドまたはプロパティを呼び出そうとすることから発生します。


Non-nullable and nullable types

(null不可型とnull可型)

Null-safetyは、型(クラス)の階層(hierarchy)を変更することで、上述した問題を根本から解決します。

Null型自体は存在するのですが、Null型が全ての型のサブタイプであるこれまでの構造を変えます。Null型は全ての型のサブタイプではありません。null-safety導入後のクラス階層は↓のようになります。

Null型はサブタイプではありませんので、Nullクラス以外の全ての型は、null値を持つことはできません。

(Swiftでの)オプショナル型(にあたるもの、nullable型)はnull値を持つことができる。

普通に宣言した全ての型の変数はnon-nullable(null不可型)です。

String型として宣言した変数は、常にString型の値を保持します。ですから、この変数からnull参照エラーが発生することありません。

nullが全く使えないと認識しているなら、その認識を改める必要があります。しかし、有益なものであるがゆえに、適切に扱う方法が必要です。

オプション引数が良い例です。下記のサンプルDartコードを考えてみましょう。

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

ここで、dairyパラメータはString型の値、またはnullを受け取ります。それを示すためにStringの後ろに?を記述して、nullable type(null可型)であることを示しています。

工事中🏗

内部では本質的にはString型とNull型のunion typeを定義しているのと同じです。

仮にDartがunion typesの機能を有しているとすると、

String?

String|Null

の省略記法ということになります。


Using nullable types

(Null可型の使用)

null可型の式がある場合、その結果をどのように処理したら良いでしょう?私たちの原則は「デフォルトで安全」なので、答えはそれほど多くありません。

値がnullの場合、基になる型のメソッドを呼び出すことに失敗しますので、私たちはあなたにそういう呼び出しをさせることはできません。それは安全とは言えません。

// null-safetyを導入しているが、安全でないサンプル
bad(String? maybeString) {
  print(maybeString.length);
}

main() {
  bad(null);
}

上記のサンプルを実行するとクラッシュしてしまいます。

NullクラスとStringクラスの両方で定義されているメソッド・プロパティのみ安全に呼び出せます。それは

toString()メソッド

==演算子

hashCodeプロパティ

です。😀😀したがって、null可型をマップキーとして使用し、それらをセットに格納し、他の値と比較して、文字列補間で使用することができますが、それだけです。

null不可型とnull可型を一緒に安全に扱いたい場合どうすれば良いでしょう?

null不可型の値をnull可型の変数に渡すことは安全です。

String?型の引数をとる関数にString型の値を渡すことは問題ありません。

私たちはnullable型(null可型)をString型のスーパータイプとすることでこれをモデル化しました。

そしてnull可型(nullable型)にnullを渡すことも安全です。

ですからNull型もnullable型(この場合String?型)のサブタイプとして設計しました。


しかしnullable型(null可型)の値をnull不可型(この場合String型)に渡そうとするのは安全ではありません。String型を期待している場所ではString型が持つメソッドを呼び出そうとします。そこにString?型の値を渡して、その値がnullだった場合エラーが発生してしまいます。

// ↓null-safetyを導入しているが、安全ではないサンプル
requireStringNotNull(String definitelyString) {
  print(definitelyString.length);
}

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

上記のサンプルは安全ではありません。

ただし、これまでのDart(null-safety導入前)はこのケースを「暗黙のダウンキャスト」と読んでいました。たとえば、Object型の値を、String型の引数を期待する関数に渡す場合、型チェッカーはそれを許可していました(null-safety導入前の話)

// Without null safety:
//null-safety導入前
requireStringNotObject(String definitelyString) {
  print(definitelyString.length);
}

main() {
  Object maybeString = 'it is';
  requireStringNotObject(maybeString);
}
//5
null-safety未導入のDartpadで上記を実行すると
5
と表示される。エラーが出ずに実行できてしまう。
例えば
requireStringNotObject(String definitelyString) {
  print(definitelyString.length);
}

main() {
  Object var1=10;
  requireStringNotObject(var1);
}

のようにString型以外の値を持つObject型の変数(var1)を引数として渡すと、

Uncaught Error: TypeError: 10: type 'JSInt' is not a subtype of type 'String'

のエラーが発生。つまり実行時エラーが発生してしまう。


null-safety導入後Dartではコンパイル時に
The argument type 'Object' can't be assigned to the parameter type 'String'

のエラーが表示される。


引数として渡す場合もそうだし、ただ変数に代入する場合もnull-safety無し時は暗黙的ダウンキャストが行われる。

//null-safety無し
//暗黙的ダウンキャストが行われて
//エラー出ずに実行できる。
void main(){
  Object obj2="other string";
  Object obj3=15;
  Object obj4=[1,2,3];
  
  int int2=obj3;
  String str2=obj2;
  List<int> list2=obj4;
  
  print(int2); //15
  print(str2.length); //12
  print(list2.length); //3
}

//null-safety有り時
//暗黙的ダウンキャストが無いので、
//エラー発生。
void main(){
  Object obj2="other string";
  Object obj3=15;
  Object obj4=[1,2,3];
  
  int int2=obj3; //←エラー
  String str2=obj2; //←エラー
  List<int> list2=obj4; //←エラー
  
  print(int2);
  print(str2.length);
  print(list2.length);
}
/*
error
A value of type 'Object' can't be assigned to a variable of type 'int' - line 11
error
A value of type 'Object' can't be assigned to a variable of type 'String' - line 12
error
A value of type 'Object' can't be assigned to a variable of type 'List<int>' - line 13
*/

 


健全性を維持するため、requireStringNotObject()メソッドに引数が渡される時にコンパイラは暗黙的に
as String
を挿入してダウンキャストをします(null-safety導入前の話)
実行時にこのダウンキャストが失敗して例外がthrowされる可能性がありますが、コンパイル時には問題としません(null-safety導入前の話)
この仕組みをそのままnull-safety導入後にも引き継いでしまうと、
String?型のサブタイプがString型、という構造上、
String?型の値が暗黙的にダウンキャストされてString型の変数(引数)に渡すことが可能になってしまう。これは安全ではない。
ですからnull-safety導入後は暗黙的なダウンキャスト機能を廃止する必要があります。
ということで、null-safety導入後に↓のコードを記述すると、コンパイルエラーとなります。(↑2021/2/16,間違い。Dartpadだとコンパイルエラー出ない。実行時エラーも出ない。文字数が表示されている。(2021年2月16日時点))
2020年11月時点ではエラー出ていたのかは不明。
//null-safety導入後
///requireStringNotObject(String definitelyString) {
///  print(definitelyString.length);
///}

///main() {
///  Object maybeString = 'it is';
///  requireStringNotObject(maybeString); //←エラー発生。(←コンパイルエラーも実行時エラーも発生しない。)
///}
暗黙的ダウンキャストを全面的に廃止すると、下記のコードでコンパイルエラーが発生することになります。
//再掲、null-safety導入後
requireStringNotNull(String definitelyString) {
  print(definitelyString.length);
}

main() {
  String? maybeString = null; 
  requireStringNotNull(maybeString);
  //↑暗黙的ダウンキャストを廃止すると、この行でコンパイルエラーが発生することになる。
}
これ自体は私たちの望む安全な状態です。
しかし、暗黙的ダウンキャスト自体を完全に廃止すると、null-safety導入前は(暗黙的ダウンキャストにより)実行できていたコードが、null-safety導入後は実行できなくなりますので、下記のように明示的にダウンキャストのコードを自分で記述する必要が出てきます。
// Using null safety:
requireStringNotObject(String definitelyString) {
  print(definitelyString.length);
}

main() {
  Object maybeString = 'it is';
//↓ダウンキャストコードを明示的に記述すればエラーは出ない。
  requireStringNotObject(maybeString as String);
}
つまりこういう箇所はnull-safetyのマイグレーションの際修正が必要、ということ。マイグレーションツールがそういう箇所を教えてくれるのか?

 


これは全体的に良い変化だと思います。私たちの印象では、ほとんどのユーザーは暗黙のダウンキャストを好まなかったようです。特に、あなたは以前にこれによってひどい目にあったことがあるかもしれません:
// Without null safety:
List<int> filterEvens(List<int> ints) {
  return ints.where((n) => n.isEven);
}

バグが見つかりましたか?

.where()メソッドは怠惰なメソッドなので、Iterable型を返します。

このプログラムはコンパイルできますが、実行時にIterableをListにキャストしようとして例外をスロー(throw)します。(←null-safety導入前の話)

暗黙的ダウンキャストを廃止することでこのエラーはコンパイル時に知らされます。(実行時エラーを防げる)

List<int> filterEvens(List<int> ints) { 
  return ints.where((n) => n.isEven).toList(); 
}

↑のようにすればコンパイルエラーは出なくなる。

 


左側がnull不可型(non-nullable型)の領域です。null不可型の変数は、その型の持つメソッドなどにアクセスすることができますが、nullを保持することはできません。

そして全ての対応する型に対するパラレルファミリーとしてnull可型(nullable型)が存在します。(右側)

null可型はnullを保持することを許可します。

null可型はnullをとり得る変数のみで使用しましょう。

安全性を確保するため、値をnull不可型(non-nullable型)からnull可型(nullable型)に流しますが、逆方向には流しません。

「流す」とは「代入」、あるいは「関数・メソッドの引数に値を渡すこと」。

安全を確保するため、null可型(nullable型)の変数を使う時はnullかどうかのチェックを徹底しましょう、ということ。

ここまで見てくると、nullable型(null可型)は役に立たないように見えるかもしれません。null可型はメソッドを持たず、null可型から逃れることはできません。

心配無用です。nullable型(null可型)からnull不可型へ値を渡す方法は用意しています。それを今から見ていきます。


Top and bottom

このセクションは少し難解です。型システムに興味がない限り、最後の2つの箇条書きを除いて、ほとんどスキップできます。

工事中🏗

 

現在(null-safety導入後)、Object型はnon-nullable型です。ということでObject型はトップタイプではなくなりました。そしてNull型はObject型のサブクラスではなくなりました。

Dartは名前の無いトップタイプ(top type)を持ちます。あなたがトップタイプを必要とするなら、それはObject?型です。

同様に、Null型はボトムタイプ(bottom type)ではなくなりました。Null型がボトムタイプだと、全ての型がnullable型になってしまうからです。

代わりに新しいボトムタイプを追加しました。それはNever型です。

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

  • 任意の型(全ての型)の値を許容することを示す場合、Object型の代わりにObject?型を使ってください。実際Object型を使うことは非常に稀になるはずです。Object型は「null値を除いたあらゆる可能性がある型」だからです。
  • 稀なケースとしてボトムタイプが必要な場合Never型を使ってください。ボトムタイプが必要かどうかわからない場合、使わなくても良いです。

とりあえずNever型とは何なのか、深く考える必要は無さそう。必要になったら考える。


Ensuring correctness

型の種類をnullable型(null可型)とnon-nullable型(null不可型)に分割しました。健全性と「自分で求める場合を除いてnull参照エラーを発生させない」という当初の方針を維持するため、non-nullable型にnull値が出現しないことを確実にする必要があります。

「暗黙的ダウンキャスト(implicit downcast)の廃止」と、「Null型をボトムタイプでなくすること」によって、「代入」や「関数の引数へ値を渡す時」などの主なパターンはカバーできます。工事中🏗

nullが忍び込むことができる主な残りの場所は、変数が最初に発生したときと、関数を離れたときです。したがって、いくつかの追加のコンパイルエラーがあります。


Invalid returns

(不正な返り値)

関数の返り値がnon-nullable型の場合、その関数は必ず値を返すreturn文が必要です。これまでのDart(null-safety導入前)Dartは、返り値(return文)の欠落についてかなり緩いものでした。例えば:

//null-safety未導入
String noReturn(){
  print("noReturn");
}

main() {
  String str1=noReturn();
  print(str1);  //←null
  print(str1.length); //←実行時エラー
}

結果

noReturn
null
Uncaught TypeError: Cannot read property 'get$length' of nullError: TypeError: Cannot read property 'get$length' of null

null-safety未導入の場合は、関数のボディにreturn文がない時は、Dartが暗黙的にreturn null;

を実行していました。null-safety未導入の場合、全ての型がnullable型ということなので、技術的にはこれでエラーが出ないのですが、これは私たちが求めている安全な仕組みではありません。

健全なnull不可型(non-nullable型)を求める我々にとって、このプログラムは完全に間違っており、安全ではありません。

安全のために、返り値がnon-nullable型(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();
    }
  }
}

次のセクションでさらに新しいフロー分析を掘り下げて行きます。


Uninitialized variables

変数を宣言した時に、明示的に初期化しなければ、Dartは自動的にnullで初期化します。これは便利と言えば便利なのですが、変数がnon-nullable型(null不可型)の場合、明らかに全体的に安全ではありません。

したがってnon-nullable型(null不可型)に関しては物事を厳しくする必要があります。

トップレベルの変数(グローバル変数)と静的フィールドの宣言には、初期化子が必要です。

「トップレベルの変数」とはmain関数の外で宣言された変数。

//null-safety導入後
int a;
void main() {
  
}
/*
エラー発生。
The non-nullable variable 'a' must be initialized
*/

 

これらはプログラムのどこからでもアクセスして割り当てること(代入)ができるため、コンパイラーは、変数が使用される前に変数に値が指定されていることを保証することはできません。

唯一の安全なオプションは、宣言自体に、適切なタイプの値を生成する初期化式を含めることを要求することです。

// Using null safety:
//必ず初期化しないといけない。
int topLevel = 0;

class SomeClass {
  static int staticField = 0;
}

インスタンスフィールドに関しては、

  • 宣言時に初期化する。
  • イニシャライジングフォーマルを使う。
  • イニシャライゼーションリストを使う。

などで初期化する必要があります。専門用語が出てきましたので、以下に具体例を示します。

// Using null safety:
class SomeClass {
  int atDeclaration = 0;//宣言時に初期化
  int initializingFormal;
  int initializationList;
        //↓イニシャライジングフォーマルで初期化
  SomeClass(this.initializingFormal)
      : initializationList = 0; //←イニシャライゼーションリストで初期化
}

別の言葉で言うと、コンストラクタのボディに到達する前に初期化する必要があります。


ローカル変数に関して

一番フレキシブルなケースです。non-nullable型(null不可型)のローカル変数はイニシャライザは必要ありません。以下のサンプルは問題ありません。

// Using null safety:
int tracingFibonacci(int n) {
//↓ローカル変数resultは初期化しなくても問題ない。
  int result;
  if (n < 2) {
    result = n;
  } else {
    result = tracingFibonacci(n - 2) + tracingFibonacci(n - 1);
  }

  print(result);
  return result;
}

唯一のルールは、使用されるまでに初期化されている必要がある、ということです。

これについても、私がほのめかした新しいフロー分析に頼ることができます。変数の使用へのすべてのパスが最初にそれを初期化する限り、使用はOKです。

工事中🏗


(関数・メソッドの)オプションのパラメータにはデフォルト値が必要です。オプションの定位置パラメーター(positionalパラメータ)または名前付きパラメーター(namedパラメータ)に引数を渡さない場合、言語はそれをデフォルト値で埋めます。デフォルト値を指定していない場合、デフォルトのデフォルト値はnullなので、パラメーターのタイプがnon-nullable型(null不可型)の場合はエラーとなります。

したがって、パラメータをオプションにする場合は、パラメータをnullable型(null可型)にするか、有効なnullでないデフォルト値を指定する必要があります。

これらの制限は面倒に聞こえますが、実際にはそれほど悪くはありません。それらはfinal変数に関する既存の制限と非常に似ており、実際に気付くことさえなく、何年もの間それらを使用している可能性があります。

また、これらはnon-nullableな(null不可型)変数にのみ適用されることに注意してください。いつでも型をnullable型(null可型)にして、デフォルトの初期値をnullにすることはできます。

それでも、ルールは摩擦を引き起こします。幸いなことに、これらの新しい制限によってあなたの仕事を遅らせる最も一般的なパターンを滑らか(スムーズ)にするための一連の新しい言語機能があります。ただし、最初に、フロー分析について説明します。


Flow analysis

フロー分析

制御フロー分析は、コンパイラーで何年も前から存在しています。これはほとんどユーザーから隠されており、コンパイラの最適化中に使用されますが、一部の新しい言語では、可視言語機能に同じ手法を使用し始めています。Dartはすでにタイププロモーションの形でフロー分析を行っています。:

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

objectに対してisEmptyを呼び出していますが、これが実行できることに注目してください。

isEmptyはList型のメソッド(getter)であり、Object型のゲッターではありません。

object.isEmptyでエラーが出ないのは、タイプチェッカーが全てのis式とコントロールフローの道順をチェックしているからです。

if文などの制御フロー構造のボディが、その変数のis文がtrueと評価された時のみ実行される場合、そのボディ内ではその変数の型はテストされた型としてプロモートされる(みなされる)。

上記サンプルで言うと、if文の条件式

object is List

がtrueと評価されたら、そのif文のボディ内ではobjectはList型とみなされる、ということ。なのでisEmptyを参照できる。

そしてこの自動的にダウンキャストをtype promotionと呼ぶらしい。

ダウンキャストなのにpromotion(昇格、昇進)というのは少しややこしいと個人的には思う。

次のページへ>>

 

参考

https://dart.dev/null-safety/understanding-null-safety

コメントを残す

メールアドレスが公開されることはありません。