91
40

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

DartAdvent Calendar 2019

Day 8

DartのNull安全導入状況 (NNBD)

Last updated at Posted at 2019-12-08

はじめに

NNBDは純粋にDartの機能ですが、そのDartの最近の人気は一重にFlutterの人気によるものです。また、Dart 2.0でAOTコンパイルに最適化した理由のひとつとして、Flutterの存在が大きいでしょう。JITよりもAOTに親和性の高いNNBDもまた、Flutter向けと言うことが出来ます。より多くの人に読んでもらうためにFlutterタグも付けました。

Non-Null By Default (NNBD)とは

DartではNull安全のことを通称でNNBDと呼びます。これまではint i;と書くとi = null;が可能でしたが、NNBD導入後はコンパイル時エラーとなります。Null可型にするにはint? i;と書くことになります。?修飾のない既存のintの挙動を変えて非Null可型にするので、By Defaultです。Null安全を知らない方にとっては、これだけでは何のことやらわからないと思いますが、下記に良い記事が有るので説明を譲ります。

ただし、以降の説明のために、スマートキャストについて触れておきます。

f(int? x){
  if(x != null){
    return x * 2;
  }
}

上記において、if文の中ではxint?からintにキャスト(type promotionと言ったりするのでややこしい)され、コンパイルエラー無くx * 2を実行できます。

NNBDの開発ステータス

3年前の時点でDartチームの中ではNNBDについて既に長い時間を議論に費やしており、私も当時のDart Advent Calendar 2016の記事にしました。そのNNBDがついに、2019/5/13に仕様策定中から実装中のスタータスになりました。とはいえ、細部に決まっていない仕様がたくさんあります。12/8時点では、dart-lang/languageのnnbdタグがあるissuesのうち29/79しかcloseしていませんし、その分母は日々増えています。NNBDリリースの時期もバージョンも決まっていませんが、かなり大きなbreaking changeになるので、バージョンは3.0になるのではないでしょうか。並行して実装は着々と進められており、既にdart-lang/sdkにはNNBDのタグが付いたissueが232あり、そのうち79がcloseしています。

DartのNNBDの特徴

DartのNNBDは一端のNull安全になるはずですが、それに必要な言語機能の説明の多くを先の参照記事に譲り、ここではDartで特有かつ特徴的な部分を取り上げます。

型階層

TopがObject?となり、その直下にNullObjectがぶら下がります。Nullは唯一のインスタンスnullを持つBottomです。Object側のBottomは新たに導入するインスタンスを持たないNeverになります。

また、int?intNullのタグ無しUnion型であってFlatteningされます。つまり、

Map<String, int?> m = {'a': 1, 'b': null, 'c': 3};
print(m['d']); // => null

上記ではキー'd'が無いのか、キー'd'nullが格納されているのか区別できません。他方で、Dart SDKやアプリケーションがシンプルに書けるというメリットがあります。

マイグレーション

先述の通りNNBDはbreaking changeですが、既存コードのマイグレーションをサポートする機能が導入されます。

マイグレーションツール

フォーマッタdartfmtの応用で、非NNBDなDartコードをNNBDなDartコードにコンバートするツールが導入されます。オールマイティでは無いが、移行を大きく手助けするツールになるはずです。Dartチームはマイグレーションツールをとても重要視していて、Issues · dart-lang/sdkにはanalyzer-nnbd-migrationダグが付いているものが既に90もあります。

段階的マイグレーション

issue名にNull Safety: Sound non-nullable types with incremental migration (NNBD)とあるとおり、既存のアプリやライブラリの段階的な移行を支援する機能が盛り込まれます。移行期間では、NNBDなアプリからNNBDでないライブラリを利用することも、その逆もできます。ちなみに、アプリも一種のライブラリです。

ライブラリ別SDKバージョン指定

NNBDなDart SDKで非NNBDなライブラリが壊れないように、想定するDart SDKバージョンをライブラリ単位に指定します。例えば、ファイルの冒頭library宣言の前に// @dart = 2.6等と書くと、NNBDを導入したDart SDK 3.0も、そのライブラリをDart 2.6時代の非NNBDなものとして解釈します。

