22 - apply és companion object

Láttuk, hogy a List osztálynak tudjuk példányait létrehozni List(1,4) vagy List(3), sőt List() kifejezéssel is. Nézzük meg, mi is ebben az ismeretlen eddig számunkra:

  • case classokat tudunk készíteni, azonban az ő fejlécükben megadott paramétereket eddig mindig meg kellett adnunk, ,,változó aritású'' fejlécet nem tudtunk készíteni

  • a Listünk egy trait, azt nem is lehet ellátni ilyen fejléccel.

Most megnézzük, hogy mi hogy tudunk ilyet készíteni

apply

Láttunk már példákat arra, hogy a Scala fordító a motorháztető alatt egyes kifejezéseket fordítás előtt átír más kifejezésekké, ilyenek a for comprehensionök.

Egy újabb ilyen eset, mikor is egy objektumot mint egy függvényt hívunk, ebből a Scala fordító egy apply metódushívást készít, majd ezt próbálja lefordítani. Tehát mikor pl. látunk egy List(3,5,4) alakú kifejezést (hogy most a List miért is számít objektumnak és nem traitnek, arról mindjárt), akkor valójában ez egy List.apply(3,4,5) metódushívás, akkor fog lefordulni, ha deklaráltunk olyan apply metódust, mely három Intet vár.

Implementáljunk a saját listánkba egy olyan apply metódust, mely egy Intet vár, és visszaadja a lista ennyiedik elemét, ha van, vagy ???t, ha nincs! (A ??? objektumról egyelőre azt tudjuk, hogy bármilyen típusú kifejezés helyére írhatjuk, és ha ráfut a vezérlés, akkor egy kivételt fog dobni. Ezt is nemsokára megértjük, hogy miért.)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
trait MyList[T] { //a többi mellett pluszban:
  def apply(i: Int): T //T-t kell visszaadjon
}
case class MyNil[T]() extends MyList[T] {
  override def apply(i: Int) = ??? //ez így dobni fog egy kivételt, ha meghívjuk
}
case class MyCons[T](head: T, tail: MyList[T]) {
  override def apply(i: Int) = i match {  //Intre is lehet matchelni
    case 0 => head                        //a lista 0. eleme a feje
    case _ => tail.apply(i-1)             //tail rekurzió, a farok i-1. elemét adjuk vissza
  }
}

val list = MyList(1, MyList(4, MyList(2, MyNil() ) ) )

println( list(1) ) //prints 4 -- nem kell kiírjuk, hogy apply
A mintaillesztésben `tail(i-1)`-et is írhatunk. Válasz mutatása

companion object

Okay, ha a list egy List típusú objektum, akkor most már tudunk list(6) alakú kifejezéseket írni, ami titokban egy list.apply(6) metódushívásba fordul. De (Scalában) ahhoz, hogy metódust hívjunk, kell egy objektum! Ha pl. ez a list egy

1
val list = MyCons(2, MyCons(5, MyCons(3, MyNil())))

kifejezés értékeként jött létre, akkor a list(1) hívást hogyan fejtjük ki a substitution modelben?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
list(1)  list.apply(1)
         MyCons(2, MyCons(5, MyCons(3, MyNil()))).apply(1)
         1 match {
            case 0 => 2 //mert ez a this.headje ennek a MyConst objektumnak
            case _ => MyCons(5, MyCons(3, MyNil())).apply(1-1) //ez meg a this.tail
          }
         MyCons(5, MyCons(3, MyNil())).apply(1-1) //ez az eset illeszkedik
         MyCons(5, MyCons(3, MyNil())).apply(0)   //call-by-value
         0 match {
            case 0 => 5 //ez a this.head
            case _ => MyCons(3, MyNil()).apply(0-1)
          }
         5 //ez a case matchelt

Válasz mutatása
Még egyszer: metódust csak objektumon hívhatunk Scalában, a MyList[T] pedig egy traitünk. Akár case class lenne, akár trait, nem hívhatnánk rajta közvetlenül metódust, csak egy-egy példányán, a típus értéktartományának egy elemén.

De mégiscsak szeretnénk pl. MyList()-tel létrehozni üres listát, MyList(5)-tel pedig egy egyeleműt. Ha a traitbe teszünk apply metódust, azzal ezt nem érjük el.

