DartのNull safetyについて調べる

はじめに
Dart公式ドキュメントのNull safetyチャプターのページを読んでわかったことをまとめていく
動作確認にはDartPadを使う

変数はデフォルトで非Nullとなる、Nullの代入はコンパイラエラーとして警告される
void main() {
var i = 42;
i = null; // A value of type 'Null' can't be assigned to a variable of type 'int'.
}

2023年のDart3ではsoud null safetyが必須になる
soundってどういう意味だろう?

soundには「健全な」という意味もあるようだ

Introduction through examples
nullを代入可能にするには型の後にクエスチョンマークを付ける
void main() {
int? nullable;
nullable = null;
}

Null safety principles
- デフォルトでは非null
- 漸進的に適用可能
- 強固なNull safetyによってコンパイラによる最適化が可能

Enabling/disabling null safety
Sound null safetyはDart 2.12以上またはFlutter 2.0から利用可能
有効にするには例えばpubspec.yamlに下記のように記述する
environment:
sdk: '>=2.12.0 <3.0.0'

Migrating an existing package or app
下記のコマンドを実行してnull safetyではないソースコードのマイグレーションが可能
dart migrate

Where to learn more
soud null safetyに関する公式サンプル

Migrating to null safety
マイグレーションの詳しい手順が書いてある
今はスキップしてUnderstanding null safetyページに進もう

Understanding null safety
// Without null safety:
bool isEmpty(String string) => string.length == 0;
main() {
isEmpty(null);
}
Null safetyではないと上記がコンパイルを通ってしまって実行時にクラッシュする
サーバーアプリケーションならカバーできないこともないがモバイルアプリはカバーしようがない

Dartのnull safetyの原則
- コードはデフォルトで安全であるべきである
- null safeなコードが書きやすくあるべきである
- 出来上がるコードは完全に堅牢であるべきである

出来上がるコードは完全に堅牢であるべきである
Dartのnull safetyにおけるsoundnessが説明されている
For us, in the context of null safety, that means that if an expression has a static type that does not permit null, then no possible execution of that expression can ever evaluate to null.
もし式がnullを許容しない静的な型を持っているならば、式の取り得る評価結果は決してnullにならない

堅牢性によってnullでないことを仮定できればコンパイラは最適化が可能
結果としてより小さいサイズかつ高速なバイナリを生成できる

nullを無くすことがnull safetyの目的ではない
null自体は悪くなく、値が不在であることを示すのに便利

Nullability in the type system
訳するとタイプシステムにおけるnullability(nullになる可能性)
nullは全ての型との間にis-a関係があるように扱われる
null値はいかなるメソッドやオペレーターを持たない
持たないのに呼び出そうとするからクラッシュする

Non-nullable and nullable types
訳すると非Null許容とNull許容の型
Null safetyは型の階層構造を変えることでnullによるクラッシュの恐れを低減する
Null safetyの型の階層構造ではnullと全ての型との間にis-a関係があるように扱わない

void main() {
makeCoffee("coffee");
makeCoffee("coffee", "dairy");
}
makeCoffee(String coffee, [String? dairy]) {
if (dairy != null) {
print('$coffee with $dairy');
} else {
print('Black $coffee');
}
}
知らなかったけど省略可能引数とかvoid省略とかできるんだね

String?
はString|Null
を省略記法、StringとUnionの和集合

Using nullable types
訳するとNull許容の型を使う
bad(String? maybeString) {
print(maybeString.length);
}
main() {
bad(null);
}
下記のコンパイルエラーが発生して実行できない
The property 'length' can't be unconditionally accessed because the receiver can be 'null'.
null値でも下記は使えるらしい
- toString
- ==
- hashCode
bad(String? maybeString) {
print(maybeString.toString()); // null
print(maybeString.hashCode); // 0
print(maybeString == null); // true
}
main() {
bad(null);
}
たしかに使える、従ってMapのキーなどにも使える

requireStringNotNull(String definitelyString) {
print(definitelyString.length);
}
main() {
String? maybeString = null; // Or not!
requireStringNotNull(maybeString);
}
上記の場合はコンパイルエラーが発生する
The argument type 'String?' can't be assigned to the parameter type 'String'.
StringとString?の間にはis-a関係がある
子クラスであるStringの引数に親クラスであるString?(null値)を渡そうとしているからエラーになる

