null 制限型と nullable 型
(bugs.openjdk.org)- Java が型に null 性マーカー を付けて、
Foo!はnullを拒否し、Foo?は意図的に許容する プレビュー言語機能 を準備中 - マーカーのない
Fooは null 性が 未指定 のままで既存コードと互換性があり、null 性の異なる型間の変換には警告やランタイム検査が伴うことがある nullを拒否するフィールドと配列はデフォルト値nullを使えないため、インスタンスフィールドはsuper(...)呼び出し前に確定代入され、配列にはコンポーネント 初期値 が必要nullがFoo!に狭められるとNullPointerExceptionが発生し、配列・フィールドへの保存経路では ArrayStoreException と FieldStoreException により汚染を防ぐ- Valhalla の value class フラット化 のような最適化は、
null排除を信頼できるようにする基盤であり、標準ライブラリへの適用や既存コードの自動再解釈は現時点の目標ではない
Java の型に null 性を明示するプレビュー機能
- Java の型に null 性マーカー を付けて、型の値集合に
nullが含まれるかどうかを表現する Foo!は null-restricted 型で、値集合からnullを除外するFoo?は nullable 型で、値集合にnullを意図的に含める- マーカーのない
Fooは null 性が unspecified であり、nullが発生し得るものの、その存在が意図的かどうかは分からない - この機能は
--enable-previewコンパイルおよびランタイムフラグで有効化される プレビュー機能 である
目標と非目標
- Java の参照型が
nullを想定するかどうかを表現し、null 性が異なる型間の変換に対して警告と検査を提供する - 既存の Java コードと 互換 であり、ソース互換性やバイナリ互換性を壊さずに段階的に導入できる必要がある
nullを拒否する型の変数は最初に読み取られる前に初期化されなければならず、別個にコンパイルされたクラスに対してもランタイムでnull拒否が強制される- Valhalla の value class フラット化のようなランタイム最適化が null 制限型を信頼できるようにするため、メタデータと整合性保証を提供する
- 現時点での非目標は次のとおり
- 既存コードを自動的に再解釈しない
- あらゆる可能な
nullをコンパイルエラーにはしない intのような基本型に nullable 形式を追加しない- 現段階では標準ライブラリに言語拡張を適用しない
なぜ必要か
- Java の
String変数にはStringオブジェクト参照またはnullを入れられるが、言語レベルでどちらを意図しているか表現する方法がない - 多くのプログラムは
nullがないことを前提にしているが、Javadoc の仕様や実装コードでそれを一貫して強制するには追加作業が必要になる - この前提が崩れると、
null値が実装コード内を流れ回り、本来のバグから離れた場所で例外を起こす可能性がある - 開発者が型の一部として
nullを拒否または許容する意図を表明すれば、コンパイル時フィードバックとランタイム検査により、予期しないnullをより早く見つけやすくなる - Valhalla では value class 型変数を値の フラット化表現 に最適化できるが、
nullをエンコードするために追加ビットが必要なら、メモリ使用量が増えたり、格納最適化ができなくなったりする - Amber ではパターンマッチ候補の null 性が
switchの exhaustiveness 判定に影響し、型パターンの null 性がnullにマッチするかどうかに影響し得る
null 性マーカーの構文と型構造
- null 性は型の固有の一部として扱われ、
Foo?とFooは null 性が異なるため 別の型 である - 配列型と配列コンポーネント型の両方に null 性マーカーを付けられる
Foo?[]!は、配列自体が null-restricted で、コンポーネントが nullableFooである型- 多次元配列では各角括弧の後ろにマーカーを付けられ、慣例的に左から右へ、外側から内側へ解釈する
- パラメータ化型と型引数にも null 性マーカーを付けられる
Predicate!<Foo?>は null-restricted なPredicateで、型引数は nullableFoo
- null 制限型または nullable 型を表すには、ソース中に
!または?が 明示的に 現れなければならない - 将来的には、クラスやコンパイル単位内のすべての型をデフォルトで null-restricted と解釈し、
?のみを例外として使う方式も検討され得るが、詳細は別作業である
フィールドと配列の初期化規則
- 既存の Java では参照型フィールドと配列コンポーネントのデフォルト値は
nullだが、null-restricted なフィールドや配列コンポーネントの初期値としては不適切である - null-restricted なフィールドと配列は、読み取られる前にプログラムが常に初期化しなければならない
- null-restricted なインスタンスフィールドにイニシャライザがない場合、各コンストラクタの明示的または暗黙的な
super(...)呼び出しの前に 確定代入 されていなければならない- Flexible Constructor Bodies JEP は、コンストラクタ先頭で必要な初期化コードを書けるようにする
- この early construction context では、
thisを参照したり、未初期化フィールドを読み取る危険がある操作は許可されない
- null-restricted なインスタンスフィールドにイニシャライザがある場合、各コンストラクタの開始時に
super(...)呼び出し前に実行されるthis(...)を呼び出すコンストラクタは、従来規則どおりイニシャライザを実行しない特殊ケースである
- null-restricted な静的フィールドは、クラス内のすべての静的イニシャライザと初期化ブロックが終了するまでに確定代入されていなければならない
- 他クラスがクラス初期化中にそのフィールドを読み取ろうとすると、ランタイム検査が早期読み取りを検出して例外を投げる
- null-restricted なコンポーネント型の配列は、配列生成式で各コンポーネントの 初期値 を提供しなければならない
- 配列イニシャライザで値をすべて列挙できる
- 新しい短縮構文も可能だが、構文はまだ TBD である
式の null 性と null 性変換
- Java コンパイラは型検査の過程で、すべての式の null 性 を決定する
- 変数参照の null 性は変数宣言から来て、メソッド呼び出しの null 性は参照先メソッドの戻り値型から来る
nullリテラルは nullable である- それ以外のほとんどの参照型式は null-restricted である
- リテラル、文字列連結、
this、クラスインスタンス生成、配列生成、メソッド参照、ラムダ式が含まれる
- リテラル、文字列連結、
- null 性変換は代入、呼び出し、キャストの文脈で許可される
- 拡大型 null 性変換 には次が含まれる
Foo!→Foo?Foo!→ 未指定のFooFoo?→ 未指定のFoo- 未指定の
Foo→Foo?
- 縮小 null 性変換 には次が含まれる
Foo?→Foo!- 未指定の
Foo→Foo!
- 縮小 null 性変換は unboxing 変換と同様にコンパイラが自動実行するが、ランタイムでは動的検査が行われ、
NullPointerExceptionを起こし得る nullリテラルを null-restricted 型へ直接変換しようとする試みはコンパイル時エラーである
ランタイム検査と例外
- ランタイムで
null値が null-restricted 型に縮小 null 性変換されると NullPointerException が発生する - ソースコードに明示的に見えない縮小 null 性変換も、実行時には発生し得る
- null-restricted なコンポーネント型を持つ配列は、ソースコード上ではより一般的な型として扱われても、通常の配列格納検査で
null値を拒否する- この変換失敗は ArrayStoreException を引き起こす
- コンパイル時点では null-restricted ではなかったフィールドが、後の別コンパイルで null-restricted になった場合、新しい field store check が
null格納を拒否する- この変換失敗は FieldStoreException を引き起こす
- オーバーライド関係にあるメソッド呼び出しでは、上位メソッドのパラメータ呼び出し型への変換の後、オーバーライド先メソッドのパラメータ型への変換が続くことがある
- メソッド戻り値も、宣言されたメソッド戻り値型へ変換された後、呼び出し地点で期待される戻り値型へ変換され得る
ジェネリクス、型引数、オーバーライド
- 型変数の使用にも null 性マーカーを付けられ、
T!は null-restricted 型、T?は nullable 型である - null-restricted および nullable な型変数型は、ジェネリックコード内部で特定の null 性を主張する
- 型引数として使われる型も null 性を表現でき、型変数型に付いた null 性マーカーは、型引数で主張された null 性を上書きする
- ジェネリック API の erased 実装内部では null 制限を強制できない
- ただし、ジェネリック API 境界で発生する通常の暗黙キャストが、ランタイムで null-restricted な型引数を強制する
- 相互運用性のため、型引数内の null 性は厳格には強制されない
Predicate<String!>はPredicate<String>またはPredicate<String?>に変換できる- このような unchecked nullness conversion は警告を引き起こし得る
- 配列コンポーネント型の null 性変更も unchecked nullness conversion として許可され、どの条件でランタイム検査が行われるかは TBD である
- メソッドシグネチャの同一性判定では null 性は無視される
- パラメータや戻り値型の null 性が一致しなくても、一方のメソッドが他方をオーバーライドできる
- 異なる API が独立に null 性マーカーを導入すると、このような不一致は一般的になり得る
- null 性はメソッド適用可能性に影響せず、型引数推論失敗の原因にもならないが、ジェネリックメソッドの戻り値型に対して推論される null 性には影響し得る
- 推論アルゴリズムの詳細は TBD である
コンパイラ警告とエラー
- 型を null-restricted にすると、新たなコンパイル時エラーが発生し得る
- その型のフィールドや配列が初期化されていない場合
nullリテラルをその型へ変換しようとする場合nullリテラルを null-restricted 型式と比較する場合も、コンパイル時エラーになり得る
- それ以外の状況では、null 性解析は補助的であり、コンパイル時エラーは生じない
javacはランタイムエラーを避けるための警告を提供し、IDE や他の解析ツールにも同様の方向性が推奨される- 想定される警告要因は次のとおり
?型に由来するものを含む縮小 null 性変換?型の式をメンバーアクセスやその他の null-hostile な演算に使う場合- 型引数の null 性が境界と整合しない場合
- メソッドパラメータや戻り値の null 性がオーバーライドされたメソッドと一致しない場合
- 型の null 性を変える unchecked conversion
クラスファイル、リフレクション、補助的変更
- ほとんどの null マーカー使用は
classファイルでは消去され、対応するランタイム変換はバイトコードに直接表現される Signature属性の構文は、型内で!と?を許可するよう更新される- null 性はメソッドやフィールドの descriptor にはエンコードされない
- フィールド汚染を防ぐため、新しい
NullRestricted属性が、そのフィールドがnull値を許容しないことを示す- このフィールドには
ACC_STRICTも付与される必要があり、厳格初期化されなければならない - ベリファイアは、コンストラクタが
super(...)呼び出しを行う時点で、厳格初期化インスタンスフィールドがすべて代入済みかどうかを確認する - フィールドへの書き込みはすべて
null値を検査し、見つかった場合はFieldStoreExceptionを投げる
- このフィールドには
- null-restricted 配列の生成は
anewarray命令ではサポートされず、リフレクション API 呼び出しで行う必要がある Foo!.classやFoo?.classのリテラルはなく、対応するjava.lang.Classインスタンスも存在しない- 新しい RuntimeType API は、配列およびフィールド格納検査でランタイムに強制される型集合を記述し、すべてのクラス型とインターフェイス型の null-restricted な変形を含む
FieldAPI はフィールドのRuntimeType取得をサポートし、この値はgetTypeの結果と異なる場合があるArrayAPI は、コンポーネント型をRuntimeTypeで表すnewInstanceの派生版をサポートする- この派生版は配列コンポーネント初期値の提供も許可する
- 初期値なしで null-restricted 配列を生成しようとする試みは拒否する
- 従来のデシリアライズは null-restricted なフィールドや配列と互換性がなく、別の JEP で、未初期化の null-restricted フィールドや配列を露出しないシリアライズ機構を提供する予定である
javadocが生成する文書には null 性マーカーが含まれるjava.lang.reflect.Typeとjavax.lang.modelAPI は型表現に null 性をエンコードする
代替案と依存関係
- Java エコシステムのさまざまな開発ツールは独自のコンパイル時 null 追跡を実装してきたが、Java 言語自体を変更しないため、構文は主に annotation に制限され、影響できる動作もコンパイル時検査に限られる
- 他言語では型システムで null 性を追跡しており、多くの言語はデフォルトで null-restricted で、明示的な null チェックなしに null-restricted 型へ代入することをエラーと見なす
- Java ではこの機能は選択的でなければならず、単一の大規模移行なしに段階的に利用できる必要がある
- ランタイムでの null 性強制は、明示的なチェックや
Objects.requireNonNull呼び出しでも実装できる- しかし一貫して適用するのは煩雑で、追加の文書化が必要になり、プログラムの可読性も下がる
- フィールドや配列のような変数格納先に直接適用する方法はない
- 前提条件は Flexible Constructor Bodies (Second Preview) である
- コンストラクタが
super(...)呼び出し前に文を実行してインスタンスフィールドに代入できるため、null-restricted フィールド初期化要件を可能にする
- コンストラクタが
- 今後の作業には次が含まれる
- Null-Restricted Value Class Types (Preview): null-restricted な value class 型フィールドと配列のエンコード最適化
- JEP 402: Enhanced Primitive Boxing (Preview): 言語で暗黙ボクシング変換をより広く使う際の null 性追跡
- JVM のクラスおよびメソッド特殊化(JEP 218 および改訂版): 一部の型引数の null 性を具体化して強制
- 追加で考えられる将来改善には、標準 API の一部への null 性マーカー適用、バイトコードにおける簡潔な null check 表現、null-restricted メソッドパラメータに対するより強い低レベル強制、特定文脈のすべての型を暗黙的に null-restricted と見なす言語メカニズムが含まれる
1件のコメント
Hacker News のコメント
このアプローチが、数年前に C# が導入した方式と違う点が興味深い。C# はプロジェクトで null 許容性を有効にすると、明示的に nullable と示さない限りすべての変数が non-null として宣言される。一方、この提案では既存の変数が事実上、nullable、明示的 nullable、明示的 non-nullable のいずれかになる
Kotlin も JVM 言語だが、C# のように明示しなければ non-null と見なす。ただし Kotlin には後方互換性の負担がない。
lateinit varによって、non-nullable 型を別のメソッドで初期化するまで空のままにでき、初期化前にアクセスすると専用の例外を投げる回避策も提供しているなぜ3つの選択肢を用意したのか気になる。注釈のない変数は nullable のままにして、注釈のある変数だけを明示的 non-null にすればよいのではないかと思う。何も印を付けなくても自動的に nullable なら、あえて nullable を宣言したい理由が思い浮かばない
C# の方式のほうが好みだが、この方式にはレガシーコードベースで null 許容性の問題をすべて解決しなくても使えるという利点がある。逆に C# は null 許容性の問題を即座にあぶり出すが、この提案ではこれまでどおり隠しておくことができる
また「あるメソッドは、引数と戻り値の null 可否が一致していなくても別のメソッドをオーバーライドできる」という部分は奇妙だ。コールバックをオーバーライド/実装する際、元のメソッドが non-null 戻り値を指定しているのに null を返す足撃ち銃になりそうだ
時間の都合で今は一部の重要箇所を除いて注釈だけを有効にする方針を取っているが、意識的に決めた箇所をすぐ確認できるよう、JetBrains の
NotNullAttributeとCanBeNullAttributeをまだマーカーとして残している。後者は nullable に明示マーカーがあるので削除できるが、前者は C# 自体の機能と名前が衝突するそういう意味で、3つの選択肢はかなり望ましい。コードが何十万行もあると、素早く簡単に移行するのは難しいからだ
別の社内プロジェクトでは、触るコードの周辺に
#nullable enableを散りばめて、段階的に null 許容性の範囲を広げている。新しいコードは nullable コンテキストでなければならないという条件も置いている。これもすでに注釈済みの部分を明示するには悪くないが、はるかに小さいコードベースとチームだから可能な方式だString!のように感嘆符で表示する「プラットフォーム型」という用語と感嘆符の記号は紛らわしいが、それ以外では Kotlin の方式はかなりうまく機能している。Kotlin には最初から null 許容性があったため、プログラマーがプラットフォーム型を直接指定することはできないが、JVM や JavaScript のように null 許容性が不明確な基盤プラットフォームと互換性を保つには依然として必要だ
このアプローチでは、デフォルト値はなお妥当だ。基本的には常に non-nullable であるべきで、そう考えない人は Tony Hoare から何も学んでいないということだ。同時に後方互換性も維持される。Kotlin は比較的容易だったが、Java と C# は既存ソースコードとの互換性も守らなければならない
Java 方式も C# 方式も理想的ではない。C# 方式はコンパイラフラグによってコードの挙動を大きく変え、Java 方式はデフォルト値を最悪の選択肢にしてしまう
それでも C# 側により傾く。「たぶん null 可」を最も簡単な選択肢にすると、大半のプログラマーがデフォルトでそれを選ぶだろうからだ。特に Java のように企業向け色の強い言語ではなおさらそう思う。リンターとコンパイラ警告は長期的には役に立つだろうが、大半の Java コードが null 許容性をきちんと注釈するまでには何年もかかりそうだ。C# ユーザーは短期的にはより苦しむだろうが、明確な null 許容性という目標にははるかに早く到達する可能性が高い
https://kotlinlang.org/docs/java-interop.html#null-safety-an...
https://www.infoq.com/presentations/Null-References-The-Bill...
T?は nullable、T!は non-nullable を意味し、新しくコンパイルされるソースの通常のTのデフォルト値は、ソースファイル単位、package-infoファイル単位、またはコンパイラスイッチ全体で指定するpragma に似た宣言を置くものだったその後の LTS Java リリースでグローバルなデフォルト値を変えればよい。そうすればプロジェクトの移行が容易になり、pragma を自動的に挿入/更新でき、必要なら以前のデフォルト値も維持できる
さらに既存の JSR-305
@Nonnull注釈を、クラスファイル内で non-nullable 型の出現を示す方式として活用していれば、古い JDK との双方向互換性も提供できたはずだ!記号を書かなくても、特定のコンテキストのすべての型が暗黙的に null 制限されることを言語で表明するメカニズムを提供する」というセクションがあるコンパイラフラグやモジュールタグ程度でもよさそうだ
それでも、別途指定がなければ自動的に non-nullable と仮定するコンパイラフラグが出てくるだろう
よさそう。ようやく言語レベルで、何千もの不要な例外や null チェックを取り除く方法ができた。ただ、null かどうかを絞り込む自動変換は違和感がある
提案書の例にある
String? id(String! arg) { return arg; }、String s = null;、Object! o1 = s; // NPE、Object o2 = id(s); // NPE、Object o3 = (String!) s; // NPEでは、少なくとも前の2つはコンパイルエラーになるべきではないかと思う最後は明示的なので微妙だが、むしろ
if (s != null)の中ではコンパイラが実効型をString!と認識してString! ss = s;を許可する、という形がよさそう。そうすればエラーの可能性がない実際、最後のケースのキャストよりも
Objects.requireNonNull(s)を使うほうが明示的なので好み。ただし、明示的なチェックを回避しつつ、最適化のために妨げられる場合だけを除外するObjects.unsafeForceNonNull(s)のようなものもあるとよい。unsafe メソッドがあれば、複雑な静的解析を追加しなくても自前でrequireNonNullを実装できる残念ながら、これは実行時にしか確認されないだろう
たとえば標準ライブラリは、少なくとも当面はnull 型へ移行しないと明示している
パッケージ、少なくともファイル単位で、すべての変数をデフォルトで non-null として示す方法がぜひ必要に見える。そうでないと、安全性のためにほぼすべての変数に
T!構文を書こうという主張が強まり、ノイズばかり増えるだろう!記号を書かなくても、特定のコンテキスト内のすべての型が暗黙に null 制限されることを言語で表明するメカニズムの提供」がある@Nullで注釈を付ける必要がある問題はライブラリと相互作用するコード境界でだけ起きる。この新しい構文も似たようなものになりそう
「現時点では、言語改善を標準ライブラリに適用することは目標ではない」という部分は残念
やむを得ず PHP を使った経験からすると、データについて事前に保証しておいた性質を、巨大な標準ライブラリと相互作用するたびに剥がしたり積み直したりしなければならないのは面倒だ
Java もこうした表現力を標準ライブラリにもっと積極的に取り入れ、第一級市民にすべき
Optionalを使うレガシーコードは、明示的な nullable/non-nullable 提案にとって負担になる。レコード型もデフォルトで non-nullable にできたはずなのに、と思うまた、2段階に分ければ、この機能をプレビュー機能としてより容易にリリースし、フィードバックを受けたうえで設計を確定できる。一度に全部やろうとすると、機能を実際に反復改善する余地がほとんどなくなる
Java にこの機能が入るといい。
T?のような言語レベルでの明示的な選択可能性は、Kotlin と TypeScript で開発者の生活の質を大きく上げてくれた。Java には NullAway のようなツールがあるが、面倒だ言語レベルのサポートは
Optional/Maybeよりずっと良いと思う。コードをmap/flatMapのレールに乗せるより、実際のロジックに集中させてくれるからだhttps://github.com/uber/NullAway
単に別の JVM 言語を使えばいいのではないかと思う
サイトが現在落ちているので、アーカイブリンクを置いておく: https://web.archive.org/web/20240802081039/https://bugs.open...
「発生し得るすべての null 値をプログラムが明示的に処理することを要求するのが目標ではなく、処理されていない null 値はコンパイル時の警告にはなり得るが、エラーではない」というのは悪い判断だと思う
Java は大半が静的型付け言語なのに、なぜまた別の動的な振る舞いを入れるのか分からない。こうした警告をエラーに昇格させる簡単な方法があることを願う
すべての場合に null/nil の処理を型システムが強制する言語のほうがはるかに良いのは確かだ。ただ、現在の Java はそういう言語ではない。それでもこれは大きな改善になるはず
こういう教訓を学ぶのが遅すぎるのが残念。デフォルトで non-nullable、デフォルトで不変、デフォルトで最も狭いスコープであるべき
新しい設計では、「安全な道に落ちる」ようにするより、目先の利便性を選びすぎている。安全なデフォルト値には、はるかに慎重な設計とユーザー体験が必要だが、その結果、ほぼすべての言語・プラットフォーム・技術に足を撃つ銃が大量に生まれている。土木や電気工学には規格があるのに、ソフトウェアは30年くらいごとに新しい言語や技術で同じ教訓を学び直している
Java は、細菌を殺すには水を沸騰させればよいと学んでいる第三世界の村のようだ
Facebook でやった仕事の大半は Hack を使うことだった。null であるかどうかは Hack の型システムの中核要素で、無駄なエラーを本当にたくさん解消してくれる
もちろん、予期しない null をまったく受け取らないという意味ではない。この機能も後から言語に追加されたため、PHP 由来のレガシーな
mixed型がまだ多く、これは実質的に何でもありという意味だったまず nullable な配列はどうなるのか気になる。
String![]の例はオブジェクトが null になり得る場合を示しているが、配列そのものはどうなのだろう? Java ではString labels[] = null;は完全に合法だ。ではString![]! labels;のように宣言する必要があるのだろうか?Hack では
vec $fooは foo も要素も null ではなく、?vec $fooは要素は non-null だが foo は null 可能という意味になる。実際、null 配列を使う理由はほとんどないので、デフォルトは null 不可であるべきだ。ただし Java には、すべてのレガシーコードが null 可能性を前提としているという問題がある提案書の
Object! o1 = s、Object o2 = id(s)、Object o3 = (String!) sの例では、2番と3番はコンパイルエラーであるべきではないかと思う最後に、Java のキャストより Hack の
as強制演算子のほうが好みだ。例えばfoo($b)はコンパイルエラー、$b as Aは null なら実行時エラー、$a as ?Bは B ならキャストし、そうでなければ null を返す、という具合だ結局のところ、Java SDK にこれをかぶせられるのか、レガシーコードではどのような姿になるのかが問題になる
https://docs.hhvm.com/hack/types/nullable-types
String![]? labels = null;になると思うKotlin の良いものが今や Java に全部入ってきているようだ
それでも Lombok のようなものを相手にしなくて済む Kotlin で働き続けたい。Java レコードは良いけれど