はじめに
この記事は Kyash Advent Calendar 2023 4日目の記事です。
長く Android エンジニアをやっていて、4月に Kotlin Multiplatfrom (KMP) をプロダクションに導入している会社に転職しました。最近は個人で KMP と Swift を学習しつつ、業務では Android アプリ開発だけでなく KMP ライブラリのマルチモジュール構成の調整などを行っています。
その過程で KMP の Swift 言語との相互運用機能について、公式ドキュメントに明確には載ってなく、iOS エンジニアからすると分かりにくい挙動をいくつか見つけました。今回はその中で、パッケージが違う同名クラスの後ろにアンダースコアが付く件について紹介します。
パッケージが違う同名クラスには後ろにアンダースコアが付く
例えば以下のようにパッケージは違いますが、クラス名が同じクラスが2つあるとします。モジュール名は shared
です。
package com.tfandkusu.foo
data class Hoge(val id: Long, val name: String)
package com.tfandkusu.bar
data class Hoge(val id: Long, val name: String)
この2クラスは Kotlin では明確に区別されます。
これらを Swift から使うとこうなります。
import shared
let hoge1 = Hoge(
id: 1,
name: "こっち"
)
let hoge2 = Hoge_(
id: 2,
name: "あっち"
)
片方にアンダースコアがつきます。もしコンストラクタ引数やメソッドが同じだとしたら、どちらのパッケージ所属が区別が付かないです。
Objective-C にはパッケージが無いので KMP はクラス名を変更する
まず、Kotlin 公式の Interoperability with Swift/Objective-C によると相互運用は Objective-C によって提供されています。そして Name translation 節には Objective-C には package が無く、違うパッケージの同名クラスについては名前を変更するとあり、そのアルゴリズムは今後の Kotlin リリースで変化する可能性があるとありました。この記事を執筆時点では Kotlin 1.9.20 を使っていますが、後ろにアンダースコアが付いたのはそのバージョンのアルゴリズムがそうなっていたからのようです。
ちなみにパッケージが違う同名クラスが3個あった場合はアンダースコアが後ろに2つ付くクラスが作成されます。
Kotlin で違うパッケージの同名クラスが作られる原因
Kotlin を書いた時点では意図的なケース
コンテキストをパッケージで表現してクラス名を簡潔にする開発スタイルを取っている場合は、 Kotlin を書いた時点では意図的に違うパッケージの同名クラスが作られることがあります。
Kotlin を書いた時点で意図しないケース
違うパッケージの同名クラスを作るつもりは無くても、機能ドメイン別のマルチモジュール構成を採用したときのクラスの所属機能ドメインのあやふやさによる同じ内容のクラスの重複や、複数の開発プロジェクトが同時進行するときに別プロジェクトのコードを把握しきれない等の理由で発生しうると考えています。
ObjCName アノテーションで Swift 向けの名前を付ける
意図的に Kotlin 側で違うパッケージの同名クラスを作る場合は、前述した Name translation 節の説明にあるように @ObjCName
アノテーションを使用して Swift 向けの名前をつけます。
package com.tfandkusu.foo
@OptIn(ExperimentalObjCName::class)
@ObjCName(name = "FooHoge")
data class Hoge(val id: Long, val name: String)
Swift 側では FooHoge
クラスとして使えます。
let hoge1 = FooHoge(
id: 1,
name: "foo"
)
@ObjCName
アノテーションでつけた名前が重複している場合はビルドエラーにならず、後ろにアンダースコアが付くクラスになるのでご注意ください。
Konsist ライブラリを使って単体テストで同名クラスを検出する
やっぱり仕組みで防ぎたいです。ここでは Konsist ライブラリを使って単体テストで同名クラスが検出されたら失敗にする方法を紹介します。
KMP プロジェクトに対する Konsist の導入方法はこちらで解説しています。
作成した単体テストはこちらです。
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 向けの別名を付ける開発スタイルの場合は、アノテーションも確認します。
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 ライブラリを使うことで単体テストでチェックすることができます。