2021/2/16 Understanding null safetyの訳パート3

<<前ページへ

 

Null assertion operator

フロー分析を使用してnullable変数をnon-nullable側に移動することの優れている点は、そうすることが確実に安全であることです。non-nullable型による安全性やパフォーマンスを放棄することなく、nullable型だった変数に対してメソッドを呼び出すことができます。

しかし、nullable型の多くの正しい使用例は、静的分析を満足させる方法で安全であると証明することはできません。例えば、

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

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

  String toString() {
    if (code == 200) return 'OK';
    return 'ERROR $code ${error.toUpperCase()}';// ←(2)
  }
}

↑のサンプルをhttps://nullsafety.dartpad.dev/dartにコピペすると、

error(1)
All final variables must be initialized, but 'error' isn't - line 7

error(2)
An expression whose value can be 'null' must be null-checked before it can be dereferenced - line 14

(1),(2)の行でそれぞれ↑のエラーが発生した。(2021年2月17日時点)

//サンプル1
// Using null safety, incorrectly:
//null-safety導入後(https://nullsafety.dartpad.dev/dart)
class HttpResponse {
  final int code;
  final String? error;

  HttpResponse.ok() : code = 200,error=null;//とりあえずnullをセットしとく。
  HttpResponse.notFound()
      : code = 404,
        error = 'Not found';

  String toString() {
    if (code == 200) return 'OK';
    return 'ERROR $code ${error.toUpperCase()}';
    //↑errorはnullable型なのでnullチェック無しで
    //メソッドを呼び出そうとするとコンパイルエラー
  }
}
/*
An expression whose value can be 'null' must be null-checked before it can be dereferenced
*/

 

//サンプル2
// Using null safety, incorrectly:
//null-safety導入後(https://nullsafety.dartpad.dev/dart)
class HttpResponse {
  final int code;
  final String? error;

  HttpResponse.ok() : code = 200,error=null;//とりあえずnullをセットしとく。
  HttpResponse.notFound()
      : code = 404,
        error = 'Not found';

  String toString() {
    if (code == 200) return 'OK';
    return 'ERROR $code ${(error as String).toUpperCase()}';//(A)
    //↑asでキャストするとコンパイルエラーは消える。
    //if文の場合わけにより、(A)の行のerrorがnullになることは無いので、
    //asでキャストしても問題無い、ということ。
  }
}

void main(){
  HttpResponse http1=HttpResponse.ok();
  HttpResponse http2=HttpResponse.notFound();
  print(http1);//OK
  print(http2);//ERROR 404 NOT FOUND
}

 

//サンプル3
// Using null safety, incorrectly:
//null-safety導入後(https://nullsafety.dartpad.dev/dart)
class HttpResponse {
  final int code;
  final String? error;

  HttpResponse.ok() : code = 200,error=null;//とりあえずnullをセットしとく。
  HttpResponse.notFound()
      : code = 404,
        error = 'Not found';

  String toString() {
    if (code == 200) return 'OK';
    return 'ERROR $code ${(error as String).toUpperCase()}';
    //↑asでキャストするとコンパイルエラーは消える。
  }
}

void main(){
  HttpResponse http1=HttpResponse.ok();
  HttpResponse http2=HttpResponse.notFound();
  print(http1.error.toUpperCase());
  //↑errorはnullable型なのでnullチェック無しでtoUpperCase()を
  //呼び出そうとするとコンパイルエラー
  //An expression whose value can be 'null' must be null-checked before it can be dereferenced
}

 

//サンプル4
// Using null safety, incorrectly:
//null-safety導入後(https://nullsafety.dartpad.dev/dart)
class HttpResponse {
  final int code;
  final String? error;

  HttpResponse.ok() : code = 200,error=null;//とりあえずnullをセットしとく。
  HttpResponse.notFound()
      : code = 404,
        error = 'Not found';

  String toString() {
    if (code == 200) return 'OK';
    return 'ERROR $code ${(error as String).toUpperCase()}';
    //↑asでキャストするとコンパイルエラーは消える。
  }
}

void main(){
  HttpResponse http1=HttpResponse.ok();
  HttpResponse http2=HttpResponse.notFound();
  print((http1.error as String).toUpperCase());
  //↑asでString型にキャストするとコンパイルエラーは消える。
  //しかしhttp1.errorはnullなので、実行時エラーが発生する。
  //Uncaught Error: TypeError: null: type 'JSNull' is not a subtype of type 'String'
}

 

