LoginSignup
711
649

More than 5 years have passed since last update.

null安全を誤解している人達へのメッセージ

Last updated at Posted at 2016-11-09

null安全を誤解している人達へのメッセージ

先日koherが投稿した記事が多く読まれたようです。記事の内容は僕とkoherが普段話してきた内容が多く登場しているため、僕が人々に伝えたい内容とも強く合致しています。しかし残念な事にインターネットの反応を見ていると、誤解しているケースが思ったより多くありました。

そこで、ネットで見られた意見に対して返答を書きました。 特定の実在する意見は指さずに、僕が感じ取った文脈を編集したものを対象にします。それによって、「そんな事言われてないじゃないか」と思うものがあれば、僕としてもそのほうが嬉しいのでそれで問題ないです。 「たしかにそうだ」と思ってnull安全に今一度興味をもってもらえれば嬉しいです。

なお、記事中のコードは特に言及が無ければswiftです。

意見: null安全があっても、ちゃんとやるのを忘れているかもしれないのでは

忘れません。ちゃんとやらないと、コンパイルが通らないからです。フェッチしてきたコードがコンパイルできなければ、それをコミットした人がミスをしていることが確定します。コンパイルできているのであれば、少なくとも1度はコンパイラが、正しくハンドリングしてください、と矯正していることが保証されます。なお、その矯正された結果、めちゃくちゃなことをやってしまう問題は起こりえますが、それについての意見は後述します。

意見: 文法が難しそう、例えばデフォルト値を書くのはめんどくさそう

そんな事ないと思います。例えば、文字列を渡してそれが数値ならIntを返し、数値で無ければnilとなる、式の型としてはInt?を返すコンストラクタInt()があります。これを、もし数値文字列じゃない場合のデフォルト値999を与えたければ、以下のようにかけます。

let number: Int = Int(str) ?? 999

Rubyで x || 999 と書くのに似て簡潔で良いと思います。しかしRubyのそれとは決定的に違う点があります。それは、??が普通に書けるのは左オペランドがnullableの場合のときだけで、左オペランドがnonnullのときはコンパイラが警告を出してくるという事です。例えば、Swiftで user.age ?? 999などと書いたら、右側の 999 の値になることは絶対に有り得ない無意味なコードだよ、という警告が出ます。一方、nullableに対して??を書き忘れた場合はコンパイルエラーになります。例えば、 let number: Int = Int(str)と書くことはできません。右辺がnullableだから左辺のnonnullの変数には代入できないからです。書き方も楽だしミスも起こらない良いことずくめになる好例です。

意見: Swiftで実用的なWebアプリケーションは作れない。null安全は実用的ではない。

前段の意見は正しいと思います。RubyやNodeで使われているフレームワークや大量に登録されたライブラリは素晴らしく生産性を向上させています。一方Swiftはそこまでではありません。ですが、それによってnull安全を見放すのは話が混ざっています。誤解です。もったいない。

一般にアプリケーションの生産性は、言語機能、フレームワークやライブラリの充実、ドキュメントの充実、先人の残したヒントの充実、といった複数の要因の積み重ねで決まります。その点Swiftがまだ未熟です。ですが、null安全性は特定言語の話ではなくて、言語機能一般の話です。生産性を比較するのであれば、フレームワークなど、言語機能以外の条件は全く同じように整った未来がきて、違いが言語機能自体にしかない状態で議論するべき です。それによって出て来る差分が、null安全性がもたらす生産性の変化です。そのように考えればnull安全自体は生産性を上げるといえます。今それがなくて実用できないというのは関係ない話です。今は無いものだから良いものではない、という考えが間違っていることは上述しました。それよりも、もしいろいろなものが整ったときにはnull安全言語に乗り変えることができるときがweb開発にもやってくるかもしれない、楽しみだ。と考えてください。実際、TypeScript2はnull安全になるので、nodeの方では割りと近い未来の話なのではないでしょうか。

ところで、Railsではテーブル定義の変更に基づいて自動的にActiveRecordがカラムを表すプロパティをもつという動的性が便利だが、型定義を事前にコードで書く必要がある静的言語ではこのように便利にはならない、という意見を聞いたことがあります。これについても、コードを書くときに一度だけDBにアクセスしてソースを自動生成すれば対応できます。実行中のサーバーでテーブル定義が動的に変化するのであれば対応できませんが、そのような場合、現実には一度テーブル定義を変更してもちゃんと動くかテストしますよね。そのテストするタイミングで、自動ソース生成を1度行うだけなので、あまり手間も変わらないと思います。まあ、現状はそんなものは無いですけど、理論上将来の登場を期待できる、という事です。1

意見: ?.のような機能はRubyにも&.がある。Rubyはnull安全に分類されるはずだ。