requireStringNotObject(String definitelyString) {
print(definitelyString.length);
}
main() {
Object maybeString = 'it is';
requireStringNotObject(maybeString);
}
ちょっと内容は異なるけれど同じ理由でエラーになる
The argument type 'Object' can't be assigned to the parameter type 'String'.
暗黙的にダウンキャスト(親クラスや先祖クラスから子クラスへのキャスト)することはできない

requireStringNotObject(String definitelyString) {
print(definitelyString.length); // 5
}
main() {
Object maybeString = 'it is';
requireStringNotObject(maybeString as String);
}
明示的にキャストすればエラーもでないしクラッシュもしない

List<int> filterEvens(List<int> ints) {
return ints.where((n) => n.isEven);
}
main() {
print(filterEvens([1, 2, 3]));
}
上記はうまくいきそうだけどコンパイルエラーになる
A value of type 'Iterable<int>' can't be returned from the function 'filterEvens' because it has a return type of 'List<int>'.
理由はwhere
メソッドの戻り値がイテラブルなので暗黙的にリストには変換できないため

Top and bottom
直訳すると最上位と最下位
最後の箇条書き以外は読み飛ばしても良いとのことだったので読み飛ばして必要なら読もう
- 最上位のクラスはObjectではなくObject?である
- 最下位のクラスはNullではなくNeverである
Neverってなんだろうって調べたらわかりやすい記事を見つけた
必ず例外が発生する関数や無限ループの関数の戻り値やswitch文の網羅性チェックに便利そう

Ensuring correctness
Null safetyによる静的解析が機能するのは主に下記の3つ
- 代入
- 引数
- 戻り値
Invalid returns
String missingReturn() {
// No return.
}
上記のコードでは下記のコンパイルエラーが発生する
The body might complete normally, causing 'null' to be returned, but the return type, 'String', is a potentially non-nullable type.
何もreturn
しなければ戻り値はnull
になるがString
はnullを許容しないのでエラーになる
Uninitialized variables
Dartでは変数を定義する時に何も指定しないとデフォルト値がnullになる
グローバル変数とクラスのスタティックフィールドは宣言時の初期化が必須
int topLevel;
class SomeClass {
static int staticField;
}
The non-nullable variable 'topLevel' must be initialized.
The non-nullable variable 'staticField' must be initialized.
インスタンスフィールドは宣言時に初期化するかコンストラクターで初期化する
class SomeClass {
int atDeclaration = 0;
int initializingFormal;
int initializationList;
SomeClass(this.initializingFormal)
: initializationList = 0;
}
初期化しない場合
class SomeClass {
int uninitialized;
SomeClass();
}
Non-nullable instance field 'uninitialized' must be initialized.
コンストラクターのボディーに来るまでに初期化されていればOK
関数やメソッド内のローカル変数は初期化が不要だが、使用する前に値を代入する必要がある
int tracingFibonacci(int n) {
int result;
if (n < 2) {
result = n;
} else {
result = tracingFibonacci(n - 2) + tracingFibonacci(n - 1);
}
print(result);
return result;
}
ない場合
int tracingFibonacci(int n) {
int result;
if (n < 2) {
result = n;
}
print(result);
return result;
}
The non-nullable local variable 'result' must be assigned before it can be used.
The non-nullable local variable 'result' must be assigned before it can be used.
オプショナルな引数にはデフォルト値が必要
引数がNullableな場合はnullとなるが、それ以外の場合はエラーとなる
void myPrint([String message]) {
print(message);
}
The parameter 'message' can't have a value of 'null' because of its type, but the implicit default value is 'null'.
これらの制約は面倒だが堅牢なコーディングを行う上では便利
例えばfinalは再代入できない制約があるが保証もあるので意図しない再代入を防ぐことができる
そうは言っても制約は煩わしい場合がある
これらの制約によるコーディングしにくさを緩和するDartの言語機能があるらしい、楽しみ

Flow analysis
フロー解析
bool isEmptyList(Object object) {
if (object is List) {
return object.isEmpty;
} else {
return false;
}
}
上記ではif (object is List)
によってobject
がListであることがコンパイラは解析できる
従ってListクラスのプロパティであるisEmptyを呼び出すことができる
こういうのをタイププロモーションと呼ぶらしい
Null safety以前は下記でタイププロモーションが行われなかったらしい
bool isEmptyList(Object object) {
if (object is! List) return false;
return object.isEmpty;
}
void main() {
print(isEmptyList([]));
}
Null safetyでは問題なくコンパイルも通るし、実行時エラーも発生しない

