この記事はKotlin Advent Calendar 2023
の20日目の記事です。
TL;DR
- デフォルト引数はバイナリ互換性の問題を引き起こしやすいため、不特定多数の利用するライブラリを作成するような場面では注意が必要
はじめに
Kotlin
のデフォルト引数は、ボイラープレートの削減や柔軟な表現を実現する、とても便利で強力な機能です。
一方、特に不特定多数の利用するライブラリを開発するような場合、デフォルト引数のせいでバイナリ互換性エラーが生じる可能性も有ります。
この記事では、エラーの発生する状況とその回避方法についてまとめます。
デフォルト引数の仕組み
まず初めに、デフォルト引数の仕組みについて軽く解説します。
解説には以下の関数を使います。
fun foo(str: String = "") { println(str) }
上記の関数をデコンパイルすると、以下のような2つの関数になります。
foo
がKotlin
上の関数の本体で、foo$default
がデフォルト引数を使った呼び出しを処理するためのメソッドです。
public static final void foo(@NotNull String str) {
Intrinsics.checkNotNullParameter(str, "str");
System.out.println(str);
}
// $FF: synthetic method
public static void foo$default(String var0, int var1, Object var2) {
if ((var1 & 1) != 0) { var0 = ""; }
foo(var0);
}
foo$default
では、通常の引数(var0
)の他に、マスク(var1
)とマーカー(var2
)が追加されています。
内部的には、マスクの1ビット目(nビット目 = n番目の引数)が立っていればデフォルト引数が、立っていなければ引数が使用されています。
マーカーは通常のメソッドとの区別のためだけにあるため、内部的には利用されません。
foo$default
をデフォルト値利用で呼び出すコードのデコンパイル結果は以下のようになります。
指定されなかった引数にはアブセント値としてnull
が入力されています1。
マスクは1ビット目が立っています。
foo$default((String)null, 0b0000000000000001, (Object)null);
表にまとめると、デフォルト引数が利用される場合引数指定の有無によって以下のようにコンパイル結果が変化するということになります。
引数指定 | 有り | 無し |
---|---|---|
引数 | 値 | アブセント値 |
マスク | 該当するビットが0になる | 該当するビットが1になる |
バイナリ互換性の問題が起きる状況
先ほどの説明で重要なのは、「デフォルト引数の利用に関する制御はコンパイル結果に焼き込まれた静的なものである」という点です。
つまり、当該関数で引数の増減が有った場合、再コンパイルしなければバイナリ互換性の問題が出ます。
書いているコードから関数を直接呼び出す場合、再コンパイル対象になるため、この問題は起きません。
この問題が生じるのは、「ライブラリAをBが使っている状況で、Aだけアップデートした」というような場合です。
つまり、注意が必要なのは、デフォルト引数を使った関数を公開したり、そのような関数を使うライブラリを公開する場合だけです。
この問題を起こさない方法
作成側と利用側それぞれで、この問題を起こさないようにする方法を、自分が知っている限り紹介します。
作成側
作成側で可能な対策は、大まかに以下の2種類に分けられます。
- バイナリ互換性を維持し続ける
- バイナリ互換性の問題を起こさない
API
だけを提供する
また、引数を減らす方向になることは殆ど無いと思われるため、基本的に増やす場面を前提に書きます。
手動でオーバーロードする
最もシンプルな方法です。
デフォルト引数を利用せず、引数を追加する度にオーバーロードを作成すれば、バイナリ互換性の問題は生じません。
fun foo() { foo(str = "") }
fun foo(str: String) { println(str) }
既にデフォルト引数を使っているような場合も、既存同様のAPI
を維持し続ければ、当然バイナリ互換性の問題は生じません。
後述するJvmOverloads
を使った方法に比べると、手動で書く分コード量が増えますが、シンプルかつ最低限のオーバーロードだけ作成できる利点が有ります。
JvmOverloads
を用いる
手動で全てのオーバーロードを作成するのが大変な場合、Kotlin
から提供されているJvmOverloads
アノテーションを用いることもできます。
これを用いると、以下のように、引数無しのメソッドを生成してくれます。
@JvmOverloads
fun foo(str: String = "") { println(str) }
@JvmOverloads
public static final void foo(@NotNull String str) {
Intrinsics.checkNotNullParameter(str, "str");
System.out.println(str);
}
// $FF: synthetic method
public static void foo$default(String var0, int var1, Object var2) {
if ((var1 & 1) != 0) { var0 = ""; }
foo(var0);
}
@JvmOverloads
public static final void foo() {
foo$default((String)null, 1, (Object)null);
}
より多くの引数が有る場合、その引数分関数が生成されてしまうことと、間に引数を追加した場合はバイナリ互換性の問題生じてしまうことには注意が必要です。
@JvmOverloads
fun f(p0: Int = 0, p1: Int = 1, p2: Int = 2, p3: Int = 3, p4: Int = 4) {}
@JvmOverloads
public static final void f(int p0, int p1, int p2, int p3, int p4) {}
// $FF: synthetic method
public static void f$default(int var0, int var1, int var2, int var3, int var4, int var5, Object var6) {
if ((var5 & 1) != 0) { var0 = 0; }
if ((var5 & 2) != 0) { var1 = 1; }
if ((var5 & 4) != 0) { var2 = 2; }
if ((var5 & 8) != 0) { var3 = 3; }
if ((var5 & 16) != 0) { var4 = 4; }
f(var0, var1, var2, var3, var4);
}
@JvmOverloads
public static final void f(int p0, int p1, int p2, int p3) {
f$default(p0, p1, p2, p3, 0, 16, (Object)null);
}
@JvmOverloads
public static final void f(int p0, int p1, int p2) {
f$default(p0, p1, p2, 0, 0, 24, (Object)null);
}
@JvmOverloads
public static final void f(int p0, int p1) {
f$default(p0, p1, 0, 0, 0, 28, (Object)null);
}
@JvmOverloads
public static final void f(int p0) {
f$default(p0, 0, 0, 0, 0, 30, (Object)null);
}
@JvmOverloads
public static final void f() {
f$default(0, 0, 0, 0, 0, 31, (Object)null);
}
バイナリ互換性チェッカーでチェックする
Kotlin
の公式からバイナリ互換性チェッカーが提供されているため、これを使ってCI
等で互換性をチェックし、破壊的変更を検出することもできます。
プログラミング的なテクニックではありませんが、意図せぬ破壊を防ぐにはこのようなツールを用いるのが良いと思います。
バイナリ互換性の問題を起こさないAPI
だけを提供する
特に引数が沢山有る場合、バイナリ互換性の問題を起こさない呼び出し方法のみ公開する(コンストラクタ・関数はprivate
や@Deprecated(level = DeprecationLevel.HIDDEN)
にする)ことをお勧めします。
恐らく一番良いのはビルダーパターンを使うことです。
他にもMap
的なものを渡す方法などが考えられます。
基本的にデフォルト設定でしか呼び出されない場合、引数無しのオーバーロードだけを公開する方法も考えられます。
この方法はリフレクション(Java
リフレクション含む)からの呼び出しが容易になるという利点も有ります。
利用側
kotlin-reflect
で呼び出す
利用側の書き方で何とか出来る部分はあまり有りませんが、kotlin-reflect
を使って呼び出すのは1つの対策になります。
リフレクション処理は動的であるため、callBy
を使って呼び出せば、互換性の問題をある程度吸収することが出来ます。
コントリビュートする
OSS
で問題を起こしうるコードを見つけたら、issueを立てたりPRを送ったりしてみて下さい!
-
プリミティブ型など
null
を入力できない場合は適当な値が入力されます。 ↩