15 - member függvények

Lehet függvényeket vagy értékeket is deklarálni case classon case objecten belül is, pl. így:

1
2
3
4
5
case class Vektor(x: Double, y: Double) {
  def length = Math.sqrt(this.x * this.x + this.y * this.y)  //így
}
val v = Vektor(1.0,2.0)
println(v.length)  //prints 2.23606797749979

Mit tanultunk ebből a kódból?

  • A case class deklarációja után nyithatunk kapcsost, és írhatunk az osztály törzsébe további függvényeket (,,tagfüggvényeket'' vagy ,,metódusokat'')
  • ezeket a függvényeket az egyes objektum után ponttal kapcsolva tudjuk hívni, hasonlóan a mezőihez
  • a this kulcsszó ekkor magát az objektumot jelenti az osztály tagfüggvényeinek törzsében

operatív szemantika

Matematikailag, ha pure funkcionálisan dolgozunk:

  • ha a C osztályban az a példánynak hívjuk az f(x1:T1, x2:T2, ..., xn: Tn) metódusát, akkor a.f(a1,..,an) alakban tudjuk megejteni ezt a callt, ahol az ai argumentumok mind Ti típusúak
  • a függvény törzsében az xi-k helyére az ai értékek kerülnek, mint eddig is,
  • a this kulcsszó helyére pedig maga az a érték.

Pl. a fenti v.length hívásnál (note: nincs kerek zárójel utána, mert nincs mellékhatása: kiszámítja és visszaadja a vektor hosszát)

1
2
3
4
5
v.length  Vektor(1.0, 2.0).length  //mert így hoztuk létre: val v = Vektor(1.0,2.0)
          Math.sqrt(Vektor(1.0,2.0).x*Vektor(1.0,2.0).x+Vektor(1.0,2.0).y*Vektor(1.0,2.0).y)
          Math.sqrt(1.0 * 1.0 + 2.0 * 2.0) //mezők elérése
          Math.sqrt(5.0)
          2.2360679...

Még egyszer: a fenti length zárójel nélkül van, nincs mellékhatása. Szemre a v.length akár egy val member adatmező is lehetne.

def, val, lazy val

És tényleg:

1
2
3
4
5
6
7
8
9
case class Vektor(x: Double, y: Double) {
  def length_def = Math.sqrt(this.x * this.x + this.y * this.y)          //def
  val length_val = Math.sqrt(this.x * this.x + this.y * this.y)          //val
  lazy val length_lazy_val = Math.sqrt(this.x * this.x + this.y * this.y)//lazy val
}
val v = Vektor(1.0,2.0)
println(v.length_def)  //prints 2.23606797749979
println(v.length_val)  //prints 2.23606797749979
println(v.length_lazy_val)  //prints 2.23606797749979

Kerek zárójel nélkül deklarálva tagot nem csak a korábban már látott def és val, hanem az eddig még nem látott lazy val is opció. A különbségek:

  • a val értéke az objektum létrehozásakor (ez a val v=Vektor(1.0,2.0) értékadás jobb oldali kifejezésének kiértékelésekor történik) kiszámítódik, ez az érték bekerül plusz egy adattagba, innentől kezdve ezt adjuk vissza.
  • a def értéke minden egyes lekérdezéskor újra és újra kiértékelődik a definíciójának megfelelően, viszont nem foglalódik neki plusz adattag objektumpéldányonként.
  • a lazy valnak foglalódik egy plusz adattag, de létrehozáskor még nem értékelődik ki, csak az első lekérdezéskor. Ekkor az érték bekerül az adattagba és onnantól kezdve nem számolódik újra.

Tehát: a valok kicsit több helyet foglalnak, a val mindenképp kiszámolja létrehozáskor az értékét, a def lekérdezésenként tovább tart, mire megjön az eredmény. A lazy val sem mindig jobb választás, mint a val, mert annak is van plusz költsége (adattagban is, és a val lekérdezéseként is), hogy minden lekérdezéskor fut egy check, hogy ki van-e már számolva ez az érték.

