28 - kovariancia

Pet

Tegyük fel, hogy van egy típushierarchiánk. Mondjuk veszünk egy NamedObject traitet:

1
2
3
trait NamedObject {
  def name: String
}

és ezt szeretnénk arra használni, hogy mindenféle dolgoknak, ha csak a nevük az érdekes, tudjunk mondjuk köszönni:

1
2
3
def hello( namedObject: NamedObject ) {
  println( s"Hello, ${namedObject.name}!" )
}

Legyenek mondjuk háziállataink, amiknek persze van nevük, ezért az ő traitjük extendeli a NamedObjectet:

1
trait Pet extends NamedObject

Konkrét osztályunk legyen mondjuk a macska és a kutya, mindkettő Pet:

1
2
case class Cat( name: String ) extends Pet
case class Dog( name: String ) extends Pet

Miért nem baj az, hogy sem a Cat, sem a Dog osztályban nem defeltünk egy name metódust? Hiszen extendelik (tranzitívan) a NamedObjectet, aminek szerepel a törzsében, hogy kéne egy def name: String.

Mert val mezővel is lehet extendelni a kért metódust, mivel nincs utána (), a két osztálynak pedig a fejlécében pont ezzel a névvel deklaráltuk az egyetlen mezőjét. Válasz mutatása

Magában ez jól működik:

1
2
3
4
5
val morcos = Cat("Morcos")
val freya  = Dog("Freya")

hello( morcos ) //prints "Hello, Morcos!"
hello( freya  ) //prints "Hello, Freya!"

Eddig nincs meglepetés: a hello függvény mindent el kell fogadjon argumentumnak, ami a NamedObject típusba beletartozik, ami - ha úgy nézzük, mint algebrai adattípust - egy összeg típus, pillanatnyilag egyetlen másik típus, a Pet szerepel az ,,összeg'' típusában, tehát minden, ami Pet, egyben NamedObject is, és a Pet megint egy összeg típus, Pet = Cat + Dog a mostani hierarchia szerint, tehát ami Cat vagy Dog, az egyben Pet és ezért egyben NamedObject típusú is.

Option[Pet]

Készítsünk most egy saját Option implementációt foreach-csel. Ezt majd arra szeretnénk használni, hogy mindenkinek lehessen egy kedvenc állata, legfeljebb egy, de belefér az is, ha nincs neki egyáltalán. Ez az, amit Scalában az Option monáddal oldunk meg, most szándékosan nem a beépítettet használjuk, hogy rájöjjünk arra, mit csinál másképp, mint mi eddig.

1
2
3
4
5
6
7
8
9
trait Option[T] {                 //generikus
  def foreach[U]( f: T=>U ): Unit //standard foreach fejléc
}
case class None[T]() extends Option[T] {   // ,,nincs''
  override def foreach[U]( f: T=>U ) = ()  // üres opción foreach: semmit nem kell tenni
}
case class Some[T]( value: T ) extends Option[T] { // ,,van''
  override def foreach[U]( f: T=>U ) = { f(value); () }
}

Akkor, ahogy ezt terveztük, legyen mondjuk egy Ember osztályunk, akinek szintén van neve, lehessen nevén is nevezni, és lehessen legfeljebb egy macskája, és legfeljebb egy kutyája. Hogyan implementáljuk ezt a case classt?

1
case class Ember( name: String, catOption: Option[Cat], dogOption: Option[Dog] ) extends NamedObject
Válasz mutatása

Most tudunk készíteni egy "Tibi" nevű embert, akinek mondjuk a fentiek közül van a Morcos nevű macskája, kutyája meg nincs. Hogyan?

1
val tibi = Ember("Tibi", Some(morcos), None() )

Note: persze Morcost Some-ba kell rakni, hogy forduljon, így lesz opció, kell a unit hozzá.

Válasz mutatása

printing Ember

Most ezek után szeretnénk egy olyan függvényt írni mondjuk az Ember osztályba, ami kiírja az ember nevét, ha van kutyája, annak a nevét is, ha van macskája, akkor annak a nevét is, a fenti tibi esetén pl. valahogy így:

1
2
Ember: Tibi
  Macskája: Morcos

Az ember neve jöjjön előre az Ember prefixben, majd a macskáé Macskája: prefixszel és ha van kutyája, akkor ugyanez Kutyája: prefixszel. Első körben készíthetünk akár egy ilyen metódust is:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
case class Ember( name: String, catOption: Option[Cat], dogOption: Option[Dog] ) extends NamedObject {
  def print = {
    println( s"Ember: $name")
    catOption match {
      case None()    => ()
      case Some(cat) => println( s"  Macskája: ${cat.name}" )
    }
    dogOption match {
      case None()    => ()
      case Some(dog) => println( s"  Kutyája: ${dog.name}" )
    }
  }
}

Az Option egy trait, összeg típus, az őt megalkotó case classokra pedig lehet matchelni, így ez a kód fordul és valóban ki is írja, amit szeretnénk, de nem tűnik valami elegánsnak. Tanultunk-e már valamit, amivel egy ennél idiomatikusabb Scala kóddá lehet a fentit alakítani?