たしかにRubyの&.とSwiftの?.は似ています。どちらも receiver ?. method(args) のように、メソッド呼び出しの演算子で、レシーバがnilならメソッド呼び出しをスキップして式の値がnilになり、レシーバがnilで無いならメソッド呼び出しを実行してその結果が式の値になります。それぞれ以下のようなコードになります。

# ruby
name = user&.name || "no name"
// swift
let name = user?.name ?? "no name"

確かに見た目は似ています。ですが、だから同様にRubyもnull安全だ、というのは間違いです。 コードが上記したような 見た目をしていること がnull安全なのではありません 。上記のコードの 正しさがコンパイラによってチェックされていること がnull安全なのです。

そして、 null安全性に ?. は関係ありません 。これについて説明します。

まず、 nullになりえない型 T と nullになりうる型 T? を区別する型システムが、 null安全な型システム です。これを導入することで、NullPointerExceptionが発生しないプログラムが書けるようになります。そして、nullになりうる型 TT? は全く別の型 です。全く別というのは例えば、ユーザー情報 User と ユーザー情報が入った配列 Array<User> の関係と思えば良いです。全く別のものであるからには、 T?型の値を用いて Tに関する処理をしたい場合には、変換操作として T?からTを取り出す必要があります 。ポイントは、この必要な取り出す操作を忘れていないかがコンパイラによって機械的に確認されるということです。そして、この取り出す操作とは、要するにいわゆるnullチェックです。つまりまとめると、 必要なnullチェックが機械的に強制されるからnull安全になる というわけです。

さて、上記のnull安全性の説明に ?. の話題は出てこなかった 事に注意してください。ある言語がnull安全かどうかには、その言語に ?. があるかどうかは関係ないんです。null安全性は TT? の取扱をコンパイラがチェックする事によってもたらされるのです。 だからRubyに &. があったとしても、そもそも &. がnull安全性をもたらすわけではない し、 コンパイラが nullチェックを強制していない ので、null安全ではありません 。

このように、?. がnull安全をもたらすわけではありません。しかし逆に、 null安全な言語には ?. が必要 です。ここがややこしいところだと思いますが、これを次に説明します。

ある言語がnull安全かどうかと、 実用的かどうか は別の話です。実際に使うときの事を考えると、 null安全性と実用性の 両方を達成したい です。そこで出てくるのが ?. なのです。 ?. はnull安全な言語を 使いやすくするための道具の1つ なのです。このような、null安全な言語にとって便利な道具はいろいろあります。その中でも ?. はかなり重要度が高いです。なぜなら nullじゃ無ければ中身を取り出してメソッドを呼ぶ というロジックは頻出するからです。そしてこのロジックに関して言えば、null安全ではない言語においても頻出します。だからこそRubyにも &. が導入されたのだと思います。

さらに別の視点として、 プログラマが使うときの観点で、Rubyの &. と Swiftの ?. の違いを説明します。

Swiftでは、 ?. はレシーバの型が T? であるときにしか使えません。以下に例を示します。以下のコードはコンパイルできます。

let user1: User? = searchUser(query)
user1?.gainMoney(100)

しかし、以下のコードはコンパイルエラーになります。

let user2: User = getUser(id)
user2?.gainMoney(100)

違いは、 user1 の型は User? で、user2 の型は User という点です。user2 の型は non-nullな User 型なので、 ?. は使えないのです。正しくは .を使わないといけません。もしプログラマがnullableだと誤解して ?. と書いていたなら、ここでコンパイラが指摘したことで間違いに気がつくでしょう。

また、以下のコードはコンパイルできます。

let user3: User = getUser(id)
user3.gainMoney(100)

しかし、以下のコードはコンパイルエラーになります。

let user4: User? = searchUser(query)
user4.gainMoney(100)

違いは、 user3 の型は User で、user4 の型は User? という点です。(さっきとは逆の順番で登場しています。)user4 の型は nullableな User? 型なので、 . は使えないのです。だから ?. を使うなどの適切なnullチェックが必要です。もしプログラマがnon-nullだと誤解して . と書いていたなら、ここでコンパイラが指摘したことで間違いに気がつくでしょう。

このように、null安全な言語では、non-nullの時には . を使い、nullableの時には ?. を使う、というように正しく使い分ける必要があります。正しく使い分ける必要があるということは、今操作しようとしているレシーバのnon-null/nullableをプログラマが誤認していた場合には、コンパイラがそれに気づかせてくれるという事です。このようにして、null安全言語における ?. は、 プログラマがnullableかどうかを 正しく意識する手助け をしてくれます。 

一方、Rubyでは言語仕様上どんなレシーバーにも &. を書くことができてしまいます。これはもちろん、nullでないときだけメソッド呼び出しをしたい、と思っている時には便利です。ですが、便利なのは、プログラマがその値がnullableであると 正しく意識できている時だけ の話です。 です。プログラマがnullableかどうかを 正しく意識する手助けはしません 。これがRubyの &. とSwiftの ?. の大きな違いです。