Ezt minden esetben a programozó kell mérlegelje, hogy a három lehetőség közül melyik lenne a legjobb - de a jó hír, hogy ha később mégiscsak váltani lenne jobb és átírja az osztályban az egyik kulcsszót a másikra, akkor nem töri el a hívó kódokat, mert számukra teljesen transzparens, hogy ez most épp melyik a három közül.

Ennek megfelelően, futtassuk ezt a kódot, melybe debug printlnokat csempésztünk a három definícióba:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
case class Vektor(x: Double, y: Double) {
  def length_def = { println("def"); Math.sqrt(this.x * this.x + this.y * this.y) }
  val length_val = { println("val"); Math.sqrt(this.x * this.x + this.y * this.y) }
  lazy val length_lazy_val = { println("lazy_val"); Math.sqrt(this.x * this.x + this.y * this.y) }
}

val v = Vektor(1.0,2.0)  //prints val
println("start")         //prints start
v.length_def             //prints def
v.length_val
v.length_lazy_val        //printy lazy_val
v.length_def             //prints def
v.length_val
v.length_lazy_val
v.length_def             //prints def
v.length_val
v.length_lazy_val

Egy pillantást közben vessünk a debug println-okra: így, hogy két kifejezés van egymás után, az elsőt kiértékeljük (mellékhatásként kiírja amit szeretnénk), majd eldobjuk az (amúgy is ()) értéket és folytatjuk a kiértékelést a második kifejezéssel, ez lesz majd a tényleges érték.

Ez is egy módja a debugnak, de ennél azért fogunk látni jobbat is.

def és val traitben

Nem csak classba vagy objectbe, de traitbe is írhatunk defet vagy valt (lazy valt nem). Ha például szeretnénk az IntList traitünket felruházni egy sum metódussal, ami visszaadja az elemei összegét:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
trait IntList {
  def sum: Int
}
case object Empty extends IntList {
  override def sum = 0
}
case class Nonempty(head:Int, tail:IntList) extends IntList {
  override def sum = head + tail.sum //itt fontos, hogy az IntList-nek már legyen sum-ja
}
//így használjuk
val list = Nonempty(3, Nonempty(4, Nonempty(7, Empty)))
println(list.sum) // prints 14

Amit tanultunk ebből a kódból:

  • Ahhoz, hogy egy akármilyen IntList típusú értéknek tudjuk hívni a sum függvényét az kell, hogy már eleve az IntList traitben ott legyen a függvény deklarációja. Ott is van: def sum: Int. Típust is kell adjunk neki.
  • Definíciót nem kell adjunk traitben egy függvénynek (nem is baj, mert honnan is tudnánk a traitben, ha azt se tudjuk, hogy melyik tényleges case classnak vagy case objectnek egy példánya az amiben vagyunk), elég ennyi, hogy def sum: Int, nincs utána egyenlőség
  • A case objectben és a case classban viszont, ha akarunk belőlük tényleges példányt létrehozni, akkor minden létező metódusnak kell legyen definíciója - az extends IntList résszel pedig pontosan azt érjük el, hogy mindketten megkapnak minden, az IntList-ben deklarált függvényt, amiket bennük már ezért implementálnunk is kell. Ezt úgy mondjuk, hogy öröklik az extendelt trait metódusait.
  • Örökölt metódus elé célszerű és illik beírni az override kulcsszót. Ha ezt egy olyan függvény elé írjuk, ami nem öröklődött az osztályunkba sehonnan, akkor a fordító hibát dob - ez jó, ha pl. elgépeltük a függvény nevét. Meg persze ha mások olvassák a kódunkat később, nekik is segítünk, ha ránézésből látják, hogy ez egy örökölt metódus és az osztályhierarchiában följebb is megtalálható legalább a neve.

operátornak kinéző metódusnevek

Persze nem csak paraméter nélküli metódust lehet létrehozni:

