Kotlin Serializationのポリモーフィズム

Kenji Abe
15 min readApr 29, 2024
Photo by Shubham Dhage on Unsplash

例えば、 type というキーの値によってデータ構造が違うようなJSONをエンコード、デコードしたい場合があります。このようなJSONを扱う場合の実装を紹介します。

ここではターゲットはJVMでフォーマットはJSONとしています。Kotlin Serializationはマルチプラットフォームで、いくつかのフォーマットがあるため、もしかしたら他の環境だと異なる可能性があるので注意してください。

使用バージョン

plugins {
kotlin("jvm") version "1.9.23"
kotlin("plugin.serialization") version "1.9.23"
}

dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
}

sealed classを使った実装

一番簡単なのは sealed class を使う方法です。

例として以下のようなJSONを考えてみます。

[  
{
"type":"rectangle",
        "name":"四角形",
"width":100,
"height":200
},
{
"type":"circle",
"name":"丸",
"radius":50
}
]

type によって構造が異なります。 name についてはどちらの構造でも保持しています。このJSONをデコード、エンコードできるような定義を考えていきます。

まずは sealed class を使ってシンプルに定義してみます。

@Serializable
sealed class Shape {
abstract val name: String
}

@Serializable
data class Rectangle(override val name: String, val width: Int, val height: Int) : Shape()

@Serializable
data class Circle(override val name: String, val radius: Int) : Shape()

Shape という親を作って、それを継承した RectangleCircle という data class で定義しています。

これをエンコードしてみます。

fun main() {
// 四角形と丸のオブジェクトを生成
val rectangle = Rectangle("四角形", 100, 200)
val circle = Circle("丸", 50)
// オブジェクトをStringにエンコード
println(Json.encodeToString(rectangle))
println(Json.encodeToString(circle))
}

これを実行すると以下のようになります。

{"name":"四角形","width":100,"height":200}
{"name":"丸","radius":50}

それぞれのオブジェクトをJSONにできていますが、 type がないため希望している状態ではないです。これは各変数の型がサブクラスのものになってるのが原因です。

これを今度は変数定義のときに明示的に親クラス型を指定するようにします。

fun main() {
// 明示的に`Shape`を指定して宣言
val rectangle: Shape = Rectangle("四角形", 100, 200)
val circle: Shape = Circle("丸", 50)
println(Json.encodeToString(rectangle))
println(Json.encodeToString(circle))
}

これを実行すると今度は次のようになります。

{"type":"com.example.Rectangle","name":"四角形","width":100,"height":200}
{"type":"com.example.Circle","name":"丸","radius":50}

type が入るようになりました。ただし、 type の値はクラス名となっています。次にこれを解決します。(デフォルトでは type というキー名が使われます。これを変更する方法は後述します。)

@SerialName を使うことで type の値が指定できるので、サブクラスにそれぞれおアノテーションを追加します。

@Serializable
@SerialName("rectangle") // typeの値を指定
data class Rectangle(override val name: String, val width: Int, val height: Int) : Shape()

@Serializable
@SerialName("circle") // typeの値を指定
data class Circle(override val name: String, val radius: Int) : Shape()

この定義をして、再び実行すると、以下のような結果になります。

{"type":"rectangle","name":"四角形","width":100,"height":200}
{"type":"circle","name":"丸","radius":50}

これで希望通りの結果を得ることが出来ます。

sealed classを使うときの課題

そもそも sealed class の制限として、同一package内でしかサブクラスを定義できません。そのため、サブクラスを違うpackageやmoduleで定義するような方法が出来なくなります。

これを解決するには sealed class ではなく、 open classabstract classinterface を使う必要があります。

サブクラスの登録

例として abstract class を使った実装を考えてみます。 abstract class の場合はコンパイル時にはサブクラスが決定できないため、明示的に実行時に登録してあげる必要があります。

先ほどと同じ例で、 sealed class ではなく、 abstract class で定義し直してみます。

@Serializable
abstract class Shape { // `selaed class`から`abstract class`に変更
abstract val name: String
}

// ここから下は変わってない
@Serializable
@SerialName("rectangle")
data class Rectangle(override val name: String, val width: Int, val height: Int) : Shape()

@Serializable
@SerialName("circle")
data class Circle(override val name: String, val radius: Int) : Shape()

これを単純に実行すると例外が発生します。

Exception in thread "main" kotlinx.serialization.SerializationException: Serializer for subclass 'Rectangle' is not found in the polymorphic scope of 'Shape'.
Check if class with serial name 'Rectangle' exists and serializer is registered in a corresponding SerializersModule.

これを解決するために SerializersModule を使って明示的にサブクラスを指定してあげます。