意見: null安全にするためには型推論が必要らしいが、これはコンパイル待ち時間を増やす。生産性が下がるだけだ。

コンパイル待ち時間が増えるのはその通りです。それに関しては確かに、rubyやphpを書くときのような、ちょっと書いてはすぐ動かし、ちょっと書いてはすぐ動かし、という手順を繰り返していく開発スタイルは残念ながらできなくなってしまいます。そこは、型推論ありきの開発スタイルに変えていく必要があります。 具体的には、たくさん書いて、たくさん書いて、たくさん書いて、いっぺんに全部一発で完璧に動かす、というスタイルです。2 そんな事不可能だと思うかもしれませんが、やってみると意外とできます。コンパイラが見ててくれるから僕できるよ、という感じです。型が通っているならバグは無い、という感じです。これについては信じて入門してみてほしいです。

意見: nullになりうるポインタ変数はよく扱う。私はその変数がnullになる場所、ならない場所を考慮できるし、assertionを書いているからバグもすぐに発見できる。今更特に何も便利にならない。

そのようなスタイルは従来型言語で正しいプログラミングだと思います。みんながそのように使いこなすべきです。ただ、万が一にもミスすることはあると思います。そういうときにミスしない機械の補助があれば助かります。一方、正しく意識している限りnull安全が邪魔になることはありません。後置演算子!をちょっと書くだけです。

それよりも、null安全には視点を変えるともう一つ別のメリットがあります。ここやその他多くの場合で述べられているのは、nullable Tのハンドリングを正しくおこなう話ですが、一方で、nonnull Tが結構便利 なのです。話に出てくるようなややこしい処理をするクラスは別として、アプリケーションのあちこちに、ちょっとしたデータ型のような簡単なクラスはよく出てきます。そして、そいつらがnullにならないプロパティを持つ、ということがよくあります。そして、たま~にnullにしたいプロパティが混ざってきたりします。こいつらはたくさんあって、しかもところどころで使われるため、通しで複雑なロジックを書くようなやつとは違って、たまに出てきては、このプロパティnullになるんだっけ?と訪ねながら思考に割り込んできます。null安全ではこうしたやつらについて自分は詳細を忘れてもコンパイラはずっと見ていてくれるので、とりあえずnonnullと思って書いてコンパイルエラーが出たら直せばいいや、という気持ちで扱えるので思考の割り込みを受けません。

意見: 新しい機能を導入すると理解しない人によって新しいパターンのバグが生み出されるだけだ。null安全には意味がない。

理解していなければ新しいパターンのバグが生まれるのはその通りです。これは別にnull安全に限った話ではなくて、どんな技術でも理解しないで使用すれば固有の問題が生じます。なので、 前提として、勉強する時間が取れないなら導入しないほうがいいです。そうではなくて、理解して使ったとき、とても便利なものであるから、理解してほしい のです。でも、勉強にかけた時間はあとで生産性の向上によって取り戻せます。このお釣りが来るか来ないかを、内容を理解して見極め、チームに広めてください。

しかし、新しいパターンのバグとは言っても、深刻なことにはならない場合も多いと考えています。従来の言語しか知らない人が、間違ってnull安全機能を使った場合に起こりうる失敗を具体的に想定してみます。

後置!多用パターン

null安全言語では、nullableなT型の値をT型の値として使おうとしてもコンパイルできません。そこで、コンパイルエラーが出て、ちくしょうと思っていろいろやって、よくわからんが後ろに!をつければコンパイルできるぞ、と言って!をつけまくる可能性です。 これはかなりやばい危険なクソコードといえますが、 この人が書いていた従来の言語と全く等価なコードになっただけです。 導入前より何もよくなってはないですが、悪くなってもいません。さらに、Swiftの場合、危険なオペレーションは記号!を使った文法で表す、という言語設計で統一されています。すると、コード全体に!が散りばめられていて、危険臭がプンプンしてきます 。わかっているプログラマが一目レビューすれば危険性が読み取れます。そういう意味では、従来のコードより、レビューしやすくなっている分だけお得かもしれません。

?.多用パターン

null安全言語では、nullableなT型の値をT型の値として使おうとしてもコンパイルできません。そこで、コンパイルエラーが出て、ちくしょうと思っていろいろやって、よくわからんが後ろに?.をつけてメソッド呼び出しすればコンパイルできるぞ、と言って?.をつけまくる可能性です。このパターンは本来であればnullを分岐しなければならないところで、nullのまま処理をスキップし続けて、後段の処理にnullをぶち込んでしまうという問題を生みます。これは結構やっかいな問題です。しかし一つ救いがあります。そうやって?.を連鎖した最後の値はnullableです。しかし、そうやって得た値を、nonnull Tを取る関数の引数に使うことはできません。最後に使うところで?.の連鎖は止まります。そしてさらに雑にコンパイルを通そうとしたら、そこで後置!を書くことになります。ここで、前述した希望がつながります。

