日々常々

ふつうのプログラマがあたりまえにしたいこと。

2021年のJavaとnullの話

ブログの下書きを眺めてたら長文がみつかったので供養しておきます。 TODOがDOされるかはわかりません。心の目で見てください。


Javaには null があって、この子がいない世界がいいんですけど、いなくなったりはしてくれないので、仕方がありません。いい感じに付き合っていきましょうねーって話を書きます。関連エントリとかスライドはこの辺。

以降は長文ですが、この手のは実務でいくつかの条件を満たした上での話になります。 なるべく書いたつもりですが、無意識に前提としているものはスコーンと抜けているでしょうし、まるっきり同じ条件でも「こちらの方がいい」という選択肢もあっていいと思います。その辺の話は実務でやりたいなーって思ってます。

本稿は「Javaの話」として書いてます。「他の言語にすればいい」とかは異なる話です。

私の基本的な考え

NullPointerException(以降NPE)は起こるべきところで起こって良い。ダサかろうと予期しない null が混入した際に発生する適切な例外はNPEであり、他の何かで取り繕う必要はない。

予期しない nullnull チェックで誤魔化さない。null チェックして null を返すようなものは延命措置にしかならないし、そんな延命してる間にどんどん傷は深くなる。フェイルファスト。

そのような null は混入しうる境界で検疫し、丁寧に取り除く。実装は冗長で良い。下手に格好良く null を回避できると、一律それを行うような思考停止を招きかねない。 null は必要なところでだけで、泥臭くダサく目立つ形で取り扱う。

もう少し詳しい話

(なんかどっかで書いたと思うんだけどなぁ、と思いながら)

null が混入するタイミングはそれなりに限定的であり、予測可能です。

  • 自身で null を明示的に使用した場合。変数へ代入したり、メソッドの引数や戻り値にすることで伝染する。比較での使用は問題ない。
  • 参照型フィールドのデフォルト値。
  • フレームワークに設定される引数。
  • ライブラリのメソッドの戻り値。

このうち前者2つは自身の実装の話です。フィールドのデフォルト値は完全コンストラクタに拘れば、自身が混入させるのは null を明示的に記述した場合に限定されます。コンストラクタ引数に null が勝手に突っ込まれるのは、後のフレームワークやライブラリの話。

脱線: ちなみに JIGバイトコードから null を見つけて警告しています。自身のコードによる混入チェッカー。作者の null 嫌いがわかりますね(ただの自己紹介)。

自身で制御しづらいのは後者の2つですが、フレームワークやライブラリの実装都合との接点で null は片付けてしまうように設計します。 最近のフレームワークやライブラリは null の代わりに Optional を選べるものも増えてきましたが、これは別に解ではありません。てか Optional であっても考慮しなきゃいけないことには代わりないので、可能であれば null もemptyな Optional も取り除き、予期しない値であればここで素直に例外を発生させます。予期できる null であれば、適切な取り扱いをこの境界で決定し、コアである処理には持ち込まないようにします。

<<<TODO 例示>>>

null は言語都合や実装都合で混入するものなので、コアなところに null は入ってきて欲しくないわけです。Javaという言語上どこにでも入りうる null をそのままにしておくと、本当にやりたいことが null の考慮に圧迫されます。考えなければならないことを減らすが設計の基本だと思っているので、安全な領域を定めて境界で弾く。弾き損ねたものは素直にNPEのフィードバックを受け取る。そんな感じです。

nullセーフな演算子

たとえばGroovyには ?. があります。 null だった場合も気にせずにメソッドが呼び出すかのように記述でき、 nullの場合は呼び出されずにnullが返る便利さんですね。他の言語でも似たような機能は結構見ると思います。局所的にはシンプルなコードが記述できます。

これは少なくとも私がJavaで業務処理を書く時には存在していて欲しくないものです。 道具が悪いのではなく使い方の問題ではあるんですが、「hoge.method() と書けるものであっても hoge?.method() と書いておけば安全」のような思考停止を招くからです。一律やるのは避けたとしても、「NPEが発生したら .?. に書き換える」ようなことが起こります。私は意志が弱いので、その引力に負けない自信はありません。そしてきっと後悔する。

これは「強力な機能が設計の歪みを覆い隠す」パターンだと思っています。 null が混入しなくなった場合に除去するフォースも働きません。そしてそのコードがある限り、 null は混入しうるのか?と疑心暗鬼になります。1箇所あるんだから、他でも入ってきたりするんじゃ……と。そんなノイズを入れながらコードを読み書きしたくないんだ。

でももし言語機能として持ってたら、特定の文脈(ライブラリとかフレームワーク作ってる時とか)では喜んで使うと思う。便利は便利だからねぇ。

JEP 358: Helpful NullPointerExceptions

2021年風味を混ぜておきます。 Java14で入ったJEP 358は、今年9月リリース予定であり、しばらくのスタンダードになると思われるJava17で使用できます。 これは何かというと、メソッドをチェーンしている場合などの途中でNPEが発生した場合、どれが null かを教えてくれる機能です。 以下は 16.0.0-librca での実行例です。

jshell> class A { Object obj;}
|  次を作成しました: クラス A

jshell> var a = new A()
a ==> A@5f184fc6

jshell> a.obj.toString()
|  例外java.lang.NullPointerException: Cannot invoke "Object.toString()" because "REPL.$JShell$12.a.obj" is null
|        at (#3:1)

jshell>

Java13以前で a.obj.toString() のようなコードでNPEが発生した場合、スタックトレースには行番号しか表示されず、anull なのか objnull なのかわかりませんでした。そのためNPEの解析では一時変数で受けてみたり、aa.obj それぞれに対して null チェックを行ってみるなどの足掻きが必要でした。デバッガなどを使用しない場合、コードを変えないと解析が困難なこともあり(少なくともスタックトレースから瞬時に判断はつかない)、NPEが嫌煙される一因だったかと思います。

Java14以降ではObjects#requireNonNull(Object) メソッドや自身で null チェックしての例外送出より、そのままNPEを起こした方が解析しやすいまであります。予期しないNPEには備えるんじゃなく、予期しないnullを教えてくれるNPEを素直に受け止めるのがいいと思います。

nullを許容する場合

ライブラリが要求するAPIになっている場合と、閉じたスコープで使用する特殊値です。

クラスに閉じたフィールドで、最も軽量に扱える特殊値はおそらく null であり、それ以外の値を使用するのはコストが上回ります。 ただその null が外部に流出しないように設計/制御する必要はありますが、制御可能な範囲において null 以外を頑張って使用するよりかは扱いやすいです。これは単に null が言語に組み込まれた特殊値であるが故で、使えるから使うと言うスタンスです。決して「 null が最適解」ではないので、誤解されぬよう。。。

まとめ

NPEがダサいって気持ちはわからなくはないですが、NPE以外の予期しない実行時例外もどうせダサいです。NPEじゃなくす努力なんて要らないんじゃないかなって。

あー null 無くならないかなー。

おまけ(2024-05-12T0:03)

投稿したらサムネに出た画像をおまけでつけておきます。

たぶん <<<TODO 例示>>> でこうやって棲み分けるんだーみたいなことを書こうとしてたんだと思います。 ファイル名が 20210421013620.png とかから察するに、あれがアレで。もう覚えてない。 Miroで書いてるっぽいからどこかのボードに残骸はあるんだろけど……