Kotlinマルチプラットフォームモジュールのためのより信頼性の高いコンパイル手法

Kotlin 2.5.0-Beta1 で KMP に実験的な分離コンパイルが導入され、IDE とコンパイラの解析不一致問題を解決し、共通ソースセットのインクリメンタルコンパイルをサポート。

日本語
コピー
Kotlin Blog 官方题图:黑底上两块紫到洋红的渐变平行四边形拼成 K 形,中央白字写着 Kotlin Blog

Kotlin Multiplatform プロジェクトの現在のコンパイル方式は動作こそすれ、ときに予想外で再現しにくい挙動を招く。たとえば:

  • IDE の解析とコンパイラで、オーバーロードや型推論、そもそもコードがコンパイルできるかどうかの判断が食い違う。しかも IDE のほうがコンパイラより厳しい。
  • commonTest のコードが、意図に反してプラットフォームソースセットの中身を呼び出してしまう。 Kotlin 2.5.0-Beta1 では、この両方を解決する KMP 向けのオプションの「分離コンパイル」を導入した。コンパイル結果と IDE の解析が一致し、共通ソースセットがライブラリのコードを呼び出すときの問題もより一貫して指摘できるようになる。副次的な効果として、共通ソースセットのインクリメンタルコンパイルも実装できるようになった。 分離コンパイルは現在実験的な機能で、デフォルトでは無効になっている。有効にするには、gradle.properties ファイルに次のコンパイラオプションを追加する(既知の問題に注意):
kotlin.kmp.separateCompilation=true

以下、問題そのものと解決策を詳しく見ていく。

モジュール内でマルチプラットフォーム宣言がどう解決されるか

1 つのモジュール内では、マルチプラットフォームコードの挙動は予測できる:

// jvmMain
fun foo() {}

// commonMain
fun test() {
 foo() // Unresolved reference in IDE and during compilation
}

コンパイラと IDE の意見は一致している。commonMain のコードは Kotlin/JS など他のプラットフォームにもコンパイルされるが、jsMain には fun foo() 宣言がないかもしれない。この問題を解決するために存在するのが expect/actual 宣言だ。プラットフォーム固有の宣言と共通の宣言を明示的に対応づける。 foo() 関数の宣言が依存関係(同じプロジェクト内のモジュール、あるいは Kotlin 標準ライブラリや kotlinx-coroutines のようなバイナリ成果物)の中にある場合も、同じように未解決として報告されるはずだと思うだろう。残念ながら、現在のコンパイル方式では、まさにここで IDE とコンパイラの意見が分かれる。

モジュール間でマルチプラットフォーム宣言がどう解決されるか

問題の出どころを突き止めるには、現在のコンパイル設定をもう少し詳しく見る必要がある。

Kotlin Multiplatform のコンパイル設定

KMP プロジェクトでは、共通コードを含むモジュールは通常いくつかのソースセットで構成される。上の例なら、共有ソースセット(commonMain)と、宣言されたターゲットのプラットフォームソースセット(Kotlin/JVM なら jvmMain)だ。 この設定で、コンパイラは次の成果物を生成できる:

  • プラットフォームソースセットごとに 1 つのプラットフォーム成果物。たとえば JVM なら jvmMain – *.jar、他のプラットフォームなら *.klib。
  • 共通ソースセットまたは中間ソースセットごとに 1 つのメタデータ KLIB(たとえば commonMain や nativeMain)。メタデータ KLIB にはコンパイル対象ソースセットの宣言がすべて含まれるが、関数本体は含まれない。 では、問題はどこにあるのか。

共通コードはプラットフォーム成果物に対してコンパイルされるが、IDE はそうは考えない

あるプラットフォームソースセットをコンパイルするとき、その中のコード(jvmMain)および関連するすべての共有ソースセット(commonMain やその他の中間ソースセット)のコードは、依存関係のプラットフォーム成果物に対してコンパイルされる。その結果、commonMain のコードが jvmMain 宣言を暗黙に解決できる可能性が出てくる。 そうなることを最初から期待しているなら、おそらく問題はない。だが commonMain が依存関係 commonMain の宣言(依存関係のメタデータ KLIB に列挙されているもの)しか呼び出せなければ、挙動はもっと透明で予測しやすくなる。 IntelliJ IDEA のコード解析は、まさにそう仮定している。jvmMain 宣言は KLIB メタデータに含まれないため、commonMain が共通コードで明示的に宣言されていないものを参照すると、IntelliJ IDEA は未解決参照エラーを報告する:

// lib/jvmMain
class Foo

// app/commonMain
fun main() {
 Foo() // Unresolved reference in the IDE, no error during compilation
}

同じ仕組みは commonTest ソースセットにも働く。テストもメインコードに「依存」しているからだ。jvmMain に対してコンパイルされた commonTest は、commonMain の宣言に制限されず、プラットフォームコードの宣言を問題なく呼び出せてしまう。IDE は同じ未解決参照の問題を報告する:

// app/jvmMain
class Foo

// app/commonTest
fun main() {
 Foo() // "Unresolved reference" in the IDE, no error during compilation
}

次に、分離コンパイルがこれらの問題をどう解決するかを見ていく。

分離コンパイルが問題をどう解決するか

KMP の分離コンパイルにより、コンパイラは KMP プロジェクトの共通ソースセットをコンパイルするとき、より厳格に(IDE の想定と一致するように)振る舞うようになる。全体としての体験はより予測しやすくなるが、厳しくなったコンパイル時チェックを通過させるために既存コードを調整する必要が出てくるかもしれない。 具体的なコード例をいくつか見て、コンパイル方式を切り替えるとどうなるかを確認しよう。

