pondělí 5. září 2011

Dědičnost u case classes

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,