nonnull T誤用パターン

T型で宣言した変数に、nullを代入したくなって、でもコンパイルできない、というパターンです。これはT型からT?型に修正する以外にコンパイルする手段はありません。その修正方法に気がつけなければ、永遠にコンパイルできません。他に手段が無いのだから、コードと格闘するのをやめて、一度勉強してきてくれると思います。少なくとも、実行時にバグを産むコードが作られることはありません。

?? 0 誤用パターン

記事投稿後にネットを見ていて言及されているのを発見しました。nullable型の式が出てきてコンパイルできない、となったところで、とりあえず ?? 0 とか ?? "" とか型に合わせて書いてしまうパターンです。さらにこれがDB操作の中で行われていると、従来はヌルポが飛んでトランザクションがリカバリ制御になっていたものが、そのままDBに間違った値が書き込まれてしまって破壊される最悪の状態が生じる、という意見がありました。このパターンは!をとりあえず付けるのより少しめんどくさいために、僕は存在に気がついていませんでしたがたしかにやっかいな事になりますね。ヌルポの方がマシという意見にも同意します。理解していない人に無理やり導入して貰う場合は、このアンチパターンについては十分伝える必要がありますね。

意見: テストコードを書けばバグはカバーできる。null安全をありがたがっているのは、テストコードが書けない無能プログラマだ。

テストコードを書いてバグを防ぐことは素晴らしいと思います。 null安全は特にテストコードと対立するものではありません 。ただ、テストコードを書く前から、nullに関するバグを発見してくれるものです。 自動で完璧に網羅したテストコードを書いてくれるようなもの です。つまり、テストコードを書く時間を一部省略できるのです。便利ですね。また、テストコードを書くのは人間です。人間はミスをするのでバグを網羅しきれないテストコードを書くことがあります。しかし、コンパイラは機械なのでミスはしません、完全に網羅してくれます。

一方で、nullにまつわる型システム以外のところで生じるバグは発見できません。そこでテストが必要になります。人間はそういう難しい問題を解決するためにエネルギーを使うべきで、機械ができることは機械に任せたら良いと思います。根本的な考え方はそういうところです。テストコードを書いている出発点も、 機械が得意なことは機械にやってもらおう、という発想 なはずです。同じ気持ちです。

意見: null安全がそんなに素晴らしいなら、すでに普及しているはずだ。普及していないのだから、大したものではないはずだ。

null安全を導入するためには、前提として静的型を持ったコンパイル言語である必要があります。現実的な書き心地を考えると、型推論も前提になければ厳しいです。しかしそもそも、phpやrubyが流行り始めたころ、そもそも静的型付けコンパイル言語は冬の時代でした。では、null安全は最近の新しい概念かというと、違います。null安全を実現するためのOption型という機構は1970年代のMLという言語ですら導入されていたそうです。3また、2013年ごろ、モナドが流行りましたね。null安全を実現するためのOptional型は、まさにモナドで、モナドの中でもかなり基本的なやつです。そして、1991年には、モナドがプログラミングに導入されていたそうです。 つまり、null安全自体ずっと昔から導入されているし、それを包含するより抽象的な概念であるモナドも、それなりに昔から導入されていたわけです。ただそれが、広く使われるヒット言語に乗らなかっただけです。
不幸なことに素晴らしいものがとっくに発見されているのに、プロダクションの現場にはなかなかやってこない、ということが起こってしまうのが世の無常だよね、というだけの話です。プログラミング言語にかぎらず、様々な領域において品質とシェアが食い違う現象はよく見られます。別のファクターの影響がでかすぎるんです。

ところでその考え方って、Javaが出てきた後も、頑なにスタティックメソッドを作り続けたおじさんと同じものの考え方だと思うんですけど、大丈夫ですか。ものの良し悪しは中身を理解して判断するべきです。C言語が1972年に出て、Javaが1995年に出るまでの間、C言語にはグローバルなスタティック関数しかありませんでした。いや、今もそうだけども。

意見: nullに関してバグがあっても、ちゃんと動作チェックすれば発見して直せる。それに、製品の網羅的な動作チェックはどっちにしたって必要なことだ。null安全には意味がない。

まずわかりやすく書きます。

→→ 時間の流れ →→
コードを書く → コンパイルする → 動作チェックする → 動作チェックに合格する → ユーザーが利用する


これまでの動的言語:
コードを書く → ミスってnullバグを作り込む → 動作チェックする → nullバグが見つかる → nullバグを直す → ユーザーが利用する → 見逃したnullバグが生じる

これからのnull安全言語:
コードを書く → ミスってnullバグを作り込む → コンパイルする → nullバグが見つかる → nullバグを直す → 動作チェックする → ユーザーが利用する → 機械は見逃しをしない、nullバグは生じない

