Scala függvények

Kategória: Scala.

A függvénykezeléshez alapjaiban nyúltak a Scala-ban.

Alapok

A függvényeket a def kulcsszóval definiáljuk. Ezt követően jön a függvénynév, zárójelben a paraméterlista, utána kettősponttal elválasztva a visszatérési típus, majd egyenlőségjel, és kapcsos zárójelben a függvény törzse, végül a return utasítás adja vissza az eredmény, pl.:

def add(a: Int, b: Int): Int = {
  return a + b;
}

A függvény hívás pedig a függvénynév, majd zárójelben a paraméterlista. Ha az eredményt el szeretnénk tárolni, azt megtehetjük úgy, hogy értékül adjuk egy változónak:

val result = add(2, 3)

Nem lenne Scala a Scala, ha már az alapokon se csavarna egyet. Lássuk a lehetőségeket:

  • Nem kötelező a return; ez esetben az utoljára kiszámolt érték lesz az eredmény.
  • Nem kötelező a visszatérési típus, ha az kikövetkeztethető.
  • Ha egyetlen utasításból áll a függvény (ami a Scala tömörítésekkel nem is olyan ritka!), akkor a kapcsos zárójel sem kell.
  • Az utasítást követő pontosvessző se szükséges.
  • Ha a visszatérési típus Unit, akkor az egyenlőségjel is elhagyható (mint pl. a main függvények esetén láthatjuk), bár ez esetben is javasolt az egyenlőségjel használata.

Mindezeket figyelembe véve a fenti függvény definíció az alábbira egyszerűsödik:

def add(a: Int, b: Int) = a + b

Ilyen szintű egyszerűsítéseknél célszerű integrált fejlesztői környezetet használni, leginkább IntelliJ IDEA-t, amely meghatározza és kijelzi a visszatérési típust (is). Ez igen hasznos abban az esetben, ha esetleg rosszul gondoljuk; ez esetben azonnal visszajelzést kapunk róla.

Mivel a Scala funkcionális nyelv, érdemes megismernünk (vagy átismételnünk) a tiszta függvény (pure function) fogalmát. Tisztának hívjuk azt a függvényt, amelynek kimenete csak a paramétereitől függ, és nincs neki mellékhatása. Tiszta függvények használata lehetővé teszi a fordító számára az olyan jellegű optimalizálást, hogy pl. a már kiszámolt értéket elmenti, és ismételt híváskor előhívja a memóriából.

Nevesített paraméterek

A függvényhíváskor megadhatjuk a paraméter nevét, és ennélfogva a paraméterek megadási sorrendje is tetszőlegessé válik, pl.:

def salute(givenName: String, familyName: String) = println("Hello " + givenName + " " + familyName + "!")
salute(familyName = "Faragó", givenName = "Csaba")

Ez javítja a kód olvashatóságát is.

Alapértelmezett paraméterek

A paramétereknek tudunk alapértelmezett értéket adni. Emlékezzünk: Java-ban ezt úgy oldottuk meg, hogy kettő (vagy több) ugyanolyan nevű, ám eltérő paraméterlistájú függvényt készítettünk, és a kevesebb paramétert tartalmazó függvény meghívta a többet tartalmazót, az alapértelmezett értékkel kitöltve. Működik az a megoldás is, viszont túl sok felesleges kódot eredményez. A Scala-ban egy korábbi példát folytatva:

def add(a: Int, b: Int = 1) = a + b
add(3)

Változó hosszúságú paraméterlista

Más programozási nyelvekhez hasonlóan a Scala-ban is jelen van a változó paraméterhosszúságú függvény lehetősége, pl.:

def salute(names: String*) = {
  print("Hello")
  for (name <- names) print(" " + name)
  println("!")
}

salute("John", "Fitzgerald", "Kennedy")

A háttérben tömbként kapja meg a paramétereket a függvény, viszont magát a tömböt hívó oldalon nem kell létrehoznunk.

Rekurzív függvények

A rekurzív függvények fontos szerepet játszanak a funkcionális nyelvek életében. Nagy általánosságban arról van szó, hogy egy függvény saját magát hívja. Lássunk egy példát!

def factorial(n: Int): Int = if (n <= 1) n else n * factorial(n-1)

A faktoriális a rekurzió "gyógypéldája", mivel egyszerű a felépítése. A többi programozási nyelvben illik hozzátenni, hogy teljesítményben elmarad a ciklusos megvalósítástól, így csak úgymond oktatási céllal mutatják be ezt a megoldást. A ciklusos megoldás a Scala-ban valahogy így nézne ki (most a határérték ellenőrzéseket mellőzve):