val module = SerializersModule {
polymorphic(Shape::class) { // 親クラス
subclass(Rectangle::class) // サブクラス
subclass(Circle::class) // サブクラス
}
}

val json = Json { serializersModule = module }

polymorphic で親クラスを指定して、そのビルダーブロック内で対象となるサブクラスを subclass で登録していきます。
最後に Json のブロックで作成した SerializersModule を指定してあげます。

あとはこれまで通り実行するだけです。

fun main() {
val rectangle: Shape = Rectangle("四角形", 100, 200)
val circle: Shape = Circle("丸", 50)

// 注意: `Json`ではなく、上記作成した`json`変数を使ってエンコード
println(json.encodeToString(rectangle))
println(json.encodeToString(circle))
}

Serializableではない親クラスに対応する

あまり無いケースかなとは個人的に思いますが、例えば Any のようなSerializableではないものを親クラスとしてポリモーフィズムを実現したい場合です。

@Serializable
@SerialName("rectangle")
data class Rectangle(val name: String, val width: Int, val height: Int)

@Serializable
@SerialName("circle")
data class Circle(val name: String, val radius: Int)

val module = SerializersModule {
polymorphic(Any::class) { // Anyのサブクラスとして登録する
subclass(Rectangle::class)
subclass(Circle::class)
}
}

val json = Json { serializersModule = module }

ここまでは先程の abstract class と同様です。

ただ、これを今まで通り実行すると、以下の例外が発生します。

Exception in thread "main" kotlinx.serialization.SerializationException: Serializer for class 'Any' is not found.
Please ensure that class is marked as '@Serializable' and that the serialization compiler plugin is applied.

これに対応するには encodeToStringPolymorphicSerializer を渡してあげる必要があります。

fun main() {
val rectangle: Any = Rectangle("四角形", 100, 200)
val circle: Any = Circle("丸", 50)

// 第一引数に`PolymorphicSerializer`を渡す
println(json.encodeToString(PolymorphicSerializer(Any::class), rectangle))
println(json.encodeToString(PolymorphicSerializer(Any::class), circle))
}

これでこれまで同様の結果が得られます。

今回はトップレベルでしたが、別のオブジェクトのプロパティとして使用する場合は少し対応が必要になります。

@Serializable
data class Canvas(
@Polymorphic // これが必要
val shape: Any
)

fun main() {
val canvas = Canvas(Circle("丸", 50))
println(json.encodeToString(canvas))
}

上記のように、 @Polymorphic をつける必要があります。

デフォルトのデコード

JSONをデコードする際に不明な type が渡される可能性があるときに、デフォルトでデコードされる型を指定することもできます。

例えば、以下のような感じです。

fun main() {
val shape: Shape = json.decodeFromString("""
{"type":"unknown", "name":"不明な形"}
""")
println(shape)
}

この場合、対応するタイプが存在しないため例外が発生します。

Exception in thread "main" kotlinx.serialization.json.internal.JsonDecodingException: Unexpected JSON token at offset 0: Serializer for subclass 'unknown' is not found in the polymorphic scope of 'Shape' at path: $
Check if class with serial name 'unknown' exists and serializer is registered in a corresponding SerializersModule.

このような場合に対応するために、不明なタイプがきたばあいはデフォルトのオブジェクトを指定することが出来ます。

// 不明なタイプがきたときのデフォルトの型
@Serializable
data class UnknownShape(override val name: String) : Shape()

val module = SerializersModule {
polymorphic(Shape::class) {
subclass(Rectangle::class)
subclass(Circle::class)
                // デフォルトのシリアライザーを指定
defaultDeserializer { UnknownShape.serializer() }
}
}

val json = Json {
serializersModule = module
}

defaultDeserializer でデフォルトのシリアライザーを指定しておくと、不明なタイプが来たときにそのシリアライザーで処理されることになります。

キー名を変更する

これまで見てきた実装だと、すべて type というキーが自動で使用されていました。これを変更する方法もあります。

まずは Json オブジェクト全体で指定する方法です。

val json = Json {
classDiscriminator = "shape_type"
}

classDiscriminator を使ってキー名を指定することができます。これで実行すると、これまで type だったキーが shape_type に変更されます。

{"shape_type":"rectangle","name":"四角形","width":100,"height":200}
{"shape_type":"circle","name":"丸","radius":50}

別の方法として、親クラス側の定義で指定することもできます。

@Serializable
@JsonClassDiscriminator("shape_type")
abstract class Shape {
abstract val name: String
}

@JsonClassDiscriminator を親クラス側に追加することで同じ結果が得られます。

--

--

Kenji Abe

Programmer / Gamer / Google Developers Expert for Android, Kotlin / @STAR_ZERO