Kotlin sealedタイプによる論理和型の実装: ポリモーフィズム形式と代数的データ型形式

はじめに

Kotlinのsealedタイプ (sealed classsealed interface) は、外部モジュールによるクラスの継承やインターフェースの実装 (以降は実装で統一します) を制限することで、継承先クラスの一覧を静的に取得できるようにする機能です。

以下は公式ドキュメントの例ですが、sealedタイプを使うことで log() 関数内の when が全てのとり得るパターンを網羅していることがコンパイル時にチェックされ、冗長な else を省くことができます。

また、Error インターフェースの直接の実装先が増えて when が網羅性を失った場合としても、コンパイル時に自動で検知されてビルドに失敗するので、when の欠点として度々指摘される分岐の実装漏れが未然に防げるようになっています。

sealed interface Error

sealed class IOError(): Error

class FileReadError(val file: File): IOError()
class DatabaseError(val source: DataSource): IOError()

object RuntimeError : Error


fun log(e: Error) = when(e) {
    is FileReadError -> { println("Error while reading file ${e.file}") }
    is DatabaseError -> { println("Error while reading from database ${e.source}") }
    is RuntimeError ->  { println("Runtime error") }
    // the `else` clause is not required because all the cases are covered
}

プロパティやメソッドを持たない空の抽象クラスやインターフェースは、通常の場合は何も共通化しないためほぼ無意味ですが、 sealedタイプの場合はこのような型による分岐によっていわゆる論理和型を実現するための手段としてしばしば利用されます。

今回は、このような論理和型の実装方法として2つのパターンを比較検討したいと思います。

以降のこの記事では、sealed の修飾子を使った論理和型として表現されるクラスやインターフェースを「親の型」、それらの個々の具体例となるクラスを「子の型」と呼ぶことにします。

ポリモーフィズム形式の実装

1つ目は、通常の抽象クラスやインターフェースの場合と同じように、子の型に親の型を直接実装させるパターンです。 これを「ポリモーフィズム形式の実装」と呼ぶことにします。

sealed interface VehicleTicket

data class TrainTicket(
    val startStation: String,
    val goalStation: String,
    val fare: Int,
): VehicleTicket

data class BusTicket(
    val startBusStop: String,
    val goalBusStop: String,
    val fare: Int,
): VehicleTicket

ポリモーフィズム形式の実装の利点

ポリモーフィズム形式の実装の主な利点は、メソッドの引数などで指定された型の抽象度が変わっても、同じ渡し方ができる点にあるでしょう。 例えば、次の例では、buyVehicleTicketbuyTrainTicket に同じ ticket 変数をそのまま渡すことができます。