def factorial(n: Int): Int = {
  var result = 1
  for (i <- 2 to n) result *= i
  result
}

A farok rekurzió

Ebben viszont van egy csúnya dolog: a var, amit - ha csak lehetséges - érdemes elkerülni. A rekurzív megoldásban nincs var, az tehát "szép", de mit tegyünk, ha választanunk kell a szép de lassú, vagy a csúnya de gyors program között? A megoldás Scala-ban az, hogy ha a függvény utolsó utasítása a rekurzív hívás, akkor a fordító képes úgy optimalizálni, hogy ne okozzon performancia gondot. Ezt farok rekurziónak (tail recursion) hívjuk.

Megjegyzés: a fenti példa nem farok rekurzió, és nem olyan egyszerű ezt megvalósítani. Noha a rekurzív hívás az utolsó helyen áll, az utolsó művelet valójában a szorzás. A legegyszerűbben egyébként úgy győződhetünk meg arról, hogy a valóságban ez nem farok rekurzió az az, hogy elébe tesszük a @tailrec annotációt (import scala.annotation.tailrec kell). A nem farok rekurzív függvényt farok rekurzívvá alakításához a következő trükkön alkalmazhatjuk: felveszünk még egy paramétert, ami az ideiglenesen kiszámolt eredményt tartalmazza, amit becsomagolunk a fő függvénybe, így a kliens számára észrevétlen marad a változás:

import scala.annotation.tailrec

def factorial(n: Int): Int = {
  @tailrec
  def factorialWithAccumulator(n: Int, accumulator: Int): Int = {
    if (n <= 1) accumulator
    else factorialWithAccumulator(n - 1, n * accumulator)
  }
  factorialWithAccumulator(n, 1)
}

Ez viszont összetettebbé tette a példát.

Ha nem sikerült megérteni, mi a farok rekurzió, olvasd el ismét ezt az alfejezetet.

Lokális függvények

Függvényt akárhol definiálhatunk, tehát akár függvényen belül is. Ezeket a függvényeket hívjuk lokális függvényeknek. Pl.:

def outer(a: Int, b: Int): Int = {
  def inner(c: Int): Int = c + 1
  inner(a) * inner(b)
}

Magasabb rendű függvények

A magasabb rendű függvények (higher order functions) fogalma azt takarja, hogy a Scala-ban (ill. általában a funkcionális nyelvekben) egy függvényt paraméterül adhatunk másik függvénynek, lehet visszatérési érték, ill. változónak (vagy konstansnak) is értékül adhatjuk.

Tekintsük először a paraméterül történő átadást! A példában egy olyan függvényt definiálunk, ami két egészet vár, valamint egy olyan függvényt, ami szintén ét egészet vár, és egészet ad vissza eredményül. Ez utóbbi a művelet, amit a fő függvény végrehajt. A példában a konkrét művelet az összeadás és a szorzás.

def perform(a: Int, b: Int, operation: (Int, Int) => Int): Int = operation(a, b)
def add(a: Int, b: Int): Int = a + b
def multiply(a: Int, b: Int): Int = a * b

perform(2, 3, add)
perform(2, 3, multiply)

Itt tehát az újdonság az, hogy a perform függvény harmadik paraméterének a típusa ez: (Int, Int) => Int, ill. híváskor nem konkrét értéket, hanem függvényt adunk át.

Lássunk egy példát arra is, hogy egy függvény visszatérési értéke egy függvény! Létrehozunk egy olyan függvényt, ami a műveleti jelet várja paraméterül, és a megfelelő függvényt adja vissza:

def perform(a: Int, b: Int, operation: (Int, Int) => Int): Int = operation(a, b)
def add(a: Int, b: Int): Int = a + b
def multiply(a: Int, b: Int): Int = a * b

def getFunction(sign: Char): (Int, Int) => Int = sign match {
  case '+' => add
  case '*' => multiply
}

perform(2, 3, getFunction('+'))
perform(2, 3, getFunction('*'))

Végül lássunk példát értékadásra is (a fentit folytatva):

val myAdd: (Int, Int) => Int = add
perform(2, 3, myAdd)

Név nélküli függvények

Egy függvényt nem feltétlenül kell nevesíteni. Az előző példánál létrehoztuk az összeadás és a szorzás műveleteket, majd átadtuk paraméterül. Valójában a híváskor is megvalósíthatjuk név nélküli (anonymous) függvényként, a következőképpen:

