Takuji->find;

株式会社はてなでアプリケーションエンジニアやってます、技術的な記事を書いているつもり

KotlinのSealed Classを使いこなす

こんにちは、三度の飯よりKotlinが好きな id:takuji31 です。

※ これは、はてなエンジニア Advent Calendar 2017 7日目の記事です。6日目は id:hayajo_77 さんの「Webオペレーションエンジニアとし研修して1ヶ月経ちました」でした。

hayajo.hatenablog.jp

今日は私が愛してやまないKotlinのSealed Classの使い方について紹介します。

Sealed Classについて

Sealed Classとは継承を制限して、階層を明確にできるクラスです。

詳しくは公式ドキュメントを参照してください。

Sealed Classes - Kotlin Programming Language

もう少し分かりやすく言うと、1つのファイル内でだけ継承できるクラスです。

Sealed Classを使う利点

普通の(final or open)クラスと比べて

外部からの継承を制限できるので、コンパイル時に子クラスの階層が全て明らかになります。

これで何ができるかというと、when式とis演算子の組み合わせで網羅性のチェックができます。

例えば下のコードのように、通常の A を継承する B/C/D というクラスがある時に

open class A
class B : A()
class C : A()
class D : A()

このAの子クラスのインスタンスがどのクラスか判定して何か値を返す関数は

fun isSomething(obj : A) : Boolean {
  return when (obj) {
    is B -> true
    is C -> true
    is D -> false
  }
}

と書くとコンパイルエラーになる(B/C/D以外の可能性がないことを判別できない)ので、以下のように書くと思います。

fun isSomething(obj : A) : Boolean {
  return when (obj) {
    is B -> true
    is C -> true
    is D -> false
    else -> throw RuntimeException("Not reached!!!!")
  }
}

elseは(自分達が書いているコード的には)絶対通らないはずだけど、例外を投げるか何らかの値を返す必要があります。

もちろんこのコードがライブラリーにあったとして、利用者がAを継承したEというクラスを作って渡すと、このコードはelseに到達しますね。

Sealed Classにすることで、コンパイラーがこのAの子クラスはB/C/Dしかないことを知られるので、最初のコードでも問題なくなります。

こうすることで、後で自分でAを継承する別のクラスを作ると、ここに選択肢を追加しないとコンパイルが通らなくなり、処理の追加漏れがなくなります。

Enumと比べて

前の項で書いている内容は、Enumでも実現できますね。

Enumと比べてSealed Classを利用する利点は、「Sealed Classを継承するのはobjectでもclassでもよい」ということではないでしょうか。

Sealed Classの子クラスそれぞれをEnumの値のように扱うことで、「Enumの各値に可変の値を持たせるような仕組み」ができます。

SwiftだとAssociated Valueと呼ばれるものですね。

使い方

使い方は簡単で、class宣言の前にsealedと書き、子クラスを全て同じファイル内で宣言するだけです。

sealed class A
class B : A()
class C : A()
class D : A()
object Z : A()

こうすることで、クラスAはこのファイル内でしか継承できなくなります。

また、Aはabstractなクラスかつ、コンストラクターがprivateになるので、直接インスタンス生成はできなくなります。

あとは通常のクラスと同じように使います。

どういう使い方をしている?

私が実際にSealed Classを使うシチュエーションを紹介します。

決まった文字列(全てが定数ではない)を受け取りたい時

これはSealed Classの「ファイル外で継承できない」仕組みを活用する例です。

例えばAndroidアプリでGoogle Analyticsにスクリーン名を送りたい時、スクリーンは定められた値なので、大半が定数で定義可能です。

しかし、完全な定数で済めばいいのですが、ページングをしている時にパラメーターとしてページ数を送りたいかもしれません。

また、普通にStringを送る運用だとミスって意味の分からない文字列を送ってしまうかもしれません。

こういった場合にそれぞれのスクリーン用にSealed Classを継承したobjectやclassを作り、Google Analyticsの送信用のラッパーを用意して、そこで値を分解して送るとこの問題を防ぐことができます。

sealed class GAScreen() {
  abstract val screenName: String

  open class Simple(override val screenName: String) : GAScreen()

  object Top : Simple("top")
  object Settings : Simple("settings")
  object List(private val page: Int) : GAScreen() {
    override val screenName: String = "list[page=$page]"
  }
}

ラッパー側は以下のようになります。

class GoogleAnalyticsWrapper() {
  fun sendScreen(screen: GAScreen) {
    val screenName = screen.screenName
    // ここでスクリーン名を送る
  }
}

こうすることで、うっかり変な文字列を送ることがなくなります。

色々な値を受け取って表示するActivityを起動するIntentに渡すパラメーターオブジェクト

こちらは"すごいEnum"っぽく使う例ですね。

Intentに複数のパラメーターをセットしたい時、全部を別々に手で受け取るのは面倒ですよね。

そのパラメーターの中にNullableなものがあって、別のパラメーターの値によって値が入ってなかったりすると思います。

こういう時にパラメーターオブジェクトを作って、Parcelableにして渡すのが有効と考えられます。

sealed class MainScreen() {
  object Top : MainScreen()
  object Settings : MainScreen()
  class Feed(val skipTutorial: Boolean) : MainScreen()
  class Category(val category: Category) : MainScreen()
  class EntryList(val initialTab: Tab) : MainScreen()
}

これをActivity側で受け取ってwhen式を使うなどして処理してやります。

when(screen) {
  is Top -> {
    openTop()
  }
  is Settings -> {
    openSettings()
  }
  is Feed -> {
    openFeed(screen.skipTutorial)
  }
  is Category -> {
    openCategory(screen.category)
  }
  is EntryList -> {
    openEntryList(screen.initialTab)
  }
}

最後に

KotlinのSealed Classを使って堅牢な設計のアプリを作りましょう。

明日の担当は id:aereal さんです!