IDE では赤くなるのに、コンパイル時にはエラーにならない

分離コンパイルを有効にすると、このコードはコンパイルできなくなる。コンパイラが共通コードの呼び出しを、メタデータ KLIB の宣言だけに基づいて厳密に解決するようになるからだ:

// lib/jvmMain
class Foo

// app/commonMain
fun main() {
 Foo() // Unresolved reference in IDE, now also a compilation error
}

解決策は明示的に関連づけることだ。lib/commonMain で expect class を宣言し、プラットフォームの Foo クラスを actual class にする:

// lib/commonMain
expect class Foo()

// lib/jvmMain
actual class Foo

// app/commonMain
fun main() {
 Foo() // ok
}

commonTest 理屈は同じだ。分離コンパイルを使えば、テストコードは jvmMain のプラットフォーム呼び出しを解決する必要がなくなる。テストしたい場合は、commonMain で対応する expect を宣言すればよい:

// app/jvmMain
actual class Foo

// app/commonMain
expect class Foo

// app/commonTest
fun main() {
 Foo() // Ok, calls Foo from jvmMain
}

選択されたオーバーロードをめぐる IDE とコンパイラの判定の食い違い

lib/commonMain と lib/jvmMain に複数のオーバーロードが宣言されている場合、app/commonMain 内の呼び出しに対する IDE の解釈は、現時点ではコンパイラと異なる:

// lib/commonMain
fun foo(x: Any) = "common"

// lib/jvmMain
fun foo(x: String) = "platform"

// app/commonMain
fun main() {
 // "Go to declaration" on foo() jumps to lib/commonMain,
 // but at runtime "platform" is printed
 println(foo(""))
}

IDE は foo("") の呼び出しを lib/commonMain の宣言 fun foo(x: Any) に解決する。共通コードは共通の宣言にしか依存できないと想定しているからだ。しかし実際のコンパイルでは、app/commonMain は現時点で両方の宣言を認識し、より具体的なオーバーロード fun foo(x: String) を選ぶ。実行時に表示されるのは “platform” だ。

新しいコンパイル方式では、この呼び出しは lib/commonMain の fun foo(x: Any) に解決され、実行時には “common” が表示される。

予期しない型推論

厄介なのは、エラーが app/commonMain の呼び出し箇所ではなく、プラットフォームコード側で発生する場合だ。次の例では、IDE からは問題が見えないのに JVM のコンパイルが失敗する。

actual クラスは、対応する expect クラスとは異なる親型を持てる。したがって、次のようなコード構造を書くこと自体はまったく可能だ:

// eventsLib/commonMain
interface Event { val name: String }

expect class ClickEvent(target: String) : Event { override val name: String }
expect class ScrollEvent(offset: Int) : Event { override val name: String }

// eventsLib/jvmMain — serialized events get queued or stored in a session
actual class ClickEvent actual constructor(target: String) : Event, Serializable { /* ... */ }
actual class ScrollEvent actual constructor(offset: Int) : Event, Serializable { /* ... */ }

// app/commonMain
fun currentEvent() = if (clicked()) ClickEvent("buy") else ScrollEvent(120)

fun report() = currentEvent().name // e: Unresolved reference 'name' only on the JVM

IDE は commonMain の宣言しか照合しないため、currentEvent() の戻り値型を Event と(正しく)推論し、.name の参照を解決できる。

ところが JVM では、ライブラリが actual クラスごとに親型を追加で宣言している。app を JVM 向けにコンパイルするとき、コンパイラは現時点では共通コードの解決に eventsLib JAR を使い、その JAR にはクラスの eventsLib/jvmMain 版が入っている。親型が 2 つあることで、currentEvent() 呼び出しの戻り値型は Any と推論され、これには .name がないため JVM のコンパイルが失敗する。

分離コンパイルを有効にすると、app/commonMain のコードは eventsLib/commonMain の宣言だけを対象に解決される。そのため app の JVM ターゲットをコンパイルする時点で、コンパイラは解決済みの共通コードをすでに持っており、currentEvent() 関数の戻り値型を Event と推論し終えている。JVM のコンパイルは別に行われ、推論はもう不要なので問題なく通る。

分離コンパイルを有効にするには

分離コンパイルは現在実験的機能で、デフォルトでは無効だ。有効にするには、gradle.properties ファイルに次のコンパイラオプションを追加する(下記の既知の問題に注意):

kotlin.kmp.separateCompilation=true

既知の問題

分離コンパイルを KMP プロジェクトで正しく動かすには、マルチプラットフォームライブラリの作者が metadata KLIB を公開している必要がある。これは KMP Gradle プラグインの公開タスクのデフォルトの挙動だが、新しいコンパイル方式によって metadata KLIB は必須のものになった。

KMP の分離コンパイルには、現在修正中の既知の問題がほかにもいくつかある:

  • cinterop の commonization に問題がある(KT-88178 や KT-41509 など)。通常の KMP プロジェクトにはまず影響しないが、C 相互運用を使っている場合は、分離コンパイルを有効にする際に注意してほしい。
  • 共通ソースセットを持ち、ターゲットを 1 つしか宣言していない KMP モジュールは、分離コンパイルの影響を受けない。
  • そのほか細かい問題として KT-88148 などがある。

フィードバック

分離コンパイルに関するフィードバックを広く募集している。専用の YouTrack issue にコメントしてほしい。

  • 共有

前の記事 Kotlin 2.4.20 リリース

フィードバックありがとうございます。受け取りました!

出典: JetBrains Blog← ホームへ戻る