このように変わります。変わったのは、 動作チェックする前にバグが見つかる ことと、 見逃すバグが無い ことです。もちろん、nullに関するものだけですが。

動作チェックをするためには手を動かす必要があります。自動チェックするためにはそのためのテストコードを書く必要があります。言語機能で提供される安全性はどちらも必要ありません。プログラムが実行できたなら、すでにnullに関してバグはない です。

もちろん、動作チェックをする必要はあります。しかし、そこでチェックしなければならないのは、全てのバグのうちから、nullに関するものが取り除かれ、残っているバグだけです。つまり、チェックするときのバグの発生量が減るのです。だから、バグを直す時間も少なくてすむのです。その分楽に、素早く、開発を進められます。

もう一つ大事なのが、機械はミスをしない、という事です。動作チェックは人間が、もしくはテストコードが行う事です。しかし、人間はミスをします。テストはミスしませんが、テストコードを書くときにミスをします。テストの書き漏らしをします。しかし、コンパイラはミスをしません。確実性が全然違います。

意見: Null Object Patternによってnullにまつわる問題は解決する。

Null Object Pattern(NOP)というのは、ある型Tに対して、サブタイプCTとNTを定義して、nullの代わりにNTのインスタンスを使って、ポリモーフィズムでメソッドの動きを変更することで、 メソッドを呼び出してもクラッシュしないnull を実現できる、というテクニックのことです。これによってnullは攻略できるという意見ですね。

具体的な例を出します。Userインターフェースを定義して、これにNOPを適用します。サブクラスとして、実際のユーザー情報を表すConcreteUserを定義します。nullの代わりに使うNullUserを定義します。Userには年齢を取得するプロパティ、getAgeがあって、これを使って、nullとuserが混ざったリストから、年齢の合計を求めるプログラムを作ってみます。

// java

/* package whatever; // don't place package name! */

import java.util.*;
import java.lang.*;
import java.io.*;

interface User {
    int getAge();   
}

class ConcreteUser implements User {
    public ConcreteUser(int age) {
        this.age = age;
    }

    public int getAge() { return age; }

    private int age;
}

class NullUser implements User {
    public NullUser() {
    }
    public int getAge() { return 0; }
}

/* Name of the class has to be "Main" only if the class is public. */
class Ideone
{
    public static void main (String[] args) throws java.lang.Exception
    {
        List<User> users = new ArrayList<User>();
        users.add(new ConcreteUser(3));
        users.add(new ConcreteUser(5));
        users.add(new NullUser());
        users.add(new ConcreteUser(4));

        int x = getSumOfAge(users);
        System.out.println(x);
    }

    public static int getSumOfAge(List<User> users) {
        int sum = 0;
        for (User user : users) {
            sum += user.getAge();
        }
        return sum;
    }

}

テストデータとして、3歳、4歳、5歳のユーザーと、混ざり込んだnullを含んだ配列を用意して、それをgetSumOfAgeに渡します。getSumOfAgeでは、渡された配列の中身のgetAgeメソッドを呼び出して全部足し合わせるだけでうまくいきます。nullの場合について考慮する必要はありません。なぜなら、NullUserはgetAgeを呼び出すと0を返してくれるので、全部足し合わせればきちんと合計が求められるからです。やったね!

なぜうまくいくのでしょうか。それは、nullの代わりにNullUserのインスタンスが入っているからメソッドが呼び出せるし、そいつがデフォルトの値0を返してくれるからです。要するに、Tとしての振る舞いを求めたがTではなく nullだった場合に必要となるデフォルトの挙動を予めサブクラスの実装として用意しておく ことで、nullの場合の分岐が不要になったという事です。

しかし、やっぱり駄目です。

じゃあ今度は、年齢の合計ではなくて年齢の平均を求めるメソッドを作ってみましょう。平均を求めるには合計を要素数でわればいいですね、合計はさっきのメソッドで出しましょう、要素数はusers.size()ですか?だめですね、それだと、12 / 4 = 3になってしまって、ほしい答え 12 / 3 = 4と異なってしまいます。

じゃあ割る数を正しく3にするために、usersをループしてif + instanceOf NullUserでチェックしてNullでないユーザーの数を計算しますか?それだと結局、x == nullとやる代わりにx instanceOf NullUserと書いているだけですよね。文脈に応じた分岐が要らないのがNOPのメリットだったはずです。

