【Kotlin】デフォルト引数とバイナリ互換の話

この記事は`Kotlin Advent Calendar 2023`の20日目の記事です。

https://qiita.com/advent-calendar/2023/kotlin

---

# TL;DR
- デフォルト引数はバイナリ互換性の問題を引き起こしやすいため、不特定多数の利用するライブラリを作成するような場面では注意が必要

# はじめに
`Kotlin`のデフォルト引数は、ボイラープレートの削減や柔軟な表現を実現する、とても便利で強力な機能です。

https://kotlinlang.org/docs/functions.html#default-arguments

一方、特に不特定多数の利用するライブラリを開発するような場合、デフォルト引数のせいでバイナリ互換性エラーが生じる可能性も有ります。
この記事では、エラーの発生する状況とその回避方法についてまとめます。

# デフォルト引数の仕組み
まず初めに、デフォルト引数の仕組みについて軽く解説します。
解説には以下の関数を使います。

```kotlin
fun foo(str: String = "") { println(str) }
```

上記の関数をデコンパイルすると、以下のような2つの関数になります。
`foo`が`Kotlin`上の関数の本体で、`foo$default`がデフォルト引数を使った呼び出しを処理するためのメソッドです。

```java:デコンパイル結果（整形・抜粋後）
   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`が入力されています[^absent]。
マスクは1ビット目が立っています。

[^absent]: プリミティブ型など`null`を入力できない場合は適当な値が入力されます。

```java:デコンパイル結果（整形・抜粋後）
foo$default((String)null, 0b0000000000000001, (Object)null);
```

表にまとめると、デフォルト引数が利用される場合引数指定の有無によって以下のようにコンパイル結果が変化するということになります。

| 引数指定           | 有り                    | 無し                    | 
| ------------------ | ----------------------- | ----------------------- | 
| **引数** | 値                      | アブセント値            | 
| **マスク**         | 該当するビットが0になる | 該当するビットが1になる | 

# バイナリ互換性の問題が起きる状況
先ほどの説明で重要なのは、「デフォルト引数の利用に関する制御はコンパイル結果に焼き込まれた静的なものである」という点です。
つまり、当該関数で引数の増減が有った場合、再コンパイルしなければバイナリ互換性の問題が出ます。

書いているコードから関数を直接呼び出す場合、再コンパイル対象になるため、この問題は起きません。
この問題が生じるのは、「ライブラリAをBが使っている状況で、Aだけアップデートした」というような場合です。

つまり、注意が必要なのは、デフォルト引数を使った関数を公開したり、そのような関数を使うライブラリを公開する場合だけです。

# この問題を起こさない方法
作成側と利用側それぞれで、この問題を起こさないようにする方法を、自分が知っている限り紹介します。

## 作成側
作成側で可能な対策は、大まかに以下の2種類に分けられます。

- バイナリ互換性を維持し続ける
- バイナリ互換性の問題を起こさない`API`だけを提供する

また、引数を減らす方向になることは殆ど無いと思われるため、基本的に増やす場面を前提に書きます。

### 手動でオーバーロードする
最もシンプルな方法です。
デフォルト引数を利用せず、引数を追加する度にオーバーロードを作成すれば、バイナリ互換性の問題は生じません。

```kotlin
fun foo() { foo(str = "") }
fun foo(str: String) { println(str) }
```

既にデフォルト引数を使っているような場合も、既存同様の`API`を維持し続ければ、当然バイナリ互換性の問題は生じません。

後述する`JvmOverloads`を使った方法に比べると、手動で書く分コード量が増えますが、シンプルかつ最低限のオーバーロードだけ作成できる利点が有ります。

### `JvmOverloads`を用いる
手動で全てのオーバーロードを作成するのが大変な場合、`Kotlin`から提供されている`JvmOverloads`アノテーションを用いることもできます。

https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.jvm/-jvm-overloads/

これを用いると、以下のように、引数無しのメソッドを生成してくれます。

```kotlin
@JvmOverloads
fun foo(str: String = "") { println(str) }
```

```java:デコンパイル結果（整形・抜粋後）
   @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);
   }
```

より多くの引数が有る場合、その引数分関数が生成されてしまうことと、間に引数を追加した場合はバイナリ互換性の問題生じてしまうことには注意が必要です。

```kotlin
@JvmOverloads
fun f(p0: Int = 0, p1: Int = 1, p2: Int = 2, p3: Int = 3, p4: Int = 4) {}
```

```java:デコンパイル結果（整形・抜粋後）
   @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`等で互換性をチェックし、破壊的変更を検出することもできます。

https://github.com/Kotlin/binary-compatibility-validator

プログラミング的なテクニックではありませんが、意図せぬ破壊を防ぐにはこのようなツールを用いるのが良いと思います。

### バイナリ互換性の問題を起こさない`API`だけを提供する
特に引数が沢山有る場合、バイナリ互換性の問題を起こさない呼び出し方法のみ公開する（コンストラクタ・関数は`private`や`@Deprecated(level = DeprecationLevel.HIDDEN)`にする）ことをお勧めします。

恐らく一番良いのはビルダーパターンを使うことです。
他にも`Map`的なものを渡す方法などが考えられます。

基本的にデフォルト設定でしか呼び出されない場合、引数無しのオーバーロードだけを公開する方法も考えられます。
この方法はリフレクション（`Java`リフレクション含む）からの呼び出しが容易になるという利点も有ります。

## 利用側

### `kotlin-reflect`で呼び出す
利用側の書き方で何とか出来る部分はあまり有りませんが、`kotlin-reflect`を使って呼び出すのは1つの対策になります。
リフレクション処理は動的であるため、`callBy`を使って呼び出せば、互換性の問題をある程度吸収することが出来ます。

https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.reflect/-k-callable/call-by.html

### コントリビュートする
`OSS`で問題を起こしうるコードを見つけたら、issueを立てたりPRを送ったりしてみて下さい！