背景
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