Kotlin Compiler Pluginでは、コードから特定の関数が呼び出されていることを検知して処理内容差し替え行うようなツールを作成できます。
一方、関数呼び出しをフックとする場合、Kotlinのインクリメンタルコンパイルの仕組みによってコンパイルがスキップされることもあります。
この挙動は、特定のケースでは不具合につながります。
そこで、特定関数の呼び出しに限り常にコンパイル対象とする方法を確立しました。
かなりハッキーかつデメリットを伴う方法となってしまったため、よりよい方法をご存じの方がいらっしゃいましたら是非アドバイス頂きたいです。
検証に用いたプロジェクトは以下にアップロードしています。
前提
今回定義したいプラグインと、不具合の起きる状況を説明します。
プラグインの処理内容
まず、プラグインの処理内容を簡単化して説明します。
このプラグインは、以下のように動作します。
- ターゲット関数の呼び出しを検知
- 呼び出しの内容を解析した上で、実際に実行される関数を、全体共通のコンテキストオブジェクトへ生成
- 1のターゲット関数の呼び出しを、2で生成されたコンテキストオブジェクト上の関数の呼び出しに置き換え
以下、動作イメージをコードで説明します。
プラグインの処理対象となるターゲット関数の名前はtargetCallとします。
// ターゲット関数、ここでの定義は実際には呼び出されないダミー
fun targetCall() ...
この関数が、クラスAとBそれぞれから呼び出されているとします。
class A {
fun a() { targetCall() }
}
class B {
fun b() { targetCall() }
}
この呼び出しに対してプラグインによる処理が行われ、最終的な処理内容は以下のように置き換えられます。
// 内部で生成されたコンテキストオブジェクト
object Context {
// Aからの呼び出しに対する生成結果
fun targetCallA() ...
// Bからの呼び出しに対する生成結果
fun targetCallB() ...
}
// プラグインの処理によって、targetCallがContextの関数の呼び出しに置換される
class A {
fun a() { Context.targetCallA() }
}
class B {
fun b() { Context.targetCallB() }
}
不備の起きる状況
前述した「全体共通のコンテキストオブジェクトへ生成」という点が、インクリメンタルコンパイルとの組み合わせでしばしば不具合を起こします。
例えば、Aからの呼び出しだけ存在する状況でBを追加すると、デフォルトではインクリメンタルコンパイルによってAに関するコンパイルがスキップされます。
この場合もコンテキストオブジェクトの生成自体は行われますが、Aに関する処理は当然スキップされるため、Context.targetCallAが消えてしまい、実行時にNoSuchMethodErrorが生ずるようになります。
class A {
fun a() { targetCall() }
}
+ class B {
+ fun b() { targetCall() }
+ }
// 内部で生成されたコンテキストオブジェクト
object Context {
// Aからの呼び出しに関するコンパイルがスキップされるせいで、Aに関する生成結果が消える
- // Aからの呼び出しに対する生成結果
- fun targetCallA() ...
+ // Bからの呼び出しに対する生成結果
+ fun targetCallB() ...
}
対策
インクリメンタルコンパイルの仕様上、特定関数に対する呼び出し全てを再コンパイル対象としてマークするには、基本的に関数のABIを変化させる必要があります。
ただし、inline関数に関しては、その内容が呼び出し側へ展開される都合から、その内容を変更するだけで呼び出し全てを再コンパイル対象とすることができます。
つまり、ターゲット関数は利用側モジュールでinline関数として生成し、その内容をランダムに書き換える機構を作れば、呼び出し全てを再コンパイル対象に含めることができます。
実現方法
今回はgradleプラグインでtargetCallを生成する方式を試しました。
具体的には、以下の生成結果の内、marker部分をランダムに変化させることで、コンパイル毎に変更が自動変化する関数を実現しました(実際には、任意のKotlinファイルが変更された場合だけ再生成されるようにしています)。
package org.wrongwrong.sample.aics
@Suppress("NOTHING_TO_INLINE")
internal inline fun targetCall(): String {
val marker = "6634dafb-4af9-49be-a110-bd61a06aee43"
error("targetCall() must be replaced by the compiler plugin. marker=$$marker")
}
これによって、呼び出し全体をコンパイル対象と扱うことに成功しました。
手法のメリット・デメリットに関する補足
この手法には、以下のように多くのデメリットがあります。
- そもそも利用側のインクリメンタルコンパイルが大幅に制限される
- ターゲット関数を配布内容に含められず、利用側で1度ビルドして生成を行う必要がある
- ハッキー過ぎて、他の副作用が無いか、今後壊れないかなど、懸念が大きい
また、そもそもの話として、コンテキストオブジェクトを介さず呼び出し側へインラインで生成を行う形式であれば、このような問題に怯える必要も有りません。
それでもこの手法を取るべき理由が有るとすれば、それは以下のようなニーズが有る場合でしょうか。
- 生成結果を集約し、別途利用したい
- 例えば、既存のリフレクション系ライブラリの一部を高速化するアダプタを生成したいような場合
- 生成内容を使いまわしてバイナリサイズを節約したい
冒頭で述べた通り、より良い方法が有れば是非提案頂きたいです。
自分で考えた限り、独自のキャッシュ機構を実装する位しか思いつきませんでした……(これは単に難しいことに加え、呼び出しが消えた場合のクリーンアップ戦略が考え付いていません)。