Erre az igényre megoldás a companion object használata:

  • ha egy T típusnak szeretnénk magán a típuson hívható metódusokat hívni (mint pl. most a MyList(5)-öt, kéne egy apply metódus)
  • akkor a T (trait vagy case class, legalábbis eddig ezt a kettőt láttuk) típus mellé létrehozunk egy object T-t is, ugyanezen a néven
  • és ezen a T objecten belül deklarálhatunk ugyanúgy valokat és defeket, mint ahogy az osztályban tettük,
  • melyeket T.f(..) formában fogunk tudni meghívni.

(note: Scalában egy forrásfile-ba szokás pakolni a classt és mellé a companion objectet.)

Persze az object T-n belül ne használjunk thist, hiszen ez nem egy példányához tartozó metódus lesz, hanem magához az osztályhoz ,,mint objektumhoz''. (A konstrukciót pl. Javában vagy c++ban az osztálytörzsben static módosítóval deklarált metódusokkal és adattagokkal valósítjuk meg, Scalaban companion objecttel.)

Még egy dolog van, amire oda kell figyelnünk: object nem lehet generikus, companion object sem. (Ezzel már találkoztunk, a generikus üres listánk egyelőre ezért osztály, adattag nélkül, mert még nem tudtuk megoldani, hogy típusparaméterezhető objektum legyen.) Viszont a metódusai igen!

Ez alapján próbáljunk meg implementálni egy companion objectet a MyList[T] traitünk mellé úgy, hogy pl. a MyList[Int]() egy üres Int listára értékelődjön ki, a MyList[String]("alma", "körte") pedig egy kételemű String listára!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
trait MyList[T]

case class MyNil[T]() extends MyList[T]

case class MyCons[T](head: T, tail: MyList[T]) extends MyList[T]

object MyList {
  def apply[T](): MyList[T] = MyNil[T]() //generikus metódus, T típusú elemek üres listáját adja vissza
  def apply[T](elem: T): MyList[T] = MyCons(elem, MyNil()) //generikus metódus, így tudunk egyelemű listát készíteni
  def apply[T](elem1: T, elem2: T): MyList[T] = MyCons(elem2, MyList(elem1))
}

val list = MyList(2,5)  //fordul!
println( list ) // prints MyCons(5,MyCons(2,MyNil()))
Válasz mutatása

Function1[T,R]

Korábban volt róla szó, hogy nem tudunk létrehozni pl. a Lista[T] traitünkön belül egy map( f: T=>Int ) és egy map( f: T=>Boolean ) függvényt egyidőben. Most már meg tudjuk érteni az okát is:

  • A T=>R függvény típusból a Scala fordító under the hood egy Function1 típust kér: ez egy trait, két generikus típusparaméterrel és egy apply(x: T): R metódussal, kb. így:
1
2
3
trait Function1[T,R] {
  def apply(x: T): R
}
  • ha egy függvényt készítünk és átadjuk, mondjuk az val f = { y: Int => y+1 } egy Int=>Int szignatúrájú függvény, ebből ennek a traitnek egy példánya készül el, melynek az apply metódusa a def apply(y: Int) = y+1 implementációt kapja
  • ha meghívjuk később az f-et valamilyen argumentummal, pl. f(3), akkor ennek az elkészült objektumnak az apply metódusát hívjuk meg.

Complicated a bit, de a JVM így érti meg, a Scala konstruktumai mind megvalósíthatók a JVM natívájában, Javában is.

Mindenesetre ennek az a hátránya van, hogy ha van ez a két mapunk mint fent, akkor ebből a Scala fordító először is ezt készítené:

1
2
3
4
trait Lista[T] {
  def map( f: Function1[T,Int] ): Lista[Int]
  def map( f: Function1[T,Boolean] ): Lista[Boolean]
}

és ezzel az a baj, hogy a JVM type erasure-ja arról szól, hogy a generikus paraméterek csak forráskódi szinten léteznek, hogy kényelmesebbé tegyék a fejlesztést; magába a bytekódba már nem kerülnek bele a generikusok, hanem (kb) a típusparaméterek helyébe mindenhova Objectek kerülnek, és a JVM már két teljesen egyforma szignatúrájú map( f: Function1 ) metódust látna, amik közt persze nem tudna különbséget tenni. Ezért van az, hogy (sem Javában, sem Scalában) nem fogadja el a fordító azt, ha két metódus csak a bejövő paraméterek generikusaiban különbözik (és ezért nem tudunk generikus objektumot sem készíteni).


Utolsó frissítés: 2020-12-24 19:34:45