Reachability analysis
到達性分析
returnの他にもbreakやthrowがブロックを終了させる言語としての機能を持つ
例えば下記は普通にコンパイルが通る
void func(Object o) {
for (var i = 0; i < 10; i += 1) {
if (o is! List) {
break;
}
print(o.isEmpty);
}
}

Never for unreachable code
到達しないコードのためのNever型
Neverは到達しないことを保証するのに使える
到達する可能性がある場合はコンパイラエラーとして捕捉できる
Never wrongType(String type, Object value) {
throw ArgumentError('Expected $type, but was ${value.runtimeType}.');
}
下記は必ず例外が発生するので戻り値はないのでNeverでOK、もし戻り値がある場合
Never wrongType(String type, Object value) {}
The body might complete normally, causing 'null' to be returned, but the return type, 'Never', is a potentially non-nullable type.
例外を投げるヘルパー関数にはvoid
よりもNever
の方が向いている
下記のような例ではwrongType
関数が呼び出された時点でそれ以後はother
がPoint
であることが確定する
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
がPointだとわかるのでx
やy
などのプロパティを呼び出せる

Definite assignment analysis
確定代入分析
int tracingFibonacci(int n) {
final int result;
if (n < 2) {
result = n;
} else {
result = tracingFibonacci(n - 2) + tracingFibonacci(n - 1);
}
print(result);
return result;
}
ローカル変数が使用される前に代入が済んでいるかどうかを解析する
finalって初期化しなくても良いんだ、知らなかった
初期化されていないケースがある場合はエラーとなる
int tracingFibonacci(int n) {
final int result;
if (n < 2) {
result = n;
}
print(result);
return result;
}
The final variable 'result' can't be read because it's potentially unassigned at this point.

Type promotion on null checks
Nullチェックによるタイププロモーション
String makeCommand(String executable, [List<String>? arguments]) {
var result = executable;
if (arguments != null) {
result += ' ' + arguments.join(' ');
}
return result;
}
if (arguments != null)
によってブロック内がnull
ではないことが保証される
従ってjoin
メソッドを呼び出すことができる
!= null
だけではなく== null
を使うことも可能
String makeCommand(String executable, [List<String>? arguments]) {
var result = executable;
if (arguments == null) return result;
return result + ' ' + arguments.join(' ');
}
セクションの最後にタイププロモーションはローカル変数にだけ働きますと書いてある、なぜだろう

Unnecessary code warnings
不要なコードに関する警告
String checkList(List<Object> list) {
if (list?.isEmpty ?? false) {
return 'Got nothing';
}
return 'Got something';
}
list?
の?
は不要なので警告が表示される
The receiver can't be null, so the null-aware operator '?.' is unnecessary.

Working with nullable types
Null許容タイプを扱う
Smarter null-aware methods
スマートなNull認知メソッド
void main() {
String? notAString = null;
print(notAString?.length);
}
今更だけど?.
オペレーターってオブジェクトの方がnull
だったらnull
に評価されるんだね
void main() {
String? notAString = null;
print(notAString?.length.isEven);
}
isEven
の前に?
はいらない?
(notAString?.length)
はInt?型なので必要そうな感じもする
答えは?
はなくても大丈夫
理由はDartでは?.
が論理演算子のように短絡するようになっているから
前の例ではnotAString
がnullだった時点でisEvenは評価されなくなる
仮にlength
がInt?
型だったら.
ではなくて?.
でアクセスする必要がある

こんなのもある
receiver?..method();
receiver?[index];
前者はreceiver
がnullだったらnullになって、それ以外の場合はmethod
を呼び出してreceiver
を返す
後者はreceiver
がnullだったらnullになって、それ以外の場合はindex
番目の要素にアクセスする
前者だが2行目以降は..
でいいのかな?
void main() {
final list = null as List<int>?;
list?..add(1)
..add(2);
print(list); // null
}
良いようだ

function?.call(arg1, arg2);
なんと関数のメソッドであるcall
を?.
を使って呼び出すこともできる
どのような状況で使うのか興味深い