このサンプルを実行させると、toUpperCase()メソッドの呼び出しでコンパイルエラーが発生します。

通信に成功してレスポンス得られた場合errorの値は得られないので、errorフィールドはnullable型としています。

このクラスを見てみると、(toString()内でif文で場合わけしているので)errorがnullの時、errorにアクセスできないことを確認できます。

ただそのことを確認するためには、「codeフィールドの値」と「errorフィールドのnullである可能性」の間の関係性を理解していることが必要です。

型チェッカーはその関係性を理解できません。

言いかえると、このコードをメンテナンスする我々人間は、errorを使う時はerrorはnullでない値を持っていることを知っていますが、それを確認する方法(つまりnullable型へのnullチェック)が必要です。

通常はあなたはasでのキャストを使って型を確認するでしょう。

このように:

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

errorをnon-nullable型であるString型へキャストすると、もしキャストが失敗した時、実行時に例外がスローされます。(サンプル4)

キャストが成功した場合は文字列が得られるので、toUpperCase()などのメソッドを呼び出すことができます。

「null可能性を排除する(casting away nullability)」という言葉が頻繁に出てくるので、新しい省略構文があります。

キャストさせたい式の後ろにexclamation mark( ! )を付けるとその式の型に対応するnon-nullable型にキャストします。ですから上記のサンプルは下記のコードと等しいです。

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

キャストしたいNon-nullable型が回りくどい名前の時など、この一文字の”bang operator”( ! )が役立ちます。

as Map<TransactionProviderFactory, List<Set<ResponseFilter>>>

のように書かないといけないのは非常に面倒臭いですね。

もちろん、!を使ったキャストは静的分析で得られる安全性を失うことになります。

キャストは、健全性を維持するために実行時にチェックする必要があるので、キャストに失敗して実行時に例外をスローする可能性があります。

ただし、これらのキャストを挿入する場所は自分でコントロールでき、コードを確認することでいつでも確認できます。

asと!を「null assertion operator」と呼ぶらしい。

だから使いたいなら使ったらいいけど、気をつけましょう、という話。


Late variables

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

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

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

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

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

ここで、heat()メソッドはserver()メソッドより前に呼び出されています。

しかし、静的分析でそれを判断することは現実的ではありません。

このような些細な例でも可能かもしれませんが、クラスの各インスタンスの状態を追跡しようとする一般的なケースは扱いにくいです。

タイプチェッカーはフィールドとトップレベル変数の使用を分析できないため、non-nullabe型のフィールドは、宣言時(またはコンストラクタのインスタンスフィールドの初期化(イニシャライザ)リスト)で初期化する必要があるという保守的なルールがあります。

そのため、Dartはこのクラスのコンパイルエラーを報告します。

// Using null safety, incorrectly:
//null-safety導入後(https://nullsafety.dartpad.dev/dart)

class Coffee {
  String _temperature;

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

  String serve() => _temperature + ' coffee';
}
//コピペした時点で下記のコンパイルエラーが発生する。
//Non-nullable instance field '_temperature' must be initialized

これを修正するためには、_temperatureフィールドをnullable型(String?型)で宣言して、_temperatureを使う時にnull assertion operatorを使います↓。

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

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

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

 

// Using null safety, incorrectly:
//null-safety導入後(https://nullsafety.dartpad.dev/dart)

class Coffee {
  String? _temperature;

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

  String serve() => _temperature! + ' coffee';
}
//_temperatureフィールドをString?型にすると
//serveメソッド内でコンパイルエラー
//An expression whose value can be 'null' must be null-checked before it can be dereferenced
//が発生するので、null assertion operator(!)を付ける。

void main(){
  var coffee = Coffee();
  coffee.heat();
  print(coffee.serve());//hot coffee
}
//一応動く。

この修正は機能します。しかし、これはこのクラスのメンテナンスをする人に対して混乱させるシグナルを送ります。

_temperatureをnullable型にすると、それをみた人は「nullは、_temperatureフィールドにとって有用で意味のある値である」と考えるかもしれません。

しかし実際そんなこと(意図)はありません。

このクラスで_temperatureフィールドは、null状態では決して観察されるべきではありません。

_temperatureフィールドにnullがセットされても何の意味も無いので、nullがセットされるべきではない。

遅延初期化を使って状態の通常のパターンを処理するために、私たちは新しい修飾子lateを追加しました。lateはこのように使います↓。

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

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

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

 

// Using null safety, incorrectly:
//null-safety導入後(https://nullsafety.dartpad.dev/dart)