このように場合によっては分岐しないといけないのであれば、これまでのnullハンドリングと同じです。というか、見方によってはもっと状況が悪くなっています。なぜなら、 あらゆる 分岐せずにメソッド呼び出ししている箇所において、(1)デフォルト値が使われることを期待していて正しいのか、(2)instanceOf NullObjectしなければならないが忘れているバグなのか、(3)デフォルト値を使ったらおかしくなるがNullObjectがやってこないことが保証されているから正しいのか、のいずれか3択 であり、コードをレビューする人は、そこで正しいハンドリングは3択のうちどれ なのかを確認しなければならないからです。nullとTだけ使っていたときには、(1)nullが来る可能性を忘れているのか、(2)nullがこないことを前提にしているのか の2択で済みました。 デフォルト値を与える場合は分岐が明示的に記述されるため見た目が異なります。後者のほうがコードの見通しとしてマシです。

では、ifを絶対書かないために、平均を求めるために、自分の個数を返すメソッドgetCountUserに追加しますか?ConcreteUserでは固定値1、NullUserでは固定値0を返すメソッドです。それならこれを全部足せば分岐なしで要素数がわかるので、平均が求められますが、Userの定義は汚くなっていますね。

まだあります。今求めたのは平均ですが、「相加平均」です。世の中には「相乗平均」なんてものもあります。これを実装するにはどうしたら良いでしょうか。全部の年齢を掛け算しようとすると、Nullが来たら0がかけられてしまって値がおかしくなってしまいます。掛け算におけるデフォルト値としては、1が来てくれないとこまります。

掛け算に使うための年齢を取得するメソッド、getAgeForMultiplyUserに追加しますか?ConcreteUserではgetAgeの値を返し、NullUserでは固定値1を返すメソッドです。これなら分岐無しで平均が求められますが、Userの定義がやはり汚くなります。

うまくいきそうだったのに何がだめだったのでしょうか。それは、無効であるときに欲しい値、すなわち期待するデフォルト値は、文脈によって違う という事実に対して、NOPというパターンでは対応しきれないからです。もしさっきの例で、getAgeForMultiplyを追加するとしたら、それは結局、文脈ごとに値の定義を拡張していることを意味します。文脈ごとに拡張していくのであれば、それは都度デフォルト値を記述するnull安全なやり方と同じです。むしろ、実際の文脈が生じている箇所と、その文脈に応じた値を記述する箇所が離れてしまって、コードの見通しが悪くなる と思います。

NOPがわりと使われる領域として、デコードしたJSONを表すクラスをNOPにするという事があります。そうしておいて例えば、jsonがぶっ壊れていてパースできなかった場合とか、JSONオブジェクトに対してjson["name"]とかやってキーでアクセスするときにそのキーの値がなかった場合に、NullObjectを返すようにしておくことで、JSONから値を取得するときにエラーにならずにJSONのオブジェクトを掘っていって、最後のところでgetIntOrDefault(999)とかやってデフォルトとともに取得するパターンです。これはわりとうまくいくケースです。おそらく、JSONをデコードするという文脈において期待する挙動は本当にほぼ1つしか無いのと、そのJSON自体をアプリケーションで取り回すことがなくて、デコードしたオブジェクトのほうに仕事がいくので、その他のややこしい文脈に遭遇しないからです。ですが、実はこのようなケースでは、null安全でも同じように楽ちんにコードを書くことができます。null安全なJSONクラスでは、デコーダーはnullable JSONを返して、キーアクセスでもnullable JSONを返すようにします。すると以下のような感じになります。

let userName = json?["users"]?[0]?["name"]?.stringValue ?? "no name"

このように、アクセスする箇所で?[]?.を使いながら潜っていって、最後のところで ?? を使ってデフォルト値を与えます。このやり方のまずいところがあるとすれば、?1文字ぶんだけコードが長くなっていく事ぐらいです。しかもこの機能はわざわざNull Objectを実装することなしに、言語機能として全ての型に対して最初から提供されているのです。そういう意味では、有用なNOPパターンが欲しいと思ったときにはnull安全言語にはすでにそれが与えられているのです。しかもNOPでは対応できないケースにも対応しているのです。

だから、NOPよりもnull安全言語機能のほうが優れていると思います。

意見: null安全がそんなに素晴らしいなら、その反面高いコストがあるはずだ。生産性が下がるはずだ。

だいたいの場合、技術にはトレードオフがあります。メリットとデメリットが同時にもたらされ、時と場合によってはデメリットの方を高く見積もります。null安全も同じだろう、という意見がありました。

しかしそうではありません。null安全にデメリットはないし、生産性も低下しません。4上記のトレードオフ論は一般的には妥当だと思いますが、例外があります。それは、 そもそも間違っているものを正すことにはメリットしかない ということです。null安全はこの、間違いを修正するタイプのアイデアです。

null安全が修正する間違いというのは、型Tの変数にnullが代入できるという言語設計 です。Java以降このような設計をしてしまった言語がとてもたくさんあります。そしてとても長く使われてきました。そのため、「みんなやってるんだからこれは常識だ、正しいことだ」と考えられています。しかしこれはみんながやらかしてきた壮大な間違いなんです。

