LoginSignup
132

More than 1 year has passed since last update.

Dartの型の理解しておきたいあれこれ(Null safety編)

Last updated at Posted at 2021-03-05

2021 年 3 月 3 日、ついに Dart 2.12 がリリースされて Dart は晴れて null-safe(null 安全)な言語となりました。
同日リリースされた Flutter 2 でも Dart 2.12 が同梱されています。

本記事について

この機会に、「Dartの型の理解しておきたいあれこれ」に含めていた null safety 関連の情報を分離し、更に充実させることにしました。

ただし null safety 全般ではなく、あくまで型に焦点を置きます。
null safety 自体は今後当たり前に使われるものになっていくので、基本的すぎることは省いて見落としがちな部分を中心に扱います。

前提知識と資料

型の比較

nullable な型(Null 許容型: int? のように ? が付いた型)の変数は、null 以外の値が代入されると flow analysis という解析によって non-nullable な型(Null 非許容型: int など)とみなされます。

int? v;
print(v.runtimeType);  // Null
print(v is int);       // false
print(v is int?);      // true

v = 10;                // この時点で non-nullable
print(v.runtimeType);  // int
print(v is int);       // true
print(v is int?);      // true

次のように if によって null の可能性を排除した場合も、その後ろでは non-nullable な int だと賢く判断してくれます。

final v = someFunc();  // int? を返す関数
print(v is int);       // false
print(v is int?);      // true

if (v == null) {
  return;
}

print(v.runtimeType);  // int
print(v is int);       // true
print(v is int?);      // true

is int? では、nullable(int?)と non-nullable(int)のどちらの値とも比較できます。

注目すべきは、int が確定した後はもう int? ではないのに is int?true になる点です。
これは non-nullable な型は nullable な型の派生型 だからです。1
int?int の親なので、通常の親と同様に is で比較できるのです。

もう一つ注目しておきたいのは、nullable な変数にまだ値が入っていない状態の型比較です。

String? v;
print(v is int?);  // true

String? として宣言した変数でも値が入っていなければ is int?true になります。
不思議に思えるでしょうか?

変数に何も代入していなければ null であり、null は前述のとおり Null 型です。

String? v;
print(v is Null);  // true

Null 型には「int? の null」や「String? の null」という区別はありません。
そのため、null に関しては単に v == null という比較になります。
つまり v is int? では v is int || v == null という評価が為されます。

Object との比較

Dartの型の理解しておきたいあれこれ」で確認したことの復習

  • Object 型はあらゆる型の基底
  • is Object は常に true

null safety においては Null 型はこれに当てはまりません。
そのため nullable な型も当てはまりません。

Dart2.10の場合
print(null is Object);  // true

int v;
print(v is Object);     // true
Dart2.12の場合
print(null is Object);  // false

int? v;
print(v is Object);     // false

v = 10;
print(v is Object);     // true

これは Dart が null safety に対応する過程において 変更された仕様 です。
Object 型は null 以外を示すものになりました。

ちなみに、Object 型と Null 型には共通の基底クラスが存在していて、それが null-safe になる前の Object に近いもの(Object? に相当する無名のクラス)になっているようです。

このような仕組みを知ると、nullable な型がどのように実現されているのかが見えてきますね。

型の nullability を判定

値の型を判定するのはここまでの情報で足りますが、ある型が nullable かどうかを知りたいときにはどれも使えなかったので、新たな記事として書きました。

Never 型

Never は関数やメソッドの戻り値に使える特殊な型(ボトム型)です。

null safety の話ではないのですが、flow analysis が関わりがあって文脈に馴染むのでこの記事に含めました。
null safety が導入されるより前の Dart 2.9 で追加された型です。

ちょっとわかりにくいものなので、Never を使わない例で問題点を見てみましょう。

void main() {
  int? v;
  if (v == null) {
    throwException();
  }

  // An expression whose value can be 'null' must be
  // null-checked before it can be dereferenced.
  // というエラーになる
  print(v * 2);
}

// 必ず例外が発生する関数
void throwException() {
  throw Exception('Oops!');
}

これはコード内に注記したとおりエラーになります。
下記がその理由です。

  • vnull なら throwException() が呼ばれて必ず例外が発生する
  • 例外で止まればその後ろのエラー箇所には到達しない
    • 逆に言うと、vnull でなければ例外が発生せずに到達する
  • 到達したときには v は non-nullable なので v * 2 の算術ができるはず
    • しかし、コンパイラや linter はそれを判断できない ⇒ エラー

