JSR305 の nullness annotations についてよく勘違いをしてしまうので、まとめてみました。
JSR305 はソフトウェアのバグを検出をサポートするためのアノテーション群の提供を求めるJSRです。 ここで提案されているアノテーションとfindbugsやIntelliJなどの静的解析ツールを用いることで、ソフトウェアの品質を高められます。
ここでは JSR305 のアノテーションとして JetBrains Annottions を事例として取り扱います。なお、同等の意味を持つ findbugs アノテーションでも同じことが 言えますので、そちらを利用している方は適宜読み替えてください。
JetBrains Annotations はGradleで次のように指定することでプロジェクトに組み込めます。
build.gradle.kts
dependencies {
implementation("org.jetbrains:annotations:13.0")
// または
compileOnly("org.jetbrains:annotations:13.0")
}
JetBrains Annotations の nullness annotations には次のようなものがあります。
- このアノテーションが付与されたパラメーター/変数/フィールド/戻り値を
null
にすることは禁止されている
- このアノテーションが付与されたパラメーター/変数/フィールド/戻り値が
null
となることは妥当である
また、この二つの関係ですが次のような包含関係になっています。
では、早速試してみましょう。次のような build.gradle.kts
を書いてプロジェクトを作ります。
plugins {
id("java-library")
}
repositories {
mavenCentral()
}
dependencies {
implementation("org.jetbrains:annotations:13.0")
}
次のようなインターフェースを作ります。
interface Packet {}
interface Network {}
interface Io {
@Nullable
Packet packetFrom(@NotNull final Network network); // [1]
@NotNull
Packet merging(@NotNull final Network leftChannel, @Nullable final Network rightChannel); // [2]
}
- メソッド
packetFrom
はnull
ではないNetwork
を受け取って、null
なこともあるPacket
を返す - メソッド
merging
はnull
ではないNetwork
と、null
も渡せるNetwork
を受け取り、null
ではないPacket
を返す
この Io
インターフェースを実装したクラスを作って適当な実装をします。
class DiskIo implements Io {
private final int io;
DiskIo(final int io) {
this.io = io;
}
@Nullable
@Override
public Packet packetFrom(@NotNull final Network network) {
return null;
}
@NotNull
@Override
public Packet merging(@NotNull final Network leftChannel, @Nullable final Network rightChannel) {
return new Packet() {};
}
}
上記のような実装であれば、IntelliJ のインスペクション Probable bugs には特に何も出力されません。
では敢えて @NotNull
を 違反してみます。 merging
メソッドを次のように変えてみましょう。
@NotNull
@Override
public Packet merging(@NotNull final Network leftChannel, @Nullable final Network rightChannel) {
return null;
}
そして IntelliJ のインスペクションを走らせると次のように出力されます。
'null' is returned by the method decleared as @NotNull
大方の予想通り、 null
が許されない戻り値に null
を指定したため、インスペクションエラーとなっています。あまりにも当然すぎる結果なので、これは特に驚くにあたりません。
それでは別の @NotNull
の違反を作ってみましょう。 packetFrom
メソッドを次のように変更します。
@Nullable
@Override
public Packet packetFrom(@NotNull final Network network) {
if (network == null) throw new IoNotAvailableException("network should be not null."); // 独自の例外を投げる
return null;
}
これを見たとき、どのように考えるでしょうか?
null
値の入力は許されていないので、このnull
チェックは不要である
でしょうか?それとも
null
値の入力は許されていないため、実際にnull
チェックをおこなうことで不正な入力を防いでいる
でしょうか?
最近までは僕自身は後者の考え方をすることが多かったのですが、一般的には契約プログラミングあるいはDesign by Contractに基いて前者の考え方を採用する人が多いのではないでしょうか。 packetFrom
というメソッドが期待通り動くという利益を享受するためには、メソッドの使用者はパラメーター network
に対して null
ではない値を
渡す責務を負うという考え方です。 null
を渡した時に packetFrom
メソッドが NullPointerException
ないしは何らかの例外または期待していない出力値が返ってきた場合には Io
インターフェースの実装のバグではなく、 使用者のバグとします。それによって、 Io
インターフェースの実装をシンプルに保てるということになります。契約プログラミングについては日本でも多くの方がブログなどを書いていますので、詳しくはそちらを参照するとよいでしょう。
話を元に戻すと、 @NotNull
なパラメーターに対して null
チェックを行ったコードに対して IntelliJ でインスペクションすると probable bugs に次のような結果が返ってきます。
Condition 'network == null' is always 'false'
この条件の結果は常に false
になるということです。そして IntelliJ IDEA による修正提案は Remove 'if' statement となります。
世の中には決まりを破ってしまう人もいるもので、 packetFrom
メソッドにnull
を渡そうとする人がいます。例えば、テストコードを書く我々のような人びとです。
class IoTest {
@Test
void passNull() {
final Io io = new DiskIo();
assertThrows(IoNotAvailableException.class, () -> io.packetFrom(null));
}
}
このテストをIntelliJ IDEA上で実行してみると次のような結果になります。
org.opentest4j.AssertionFailedError: Unexpected exception type thrown ==> expected: <com.example.quality.Ios.IoNotAvailableException> but was: <java.lang.IllegalArgumentException>
at org.junit.jupiter.api.AssertThrows.assertThrows(AssertThrows.java:59)
at org.junit.jupiter.api.AssertThrows.assertThrows(AssertThrows.java:38)
at org.junit.jupiter.api.Assertions.assertThrows(Assertions.java:1108)
at com.example.IosTest.passNull(IosTest.java:32)
...(省略)...
Caused by: java.lang.IllegalArgumentException: Argument for @NotNull parameter 'network' of com/example/quality/Ios$DiskIo.packetFrom must not be null
at com.example.quality.Ios$DiskIo.$$$reportNull$$$0(Ios.java)
at com.example.quality.Ios$DiskIo.packetFrom(Ios.java)
IoNotAvailableException
を期待していましたが、 IllegalArgumentException
が返されてテストが失敗しました。しかし IllegalArgumentException
を投げるようなコードは記述していません。この例外は一体どこから湧いてきたのでしょうか?
DiskIo.class
を javap
で覗いてみると次のようになっています。
public com.example.quality.data.Packet packetFrom(com.example.quality.data.Network);
Code:
stack=3, locals=2, args_size=2
0: aload_1
1: ifnonnull 8
4: iconst_0
5: invokestatic #50 // Method $$$reportNull$$$0:(I)V
8: aload_1
9: ifnonnull 22
12: new #2 // class com/example/quality/Ios$IoNotAvailableException
15: dup
16: ldc #3 // String network should be not null.
18: invokespecial #4 // Method com/example/quality/Ios$IoNotAvailableException."<init>":(Ljava/lang/String;)V
21: athrow
22: aconst_null
23: areturn
メソッドの先頭でパラメーターを読み込んでおいて null
チェックを行った後に null
だった場合には $$$reportNull$$$0
という書いた記憶のないメソッドを呼び出すようになっています。IntelliJ IDEAは @NotNull
が付与されているフィールド/ローカル変数/パラメーター/戻り値には上記の null
チェックを挟み込んでいるようです。したがって、 null
チェックによる例外の発生を確かめるテストは、IDEでは失敗するのに、ビルドツールでは成功するため、不安定になります。 @NotNull
はプログラム内の null
チェックを減らしてプログラムをシンプルにすることを目標にしていることを考えると、 @NotNull
が付与されたパラメーターに対する null
チェックが働くことのテストは書かないほうがよいかもしれません。
タイトルにKotlinを含めたので、Kotlinに言及しないわけにはいきません。
先ほどの Io
インターフェースをKotlinで実装すると次のような実装になります。
class KIo: Io {
override fun packetFrom(network: Network): Packet? = null
override fun merging(leftChannel: Network, rightChannel: Network): Packet = object: Packet {}
}