Kotlin Multiplatform でパッケージが違う同名クラスは Swift では後ろにアンダースコアが付く件

# はじめに

この記事は [Kyash Advent Calendar 2023](https://adventar.org/calendars/8604) 4日目の記事です。

長く Android エンジニアをやっていて、4月に Kotlin Multiplatfrom (KMP) をプロダクションに導入している会社に転職しました。最近は個人で KMP と Swift を学習しつつ、業務では Android アプリ開発だけでなく KMP ライブラリのマルチモジュール構成の調整などを行っています。

その過程で [KMP の Swift 言語との相互運用機能](https://kotlinlang.org/docs/native-objc-interop.html)について、公式ドキュメントに明確には載ってなく、iOS エンジニアからすると分かりにくい挙動をいくつか見つけました。今回はその中で、パッケージが違う同名クラスの後ろにアンダースコアが付く件について紹介します。

# パッケージが違う同名クラスには後ろにアンダースコアが付く

例えば以下のようにパッケージは違いますが、クラス名が同じクラスが2つあるとします。モジュール名は `shared` です。

```kotlin
package com.tfandkusu.foo

data class Hoge(val id: Long, val name: String)
```

```kotlin
package com.tfandkusu.bar

data class Hoge(val id: Long, val name: String)
```


この2クラスは Kotlin では明確に区別されます。
これらを Swift から使うとこうなります。

```swift
import shared

let hoge1 = Hoge(
    id: 1,
    name: "こっち"
)
let hoge2 = Hoge_(
    id: 2,
    name: "あっち"
)
```

片方にアンダースコアがつきます。もしコンストラクタ引数やメソッドが同じだとしたら、どちらのパッケージ所属が区別が付かないです。

# Objective-C にはパッケージが無いので KMP はクラス名を変更する

まず、Kotlin 公式の [Interoperability with Swift/Objective-C](https://kotlinlang.org/docs/native-objc-interop.html) によると相互運用は Objective-C によって提供されています。そして [Name translation](https://kotlinlang.org/docs/native-objc-interop.html#name-translation) 節には Objective-C には package が無く、違うパッケージの同名クラスについては名前を変更するとあり、そのアルゴリズムは今後の Kotlin リリースで変化する可能性があるとありました。この記事を執筆時点では Kotlin 1.9.20 を使っていますが、後ろにアンダースコアが付いたのはそのバージョンのアルゴリズムがそうなっていたからのようです。

ちなみにパッケージが違う同名クラスが3個あった場合はアンダースコアが後ろに2つ付くクラスが作成されます。

# Kotlin で違うパッケージの同名クラスが作られる原因

## Kotlin を書いた時点では意図的なケース

コンテキストをパッケージで表現してクラス名を簡潔にする開発スタイルを取っている場合は、 Kotlin を書いた時点では意図的に違うパッケージの同名クラスが作られることがあります。

## Kotlin を書いた時点で意図しないケース

違うパッケージの同名クラスを作るつもりは無くても、[機能ドメイン別のマルチモジュール構成](https://developer.android.com/topic/modularization?#what-is-modularization)を採用したときのクラスの所属機能ドメインのあやふやさによる同じ内容のクラスの重複や、複数の開発プロジェクトが同時進行するときに別プロジェクトのコードを把握しきれない等の理由で発生しうると考えています。

# ObjCName アノテーションで Swift 向けの名前を付ける

意図的に Kotlin 側で違うパッケージの同名クラスを作る場合は、前述した [Name translation](https://kotlinlang.org/docs/native-objc-interop.html#name-translation) 節の説明にあるように `@ObjCName` アノテーションを使用して Swift 向けの名前をつけます。

```kotlin
package com.tfandkusu.foo

@OptIn(ExperimentalObjCName::class)
@ObjCName(name = "FooHoge")
data class Hoge(val id: Long, val name: String)
```

Swift 側では `FooHoge` クラスとして使えます。

```swift
let hoge1 = FooHoge(
    id: 1,
    name: "foo"
)
```

`@ObjCName` アノテーションでつけた名前が重複している場合はビルドエラーにならず、後ろにアンダースコアが付くクラスになるのでご注意ください。

# Konsist ライブラリを使って単体テストで同名クラスを検出する

やっぱり仕組みで防ぎたいです。ここでは [Konsist](https://github.com/LemonAppDev/konsist) ライブラリを使って単体テストで同名クラスが検出されたら失敗にする方法を紹介します。

KMP プロジェクトに対する Konsist の導入方法は[こちら](https://qiita.com/tfandkusu/items/5319b755ab2f1293359a#kmp-%E3%83%A2%E3%82%B8%E3%83%A5%E3%83%BC%E3%83%AB%E3%81%AE%E3%83%86%E3%82%B9%E3%83%88%E3%81%8B%E3%82%89-konsist-%E3%82%92%E4%BD%BF%E3%81%88%E3%82%8B%E3%82%88%E3%81%86%E3%81%AB%E3%81%99%E3%82%8B)で解説しています。

作成した単体テストはこちらです。

```kotlin
class DuplicateClassTest {
    @Test
    fun check() {
        Konsist.scopeFromProject() // このプロジェクトについて
            .classes() // すべてのクラスを取得
            .map { it.name } // クラス名に変換
            .groupBy { it }
            .mapValues { it.value.size } // キーはクラス名、値はクラス数の Map にする
            .forEach { (className, count) ->
                assertEquals(1, count, "$className クラスが重複しています。")
            }
    }
}
```

同名クラスが存在するときの単体テストの出力はこのようになります。

```
Hoge クラスが重複しています。
Expected :1
Actual   :2
```

`@ObjCName` アノテーションを使って Swift 向けの別名を付ける開発スタイルの場合は、アノテーションも確認します。

```kotlin
class DuplicateClassTest {
    @Test
    fun check() {
        Konsist.scopeFromProject()
            .classes()
            .map {
                // ObjcName アノテーションを取得
                val objCNameAnnotation = it.annotations.find { annotation ->
                    annotation.name == "ObjCName"
                }
                // name 引数の値を取得
                val objCName = objCNameAnnotation?.arguments?.find { argument ->
                    argument.name == "name"
                }?.value
                // ObjCName アノテーションの name 引数があればそれを、なければクラス名を返す
                objCName ?: it.name
            }
            .groupBy { it }
            .mapValues { it.value.size }
            .forEach { (className, count) ->
                assertEquals(1, count, "$className クラスが重複しています。")
            }
    }
}
```


# まとめ

KMP で iOS 向けのライブラリを作成する場合 Kotlin と Objective-C では言語仕様が違うため、Kotlin を書く人は注意深くコーディングしないと iOS エンジニアを混乱させる原因となります。その1つがパッケージが違う同名クラスは後ろにアンダースコアが付く件です。ある程度規模が大きい開発チームなどで問題の混入を防ぎたい場合は CI での自動検出をお勧めします。Konsist ライブラリを使うことで単体テストでチェックすることができます。