エラーをなくす対策方法としては、算術の前に null でないことをチェックする方法がありますね。

もう一つの対策として使えるのが Never です。
関数で処理が止まって何も返されないことを Never 型で明示できます。

Never throwException() {
  throw Exception('Oops!');
}

これで if (v == null) の該当時に処理が止まることをコンパイラ等が容易に理解できます。
そして、print(v * 2); の時点で v が non-nullable であることを判断できてエラーが出なくなります。

super 引数を使いつつ型を狭める

Dart 2.17 で super 引数が追加 されました。

class Foo {
  const Foo(this.value);

  final int? value;
}

class Bar extends Foo {
  const Bar(super.value); // const Bar(int? value) : super(value); と同等
}

この場合、Bar の引数である value は Foo と同じく int? 型になります。
これを int 型に狭めて null を禁止したい場合、super の前に型を書いて実現できます。

class Bar extends Foo {
  const Bar(int super.value);
}

引数を int にしたならアクセスするときも int になっていると便利ですが、残念ながら自動的にはそうならず、次のような工夫が必要です。

class Bar extends Foo {
  const Bar(int super.value) : _value = value;

  final int _value;

  @override
  int get value => _value;
}

nullable から non-nullable への変換

冒頭付近に書いた「flow analysis」により、変数の値が null でないことを確実に判断できる場合には自動的に non-nullable として扱われるようになります。

FlowAnalysis
int? value = getValue();
if (value != null) {
  // ここではvalueはint?ではなくintになる
}

また flow analysis が効かないケースで絶対に null でない場合には、手動で ! を付けることで non-nullable な型にキャストできます。

!によるキャスト
final list = <int?>[1, 2, null];
list.remove(null);
print(list.runtimeType);  // List<int?>

final int value = list[0]!;

このどちらもできないケースがあります。

List の中身

List の中身を丸ごと non-nullable にしたくなったとします。
どうすればできるのでしょうか。

たった今見た例でわかるように List から null を取り除くだけでは nullable なままです。
null との比較の条件で除去すれば flow analysis が効きそうに思えますが、それも効きません。

final list = <int?>[1, 2, null];
list.removeWhere((v) => v == null);
print(list.runtimeType);   // List<int?>

final list2 = list.where((v) => v != null).toList();
print(list2.runtimeType);  // List<int?>

要素だけを ! でキャストすることもできません。

方法 1

whereType<T>() が使えます。
ジェネリック型として int を指定するだけで int だけの List になります。

final list = <int?>[1, 2, null];
final list2 = list.whereType<int>().toList();
print(list2.runtimeType);  // List<int>

方法 2

package:collection にある whereNotNull() という extension method が使えます。
上述の whereType() とほとんど変わりませんが、こちらはジェネリック型の指定が不要です。

import 'package:collection/collection.dart';

...

final list = <int?>[1, 2, null];
final list2 = list.whereNotNull().toList();

こちらの方法はへぶんさんが Twitter に書かれていたのを参考にしました。

Map の中身

List と同様に値が null の項目を消しただけでは、型としては non-nullable なままとなります。
また、Map には whereType<T>()whereNotNull() は使えませんし、map.entries に対して使っても効きません。

方法

いろいろと試してみたのですが、下記のような地道な方法しか見つかりませんでした。

final map = <String, int?>{'a': 0, 'b': null, 'c': 2};
map.removeWhere((_, v) => v == null);
final map2 = map.cast<String, int>();
print(map2.runtimeType);  // CastMap<String, int?, String, int>

// 一行で書くなら
final map2 = (map..removeWhere((_, v) => v == null)).cast<String, int>();

castFrom<String, int?, String, int>() の結果は Map<String, int> 型になります。
CastMap(Map の派生型)と出力されていますが runtimeType がそうなっているだけであり、Map<String, int> と同様に扱えます。2

これより良い方法を見つけた方はぜひお知らせください!

おまけ

flow analysis が効かないケースで悩むことが多そうですのでまとめておきます。
型に関連する話ではありますが、直接的な話ではないのでおまけとしました。
おまけにしては長いですが、効かなくて困ったときにでもお読みください。

