Case classes jsou ve Scale jednoduchým nástrojem, který slouží zhruba ke stejnému účalu jako struktury v Cčku nebo entitní třídy v Javě - seskupují data, která patří k sobě. Vše je velmi jednoduché, než se sem vloží dědičnost. Pak je potřeba trochu přemýšlet, protože se situace trochu komplikuje. Ale jen trochu...
Budu postupovat po malých krocích, takže článek vypadá dlouze. Přidaný nebo změněný kód se snažím zvýrazňovat.
Terminologická odbočka
- field - datový atribut třídy, který by měl být podle zásad zapouzdření pokud možno private. Napíšete-li v Javě ve třídě třeba
private String foo = null;
, vytvořili jste field.
- vlastnost (vlastnost třídy) - data, která mají vztah ke třídě a která lze zjistit zavoláním getteru a případně nastavit zavoláním setteru. Data lze číst z fieldu (a případně zapisovat do něj), ale obecně to není nutné.
Co nám case classes nabízejí?
Když už víme, kdy zvažovat jejich použití, přejděme k tomu, co od nich dostaneme.
- Metody pro porovnávání - equals a hashCode. Díky tomu nebudou instance té třídy ve výchozím stavu porovnávány podle identity, ale podle ekvivalence jejich členů.
- Parametry třídy jsou defaultně veřejně viditelné, takže nemusíte psát
val
. (Generují se fieldy a gettery k nim.) - Metodu toString pro převod na čitelný řetězec.
- Metodu apply ("statickou" metodu) pro konstrukci objektu bez klíčového slova
new
. - Metodu unapply ("statickou" metodu) pro pattern matching
Podle článku o case classes z FAQ by to mělo být již vše.
Úkol
Určeme si nějaký úkol s dědičností. Navrhuji použít elipsu a kružnici (jako její speciální případ). Každý tvar bude mít i barvu. (Možná budu za tu barvu kritizován, ale zkuste navrhnout příklad, který je co nejjednodušší, nejnázornější a nemá znaky něčeho umělého...)
Nápad s case classes
Možná vás jako první napadne toto:
package com.v6ak.example.geometry case class Shape(color: String) case class Ellipse(color: String, a: Int, b: Int) extends Shape(color) case class Circle(color: String, r: Int) extends Ellipse(color, r, r)
Zkuste si to. Kompilátor řve? No dobře, tudy cesta asi nevede.
Problém
Kompilátor nám vlastně řval, že se snažíme znovu přidávat vlastnost (vlastně jen getter), která již existuje díky rodiči. A je plně implementovaná. (Onen getter není v rodiči abstraktní.) Což o to, přidáním override val
k vlastnosti color bychom kompilátor umlčeli. (On by stále řval, že se to tak dělat nemá, ale bylo by to jen varování.)
U Shape jsme definovali i to, jak se barva získává. V Javě by tomu totiž odpovídal privátní field s getterem. Možná si myslíte, že budou následovat nějaké "kecy o zapouzdření". To ne, u kružnice a elipsy máme zajímavější situaci. Elipsa má hlavní a vedlejší poloosu a ve speciálním případě (pokud si jsou obě poloosy rovny) jde o kružnici. U kružnice ale máme tři vlastnosti, které nesou tu stejnou hodnotu - jednou jako hlavní poloosu, podruhé jako vedlejší poloosu a potřetí jako poloměr. Jedna hodnota by tedy byla v jedné instanci kružnice uložena třikrát! To vypadá už trošku divně, ne? No ano, ale když jsme si takto implementovali elipsu, tak to jinak nepůjde. Elipsa prostě je implementovaná tak, že má dva fieldy a k nim dva gettery pro hlavní a vedlejší poloosu. A kružnice k nim takto přidává třetí. Fieldy odebírat prostě nejdou. A i kdyby to šlo, byla by to celkem úzká vazba na implementaci v rodičovi.
Varianta s abstraktními třídami
Můžeme to ale řešit jinak:
- Každá case class bude finální.
- Všichni předci case classes budou abstraktní. (Pro úplnost: třídu AnyRef teď vynechávám.)
- Díky tomu se nám nestane, že by jedna case class dědila z druhé. Třídy si tedy nebudou lézt do zelí (fieldů).
První verze
Tato verze je záměrně velmi podobná verzi plné case classes a přízpůsobil jsem tomu i styl odsazování.
package com.v6ak.example.geometry abstract class Shape{def color: String} abstract class Ellipse extends Shape {def a: Int; def b: Int} final case class Circle(color: String, r: Int) extends Ellipse {def a=r; def b=r}
No, už to vypadá lépe a kompilátor to vezme. Kružnice má jen jeden field pro poloměr, ne tři stejné. Ale co elipsa? Jak vytvořím elipsu? Asi to chce ještě něco trošku přidat.
Přidáváme konkrétní elipsu
package com.v6ak.example.geometry abstract class Shape{def color: String} abstract class Ellipse extends Shape {def a: Int; def b: Int} final case class GeneralEllipse(color: String, a: Int, b: Int) extends Ellipse final case class Circle(color: String, r: Int) extends Ellipse {def a=r; def b=r}
Teď již není problém vytvořit konkrétní elipsu. Používá se ale "škaredé" GeneralEllipse, které "ční" ven. Třídu radši skryjeme a vytvoříme jí továrnu.
Skrýváme GeneralEllipse
package com.v6ak.example.geometry abstract class Shape{def color: String} abstract class Ellipse extends Shape {def a: Int; def b: Int} private[geometry] final case class GeneralEllipse(color: String, a: Int, b: Int) extends Ellipse final case class Circle(color: String, r: Int) extends Ellipse {def a=r; def b=r} object Ellipse { def apply(color: String, a: Int, b: Int) = GeneralEllipse(color, a, b) }
Už je to celkem použitelné, ale pořád se dá vylepšovat. Ellipse nemá pattern matching.
Doplňujeme pattern matching
package com.v6ak.example.geometry abstract class Shape{def color: String} abstract class Ellipse extends Shape {def a: Int; def b: Int} private[geometry] final case class GeneralEllipse(color: String, a: Int, b: Int) extends Ellipse final case class Circle(color: String, r: Int) extends Ellipse {def a=r; def b=r} object Ellipse { def apply(color: String, a: Int, b: Int) = GeneralEllipse(color, a, b) def unapply(e: Ellipse) = Option(e).map(e => (e.color, e.a, e.b)) }
Toto si už zaslouží menší komentář. Jednodušší verze by nebyla odolná null hodnotám:
def unapply(e: Ellipse) = Some((e.color, e.a, e.b)) // vrací přímo uspořádanou trojici, ale selže u null hodnot
Proto pomocí Option(e)
konvertujeme na Option, což je vpodstatě kolekce o nejvýše jednom prvku. Pro null
dostáváme prázdnou kolekci (tj. None
), pro jinou hodnotu dostáváme kolekci o právě jednom prvku (tj. Some(e)
). A nakonec pomocí metody map konvertujeme případný obsah na uspořádanou trojici. Pro null
tedy dostáváme None
, protože to není elipsa, pro ostatní dostáváme rozloženou elipsu na uspořádanou trojici prvků, že kterých byla vytvořena. Narozdíl od GeneralEllipse toto funguje i pro kružnice.
K čemu to unapply využijeme?
Zde trošku odbočím. Možná si říkáte, k čemu je tu to podivínské unapply. Díky němu totiž můžete psát toto:
shape match { case Circle(c, r) => "kružnice o poloměru "+r case Ellipse(c, a, b) => "elipsa (...)" case _ => "neznámé" }
Možná to nepotřebujete, pak to můžete vynechat.
Vylaďujeme detaily (někdy podstatné)
Každá kružnice je Circle
Pokud nyní vytvoříme Ellipse("yellow", 5, 5)
, dostaneme tím něco jiného než Circle("yellow", 5)
. Sice se to bude chovat podobně, ale podle ==
si rovny nebudou. (Metoda/"operátor" ==
porovnává podle metody equals a řeší null
hodnoty.) Navíc Ellipse(5, 5)
nebude instancí třídy Circle
. Proto trošku upravíme továrnu:
package com.v6ak.example.geometry abstract class Shape{def color: String} abstract class Ellipse extends Shape {def a: Int; def b: Int} private[geometry] final case class GeneralEllipse(color: String, a: Int, b: Int) extends Ellipse final case class Circle(color: String, r: Int) extends Ellipse {def a=r; def b=r} object Ellipse { def apply(color: String, a: Int, b: Int) = if(a == b){ Circle(color, a) }else{ GeneralEllipse(color, a, b) } def unapply(e: Ellipse) = Option(e).map(e => (e.color, e.a, e.b)) }
Stylystická: Použijeme match místo podmínek
Toto je otázka spíše stylu, ale přijde mi hezčí použít match
než if. Fungovat by to mělo naprosto stejně.
package com.v6ak.example.geometry abstract class Shape{def color: String} abstract class Ellipse extends Shape {def a: Int; def b: Int} private[geometry] final case class GeneralEllipse(color: String, a: Int, b: Int) extends Ellipse final case class Circle(color: String, r: Int) extends Ellipse {def a=r; def b=r} object Ellipse { def apply(color: String, a: Int, b: Int) = a match { case `b` => Circle(color, a) case _ => GeneralEllipse(color, a, b) } def unapply(e: Ellipse) = Option(e).map(e => (e.color, e.a, e.b)) }
Uzavíráme okruh tříd
Můžeme chtít, aby nám Shape neimplementoval někdo jiný. První možnost je dát jeho konstruktoru viditelnost jen pro balíček, ve kterém je:
abstract class Shape private[geometry]() {def color: String}
Máme tu ale možnost vše mít v jednom souboru a použít modifikátor sealed
. Díky tomu nebude možné dělat potomky Shape
v jiném souboru a navíc dostaneme u pattern matchingu varování při nevhodném použití. Modifikátor dostanou všechny veřejné abstraktní třídy.
package com.v6ak.example.geometry abstract sealed class Shape{def color: String} abstract sealed class Ellipse extends Shape {def a: Int; def b: Int} private[geometry] final case class GeneralEllipse(color: String, a: Int, b: Int) extends Ellipse final case class Circle(color: String, r: Int) extends Ellipse {def a=r; def b=r} object Ellipse { def apply(color: String, a: Int, b: Int) = a match { case `b` => Circle(color, a) case _ => GeneralEllipse(color, a, b) } def unapply(e: Ellipse) = Option(e).map(e => (e.color, e.a, e.b)) }
Vylepšujeme toString u elips
Pokud nechceme při převodu elipsy na řetězec dostávat GeneralEllipse(...)
, uvedeme, co chceme místo toho:
package com.v6ak.example.geometry abstract sealed class Shape{def color: String} abstract sealed class Ellipse extends Shape { def a: Int def b: Int } private[geometry] final case class GeneralEllipse(color: String, a: Int, b: Int) extends Ellipse{ override def productPrefix = "Ellipse" } final case class Circle(color: String, r: Int) extends Ellipse {def a=r; def b=r} object Ellipse { def apply(color: String, a: Int, b: Int) = a match { case `b` => Circle(color, a) case _ => GeneralEllipse(color, a, b) } def unapply(e: Ellipse) = Option(e).map(e => (e.color, e.a, e.b)) }
A nakonec trosku whitespace
Při prvních úpravách jsem pro pocit podobnosti použil trošku nekonvenční pravidla pro odsazování apod., tak to teď napravím.
package com.v6ak.example.geometry abstract sealed class Shape{ def color: String } abstract sealed class Ellipse extends Shape { def a: Int def b: Int } private[geometry] final case class GeneralEllipse(color: String, a: Int, b: Int) extends Ellipse{ override def productPrefix = "Ellipse" } final case class Circle(color: String, r: Int) extends Ellipse { def a=r def b=r } object Ellipse { def apply(color: String, a: Int, b: Int) = a match { case `b` => Circle(color, a) case _ => GeneralEllipse(color, a, b) } def unapply(e: Ellipse) = Option(e).map(e => (e.color, e.a, e.b)) }
Vyzkoušíme si
Nedělali jsme unit testy, ale aspoň si napíšeme jednoduchý kód, který demonstruje funkčnost:
package com.v6ak.example.geometry object Demo extends Application{ // use App instead of Application in Scala <= 2.9 val a = Circle("yellow", 5) val b = Ellipse("yellow", 5, 5) val c = Ellipse("yellow", 4, 5) println(a + " is equal to " + b + ": " + (a == b) + " (should be true)") println(a + " is equal to " + c + ": " + (a == c) + " (should be false)") }
Nyní můžeme uložit poslední verzi tříd třeba do classes.scala a tuto ukázku do demo.scala, zkompilovat příkazem fsc *.scala
a spustit příkazem scala com.v6ak.example.geometry.Demo
. Měli bychom dostat toto:
Circle(yellow,5) is equal to Circle(yellow,5): true (should be true) Circle(yellow,5) is equal to Ellipse(yellow,4,5): false (should be false)
Konečnou verzi zdrojáků spolu s demem si můžete stáhnout z Githubu,
Žádné komentáře:
Okomentovat