def perform(a: Int, b: Int, operation: (Int, Int) => Int): Int = operation(a, b)
perform(2, 3, (a, b) => a + b)
perform(2, 3, (a, b) => a * b)

Hasonlóan, a fenti getFunction függvényt is átírhatjuk erre a formára:

def getFunction(sign: Char): (Int, Int) => Int = sign match {
  case '+' => (a, b) => a + b
  case '*' => (a, b) => a * b
}

Ha az eredeti sorrendben használjuk a paramétereket, akkor azokat sem kell nevesíteni, hanem aláhúzással (_) tudjuk helyettesíteni. Ez esetben a => operátort sem kell kiírni, pl.:

def perform(a: Int, b: Int, operation: (Int, Int) => Int): Int = operation(a, b)
perform(2, 3, _ + _)
perform(2, 3, _ * _)

Név szerinti hívás

A magasabb rendű függvények egy speciális esete a név szerinti hívás (call-by-name). Vegyünk rögtön egy példát, és a magyarázat ez alapján történik:

def callByValue(t: Long) = for (i <- 1 to 5) {
  println(t)
}

def callByName(t: => Long) = for (i <- 1 to 5) {
  println(t)
}

val random = scala.util.Random
callByValue(random.nextInt(100))
println()
callByName(random.nextInt(100))

A példában a függvény hívásakor a paramétert szintén egy függvényhívás határozza meg. Alapesetben (és ez a programozási nyelvek többségében így van) kiszámolja az értéket, és ezt adja át paraméterül. Ezt illusztrálja a callByValue függvény. Híváskor ötször kiírja ugyanazt a számot. Ellenben a callByName magát a függvényt várja paraméterül (vegyük észre az apró, de lényeges eltérést: t: Long helyett ezt szerepel: t: => Long), ennek következtében a hívó oldalon nem történik meg ténylegesen a függvényhívás, csak a használatkor. Mivel a példában ötször használjuk, öt különböző értéket ír ki eredményül.

Valójában ez egy szintaktikai cukorka (syntax sugar). Pusztán az előző alfejezetekben leírtak alapján is meg tudnánk valósítani, a következőképpen:

def callByName(t: () => Long) = for (i <- 1 to 5) {
  println(t())
}

callByName(random.nextInt(100))

De az imént bemutatott módszerrel megtakarítottunk pár zárójelet.

Az apply és unapply függvény

A háttérben az osztályok és függvények "összeérnek": valójában a függvények bizonyos formátumú osztályok. Ahhoz, hogy "végre tudjuk hajtani" az osztály példányosítása során létrejövő objektumot függvényként, az apply() függvényt kell megvalósítani. Lássunk egy példát!

class IntSquare extends (Int => Int) {
  def apply(n: Int) = n * n
}

val intSquare = new IntSquare
intSquare(5)

A példában létrehoztunk egy osztályt, ami az egészek négyzetre emelését valósítja meg. Ezen a ponton álljunk meg egy pillanatra: mit is jelent az Int => Int? Egy olyan függvény, ami egy Int típusú paramétert vár, és a visszatérési értéke is Int típusú. Ez valójában a Function1[Int, Int], ahol az első típus a paraméter típusa, a második pedig a visszatérésé. Hasonlóan létezik Function0 a paraméter nélküli függvények számára, ahol csak a visszatérési típust kell megadni, Function2 a két paraméterű függvények számára (tehát ez esetben a két paraméter típusát és a visszatérési típust kell megadni, ebben a sorrendben), Function3, Function4, stb., egészen Function22-ig. (Mi van, ha 23 paraméteres függvényt szeretnénk írni? Ne szeressünk olyat írni.)

A fenti példát tehát így is írhatnánk:

class IntSquare extends Function[Int, Int] {
  def apply(n: Int) = n * n
}

Ez persze annyira ronda, hogy még a fordító is szól, hogy lehetne másképp is. Igazából az extends nem is szükséges, a Scala rájön az apply() szignatúrájából a pontos típusra. Így ezt is írhatjuk:

class IntSquare {
  def apply(n: Int) = n * n
}

Az apply() párja az unapply(), és ez utóbbit extraktornak hívjuk, mivel megpróbálja "visszacsinálni" a műveletet. Ez persze nem mindig lehetséges, emiatt az unapply() visszatérési típusa Option. (Ezt a típust ld. később. Azt jelenti, hogy egy érték lehet, hogy meg van adva, lehet, hogy nincs.) A példában a négyzetre emelés ellentettje a gyökvonás. Négyzetszámok esetén tehát vissza kell adni a négyzetgyököt, minden más esetben (negatív számok, nem négyzetszámok) a visszatérési értéknek az eredmény hiányát kell jeleznie.