fun buyVehicleTicket(ticket: VehicleTicket) { // DO SOMETHING }

fun buyTrainTicket(ticket: LocalTrainTicket) { // DO SOMETHING }


val ticket = TrainTicket("here", "there", 220)
buyVehicleTicket(ticket)
buyTrainTicket(ticket)

もし TrainTicket をインターフェースにして、抽象化のレイヤーを増やしたとしても、それはクラスの使い方に影響を与えず、上記のコードはそのまま使うことができます。

// TrainTicketをインターフェースにする
sealed interface TrainTicket: VehicleTicket

data class LocalTrainTicket(
    val startStation: String,
    val goalStation: String,
    val fare: Int,
): TrainTicket

data class ShinkansenTicket(
    val startStation: String,
    val goalStation: String,
    val fare: Int,
    val expressFare: Int,
): TrainTicket

// 使い方は上の例と同じ
val ticket = LocalTrainTicket("here", "there", 220)
buyVehicleTicket(ticket)
buyTrainTicket(ticket)

ポリモーフィズム形式の実装の欠点

ポリモーフィズム形式の実装では、親の型を子の型がsealedタイプを使った形で継承する必要がある点が、実装上の大きな制約となります。

これは、継承によって子の型から親の型への依存関係が発生することだけでなく、sealedタイプの制約として、両者が同じモジュールに存在しなければならないことも意味します。

例えば、java.time.LocalDatejava.time.LocalDateTime のどちらかをとる型を定義したい場合、これらのクラスには新たなインターフェスを実装させることはできないので、ポリモーフィズム形式の実装以外の方法を選択する必要があります。

また、子の型として独自定義のクラスを利用する場合でもポリモーフィズム形式の実装を選択すると、例えば上記の例では BusTicket クラスをバスを扱うモジュールに定義し、TrainTicket クラスを電車を扱うモジュールに定義し、切符全般を扱うモジュールに VehicleTicket のインターフェースを定義するというモジュール分割ができなくなります。

代数的データ型形式の実装

2つめのパターンは、親の型の定義にネストする形で子の型をラップするクラスを定義し、そのラッパークラスに親の型を継承させる形式です。 それぞれのラッパークラスが親の型のコンストラクタ関数のように見える書き方になるので、これを「代数的データ型形式の実装」と呼ぶことにします。

sealed interface VehicleTicket {
    value class OfTrain(val value: TrainTicket): VehicleTicket

    value class OfBus(val value: BusTicket): VehicleTicket
}

data class TrainTicket(
    val startStation: String,
    val goalStation: String,
    val fare: Int,
)

data class BusTicket(
    val startBusStop: String,
    val goalBusStop: String,
    val fare: Int,
)

代数的データ型形式の実装の利点

この形式での大きな利点は、子の型の定義に手を加えることなくに論理和型を実現できることです。

例えば、先にポリモーフィズム形式の実装では実現できない例として挙げた「java.time.LocalDatejava.time.LocalDateTime のどちらかをとる型」も、代数的データ型形式で実装すれば、次のような形で実現できます。

sealed interface DateOrTime {
  value class Date(val value: java.time.LocalDate): DateOrTime

  value class Time(val value: java.time.LocalTime): DateOrTime
}

また、依存関係の整理も行いやすく、代数的データ型形式で実装すれば子の型から親の型への直接の依存は無くなるので、前述の例の「バスモジュールと電車モジュールと切符モジュールへの分割」も問題無く実現できるでしょう。

上記の例のように、各ラッパークラスが子の型を直接プロパティとして持つ形にすれば、切符モジュールがバスモジュールと電車モジュールに依存する形になります。 次の例のように、間にsealedではないインターフェースを設ければ依存関係が逆転し、バスモジュールと電車モジュールが切符モジュールに依存する形になります。

// 切符モジュール

sealed interface VehicleTicket {
    value class OfTrain(val value: TrainTicketInterface): VehicleTicket

    value class OfBus(val value: BusTicketInterface): VehicleTicket
}

interface TrainTicketInterface {
    val startStation: String
    val goalStation: String
    val fare: Int
}

interface BusTicketInterface {
    val startBusStop: String
    val goalBusStop: String
    val fare: Int,
}
// 電車モジュール
data class TrainTicket(
    override val startStation: String,
    override val goalStation: String,
    override val fare: Int,
): TrainTicketInterface
// バスモジュール
data class BusTicket(
    override val startBusStop: String,
    override val goalBusStop: String,
    override val fare: Int,
): BusTicketInterface

代数的データ型形式の実装の欠点

代数的データ型形式の実装の欠点は基本的に、ポリモーフィズム形式の実装の利点の裏返しです。

代数型データ型形式で実装した場合は、メソッド等がどの抽象度の型を要求しているかによって渡し方を変える必要があります。

fun buyVehicleTicket(ticket: VehicleTicket) { // DO SOMETHING }

fun buyBusTicket(ticket: BusTicket) { // DO SOMETHING }


val trainTicket = TrainTicket("here", "there", 220) 
// TrainTicket型の変数をVehicleTicket型として扱う場合はラップする必要がある
buyVehicleTicket(VehicleTicket.OfTrain(trainTicket))

// VehicleTicket型にラップした変数をBusTicket型として扱う場合は明示的にプロパティを呼ぶ必要がある
val busVehicleTicket = VehicleTicket.OfBus(BusTicket("here", "there", 220))
buyBus(busVehicleTicket.value)

この不便さは型の階層が深くなるほど大きくなります。

// TrainTicketをインターフェースにする
sealed interface TrainTicket: VehicleTicket {
    value class OfLocalTrain(val value: LocalTrainTicket): TrainTicket

    value class OfShinkansen(val value: ShinkansenTicket): TrainTicket
}

data class LocalTrainTicket(
    val startStation: String,
    val goalStation: String,
    val fare: Int,
)

data class ShinkansenTicket(
    val startStation: String,
    val goalStation: String,
    val fare: Int,
    val expressFare: Int,
)

val shinkansenTicket = ShinkansenTicket("Tokyo", "Shin-Osaka", 8910, 4960)
buyVehicleTicket(VehicleTicket.OfTrain(TrainTicket.OfShinkansen(shinkansenTicket))) // 2段階のラップが必要

この煩わしさは論理和型を利用する側でも発生し、例えば型判定するためのwhen節もフラットに書くことができず、型定義の階層関係に合わせてネストを深くする必要があります。

fun checkTicketType(ticket: VehicleTicket) {
    when (ticket) {
        is VehicleTicket.OfBus ->
            println("This is a bus ticket")
        is VehicleTicket.OfTrain ->
            when (ticket.value) {
                is TrainTicket.OfLocalTrain ->
                    println("This is a local train ticket")
                is TrainTicket.OfShinkansen ->
                    println("This is a shinkansen ticket")
            }
    }
}

まとめ

これらの特性を考えると、両者の使い分けに関して次のような指針を立てることができるでしょう。

  • 型の階層関係が深く複雑だったり、後の変更が予測されるのならば、(少なくとも部分的には) ポリモーフィズム形式の実装を使う。
  • 論理和型の扱う範囲が全てモジュール境界の内側に収まるのならポリモーフィズム形式の実装を使い、モジュール境界を超えるならば代数的データ型形式の実装を使う。

これはあくまで指針であり、個々のケースはこれ以外の要件も含めて「要はバランス」で考えていく必要があります。 しかし、この2つのパターンがあることを知っておくことで取れる選択肢は増え、豊かな表現力のコードの実現に役立つことでしょう。

We are hiring!

株式会社ドワンゴの教育事業では、一緒に未来の当たり前の教育をつくるメンバーを募集しています。 カジュアル面談も行っています。 お気軽にご連絡ください!

バックエンドチームでは、特にKotlinで型を活用したサーバーアプリを作りたいメンバーを積極募集中です!

カジュアル面談応募フォームはこちら

www.nnn.ed.nico

開発チームの取り組み、教育事業の今後については、他の記事や採用資料をご覧ください。

speakerdeck.com