なぜ、Tにnullを代入できてはいけないかというと、nullはTではないからです。Tである というのは、Tとして使える ということです。nullはTとしては使えません。Tとしての値を何ももたず、Tとして振る舞うことができません。というか、Tじゃないんだから、振る舞いの定義をしようが無いからです。後述しますが、Tとして無効 を表す概念なのだから当然です。しかし、無理やり代入可能にしてしまいました。
すると、T型の式を評価するとき、常に値がnullである可能性が生まれます。その結果、どうにもならなくて、Null Pointer Exceptionを飛ばすしかなくなってしまうのです。

続きます。

意見: Javaがnullを代入できるようにしたのには、理由があるはずだ。だから正しいはずだ。

そんな基本的な仕様が間違いなわけ無いだろう、ちゃんと理由があるだろう、と思う人もいますよね。しかし、nullを発明したアントニーホーアさんは、それは間違いだったと言っています。そして、「単にそれが容易だというだけで、無効な参照を含める誘惑に抵抗できなかった。」と言っています。この 容易 というのはどういう事か説明します。

Javaでは全てのclass型の変数が参照型であることから話は始まります。T型の値を指す参照型というのは、素直な実装をすれば、Tのデータがあるメモリアドレスを指すメモリとして実現できます。メモリアドレスを指すメモリは、T型の値自体のメモリサイズとは関係なく、32bitとか64bitの整数型のデータとして実現できます。ここでそれに加えて、T型の値はメモリアドレス0番地には絶対に生成されない、というマシン設計上の決め事を作ります。ヒープとスタック領域のアドレス範囲を、0を含まないように規定したりオフセットしたりする、と考えても良いです。そうすると、 数値0を指しているポインタは、Tを指し示さない特別なポインタだ と規定することが可能になります。万が一にも0番地にTの値が存在することが無いからです。(もし0番地にT型の値が現れてしまったら、無効な参照なのか有効な参照なのか区別できませんからね。)これによって、何が嬉しいかというと、 T型の値を指す参照が、無効な参照であることを、T型の参照それ自体で表すことができる ようになります。つまり、 1つの要素で2つの機能がいっぺんに表現できて便利 という発想です。これが、 複雑な機能を達成する上で実装が容易 という話です。しかし、これが2つの機能である、というのが重要です。詰め込みすぎたんです。

ここで、重大な設計上の前提があります。Javaにおいて、class型は全て参照型と言いましたが、参照の型Tの型 は元来全く別のものです。Javaにおいては参照型しか存在しないため、そこに一つのトリックがあることが気づかれにくいですが、本質的なT型と参照型はJavaでも全く異なります。ここで本質的なT型というのは、メモリ上に存在するデータのことです。プログラミング上の構文のことではありません。さて、何が異なるかというと、T型それ自体には有効とか無効とかは無い ことです。Javaでもプリミティブ型は値型で、値型は参照型では無いですね。そして、値型のintには無効とかありません。表現範囲目一杯に意味がもたらされています。T型それ自体に無効はない、というのはそういう意味です。たまにindexOfの返り値で-1が特別な値として無効を表していたりしますが、それはその用途のときだけで、一般のintとしては、0に最も近い負の整数というデータを表現しています。もちろん、その値型の表現として無効を表現可能なのであればその限りではありません。例えばfloat型にはNaNという値がありますね。ですが、一般論としては、T型には無効な値なんてものはありません。とある型が有効無効機能を規定する場合に限ってそういう性質が生まれるのです。だからこそ 値型だけを扱っているときに、言語仕様として例外が飛ぶ余地が無いんです。 さて次に、参照型について大きく発想を変えるべきことがあります。それは、参照という概念一般について、本来有効と無効とかはない という事です。Tについて言ったのと同じことです。ある参照型という概念を考える際、常に有効な参照を考えることができるのです。これまで出てきた 有効だったり無効だったりする参照 とは分けて考えることができるのです。しかしJavaでは、 全ての参照は、有効だったり無効だったりする と、不要な柔軟性を導入し、 そして、常に有効な参照 を無くしてしまったのです。さらにこれに加えて、 T型を直接記述させることを封じた上で、T型への参照の型を「T」と表記させるようにしました。 これらの組み合わせ が全てを崩壊させました。本来分けるべき概念を、混ぜすぎて問題が生じたのです。