class IntSquare {
  def apply(n: Int) = n * n
  def unapply(n: Int): Option[Int] = if (n >= 0) {
    val sqrt = Math.sqrt(n)
    if (sqrt.isValidInt) Some(sqrt.toInt)
    else None
  } else None
}
val intSquare = new IntSquare

intSquare(5)
intSquare.unapply(25)
intSquare.unapply(22)
intSquare(-5)
intSquare.unapply(-25)

Részben alkalmazott függvények

Angolul partially applied functions. Ha meghívunk egy függvényt adott paraméterekkel, akkor azt mondjuk, hogy alkalmazzuk a függvényt az adott argumentumokkal. A Scala lehetővé teszi azt hogy csak részben alkalmazzuk a függvényt, azaz a paramétereknek csak egy részét adjuk meg. Ez hívjuk részben alkalmazott függvényeknek. Lássunk egy példát!

def add(a: Int, b: Int): Int = a + b
def inc = add(_, 1)
inc(3)

A példában a már megismert add függvényre alkalmaztuk részben a második paramétert, 1-et, az elsőt pedig az aláhúzás (_) jellel szabadon hagytuk. Az eredményt elneveztük úgy, hogy inc; ez utóbbi tehát egy paramétert vár, melyhez hozzáad egyet.

Currying

A fentiekből logikusan következik, bár első ránézésre talán meglepő az, hogy egy függvény több paraméter listát is tartalmazhat. Ezt curryingnek hívjuk. Lassunk erre egy példát:

def perform(a: Int, b: Int)(operation: (Int, Int) => Int): Int = operation(a, b)
perform(2, 3)((x, y) => x + y)
perform(2, 3)((x, y) => x * y)

A példában a korábban bemutatott két operandusú műveletet írtuk át úgy, hogy a két operandus külön paraméterlistában legyen mint a művelet. Ha a függvényt csak egy paraméter listával hívjuk meg, akkor egy olyan függvényt kapunk eredményül, amelynél az első paraméterlistának a paraméterei meg vannak adva. Annyi, hogy a hívás után egy aláhúzást (_) kell írni:

val perform2and3 = perform(2, 3)_
perform2and3((x, y) => x + y)
perform2and3((x, y) => x * y)

A példában létrehoztunk egy olyan függvényt, ami a 2 és 3 értékeken hajtja végre a paraméterül átadandó műveletet. A currying működése sokban hasonlít a részben alkalmazott függvényekhez.

Closure-ök

A closure olyan függvényt jelent, amely függ egy olyan értéktől, ami kívül esik rajta. Az alábbi példában a függvény a paraméterül átadott számot megszorozza egy értékkel, és az érték definíciója kívül esik a függvényen:

val factor = 3
val multiply = (i: Int) => i * factor
multiply(2)

Amiatt hívjuk ezt a struktúrát closure-nek, mert mintegy magába zárja a külső változót.

Minden függvény

A Scala-ban a háttérben elsőre elég furcsa dolgok történnek. Itt minden objektum vagy függvény. Lássunk egy egyszerű példát: 2+3. Az eredmény nyilvánvalóan 5, és elsőre talán el sem hisszük, hogy ez is mennyire el van bonyolítva. Kezdjük ott, hogy a Scalaban nincsenek primitívek, minden objektum, így a 2 és a 3 is. Ezeken metódusokat tudunk meghívni. Ilyen metódus pl. a + operátor. A metódushívás a Java-hoz hasonlóan úgy történik, hogy az objektum után pontot teszünk, majd jön a metódus név, végül zárójelben a paraméterek. Mindent összerakva, a 2+3 a háttérben a következőképpen néz ki: 2.+(3). Tehát a 2 objektumon meghívjuk a + függvényt (operátort), aminek paraméterül átadjuk a 3 objektumot. A függvény végrehajtódik, és eredményül 5-öt kapunk.

És miért tudunk egyáltalán 2+3-at írni? A Scala bizonyos esetekben megengedi, hogy elhagyjuk a függvényhívásnál a pontot és a zárójeleket is a paramétereknél, így ezt: 2.+(3) így is le tudjuk írni: 2 + 3. A szóközök elhagyása után is egyértelmű marad, így a fordító a 2+3-mal is megbirkózik.

Unless otherwise stated, the content of this page is licensed under Creative Commons Attribution-ShareAlike 3.0 License