Opcióra matchelni és None esetén nem tenni semmit, Some esetén pedig kiértékelni egy mellékható függvényt a Some-ba csomagolt objektumon? Ez a foreach, amit a saját Optionünkbe bele is írtunk:

1
2
3
4
5
6
7
case class Ember( name: String, catOption: Option[Cat], dogOption: Option[Dog] ) extends NamedObject {
  def print() = {
    println( s"Ember: $name")
    for( cat <- catOption ) println( s"  Macskája: ${cat.name}" )
    for( dog <- dogOption ) println( s"  Kutyája: ${dog.name}" )
  }
}

Válasz mutatása
A tibi.print() kiértékelése rendben ki is írja, ahogy szeretnénk:

1
2
Ember: Tibi
  Macskája: Morcos

printing Option[NamedObject]

Oké, az előző implementáció működik, de van benne kódismétlés, ezt jó ötlet kiszervezni egy függvénybe. Alapvetően amire az ember gondolhat, az az, hogy ír egy printNamedOption( Option[NamedObject] ) metódust, ami foreachel egyet az opción és kiírja, akit benne talál. Azt is látjuk, hogy talán még egy prefix stringet is kaphatna, és akkor tudjuk hívni a "kutyája:" meg "macskája:" stringekkel, hogy ezt írja elé.

Ez alapján megírhatunk egy függvényt ezzel a funkcióval:

1
2
3
def printNamedOption( namedOption: Option[NamedObject], prefix: String ) = {
  for( namedObject <- namedOption ) println( s"$prefix${namedObject.name}" )
}

Most az Ember osztályunkban kicseréljük a kiíratást erre:

1
2
3
4
5
6
7
case class Ember( name: String, catOption: Option[Cat], dogOption: Option[Dog] ) extends NamedObject {
  def print = {
    println( s"Ember: $name")
    printNamedOption( catOption, "  Macskája: " )
    printNamedOption( dogOption, "  Kutyája: " )
  }
}

Az ötlet jó, átláthatóbbá és kevésbé elronthatóvá tenné a kódunkat, csak egy baj van:

nem fordul

nem fordul.

Az Option[Cat] az így nem egy Option[NamedObject]

Láttuk, hogy egy Cat objektumot odaadhatunk egy függvénynek, ami NamedObjectet vár, mert a Cat az egy NamedObject, ezt jelölhetjük úgy is, hogy Cat <: NamedObject.

Azonban ettől még egy Option[Cat] nem lesz automatikusan Option[NamedObject]!

A fogalom, amit keresünk, a ko- és a kontravariancia:

Legyen C[T] egy generikus osztály.

  • Ha abból, hogy A <: B, következik, hogy C[A] <: C[B], akkor azt mondjuk, hogy a C osztály T típusparamétere kovariáns.
  • Ha pont fordítva, azaz abból, hogy A <: B, az következik, hogy C[B] <: C[A], akkor azt mondjuk, hogy a C osztály T típusparamétere kontravariáns. (Látni fogunk olyat, amikor ennek lesz értelme.)
  • Ha sem C[A] <: C[B], sem C[B] <: C[A] nem következik abból, hogy A <: B, akkor pedig a típusparaméter invariáns.

Option[+T]: így már igen

Scalában alapból minden típusparaméter invariáns, ezért hiába is igaz, hogy Cat <: NamedObject, ettől még az nem lesz igaz a saját, most készített Option osztályunkra, hogy Option[Cat] <: Option[NamedObject] is teljesüljön, ezért nem veszi be a függvényünk a catOptiont oda, ahogy Option[NamedObject]et vár.

Viszont lehet kovariánsnak deklarálni a típusparamétert: T helyett csak +T-t kell írnunk.

1
2
3
4
5
6
7
8
9
trait Option[+T] {                 // MOST MÁR KOVARIÁNS
    def foreach[U]( f: T=>U ): Unit 
  }
  case class None[T]() extends Option[T] {   
    override def foreach[U]( f: T=>U ) = ()  
  }
  case class Some[T]( value: T ) extends Option[T] { 
    override def foreach[U]( f: T=>U ) = { f(value); () }
  }

Ha ennyit változtatunk ezen a kódon, le fog fordulni, a printNamedOption( Option[NamedObject] ) metódus el fogja fogadni az Option[Cat]et és az Option[Dog]ot is, mert az Option típus típusparaméterét kovariánsnak deklaráltuk.

Nemsokára ebbe mélyebben beleássuk magunkat, mert nem mindig engedi a fordító, hogy valamit ko- vagy kontravariánsnak deklaráljunk. Hogy miért, azt hamarosan látni fogjuk. Azt érdemes tudni, hogy a beépített Option, List, Vector, Set pl. kovariánsak a típusparaméterükben (ezért ha a beépített Optiont használtuk volna, az fordult volna csont nélkül, ezért írtunk sajátot, hogy lássuk, ez így nem mindig megy ennyire magától).


Utolsó frissítés: 2020-12-25 19:16:06