はじめに
Kotlin(JVM)はJavaとの相互運用が可能である点が大きな魅力です。それもあり、実際にKotlinで実用的なアプリケーションを作るにはJavaで書かれたライブラリやフレームワークを使うことが多いです。
とても便利なのですが、ある程度気をつけないといけない点もあります。この記事では、KotlinとJavaを相互運用する上で注意が必要な点についていくつか紹介します。
(この記事で網羅できていないケースも多くあるかとは思います)
バージョン
- Java 22
- Kotlin 2.0.20
内容
null安全性関連
Kotlinの特長の一つとして、nullを許容する型と許容しない型が明確に分かれていることで、ある程度のnull安全性が備わっていることが挙げられます。非常に魅力的な機構なのですが、Kotlinのnull安全性は必ずしも万全ではありません。具体的には、Javaで書かれたメソッドの戻り値をあまり気にせず扱うとnull安全ではなくなります。
たとえばこのようなコードがあるとします。
public class SomeClass {
public static String someMethod() {
return null;
}
}
このコードをKotlinから使ってみます。
fun main() {
val s = SomeClass.someMethod()
println(s.length)
}
someMethod()
はnullを返すのでs
はnull許容型になるのかと思いきや、そうはならずこのコードではコンパイルエラーが発生しません。実行するとそのままNullPointerExceptionが発生します。つまり、このコードはnull安全ではありません。
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.length()" because "s" is null
at MainKt.main(main.kt:3)
at MainKt.main(main.kt)
この時、s
は String!
という型になっています。
これはplatform typesと呼ばれるもので、 T!
は T
または T?
を表すとされています。
なので String!
とは String
か String?
のどちらかだけどどちらなのかわからないということです。このケースだと実際には String?
のはずですが、Kotlinのコンパイラはそれを知ることができないようですね。
fun main() {
val s: String? = SomeClass.someMethod()
println(s.length)
}
このように型を明示することで、s.length
のところでコンパイラエラーが発生するようになります。
Only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type 'kotlin.String?'.
ちなみに、String
を明示することもそれはそれでできてしまいます。
fun main() {
val s: String = SomeClass.someMethod()
println(s.length)
}
コンパイラエラーは出ないですが、実行時にNullPointerExceptionが投げられます。
Exception in thread "main" java.lang.NullPointerException: someMethod(...) must not be null
at MainKt.main(main.kt:2)
at MainKt.main(main.kt)
2行目なので代入のところで発生するということですね。
緩和策
完全に解決する方法はなかなか思いつかないのですが、前述の通り型をnull許容型として明示することで少し影響を緩和できます。
また、Java側のコードをいじることができるのであれば、特定のアノテーションを付与することでnull安全性を備えることができます。
たとえば、Java側のコードに対して以下のように @Nullable
アノテーションを付与します。
import org.jetbrains.annotations.Nullable;
public class SomeClass {
@Nullable
public static String someMethod() {
return null;
}
}
そうすると、Kotlin側で someMethod()
の戻り値は String?
と認識されるようになり、Kotlinで書くのと同様にnull安全性を備えることができます。
上の例だとorg.jetbrains.annotations.Nullable
を使っていますが、それ以外の同等のアノテーションもいろいろサポートされています。
JetBrains (@Nullable and @NotNull from the org.jetbrains.annotations package)
JSpecify (org.jspecify.nullness)
Android (com.android.annotations and android.support.annotations)
JSR-305 (javax.annotation, more details below)
FindBugs (edu.umd.cs.findbugs.annotations)
Eclipse (org.eclipse.jdt.annotation)
Lombok (lombok.NonNull)
RxJava 3 (io.reactivex.rxjava3.annotations)
@NotNull
アノテーションもあります。
import org.jetbrains.annotations.NotNull;
public class SomeClass {
@NotNull
public static String someMethod() {
return "str";
}
}
その場合は s
は String
になります。
チェック例外関連
Javaにはチェック例外の機構があり、チェック例外がthrows
で宣言されている場合は例外処理をしないとコンパイルエラーになります。一方、Kotlinにはそれがありません。IOException
など、Javaにおけるチェック例外のクラスであってもKotlinで扱う場合は例外処理をしなくてもコンパイルエラーは発生しません。
気をつけないといけないのは、Javaでチェック例外として扱われる例外を投げる可能性があるメソッドをKotlinで書く場合です。
たとえば、以下のようなコードがあったとします。
import java.io.IOException
class SomeKtClass {
fun someKtMethod() {
throw IOException()
}
}
これをJavaで呼び出すコードを書いたとします。
public class Main {
public static void main(String[] args) {
var s = new SomeKtClass();
s.someKtMethod();
}
}
この時、 someKtMethod()
はIOExceptionを投げるにもかかわらずコンパイルエラーが発生しません。実行すると、当然IOExceptionで落ちます。
Exception in thread "main" java.io.IOException
at SomeKtClass.someKtMethod(SomeKtClass.kt:6)
at Main.main(Main.java:4)
実際にチェック例外を投げるにもかかわらずコンパイルエラーとして検出されないという、Javaだけで書いていたら起こり得ないことが起こってしまいました。困りましたね。
解決策
これに関しては明確な解決策が存在します。Kotlin側のコードを書くとき、以下のようにアノテーションを付与します。
import java.io.IOException
class SomeKtClass {
@Throws(IOException::class)
fun someKtMethod() {
throw IOException()
}
}
このようにすることで、Java側でこのコードのメソッドを呼ぶ際に想定通りコンパイルエラーが発生するようになります。
エラー: 例外IOExceptionは報告されません。スローするには、捕捉または宣言する必要があります
s.someKtMethod();
^
例外を複数宣言することも可能です。
import java.io.IOException
import java.sql.SQLException
class SomeKtClass {
@Throws(IOException::class, SQLException::class)
fun someKtMethod() {
throw IOException()
}
}
もっとも、チェック例外を明示的に投げるコードばかりとも限りません。たとえば、以下のようにIOExceptionを投げるコードに依存しているのにそれに気づいていなくてアノテーションをつけ忘れるということも起こり得ます。
import java.nio.file.Files
import java.nio.file.Path
class SomeKtClass {
fun readLines(path: String): MutableList<String>? {
return Files.readAllLines(Path.of(path))
}
}
こういったことを防ぐには、Java由来のライブラリを使用する場合はチェック例外をthrows宣言していないか一通り調査するとか、そもそも調査の負荷が高くならないようにコードを適切に分割するとか、Javaのコードから呼び出す想定があるならいっそJavaで書くとか、状況に応じて対応策を使い分ける必要があります。
Kotlinからの使用を想定されたライブラリの使用
Javaで書かれたライブラリは概ねKotlinでも使えますが、直接Kotlinで使用すると不便な点がある場合にKotlin用のラッパーが提供されている場合があります。
たとえばモックライブラリのMockitoではmockito-kotlinというラッパーが提供されています。メソッド名がKotlinの予約語を回避している、本家をKotlinから使うと発生する一部のエラーが発生しないようになっているなど利便性が高くなっていますので、こちらのラッパーを優先して使ったほうがよさそうです。
また、ライブラリの依存自体は本家のままでも、Kotlinから使用されることを想定したクラスが用意されている場合もあります。
たとえば、SpringにはBeanPropertyRowMapperというクラスがあります。これはSpringJDBCというORマッパーに関連する機能で、SELECTした結果をJavaのオブジェクトにマッピングする処理を自動的に実行してくれるものです。
しかし、オブジェクトの元クラスをJavaではなくKotlinのdata classとして書いている場合は、BeanPropertyRowMapperは(一般的には)使えません。1
それを想定してなのか、SpringにはDataClassRowMapperというクラスも提供されています。
こちらはKotlinのdata classに対しても使うことができ、BeanPropertyRowMapperと同等の結果を得ることができます。
「データクラス」という用語は、Java レコード、Kotlin データクラス、および対応する列名にマップされることを意図した名前付きパラメーターを持つコンストラクターを持つすべてのクラスに適用されます。
Javaのrecordなども想定されているのでKotlin専用というわけではないですが、明確にKotlinからの使用を想定されたクラスとなっています。
これらの例から、Kotlin向けに用意されたラッパーやクラスがあればそちらを優先して使うべきということが言えますが、それらのようなラッパーやクラスがわざわざ提供されるということは、提供しないと不都合が生じる場合があるということでもあります。そのため、KotlinからJavaで書かれたライブラリを使用する場合は、不都合が生じるケースがないかどうか調査する、入念にテストを行う、といったことを考える必要があります。
番外編
Kotlinサポートを打ち切られるリスク
たとえば、DomaというORマッパーがあります。
DomaはJavaで使えるのはもちろん、Kotlinにも対応しています。Kotlinからも使えるように特別な対処が入っているようです。
けっこう前ですが、このDomaのKotlinサポートを打ち切るかもしれないという話が出ました。
結果としてはKotlinサポートは継続されることとなり、記事執筆時点(2024年10月)でもサポートが継続されています。
ただ、サポートを継続するかどうかはメンテする人のさじ加減で決まるわけなので、実際にサポートが打ち切られる可能性もあり得たと思います。そのため、使用するライブラリの最新動向は常に追う必要があります。
これはKotlinとJavaの相互運用というより、特定のメンテナーへの依存度が高いOSSを使う場合のリスクの話ではあるのですが、Kotlin関連のトピックで個人的に印象に残っていたので番外編として記載しました。
-
引数なしコンストラクタを別途定義かつ全てのプロパティをvarで定義すれば使えるようになりますが、data classをそのように定義することは通常ないでしょう。 ↩