Java

Checker FrameworkによるJavaのNullnessの静的解析

More than 3 years have passed since last update.

Checker Framework を使うとJavaのソースコードに対してNullnessやコンストラクトされていないオブジェクトの逸出などを静的解析によって検出することができます。これまでもEclipseやIntelliJなどのIDEにもそのような機能はありましたが、Checker Frameworkの場合はIDEから独立したツールなので、CIなどで走らせることもできて使いやすいです。このChecker FrameworkはOracleのドキュメントブログなどでも登場しています。ここでは導入などは省き、Nullnessに対してどのような検出が可能で、どのような対処ができるのかを説明します。


基本的なNullness Check

次のようなメソッドを考えます。

String toString(Message obj) {

return obj.toString();
}

Checker Frameworkはすべての型を基本的に@NonNullとみなすため、このコードは検査に通ります。次のようなコードは通りません。

String toString(@Nullable Message obj) {

// error: [dereference.of.nullable] dereference of possibly-null reference obj
return obj.toString();
}

次のようにしても通りません。

String toString(@Nullable Message msg) {

if (msg == null) {
// error: [return.type.incompatible] incompatible types in return.
return null;
// found : null
// required: @Initialized @NonNull String
}
return msg.toString();
}

これだと通ります。1

@Nullable

String toString(@Nullable Message msg) {
if (msg == null) {
return null;
}
return msg.toString();
}

msgnullのときだけ返り値もnullになる可能性があることを示したいときは@PolyNullを使います。

@PolyNull

String toString(@PolyNull Message msg) {
if (msg == null) {
return null;
}
return msg.toString();
}

@PolyNullは同一シグネチャにある他の@PolyNullと連動して、全部が@Nullableになった場合と全部が@NonNullになった場合の2つのシグネチャがあるように振る舞います。上の例の場合は、msg@Nullableだった場合はnullを返す可能性があるので返り値も@Nullableに、逆にmsg@NonNullだった場合は必ず戻り値も@NonNullになります。@PolyNullが提供する機能はあまり直感的ではないですし、限定的です。

次のようなコードはチェックすることができません。

String firstNonNull(Message a, Message b) {

if (a != null) {
return a.toString();
}
if (b != null) {
return b.toString();
}
return null;
}

この例ではaまたはb@NonNullであったときだけ返り値も@NonNullであってほしいのですが、そのような表現をすることは、現在のChecker Frameworkではできません。


Genericsに対するNullness Check

Checker Frameworkは型変数に対してもNullnessを調べることができます。しかし、型変数は通常の型と違い、デフォルトで@Nullableのような動きになります。

<T> String toString(T obj) {

// error: [dereference.of.nullable] dereference of possibly-null reference obj
return obj.toString();
}

これを回避するために、次の2つの方法が考えられます。



  • <T>@Nullableでも動くようにif (obj == null)のようなチェックを入れる。


  • <T>@NonNullしか取れないように制限する。

前者は基本的なNullness checkと同じなので、後者について見ていきましょう。

<T extends @NonNull Object> String toString(T obj) {

return obj.toString();
}

Java8よりアノテーションは型に対してもつけることができるようになりました。ここでは型変数<T>に対する制約条件として@NonNull Objectを指定しています。先に述べたように、通常の型については@NonNullがデフォルトになっているので、実際には<T extends Object>でも同様の効果が得られます。


Nullnessと型階層

ここで一旦Javaの型階層とGenericsについておさらいしておきましょう。

Javaのすべての型はObjectを継承しています。なので、次のような2つのクラス定義は、実質的に同じです。

class Message {

}

class Message extends Object {
}

Genericsの型変数に対しては、どのような型を継承している必要があるかを指定することができます。例えば次のようなメソッドの場合、MessageまたはMessageを継承したオブジェクトしか引数として渡すことができません。

<T extends Message> void method(T obj) {}

このような継承を続けていくと、例えば次のような構造になります。

Types

これをNullableが入った型の世界に拡張すると次のようになります。

Nullable Types

ちょっと複雑になりましたが、詳しく見るとそこまでではありません。例えば@NonNull RequestMessage@NonNull Messageにも@Nullable RequestMessageにもなれます。

void nullness(@NonNull RequestMessage msg) {

@NonNull Message nonNullMessage = msg;
@Nullable RequestMessage nullableRequestMessage = msg;
}