1
2
3
4
5
6
case class Vektor(x: Double, y: Double) {
  def plus(that: Vektor) = Vektor(this.x+that.x, this.y+that.y)
}
val u = Vektor(1.0, 2.0)
val v = Vektor(3.0, 4.0)
println(u.plus(v)) //prints Vektor(4.0,6.0)

Itt tehát az történik, hogy a Vektor osztályunknak (amihez korábban az osztályon kívül deklaráltunk egy összeadó függvényt és kb add(v1:Vektor,v2:Vektor):Vektor volt a fejléce, így is hívtuk meg) belülre deklarálunk egy összeadó metódust, aki így már csak egy további vektort vár, hiszen a bal oldali vektor az lesz, akin hívjuk, aki behelyettesítődik a this helyére.

(Egyébként Scalában bevett konvenciónak számít, hogy ha egy osztálynak egy függvénye egyváltozós, és ugyanannak az osztálynak várja egy másik példányát, akkor ezt a formális paramétert thatnak nevezik el. Nem kötelező, a that nem kulcsszó, csak szokás.)

Ez már talán eggyel kulturáltabban néz ki, hogy a plus függvény a két vektor között van, mintha tényleg pl egy bináris operátor lenne...

...de bizony ezt lehet hívni így is

1
2
println( u plus(v) ) //a pont az u után sok esetben elhagyható, ha a fordító rájön, hogy oda kéne
println( u plus v )  //egyváltozós függvény argumentuma körülről a kerek zárójel is legtöbbször elhagyható

Ezeknek az elhagyhatóságoknak persze vannak konvenciói: van, aki szerint olvashatóbb a kód pontokkal, van, aki szerint nem az: szerintem ha hosszú chainelés van, akkor külön sorba érdemes írni a lánc elemeit és ekkor kell is kirakni a pontot, ha meg rövid, én nem szoktam kirakni általában.

A kerek zárójel elhagyása egyváltozós member function argumentumai körül az meg szintén lehet zavaró, pl. a fenti esetben ha mondjuk még mögé teszünk az egésznek egy .x-et, akkor most ez lehetne u.plus(v.x) is meg (u.plus(v)).x is, néha a fordító tud meglepetéseket okozni, de egyébként is jobbat teszünk kollégáinknak, ha nem támaszkodunk ilyen ezoterikus precedenciákra.

De ez így már majdnem olyan szépen néz ki, mint egy rendes, infix módon írt összeadás: u plus v. Szinte annyira jó, mintha u + v-t írhatnánk.

Plot twist: írhatunk!

1
2
3
4
5
6
case class Vektor(x: Double, y: Double) {
  def +(that: Vektor) = Vektor(this.x+that.x, this.y+that.y)
}
val u = Vektor(1.0, 2.0)
val v = Vektor(3.0, 4.0)
println( u + v ) //prints Vektor(4.0,6.0)

Scalában nagyon, nagyon sok minden elmegy metódusnévnek, itt például a metódus neve az lett, hogy +. És így már két vektor összeadása úgy is néz ki, mint két Int összeadása, egy nagyon természetes szintaxist ad a függvényhívásnak, könnyen olvashatók a programozó számára.

Nem véletlen egyébként a hasonlóság: a 2+3-at úgy is írhatjuk, hogy 2.+(3), le is fog fordulni, mert a + jel ebben az esetben az Int osztály (azaz majdnem az Int osztály, itt most csúsztatok kicsit, de erre még visszatérek but still) egyik tagfüggvényének a neve. Persze ettől még primitív típusra fog lefordulni, hatékony lesz, csak forráskódi szinten, kinézetre kezelődik minden úgy, mintha objektum lenne, ideértve a "built-in" operátorokat is.

Azt azért nem javaslom senkinek, hogy mától Intek összeadását ponttal meg kerek zárójelekkel csinálja.

Legközelebb az IntList osztályunkon keresztül folytatjuk néhány ,,standard nevű és funkciójú'' tagfüggvény definiálását.

Kérdések, feladatok


Utolsó frissítés: 2021-02-07 23:06:47