これはDart SDKの複雑化を招くので、一定の移行期間を置いてNNBD一筋になります。

NNBD導入コードと非導入コードの混在 (2020/1/23 修正)

ライブラリ別SDKバージョン指定は、新SDKが後方互換(既存コードが前方互換)にならないだけのbreaking change(SDKへの言語制限追加)には十分ですが、同時に旧SDKが前方互換(新コードが後方互換)ですらない(SDKの言語の意味論を変更する)場合には不十分です。NNBDなコードとNNBDでないコード間でやり取りする情報の意味論が変わるNNBDでは、両者の境界における不整合を解決しなければなりません。ちなみに、Dart 2.0の原型であるStrong Modeは概ね新SDKが後方非互換、旧SDKが前方互換の言語制限追加でした。

NNBDの移行期間において、NNBDでないライブラリの型intint*という特殊な内部型になります。当然ながらint*型の変数にはnullを格納できますが、int*int?と同様にスマートキャストでintになりえます。他方で、int*のメンバ参照(メソッド呼び出し)は可能であり、NNBDにおける非NULL可型の側面もあります。なお、NNBDでないintをNNBDなintと区別する目的でもint*の表示がエラーメッセージ等で役に立ちますが、プログラマがコードに記述することはできません。

このあたりの規則は難解で良く理解できていないところも多々あります。分かったら改めて解説する予定です。

また、NNBD移行期間中は非Null可型の変数等への代入箇所全てにコンパイラが実行時nullチェック(assertion)を挿入します。この性能インパクトが大きすぎる場合は、Dart 1におけるChecked Modeのようなデバッグモードを導入するかもしれません。

逆に、非NNBDなライブラリでNNBDなライブラリの型や値を利用する場合の特段の配慮はありません。

上記の対応をしたとしても、NNBD移行期間中のNull安全を保証できるわけではなく、コンパイラの最適化もできません。この観点からも、NNBD移行期間は一定の期間で終了し、その後のDart SDKはNNBD一筋になります。

非Null可型変数の遅延初期化

初期化子もコンストラクタの初期化リストも無い非Null可型のフィールドや変数の宣言には修飾子lateを付けます。これは暫定値で初期化することが許されないfinalの場合に特に有効です。例えば、late final int a;とします。aは参照時までに初期化されていればよく、初期化されていなければ実行時エラーとなります。

省略不可名前付きパラメタ

省略不可な名前付きパラメタにはrequired修飾子を付けます。これまでも同様の目的で@requiredアノテーションが有りましたが、これと異なりrequiredは言語の一級機能です。特徴的なのは、requiredであることが自明なデフォルト値無し非Null可型のパラメタでもrequredが必須であることです。これには活発な議論があり、私も省略できる方を押したのですが、結果的に必須になりました。その判断の決め手は、デフォルト値は関数型の一部ではないというもの。Function({int x=0})とは書けずFunction({int x})となるので、これと区別するためには省略不可なパラメタをFunction({required int x})とする必要があります。すると、

