🗞️

Dart の Sound null safety を試してみる

6 min read 6

Dart にも Null safety が導入されました。 (11/20 現在では beta) Flutter 2 で正式に導入されましたね!(2021/3/4)

Sound null safety | Dart

今までも ? を使った null チェックはあったのですが、それは null かもしれない変数に安全にアクセスする ための手法で、「そもそも変数に null が代入されることを防ぐ」というものではありませんでした。

そこで今回新しく導入されたのが、 変数そのものを、「null かもしれない変数」と「null になり得ない変数」で区別する という仕組みです。

パッと見た感じ、書き方は Swift の Null safety と同じ感じのようですので、Swift を書いたことがある方はイメージしやすいかもしれません。[1]

Dart の Null safety の基本

例えば、今までは以下のコードでもコンパイルエラーなく実行できました。

main() {
  int value = null;
  print(value);  //-> null
}

しかし、この Sound null safety が導入されたバージョンでは、以下のようにコンパイルエラーになります。

Error: The value 'null' can't be assigned to a variable of type 'int' because 'int' is not nullable.
  int value = null;

これは、int 型の変数には null を代入できないためです。

もし同じようなコードを書きたいのであれば、明示的に null の代入を許容するint型 ということで、以下のように型のあとに ? を記述します。

main() {
  int? value = null;
  print(value);  //-> null
}

なお、 varfinal で変数を宣言した場合の考え方は今まで通りです。

つまり、右辺が Non-nullable であれば宣言した変数も Non-nullable になりますし、逆に右辺が nullable であれば nullable になります。

main() {
  var color = getColorObject("#00ff00");
  color = null;  // OK
}

/// カラーコードから Color オブジェクトを生成するメソッドの例
Color? getColorObject(required String colorCode) {
  return isValidColorCode(colorCode) ? Color(colorCode) : null;
}

Nullable / Non-nullable な変数でできないこと

Null safety の導入によって、プログラム中の変数が Nullable なのか、それとも Non-nullable なのかはとても厳しく区別されるようになり、いろいろな「できないこと」が決められています。

Nullable の場合

Nullable な変数は、その名の通り中身(というか参照先)が null である可能性があるため、いきなりプロパティにアクセスしたりメソッドを呼び出したり、といったコードを書くとコンパイルエラーになります。

void main() {
  int? value = null;
  print(value.isEven); // -> コンパイルエラー!
}

そのため、これまでも利用可能だった ? を使ったり、後述する null チェックをした上でアクセスしたりする必要があります。

void main() {
  int? value = null;
  print(value?.isEven); // -> null
}

これにより、予期せぬ null アクセスをコンパイルの段階で防げる、というわけですね。

Non-nullable の場合

一方で、 Non-nullable な変数は「nullでない」ことがコンパイルの段階で保証されているため、何も気にせずプロパティにアクセスしたりメソッドを呼び出すことが可能です。

void main() {
  int value = 2;
  print(value.isEven); // -> true
}

簡単に言ってしまえば、今まで通り何も気にせず使うことができる、ということですね。

ただし、「nullでない」ことを 100% 保証するために、 null を代入したり、 Nullable な値を代入したりすることはできません。

void main() {
  int value = 2;
  int? value2 = getNullableValue();
  value = value2;  // -> コンパイルエラー
}

必ず、「nullでない」ことを保証する何かしらのコードを書いてあげましょう。

void main() {
  int value = 2;
  int? value2 = getNullableValue();
  value = value2 ?? 0;  // 例えば ?? でデフォルト値を与えてあげればOK
}

Null safety なコード

ある時点でその変数が Nullable かどうかは、コンパイラが細かくチェックしてくれます。

たとえば、以下のコードでは変数 value が Nullable な int 型なため、通常は value.isEven のようにノーチェックでプロパティにアクセスすることはできませんが、以下のように if 文で null チェックを入れることで、それよりも下のコードでアクセスできるようになります。

