Kotlinのsealed型を使って高機能なenum型を実装する

はじめに

blog.nnn.dev

Kotlinのsealed型の使い方を考える記事の第2弾です。

前回の記事では、sealed型を使って論理和型を実装しましたが、今回は通常の enum class よりも機能を拡張したenum型を実装したいと思います。

シンプルな実装例

通常、Kotlinでenum型を使いたい場合は、enum class を使って、次のように実装します。

/**
 * 学校で教える教科 (抜粋)
 */
enum class SchoolSubjectEnum {
    /** 日本史 */
    JAPANESE_HISTORY,

    /** 世界史 */
    WORLD_HISTORY,

    /** 化学 */
    CHEMISTRY,

    /** 生物 */
    BIOLOGY,
}

今回は、次のように sealed interfaceobject の組み合わせで実装する方法について考えていきます。

/**
 * 学校で教える教科 (抜粋)
 */
sealed interface SchoolSubject

/** 日本史 */
object JapaneseHistory : SchoolSubject

/** 世界史 */
object WorldHistory : SchoolSubject

/** 化学 */
object Chemistry : SchoolSubject

/** 生物 */
object Biology : SchoolSubject

sealed型による実装のメリット

enumに階層関係を持たせることができる

Kotlinの enum class はネストさせることができないので、例えば「科目には社会科科目と理科科目があって、それぞれ ... がある」みたいな関係を表現することができません。

sealed interface を使えば、次のような形でこれを実現することができます。

sealed interface SchoolSubject

/**
 * 歴史科目
 */
sealed interface HistorySubject : SchoolSubject

/**
 * 理科科目
 */
sealed interface ScienceSubject : SchoolSubject

object JapaneseHistory : HistorySubject

object WorldHistory : HistorySubject

object Chemistry : ScienceSubject

object Biology : ScienceSubject

when で場合分けする場合も、最終的にパターンが網羅されていれば、好きな階層で分岐することができます。

fun doYouLike(subject: SchoolSubject) {
    when (subject) {
        is HistorySubject -> println("No I don't.")
        Biology -> println("So-so.")
        Chemistry -> println("I love it!")
    }
}

また、各オブジェクトは複数のインターフェースを持つことができるので、複数の観点による分類を同居させることも可能です。

sealed interface SchoolSubject

/**
 * 歴史科目
 */
sealed interface HistorySubject : SchoolSubject

/**
 * 理科科目
 */
sealed interface ScienceSubject : SchoolSubject

/**
 * 必須科目
 */
sealed interface RequiredSubject : SchoolSubject

/**
 * 選択科目
 */
sealed interface OptionalSubject : SchoolSubject

/** 日本史。歴史の選択科目 */
object JapaneseHistory : HistorySubject, OptionalSubject

/** 世界史。歴史の必修科目 */
object WorldHistory : HistorySubject, RequiredSubject

/** 化学。 理科の選択科目 */
object Chemistry : ScienceSubject, OptionalSubject

/** 生物。理科の必修科目 */
object Biology : ScienceSubject, RequiredSubject

個々の値を型として扱える

Kotlinの enum class の個々の値は、型としてふるまうことができません。

基本的にenumは各値の種類につき1インスタンスずつしか存在しないので、型としてふるまえないことが問題に感じることは少ないです。 しかし、例えばジェネリクスと併用したいと考えたときに不便を覚えることがあります。

sealed interface を使って実装したenum型では、例えば次のように、「特定の教科を担当する教員」を型を使って表現することができます。

/*
 * 科目の担当教員
 */
data class Teacher<T : SchoolSubject>(
    val name: String,
    val subject: T,
)

/**
 * 生物の授業を開講する
 */
fun openBiologyClass(teacher: Teacher<Biology>) {
    TODO("do something")
}

val biologyTeacher = Teacher("Allie", Biology)
openBiologyClass(biologyTeacher)

val chemistryTeacher = Teacher("Bob", Chemistry)
// openBiologyClass(chemistryTeacher) // コンパイルエラー

また、個々の値が別々の型として独立したふるまいを持つことができるので、例えば次のような「特定の科目にだけ実装されたメソッド」を実現することができます。

object WorldHistory : SchoolSubject {
    fun includesHistoryOf(countryName: String): Boolean {
        TODO("$countryName の歴史を扱うならばtrueを返す")
    }
}

sealed型による実装のデメリット

言語仕様が提供する便利機能を利用できない

Kotlinの enum class には values()valueOf() などの便利なメソッドが言語仕様によって用意されています。 sealed型を使ったenum実装では、同様の機能を自前で用意する必要があります。

現在進行中のプロジェクトでは、次のようなリフレクションを使った共通実装を用意することで、実装の簡略化を図っています。