typedef F = int Function({required int x})
int f1({int x}){...}
int f2({required int x}){...}
int f3({int x=0}{...}

上記のうち、原理的にはf1f2を同じ型Fとみなすこともできますが、Ff1でシグニチャの見た目が異なることになります。これは、一貫性を重要視する言語仕様の世界ではNGだ、ということです。そこかよ!

Null Awareメンバ参照の簡略記法

aがNull可型であった場合、メンバ参照はa?.bと書けます。これがネストする場合a?.b?.c?.dと書きます。ここでbが非null可型の場合にa?.b.c?.dと書けるのが簡略記法です。anullだった場合、a?.bの評価結果はnullであるにもかかわらず、です。

なお、cがNull可型であれば再びc?.d?が必要になります。

メソッド呼び出しの場合も同様です。例えば、a?.b().c()?.d()のように書きます。

ListMapのNull Aware要素参照 (2020/1/22文法変更)

aがNull可型の場合のNull Awareな要素参照はa?[b]と書きます。これがネストする場合はa?[b]?[c]?[d]と書きます。a[]が非Null可型の場合は簡略記法でa?[b][c]?[d]と書けます。

Dart 2.7でプレビューリリースしたNNBDではa?.[b]でした。その理由は要素参照がa?[b]では{a?[b]:c}a?[b]をキー、cをバリューとする要素を持つMapリテラルなのか、3項演算a ? [b] : cを要素に持つSetリテラルなのか、心理的に曖昧というものです。

しかし、これが不評だったためa[b]に変更になりました。不評だった理由はa.ba.b()a..ba..b()a..[b]...aのNull Awareな記法が全てaの直後に?を追加するだけ、すなわちa?.ba?.b()a?..ba?..b()a?..[b]...a?であるのに対して、a[b]のNull Awareな記法がa?.[b]であることも心理的負荷が高いというものです。

文法的にも曖昧でしたが、3項演算に解釈できるところは常に3項演算として解釈することで解決しています。いわゆるぶら下がりelse問題を常に直近のifに対応付けることで解決するのに似ています。明らかなMapリテラル文脈<int, int>{a?[b]:c}であってもa?[b]:cを3項演算として解釈し、その結果キー・バリューのペアのMap要素でないとしてエラーを出力します。Mapリテラルとして解釈させる場合は{(a?[b]):c}と括弧を付ける必要がありますが、Mapのキーにnullを期待するケースは稀であろうという判断です。

なお、文法上の曖昧性を解決する別の有力策に?[をトークンとするというものが有りましたが、これはスペースセンシティブな文法になる問題がありました。?[をトークンとして採用しなかったのは、3項演算をスペース無しでa?[b]:cと書く人がそれなりに居るという判断でしょう。特に採用した解決策には自動フォーマット前提のタイプインでスペース分のキーストロークを減らす効果があります。ちなみに?.はトークンです。

これに併せて同じ問題を抱えていたNull Awareな関数実行もa.?()からa?()に変わります。

この議論には私も参加していました。Dartチームは最近まで、a?b:cの形の3項演算が無くならない限りa?[b]の形の要素参照は無い、と頑なでしたが、この記事を書くために過去の議論を整理し、それをGitHubコメントに投稿したあたりから流れが変わったと自負しています。

以下記録のために残しておきますが、おわりにまで飛んでください。

【削除】
[]も演算子の一種ですが、そのNull Awareな記法に特徴があります。aがNull可型の場合はa?.[b]?.[c]?.[d]と書きます。a?[b]?[c]?[d]ではありません

a[]が非Null可型の場合は簡略記法でa?.[b][c]?.[d]と書けます。

記法に.(ドット)が余計と感じるかと思いますが、その判断の決め手は、{a?[b]:c}がプログラマ心理的に曖昧である、というもの。つまり、ListMapのNull Awareな要素参照結果をキーとするMapリテラル{(a?[b]): c}なのか、3項演算子を用いたSetリテラル{a ? ([b]) : c}なのか、わかりにくいということ。文法的には?[を一つのトークンとすることで解決できるのですが、そういう問題ではないとのことです。

ちなみに、a ? b : cの形の3項演算子、{a, b, c}の形のSetリテラルの双方を持つ言語はDartぐらいのものです。Dartも2019/2/27にバージョン2.2でSetリテラルを導入したばかりです。やっちまったんじゃないでしょうか。

?[?.[のどちらにすべきかについてDartチームメンバが挙げたissueで議論されています。このissueは?.[に収束するかたちで2019/6/1に一旦closeしますが、2019/8/4のMediumへの公式ポストをきっかけに議論が再開し、2019/8/21に再びopenして今に至ります。今ではコメント数が93件で、dart-lang/languageの中ではclose済みのStatic Extension Methodの113件についで歴代2位、NNBDタグが付くもののなかではダントツの1位です。

議論が白熱した理由には、a?.[b]において.(ドット)が一見冗長であるためa?.ba?.b()他から連想できず、覚えにくいという純粋な反対意見の他に、下記のような説得力の弱い理由がa?.[b]支持派から多数挙げられて議論が迷走したことがあると思います。

  • a?.[b]支持派の主張の凡例
  • 上記主張に対する反論の凡例

  • 要素参照がa?[b]では{a?[b]:c}が心理的に曖昧。 // 今のところこれが決め手、というか信念。
    - 曖昧だろうと3項演算子より要素参照の方がずっと重要。 // という反論も有る。
    - むしろ3項演算子こそdeprecateすべき。 // ← いまここ
    - 式が唯一の要素を持つSetリテラルで、その要素が3項演算で、その第2項が唯一の要素を持つListリテラルで、第3項がListであることなんて、超レアじゃない?
  • 要素参照がa?[b]では{a?[b]:c}が文法的に曖昧である。
    • ?[を一つのトークンにすれば解決する。
  • 空白センシティブな構文はよろしくない。
    • 既に--a- -aは空白センシティブ。
    • フォーマッタとアナライザからのエラーが問題を緩和するはず。
  • ?.は他の演算子a?.+(b)等にも応用できる。
    • それを言うなら要素参照はa?.[](b)でしょ。
    • a?.*(b).+(c)?./(d)とか奇妙。
    • ?+?-?*?/がトークンでも良いはず。
  • JavaScriptがa?.[b]である。
    • JavaScriptは文法において見習うべき言語ではない。
    • JavaScriptのa?.[b]は提案中であり、仕様以前である。
  • a?[は3項演算子の書き始めを想起させ、混乱の元である。
    • 評判は知らないけど、ともかくSwiftやC#はa?[b]を受け入れた。
  • a?.[b]?.[c]?.[d]の方が、メソッドチェーンであることがわかりやすい。
    • 同じくメソッドチェーンのa[b][c][d]が定着しており、問題にもなっていない。
  • 特にNull Awareの簡略記法を利用する場合にメソッドチェーンを意識することが重要。
    • a?[b][c]?[d]と書けるし、問題とは思えない。
  • a?.[b]a?.ba?.b()a?..ba?..[b]に似ている。
    • a?.ba?.b()a?..ba?..[b].(ドット)を追加していないので、比較がおかしい。
    • 余計な.(ドット)を導入しないa?[b]の方がよっぽど似ている。

おわりに

既存コードの開発者にある程度の痛みを伴うエンハンスですが、同時に開発者からの期待の大きな機能でもあります。開発にはまだしばらくかかり、2020年中にリリースできれば御の字といったところかもしれませんが、今後も注視していきたいと思います。

2019/12/11 追記

早くもプレビューリリースされましたね。
Announcing Dart 2.7: A safer, more expressive Dart - Dart - Medium

2019/12/29 追記

いままで、Issue #376はDartチームが質問に答えたり、a?.[b]反対派を説得するissueでしたが、このコメントで流れが変わったように思います。3項演算子が無くならない限りa?[b]は無い、と頑なだったDartの言語チームがF2Fでミーティングを開ました。まだ何も決まっていないと言いつつ、「a?[b]は技術的には可能」、「関係した技術者は軒並みa?[b]を好む」、「a?.[b]の強い賛同者は(外部に)居ない」、「a![b]と対称的なのは良い」とa?[b]採用にも前向きです。

a?[b]に賛同する方は是非このコメントに👍してください。

その前に{a?[b]:c}の心理的曖昧性を解決すべきという方は、是非Deprecate ternary operator ?/:にも👍してください。

2020/1/23 追記

プレビューリリース後ですら外部からの声で文法さえも変更するDartチームは非常にオープンだと感じました。私には、強い主張をしながらも口やかましいマイノリティーになってやいないかと一抹の不安もありましたが、Dartチームはコミュニティの建設的な議論にこことかここで敬意を示しています。Dartをより良いものにするために、皆さんも積極的に議論に参加してみてはいかがでしょうか。

2022/8/19 追記

f?()は実現していませんでした。
f?.()でもなくf?.call()と書く必要があります。

91
40
0

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
91
40

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?