void main() {
  int? value = null;
  
  // ↓ は Nullable なためコンパイルエラーだけど
  // print(value.isEven); 
  
  // null チェックして return したり value に値を代入したりすれば。
  if (value == null) {
    return;
  }
  
  // ここでは問題なくプロパティにアクセスできる。
  print(value.isEven);
}

軽くコードを書いて動作確認したところ、コンパイラが「nullではない」と判断するため基準はいろいろあります。以下に例を列挙します。

int? value = getNullableValue();
value = 2;
print(value.isEven); // 直前で Non-nullable な値を代入しているため OK
int? value = getNullableValue();
print((value ?? 0).isEven); // ?? で null の場合の値を与えているため OK
int? value = getNullableValue();
if (value != null) {
  print(value.isEven); // value が null の場合はここを通らないため OK
}
int? value = getNullableValue();
if (value == null) {
  return;
}
print(value.isEven); // value が null の場合は前の if 文で return しちゃうから OK

という具合に、 Nullable な型であっても文脈上明らかに null ではないとコンパイラが判断した場合はそのままアクセスできるようです。

Flutter への影響

おそらく既存の Flutter プロジェクトの Dart バージョンをこの Null safety が導入されたものに変更すると、プロジェクト全体が一気に真っ赤になってしまうと思います。

例えば ↓ は、私が個人で開発している Sengyo アプリ のプロジェクトで Dart のバージョンを最新にした場合のキャプチャです。

例えば引数で受け取る key は型が Key で宣言されていて null を許容しないため、必ず何かしらのデフォルト値を与えるか required をつけて呼び出し元が値を渡すことを強制するか、 Key? に変更するかしなければならない、という旨のエラーメッセージが表示されているのが見えますね。

同様のコンパイルエラーがプロジェクト全体に出ているため、左側のファイル一覧が真っ赤になっている、というワケです。

当然これを全部手作業で直していては日が暮れてしまうので、公式から Migration GuideMigration Tool が用意されています。

まだ私は試していませんが、これを頼りに機械的に修正を加えていき、コンパイルエラーが出ないことを確認し、動作確認をする、という流れになりそうです。

依存するパッケージの対応を待つ

Migration Guide の 最初のステップ にも書かれていますが、自分のプロジェクトのマイグレーションを始めるには、まず依存している「全て」のパッケージが同様の対応を完了し、対応されたバージョンが公開されている必要があります。[2]

そのため、たくさんのパッケージやメンテナンスされていないパッケージに依存していればそれだけ Null safety 対応の Dart を使えるようになるのが遅くなる可能性がある、という点に注意してください。

また、パッケージを Null safety 対応の最新バージョンにアップデートする、ということは、それまでに入った別の変更も一緒に取り込むことになる点に注意が必要そうです。

例えば Provider や FlutterFire のように、メジャーバージョンアップによってインターフェースをガラっと変えるパッケージも少なからずありますので、 Null safety のマイグレーションとは別にそれらのライブラリのバージョンアップによる修正と動作確認が必要になる可能性についても想定しておく必要があるでしょう。

まとめ

ここまで読むと、そんなにいろんなルールと書き方が増えて、さらに既存のプロジェクトにも大幅な修正が必要になるのに、ここまでしてこの Sound null safety の仕組みが必要なのか、という疑問も出てくるかもしれません。

しかし、 予期しない null へのアクセスは、プログラムの停止を引き起こします。特にアプリの場合は「アプリのクラッシュ」という形で null なんて言葉も知らない一般ユーザーの信頼感を大きく損ねてしまいます。

そのため、今までも Dart コードを書く際は常に 「この変数は null になり得るか、どうか」を自分で、すべての変数に対して考えていた と思うのですが、 Null safety の導入によって この判断を全てコンパイラがやってくれる と考えればとても大きなメリットではないでしょうか。