ちなみに、Javaより前から存在するCとC++では、T型の値とT型への参照の値(つまり、ポインタ)をコード上の型表記として区別しています。そして、T型の値においては無効な値は存在しません。残念ながら、ポインタについては、 有効だったり無効だったりする参照 として使われています。一方、C++では、ポインタとは別に参照型というものがあります。こいつは素晴らしくて、常に有効な参照の型 です。これをどうやって実現しているかというと、参照型はT型の式から常に作られるようにすることで実現しています。T型の式は常に有効ですから、常に有効でなければならないT型の参照型が安全に生成できるのです。一方、T型のポインターからT型の参照を作ろうとすると、nullだった場合にはぶっ壊れます。有効だったり無効だったりするものから、常に有効なものを作ろうとしているので、もともとが無効だった場合には為す術がないのです。なんだ、参照でもぶっ壊れるんじゃないか、と思うかもしれませんがそれは誤解です。T型のポインタからT型を取り出すところが駄目だったのであって、参照を作る以前にバグを生んでいるのです。ポインタに起因するものです。T型の参照が作れるかどうかは、T型のポインタからT型の式を作り出せた、その次の話です。この、常に有効な参照の型を取り扱えるC++の考え方は、null安全の設計に繋がるところがあるので、参考になると思います。5

さて、T型への参照型からは、T型の値にアクセスすることができます。参照先の実体を使う、ということです。よしそうだ、じゃあ、T型の値にアクセスできるのだから、「T」と書くようにしてしまえ、というのがJavaです。これ自体は問題ないのですが、T型の参照に有効と無効がありえる、という点と組み合わさって壊れてしまいました。 「T」と書いているのにT型にアクセスできない場合ができてしまっている のです。Javaの仕様に慣れている方は、何を当たり前のことを言っているんだ、どうしろと言うんだ、と思うかもしれません。しかし、ここがミソです。 有効無効がありえる参照と、常に有効な参照を完全に区別すれば、前者からT型にアクセス箇所だけnullチェックが必要だとわかるし、後者からのT型へのアクセスは常に成功する のです。そしてこの2者の型としての区別が、null安全な言語で採用されている設計です。 null安全な言語では、ここで書いたJavaの問題点が全て解決しています。

それらをきちんと区別すると、JavaのOptionのように、オブジェクトが1段間接的な表現になってメモリ効率が悪いのではないか、と思うかもしれません。しかしその心配はありません。有効だったり無効だったりする参照が1つのポインタで表現できることは変わらないからです。2種類の参照型、両方共同じように32bit整数で実装できます。つまり、実行パフォーマンス上のロスもないのです。メモリ上のサイズや表現方法は同一ですが、 コンパイル時に型として区別されていることが重要 です。

簡易だったものを分けてしまったら、使うのがめんどくさくなるんじゃないか、と思うかもしれません。いいえ、簡易だったのは言語を実装する側の話です。それを使う我々にとっては、簡易どころか困難をもたらしてきたんです。


  1. Swiftよりもずっと前から発展しているHaskellにはそのようなライブラリが既にあるそうです。 

  2. この意見は型推論ユーザーからの反対意見が結構見られるので僕が少数派かもしれません。静的検査があってこそ少し書いてチェック少し書いてチェックというのを繰り返して、安全な関数を積み上げていくスタイルが有効だ、という意見があるようです。僕の場合SwiftやKotlinをIDEを使いながら書く事が多いです。これらは入力中のソースをIDEが自動で追いかけながらどんどん型チェックしてくれるのでそういう意味では逐次的にチェックしています。ですが、そのうちResultとかObservableとか多用し始めるとその推論が追いつかなくなってしまって、IDEがソースコード入力に置いてけぼりになるような事が多く、最近はここで述べたような意見を持っています。 

  3. コメントでいただきました。 

  4. ここは厳密には嘘が書いてあります。ツッコミが入ったので紹介しておきます。 ただここで言いたいのは一般的にトレードオフが生じる場面とは全く異なる性質の話であるという事です。 

  5. T型のポインターからT型の参照を作る時にぶっ壊れる場合があると書きましたが、クラッシュしたり、しなかったりします。言語仕様としては未定義動作で、そんな失敗は起こらないという前提でコンパイラが最適化を行ったりして(未定義動作になるケースにおいてはなにをやってもいいと解釈できる)、より深いカオスバグを産む可能性すらあります。この点では必ずヌルポが飛ぶJavaはC++より安全になったといえます。また、T型の参照にnullチェックする手段などは無いので型仕様としては常に有効な参照として扱うことが前提ではあるものの、実際にはC++では無効な参照を作る事ができます。ポインタにおいても、ヌルポインタでは無く有効とされるメモリアドレスを指しているが無効なポインタを作る事ができます。これらはどちらも、参照している領域が既に破棄されたオブジェクトだったり(このケースをdangling pointer)、でたらめな整数値をポインタ変数に代入する事などにより得られます。そのため、C++はこれらを取り回す上でバグの可能性が排除しきれておらず、安全ではありません。null安全な言語ではそうした可能性は排除されており、T? -> Tの強制変換時にクラッシュする事が保証されています。いずれにせよこの箇所の記述で伝えたいのは、Java以前にT, T?のような型の区別を超有名言語が達成しているよという事実の紹介で、C++が素晴らしいから使おうぜ、という事ではありません。 

711
649
13

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
711
649