Null assertion operator
Null言明演算子
class HttpResponse {
final int code;
final String? error;
HttpResponse.ok()
: code = 200,
error = null;
HttpResponse.notFound()
: code = 404,
error = 'Not found';
String toString() {
if (code == 200) return 'OK';
return 'ERROR $code ${error.toUpperCase()}';
}
}
上記の例ではerror
がString?型なのでコンパイルエラーが発生する
The method 'toUpperCase' can't be unconditionally invoked because the receiver can be 'null'.
でもcode
が200でなければerror
はnull
ではない
少なくともそのように使うようにこのクラスは意図されている
as
キーワードを使うことでキャストすることでコンパイルエラーを消せる
return 'ERROR $code ${(error as String).toUpperCase()}';
コーディングミスでerror
がnullだと実行時エラーが発生する
キャストの代わりに!
演算子を使うこともできる、こちらの方が簡単
return 'ERROR $code ${error!.toUpperCase()}';

Late variables
下記の例ではserve
メソッドが呼び出される前に_temprature
が初期化されていない恐れがある
class Coffee {
String? _temperature;
void heat() { _temperature = 'hot'; }
void chill() { _temperature = 'iced'; }
String serve() => _temperature! + ' coffee';
}
void main() {
final coffee = Coffee();
coffee.serve(); // Uncaught TypeError: Cannot read properties of null (reading '$add')Error: TypeError: Cannot read properties of null (reading '$add')
}
エラーそのものを静的解析で補足することは残念ながらできない
ただしlate
キーワードを使うことで?
や!
などのコーディングを減らすことができる
class Coffee {
late String _temperature;
void heat() { _temperature = 'hot'; }
void chill() { _temperature = 'iced'; }
String serve() => _temperature + ' coffee';
}
void main() {
final coffee = Coffee();
coffee.serve(); // Uncaught Error: LateInitializationError: Field '_temperature' has not been initialized.
}
また_temprature
に誤ってnullを代入するといったエラーも防ぐことができる、late
便利ですね

Lazy initialization
late
キーワードには別の使い方もある
class Weather {
late int _temperature = _readThermometer();
}
上記の例では_temprature
に初めてアクセスされる時に_readThermometer
メソッドが呼び出される
初期化の処理が重い場合や必ずしも必要がない場合はとりわけ便利
class Weather {
late int temperature = _readThermometer();
int _readThermometer() {
print("_readThermometer called");
return 30;
}
}
void main() {
final weather = Weather();
print("start");
print(weather.temperature);
print(weather.temperature);
}
start
_readThermometer called
30
30
たしかにコンストラクターの時には呼び出されずに最初に使用する時に呼び出されていることがわかる

Late final variables
late
とfinal
を組み合わせることもできる
class Coffee {
late final String _temperature;
void heat() { _temperature = 'hot'; }
void chill() { _temperature = 'iced'; }
String serve() => _temperature + ' coffee';
}
その場合は初期化を2回しようとすると実行時エラーが発生する
class Coffee {
late final String _temperature;
void heat() { _temperature = 'hot'; }
void chill() { _temperature = 'iced'; }
String serve() => _temperature + ' coffee';
}
void main() {
final coffee = Coffee();
coffee.heat();
print(coffee.serve()); // hot coffee
coffee.chill(); // Uncaught Error: LateInitializationError: Field '_temperature' has already been initialized.
print(coffee.serve());
}
KotlinやSwiftにもlateinit
やlazy
という同じようなキーワードがあるらしい

Required named parameters
required
キーワードを使うことでオプショナル引数を必須にできる
void main() {
func(n: 1);
}
void func({required int n}) {
print("n = $n"); // n = 1
}
required
なオプショナル引数はint?
などのnullableでもOK、その場合はnullを渡せる
required
ではないオプショナル引数はデフォルト値があればint
などのnon-nullableでもOK

Abstract fields
contentsというフィールドを持つCupというフィールドを定義したい場合:
abstract class Cup {
String contents;
}
上記のようにすると下記のエラーが発生する
Non-nullable instance field 'contents' must be initialized.
理由はcontentsが宣言ではなく定義として扱われるので初期化が必要だから
contentsというフィールドを持つインタフェースであること指定したい場合:
abstract class Cup {
abstract String contents;
}
class MyCup implements Cup {
String contents = "Coffee";
}
下記のようにしても良い
abstract class Cup {
String get contents;
set contents(String str);
}