@NonNull RequestMessageから@NonNull Messageへのキャストは、通常のRequestMessageからMessageへのキャストと変わりません。新しいのは@NonNullから@Nullableへのキャストです。これも確かにnullじゃない値をnullかもしれない変数に代入すると考えれば、特に大したことではありません。逆にnullかも知れない値をnullではないとされている変数に代入しようとすると、それはダメだということになります。

void nullness(@Nullable Message msg) {

// error: [assignment.type.incompatible] incompatible types in assignment.
@NonNull Message nonNullMessage = msg;
// found : @Initialized @Nullable Message
// required: @UnknownInitialization @NonNull Message
}


もう一度先ほどのGenericsに対するNullness checkを見てみましょう。

<T extends @NonNull Object> String toString(T obj) {

return obj.toString();
}

ここで@NonNull Objectを継承しているのは、先の図の中では@NonNull Object@NonNull Message@NonNull RequestMessageしかありません。型変数<T>として使えるのは@NonNullのオブジェクトしか無いことになります。

最初に型変数はデフォルトで@Nullableのような動きになると述べました。これは制約がない型変数<T>は、基本的にすべての型が取れるということになるはずなので、型階層の一番上、つまり@Nullable Objectが暗黙の制約条件として選ばれるからです。


Polymorphic Nullness

GenericsはNullnessに対してもPolymorphicな動きをするため、@PolyNullと同じようなことを簡潔に表現できます。

void modify(Message msg) {

}

<T extends @Nullable Message> T modifyIfNonNull(T msg) {
if (msg != null) {
modify(msg);
}
return msg;
}

@Nullable
Message nullableMessage(@Nullable Message msg) {
return modifyIfNonNull(msg);
}

Message nonNullMessage(Message msg) {
return modifyIfNonNull(msg);
}

modifyIfNonNull@Nullableな引数をとったときは@Nullableな値を返す一方で、@NonNullな引数ととったときは@NonNullな値を返しています。これはnullableMessagemodifyIfNonNullを呼んだときは<T>@Nullable Messageになっていて、nonNullMessagemodifyIfNonNullを呼んだときは@NonNull Messageになっているからです。


JavaのNullnessに対する基本戦略

基本的にJavaのすべての変数は@NonNullByDefaultであるべきです。特別な理由がない限り、@NonNullは単に冗長なだけなので、つけることはしません。これはChecker Frameworkにおけるデフォルト動作にもなっていますし、ドキュメントでも@NonNullを書くのは極稀であると書かれています。必要な部分だけ@Nullableをつけるようにしましょう。また、Preconditions#checkNotNullのようなNullnessに対する防御的プログラミングも推奨しません。静的解析によってこういった性質はコンパイル時に保証されるので、単に冗長なだけになってしまいます。

Java8よりOptionalが導入されましたが、Optionalの仕様策定側からもコメントがあるように、OptionalはLambdaをベースにしたコーディングスタイルと適合するようにデザインされたクラスです。OptionalによってJavaの言語レベルでNullnessを明示できるのは結構なことですが、これをnullの代用として使い、if (obj != null)の代わりにif (obj.isPresent())を使うのは、あまり効果的ではありません。2Lambdaを使ったコードベースであっても、Checker Frameworkが提供するNullnessの解析は依然として有効ですし、Genericsを使ったPolymorphicなNullnessの保証は、限定的ではあるにせよ、Optionalより綺麗に表現することができます。





  1. 普通はこういう状況では空文字列や"(null)"を返すようにする気がしますが、ここではサンプルなので無視します。 



  2. Javaにおいてnullが非常によく嫌われる傾向がありますが、よく設計されたシステムではnullがいたるところで散見されることはありません。nullや検査例外は局所的なエラー状態を表しており、回復可能性がないと判断した時点で非検査例外によって即座にトップレベルまでエスカレーションされるからです。また、nullが現れることが普通である部分では、Null Objectのようなパターンを用いることでnullチェックを回避します。あまりよく設計されていないシステムでは、ある部分でnullが出てくると、とりあえずnullを返しておくというコードが多発します。nullが出てくるということが例外的であるような状況では、回復可能性が低いことが多いため、非検査例外を投げる設計のほうが適切です。しかし例外に対する理解が低い場合、例外はなんとなく難しそうだからという理由で、とりあえずnullを返してしまうようです。この「とりあえずわからないからnullを返す」が積み重なると、いたるところでnullチェックをしなければいけなくなり、結果として「Javaはどこでも必ずnullチェックが必要で面倒くさい」という意見を持つようになります。「とりあえずnullを返す」という習慣を直さないかぎり、OptionalもChecker Frameworkもコード品質を改善することができません。