null については、他の多くの言語でも安全に扱うための様々な工夫がされていますが、それがようやく Dart にも来た、という意味で、多少の手間をかけてもこの新しい機能を取り込むメリットがあるのではないかと思います。

また、 "Sound" null safety によってコンパイラが「この変数は null になり得るかどうか」ことを区別することによって、null に起因するエラーの発生しない Sound(健全)な 実行コードが生成できます。さらにその副次的な効果として、実行コード内の不要な null チェック処理を省略できることにより、バイナリサイズを小さく、実行速度が速くなるメリットが得られます。

Dart’s null safety is sound, which enables compiler optimizations. If the type system determines that something isn’t null, then that thing can never be null. (中略) —- not only fewer bugs, but smaller binaries and faster execution.

null によるエラーを防ぐだけでなく、アプリサイズを削減し、実行速度も速くなる(どれくらいかは未調査)というのであれば、これも対応のモチベーションになるのではないかと思います。

脚注
  1. Kotlin とも似ているような気がしますが、 Sound null safety ではないという点で Kotlin の Null safety とは違うらしいです。参考 ↩︎

  2. 依存するパッケージが対応されていなくてもマイグレーション自体は可能なようですが、二度手間になることなどを考えると推奨しないとのことです。 ↩︎

Discussion

とても参考になりました、ありがとうございます。

"Sound" null safety とは、「null ではない」とコンパイラが判断したらコンパイルの最適化によってバイナリを小さく、実行速度を速くする仕組みのようです。

ここでいう sound は恐らく「健全」という意味の sound かと思います。(型界隈や論理数学などで使われる健全という言葉は https://www.slideshare.net/AkinoriAbe1/ss-74534932 などが参考になりそうです)
変数が non-nullable で宣言されていれば、実行時に null が入ることは絶対にない保証があるよという意味なので、
コンパイラも割り切って最適化ができたり、実行時の null チェックを省くことができ、
副次的な効果として、バイナリサイズも小さく、実行速度も速くなるのかなと思いました。

逆に、unsound な null 安全という概念もあり、例えば依存しているライブラリが null 安全でなければ、いくら non-nullable な変数といえど実行時に null が入ってしまう可能性があるため、unsound となり、結果として最適化なども行われなくなるのかなと思います。

最適化によるバイナリサイズの縮小や、実行速度の高速化はどちらかといえば副次的な効果で、メインはコンパイラで静的に null 安全かどうかチェックできる機能だよ、ということが言いたいのかなと思いました。

詳しい補足、ありがとうございます!

"Unsound null safety" のページはまだ読んでいませんでした。比較しながら理解を深められそうですね。読んでみます。

副次的な効果として、バイナリサイズも小さく、実行速度も速くなるのかなと思いました。

たしかに、実行速度などについては「副次的な効果」という捉え方が良さそうですね。何をもって "Sound" と呼ぶのかがあまりうまくイメージできず、それによって結果的に得られるメリットの方ばかり書いてしまいました。このあたりも表現を調整しようと思います。

@required から required になったのでしょうか?

そうですね。もともと @required は単なるアノテーションで、これに違反していてもIDEの警告が出るだけでした(私が認識する限り、ですが)。
しかし、Null Safety の導入によって required がキーワードとして採用され、そのため @ も不要になり、違反するとコンパイラがエラーを出す(つまり実行できなくなる)ように変更された、という理解です。

dart/Flutter初心者です。わかりやすい記事の投稿ありがとうございます。

1点違和感を感じた部分があったのですが、

main() {
  var color = getColorObject("#00ff00");
  value = null;  // OK
}

/// カラーコードから Color オブジェクトを生成するメソッドの例
Color? getColorObject(required String colorCode) {
  return isValidColorCode(colorCode) ? Color(colorCode) : null;
}

↑のコード3行目は value ではなく color で読み替えてよろしかったでしょうか?

確かにおっしゃる通りでした!修正しました。
ご指摘とてもありがとうございます。

ログインするとコメントできます