Working with nullable fields
下記は動きそうだが実際にはコンパイルエラーが表示される
class Coffee {
String? _temperature;
void heat() { _temperature = 'hot'; }
void chill() { _temperature = 'iced'; }
void checkTemp() {
if (_temperature != null) {
print('Ready to serve ' + _temperature + '!');
}
}
}
理由はクラスのフィールドではnon-null型へのプロモーションが行われないから
例えば_temperatureのゲッターに_tempratureを更新するコードが含まれている可能性を否定できない、可能性はかなり低いが
このような場合は下記のようにローカル変数に代入すればOK
class Coffee {
String? _temperature;
void heat() { _temperature = 'hot'; }
void chill() { _temperature = 'iced'; }
void checkTemp() {
final temperature = _temperature;
if (temperature != null) {
print('Ready to serve ' + temperature + '!');
}
}
}

Nullability and generics
ジェネリクスでnullableを使うのは面倒
class Box<T> {
final T object;
Box(this.object);
}
main() {
Box<String>('a string');
Box<int?>(null);
}
Tはnullableかもしれないしnon-nullableかもしれないのでそれぞれの制約を受ける
- nullable: nullの可能性があるのでtoStringやhashCodeメソッドくらいしか呼び出せない
- non-nullable: 必ず初期化する必要がある
T?
のようにして明示的にnullableにすることで制約を受けないようにできる
class Box<T> {
T? object;
Box.empty();
Box.full(this.object);
T unbox() => object as T;
}
main() {
var box = Box<int?>.full(null);
print(box.unbox());
}
object as T
の代わりにobject!
を使うとobjectがnullの時に例外が発生するので注意が必要
object as T
で大丈夫な理由はTがint?なのでnullであったとしてもキャストできるから
制約を回避する方法としてextends
を使用する方法もある
class Interval<T extends num> {
T min, max;
Interval(this.min, this.max);
bool get isEmpty => max <= min;
}
main() {
final interval = Interval<double>(0, 1);
print(interval.isEmpty); // false
}
Interval<double>
がInterval<double?>
の場合は下記のエラーが発生する
'double?' doesn't conform to the bound 'num' of the type parameter 'T'.
下記のようにextends
の対象としてnullableなタイプも指定できる
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;
}
}
この場合はTはnullableでもnon-nullableでもどちらでも良い

Core library changes
コアライブラリの変更

The Map index operator is nullable
Mapの添字演算子の戻り値がNullable
void main() {
var map = {'key': 'value'};
print(map['key'].length);
}
上記はコンパイルエラーが発生する
The property 'length' can't be unconditionally accessed because the receiver can be 'null'.
このような場合は非ヌル言明演算子!
を使う
void main() {
var map = {'key': 'value'};
print(map['key']!.length); // 5
}

No unnamed List constructor
Listクラスの名前なしコンストラクタは非推奨
なんとnullableタイプに対してもList()
は使えない
void main() {
print(List<int?>());
}
The default 'List' constructor isn't available when null safety is enabled.
空のリストが必要な場合はList.emptyコンストラクタや[]
を使用する

Cannot set a larger length on non-nullable lists
Listクラスのlengthプロパティにはセッターもある、知らなかった
Tがnon-nullableの場合は現在の要素数より大きい数を指定することはできない

Cannot access Iterator.current before or after iteration
イテレーションの前後にIterator.current
にアクセスできない
void main() {
final list = [1, 2, 3];
final cursor = list.iterator;
print(cursor.current); // Error
while (cursor.moveNext()) {
print(cursor.current);
}
print(cursor.current); // Error
}

Summary
- 型はデフォルトでnon-nullable、nullableにするには
?
を使用する - オプショナル引数はnullableかデフォルト値がある必要がある、
required
を使うことで必須にできてその場合は非nullableにできる(デフォルト値は指定できない)、non-nullableのグローバル変数と静的変数には初期化が必ず必要、non-nullableなクラスのフィールドはコンストラクタのボディの前に初期化が必要 -
?.
は&&
や||
のように短絡する、?..
や?[]
などのバリエーションがある、後置!
でnon-nullableにキャストできる - フロー解析によってローカル変数はnon-nullへ型プロモーションができる
-
late
キーワードによってnon-nullかつfinalなフィールドを後から初期化することや初回使用時に飲み初期化することができる - Listクラスはnull safetyのために変更された

以上で一旦クローズ、次はDartのFutureについて調べる