import kotlin.reflect.KClass

/**
 * sealed型と合わせてenum classのようにふるまうobjectに持たせるinterface
 */
interface EnumLikeObject {
    val name: String get() = toString()

    override fun toString(): String
}

/**
 * [EnumLikeObject]の実装クラスのcompanion objectに継承させることで、enum型のようなAPI ([values], [valueOf])を提供する
 */
abstract class EnumLikeSealedTypeCompanion<T: EnumLikeObject> {
    /**
     * 値を取得したい型を指定する。ジェネリックな型情報は実行時に消えてしまうため、明示的に型を再指定する必要がある
     */
    protected abstract val klass: KClass<T>

    // リフレクションは重くなるので、結果を変数に保存して、一度しか実行されないようにする
    private val valueMap: Map<String, T> by lazy {
        // ひし形継承の形で複数の親を持つobjectは重複して取得されるので、最後にそれを排除する
        val values = klass.childObjects().distinct()
        assert(values.distinctBy { it.name }.size == values.size) {
            "Children of $klass have duplicate name children <$values>"
        }
        values.associateBy { it.name }
    }

    /**
     * [T]を継承する全てのobjectを取得する
     * - 配下の sealed class / interface は再帰的に探索する
     * - object以外の実装クラスは無視する
     */
    fun values(): List<T> = valueMap.values.sortedBy { it.name }

    /**
     * [T]を継承するobjectのうち、[str]が[EnumLikeObject.name]にマッチするものを取得する
     */
    fun valueOf(str: String): T {
        return valueMap[str] ?: throw IllegalArgumentException("\"$str\" is invalid value for $klass")
    }

    private fun KClass<out T>.childObjects(): List<T> {
        assert(isSealed) { "$this cannot call childObjects() because it is not sealed type" }
        return sealedSubclasses.flatMap { childClass ->
            if (childClass.isSealed) {
                childClass.childObjects()
            } else {
                listOfNotNull(childClass.objectInstance)
            }
        }
    }
}

ただ、これを利用しても次のような toString() の個別定義と、それがオブジェクト名と一致していることを確認するようなテストコードの実装は必要になっています。1

sealed interface SchoolSubject : EnumLikeObject {
    companion object : EnumLikeSealedTypeCompanion<SchoolSubject>() {
        override val klass = SchoolSubject::class
    }
}

object JapaneseHistory : SchoolSubject {
    override fun toString(): String = "JapaneseHistory"
}

object WorldHistory : SchoolSubject {
    override fun toString(): String = "WorldHistory"
}

object Chemistry : SchoolSubject {
    override fun toString(): String = "Chemistry"
}

object Biology : SchoolSubject {
    override fun toString(): String = "Biology"
}

val subjects: List<SchoolSubject> = SchoolSubject.values()
println(subjects)
// => ["Biology", "Chemistry", "JapaneseHistory", "WorldHistory"]

まとめ

sealed型で実装したenumは、高機能な分実装の手間やコードの複雑さも増えます。

この記事でメリットとして挙げたような機能が必要無ければ通常の enum class で十分です。 後でsealed型による実装に変えたくなっても、enum class の値と object がコード上で区別される場面はほとんどないので、切り替えは比較的簡単に行えます。

通常の enum class では物足りなくなっても、欲しい機能次第ではより簡単な実装を選択することもできます。

例えば、シンプルな階層構造だけが必要ならば、sealed interfaceenum class を組み合わせて、次のような実装が考えられます。

sealed interface SchoolSubject {
    companion object {
        fun values(): List<SchoolSubject> {
            return (HistorySubject.values().toList() + ScienceSubject.values())
                .sortedBy { it.name }
        }

        fun valueOf(value: String): SchoolSubject {
            return try {
                HistorySubject.valueOf(value)
            } catch (_: IllegalArgumentException) {
                try {
                    ScienceSubject.valueOf(value)
                } catch (_: IllegalArgumentException) {
                    throw IllegalArgumentException("$value is not a SchoolSubject value")
                }
            }
        }
    }
}

enum class HistorySubject : SchoolSubject {
    JapaneseHistory,
    WorldHistory,
}

enum class ScienceSubject : SchoolSubject{
    Chemistry,
    Biology,
}

enumに限らず、モデル設計は目的に応じて適切な実装を探すことが大切です。

We are hiring!

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

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

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

www.nnn.ed.nico

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

speakerdeck.com


  1. EnumLikeObjectsealed interface ではなく sealed class にすれば、toString() の実装の共通化やリフレクションによる name の自動取得が可能になり、個別の実装はほぼゼロにできます。しかし、sealed class は1つしか継承できないため、今度は「BiologyScienceSubject かつ RequiredSubject である」のような重層的な階層構造を表現できなくなってしまいます。