Flow analysis が効かないケース

クラスのプロパティには flow analysis が効きません。

そのプロパティと同じクラス内で使おうとするときだけでなく、クラスのオブジェクト経由で他の箇所で使うときも同様です。

class Foo {
  int? value;

  ...
}

void main() {
  final foo = Foo();
  if (foo.value != null) {
    final int v = foo.value;  // エラー
  }
}

このコードでは、foo.valueint 型の変数である v に入れようとするところで「A value of type 'int?' can't be assigned to a variable of type 'int'.」というエラーになります。

理由

しばらくしてから Twitter の情報で理由がわかりました。
バグのように思えましたが仕様でした。

この Stack Overflow の回答にあるコードを見ながら解説していきます。

class A {
  final String? text = 'hello';

  String? getText() {
    if (text != null) {
      return text;
    } else {
      return 'WAS NULL!';
    }
  }
}

class B extends A {
  bool first = true;

  @override
  String? get text {
    if (first) {
      first = false;
      return 'world';
    } else {
      return null;
    }
  }
}

A クラスを継承した B クラスで text をオーバーライドしてゲッターに変えています。
親クラスにおいて final であっても子クラスでオーバーライドできます。

ゲッターの中身
if (first) {
  first = false;
  return 'world';
} else {
  return null;
}

ゲッターが一度目に呼ばれたときには文字列、二度目以降は null が返るようになっています。

さて、この text というゲッターが呼ばれるタイミングはわかりますか?

・・・

答えは、getText() 内で二度です。気づけましたか?
一度目は if (text != null)、二度目は return text; です。

  • 二度目は null を返す
  • そのときには既に if ブロックの中なので if (text != null) は再評価されない

null チェックの後に null に変わることがある わけです。
そのため、getText() の戻り値の型を String? から String に変えるとエラーになります。
そこまで考慮して nullable の可能性を警告してくれているなんて頼もしいですね!

なお、ゲッターでなくても起こるかどうかは不明です。
もし非同期にプロパティを書き換える処理があれば起こり得るかもしれません。
その場合はオーバーライドは無関係に起こる気がします。

対策

方法1

理由がわかる前は私は ! を使っていました。
! は nullable でないと断定して non-nullable な型にキャストするものです。

if (text != null) {
  return text!;
}

静的解析では return text; のところで null かどうか判断できないため警告してくれませんが、実行時に null になっていればキャストできずにエラーが発生します。
動作を把握できていないクラスの場合は、そのエラーの発生によって実行が止まってしまう可能性があるため、少しでも不安があれば確実に避けておくのが良いと個人的には考えます。
一方、問題を起こすゲッターがないことを確認してわかっていればこの方法で足りると思います。

方法2

if の前にローカル変数に入れて使う方法です。
その変数の null チェックを行い、それ以後はクラスのプロパティの代わりに使いましょう。
if ブロック内で危険なプロパティを避ければチェック後に null に変わることはなくなります。
ただし、ゲッターによって値が変わってもローカル変数は変わらないままになり、それがかえって良くない場合もあるかもしれません。

@Cat_sushi さん、コメントありがとうございました!

様々なケース

null チェックをしても non-nullable な型にならないケースは他にもあり、その多くを解説するページが Dart のドキュメントに追加されました。

コードを書いていておかしいと思ったときにはこのページを見ればいいですね。
起こる理由はほとんどが似ているので、先ほどの例を理解していればわかりやすいと思います。


null safety の理解が深まったでしょうか?
本記事では落とし穴のような部分を多く取り上げたので怖くなってしまったかもしれませんが、Dart は単なる null safety ではなく「sound null safety」3 であり、メリットのほうが大きいと思います。

今後は null safety と付き合っていくことになりますので、悩んだら読み直して慣れていきましょう。

参考

  1. "The type system understands that a union type is a supertype of its branches. In other words, int is a subtype of int?." https://medium.com/dartlang/why-nullable-types-7dd93c28c87a#2ba9

  2. Map は抽象クラスであり、それを実装した様々なクラスがあります。例えば {'a': 1} は実は _InternalLinkedHashMap<String, int> という型です。

  3. 詳しくは The Dart type system の中の What is soundness? をお読みください。

Register as a new user and use Qiita more conveniently

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