class Coffee {
  late String _temperature;

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

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

void main(){
  var coffee = Coffee();
  coffee.chill();
  print(coffee.serve());//iced coffee
}
//確かにlateを付けるとコンパイルエラーが出なくなる。
//そしてserveメソッド内で_temperatureのnullチェックを
//していない。でもコンパイルエラーは出ない。

 

_temperatureフィールドはnon-nullable型(String型)ですが、初期化されていないことに注目してください。

そしてserve()メソッド内で_temperatureに対して明示的にnull assertionをしていません。

lateの意味についての説明はいくつかあるかもしれませんが、私は次のように考えています。

late修飾子は、「コンパイル時ではなく実行時にこの変数の制約を適用する」ことを意味します。

変数に対する(nullで無いことの)保証がコンパイル時では無く、実行時に遅らせている、ということ。

// Using null safety, incorrectly:
//null-safety導入後(https://nullsafety.dartpad.dev/dart)

class Coffee {
  late String _temperature;

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

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

void main(){
  var coffee = Coffee();
  //coffee.chill();
  print(coffee.serve());//iced coffee
}
//初期化を担当するメソッドchill()の実行をコメントアウトする。
//コンパイルエラーは出ないが、実行時エラーが発生する。
//Uncaught Error: LateInitializationError: Field '_temperature' has not been initialized.
//lateを付けるとインスタンス生成時の初期化が強制されなくなる。
//アクセス(読み取り)時までに値をセットすれば問題無い、ということ。
//値をセットする前にアクセス(読み取り)しようとすると実行時エラー。
//lateを使わなければ、コンパイルエラーが出て、実行前に対策を強制される。
//対策とはフィールド宣言時の初期化、あるいはコンストラクタでの初期化の実装。

この場合、フィールドは完全に初期化されていないため、フィールドが読み取られるたびに、実行時チェックが挿入され、値が割り当てられていることを確認します。 そうでない場合は、例外がスローされます。

変数にString型を指定すると、「文字列以外の値で私が表示されることはありません」を意味し、late修飾子は「実行時にそれを確認する」ことを意味します。

ある意味で、lateキーワードの使用は、nullable型にするよりもマジカルです。

なぜならフィールドへのアクセス(読み取り)に失敗する可能性があり、アクセス(読み取り)箇所で、テキストで表示されるものがないためです。

lateキーワードをフィールド宣言の前に付けることが必要です。

lateキーワードを付けることで、遅延初期化が可能であることがわかるためメンテナンス性が高まります。

lateキーワードを使うメリットとして、フィールドの型がnon-nullable型なので、より高い静的安全性を得ることができます。

フィールドの型がnon-nullable型なので、nullやString?型をフィールドに代入しようとするとコンパイルエラーが得られます。

late修飾子は初期化を遅らせますが、しかしそのフィールドをnullable型変数のように扱うことは防げます。

// Using null safety, incorrectly:
//null-safety導入後(https://nullsafety.dartpad.dev/dart)
//non-nullable型で、とりあえず初期化しとく方法
class Coffee {
  String _temperature="";//←とりあえず初期化しとく。

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

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

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

 

 


Lazy initialization

late修飾子には別の特別な利点もあります。逆説的に見えるかもしれませんが、late修飾子を、イニシャライザのあるフィールドに使うこともできます。

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

これをした時、イニシャライザによる初期化は遅延して実行されます。上記のコードで言うと、_readThermometer()はインスタンス生成時には実行されず、一番最初に_temperatureフィールドにアクセスがあった時まで実行を遅らせます。

言い換えると、まさにトップレベル変数やstaticフィールド(クラスフィールド)のイニシャライザと同じように機能します。

This can be handy when the initialization expression is costly and may not be needed.

これは初期化の式が高負荷で必要で無いかもしれない場合に便利です。

何が必要で無いかもしれないのかはわからない。


インスタンスフィールドに対しlateを使ってイニシャライザを遅延実行させることでエクストラボーナスをもらえます。

通常インスタンスフィールドのイニシャライザーはthisにアクセスすることはできません。

なぜなら全てのフィールドの初期化(イニシャライザ)が完了しないと新しいオブジェクトにアクセスできないからです。

しかし、lateをつけたフィールドではそれはあてはまりません。ですからあなたはthisにアクセスできます。メソッドを呼び出し、インスタンスのフィールドにアクセスすることもできます。

次ページへ>>

参考

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

カテゴリーDart

コメントを残す

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