LoginSignup
19
8

More than 5 years have passed since last update.

Kotlin言語において既存の似ているクラスを変更しないで同じものとして扱いたい。sealedを使って代数的データ型(直和型)で表現できた話。

Last updated at Posted at 2018-04-30

背景

PBook(PaperBook:紙書籍)、EBook(ElectronicBook:電子書籍)というクラスがあります。

data class PBook(var ID: Long, var name:String, var kakaku: Int)
data class EBook(var ID: Long, var title:String, var price: Int)

それらをBook(書籍)クラスとして統合して扱いたい場合に、Kotlinではどのように表現すればよいでしょうか?その際に、PBook、EBookクラスに変更を加えてはならないという制約を課します。
組織的な制約だったり、そもそも紙書籍サーバと、電子書籍サーバは別々に開発されてきた経緯があったり、利用しているフレームワークやライブラリなどの関係で手を入れられないなど、現実の開発ではよくある制約だと思います。

制約がなく自由に修正して良かったら、、、

PBook、EBookクラスに変更を加えてはならないという制約がなければ、以下のようにするかもしれません。

interface Book {
    val title : String
    val price : Int
}

data class PBook(val ID: Long, val name:String, val kakaku: Int) : Book {
    override val title: String
        get() = name
    override val price: Int
        get() = kakaku
}

data class EBook(val ID: Long, override val title:String, override val price: Int) : Book

しかし、本記事ではPBook、EBookクラスに手を入れられないという制約があります。
さて、どうしたものでしょうか?

enumを検討してみる

まずは、enumを検討してみます。

enum class Book { PBOOK, EBOOK }

たとえば、Colorのenumを考えてみると、それぞれに値を持たせることができます。

enum class Color(val rgb: Int) {
        RED(0xFF0000),
        GREEN(0x00FF00),
        BLUE(0x0000FF)
}

0xFF000といったrgb値のように、PBookやEBookの値を入れれば良いかなと思い以下のように記述しました。

enum class Book(val title: String, val price: Int) {
        PBOOK(1L, "我輩は犬である", 1800),
        EBOOK(2L, "三五郎", 2000)
}

書いてみて分かったことは、kotlinでのenumは定数を表現するためのものなのでうまくいかないということです。

C言語の共用体みたいに無理やりラッパーを書いてみる

そこで力技ですが、

data class PBook(val ID: Long, val kakaku: Int, val name:String)
data class EBook(val ID: Long, val price: Int, val title:String)

class Book {
    var pBook : PBook? = null
    var eBook : EBook? = null
    constructor(pBook : PBook) {this.pBook = pBook }
    constructor(eBook : EBook) {this.eBook = eBook }
    val title : String get() { return if(pBook != null) { pBook!!.name } else { eBook!!.title } }
    val price : Int get() { return if(pBook != null) { pBook!!.kakaku } else { eBook!!.price } }
}

のようなBookクラスをラッパークラスにして、PBookとEBookも表現できるようにしてみました。しかしnullの処理が悪い意味でいい加減に実装されています。さらにこのままですとPBookもEBookも同時に格納できてしまうため、null対処も含めてもっと適切に実装する必要がありそうです。
このままではいまいちな実装といえそうです。

sealedを使う

ここでsealedを使って、代数データ(直和)型を使えば実現できそうなことが分かりました。enumを拡張したともいえると思います。まずはenumを検討したときのコードを再掲します。

enum class Book { PBOOK, EBOOK }

enumと比較して、sealedを使うと以下のように書けます。

sealed class Book {
  class PBOOK() : Book()
  class EBOOK() : Book()
}

Book.EBOOKクラスとBook.EBOOKクラスには、紙書籍と電子書籍の内容(ID、題名、価格)を持つことが可能ですので、

sealed class Book {
    class PBOOK(val ID: Long, val kakaku: Int, val name:String) : Book()
    class EBOOK(val ID: Long, val price: Int, val title:String) : Book()
}

と、普通ならば定義するところでしょう。
今回は、既にあるPBookクラスとEBookクラスを修正せずにBookクラスとして扱うという課題なので、Book.PBOOKクラスとBook.EBOOKクラスをラッパークラスとして扱います。

data class PBook(val ID: Long, val kakaku: Int, val name:String) //既存クラス
data class EBook(val ID: Long, val price: Int, val title:String) //既存クラス

sealed class Book {
    class PBOOK(val pBook : PBook) : Book() //ラッパー
    class EBOOK(val eBook : EBook) : Book() //ラッパー
}

そこで、できたのが以下のコードです。


sealed class Book {
    class PBOOK(val pBook : PBook) : Book()
    class EBOOK(val eBook : EBook) : Book()

    val title : String
        get() {
            return when(this@Book){
                is Book.PBOOK -> this@Book.pBook.name
                is Book.EBOOK -> this@Book.eBook.title
        }
    }
    val price : Int
        get() {
            return when (this@Book) {
                is Book.PBOOK -> this@Book.pBook.kakaku
                is Book.EBOOK -> this@Book.eBook.price
            }
        }
    fun unwrap() : Any {
        return when (this@Book) {
            is Book.PBOOK -> this@Book.pBook
            is Book.EBOOK -> this@Book.eBook
        }
    }
}

whenの中でelse条件が出てこないところに注目してください。ここにsealedを使った効果が現れています。
つまり、BookであればPBOOKかEBOOKに限られるのでelse条件をつけずに済みコンパイルが通ります。
BookであればPBOOKかEBOOKのどちらかであることは、それぞれのクラスがBookクラスを継承しておりまたBookクラスがsealedで封印されているためです。
これにおり、力技で書いた方法よりも、ずっとすっきり記述できたと思います(力技のコードよりも長くなっているように見えますが、力技のコードはいろいろとサボって短くなっているだけす。そのため力技のコードは脆弱で不適切ですが、こちらは適切ですっきりとしています)。
unwrap関数でラップを剥がし、元のデータを取り出すことも可能にしました。取り出したものはAnyで返しているので使うときは適切に元の型にキャストする必要があります。

最後に

統合したBookクラスを使う使用例をあげておきます。

fun main(args: Array<String>) {
    val books : List<Book> = listOf(
            Book.PBOOK(PBook(1L, "我輩は犬である", 2000)),
            Book.EBOOK(EBook(2L, "三五郎", 1300)))
    books.forEach {
        println("${it.title} ${it.price}円")
    }
}

つづく...

実はこの話にはつづきがあります。もともとPBookとEBookはAndroidで開発中のParcelableなクラスでした。そうなると、統合したBookクラスもParcelableにしたくなります。次の投稿予定は、BookクラスをParcelableにする話になります。

つづき書きました → https://qiita.com/nakana/items/51ce5db163a73d3eb0d7

19
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
19
8