A Scala programozási nyelv

Programozás magasabb rendű függvényekkel

Magasabb rendű függvények

A Scalában egy függvény első osztályú érték, vagyis mint bármilyen értéket, átadhatjuk paraméterként vagy visszatérhetünk vele. Az olyan függvényeket melyek más függvényeket fogadnak el paraméterül, vagy függvény a visszatérési értékük, magasabbrendű függvénynek nevezzük.
A lenti példában a sum egy magasabbrendű függvény:

def sum(f: Int => Int, a: Int, b: Int) : Int = if (a > b) 0 else f(a) + sum(f, a + 1, b) def square(x: Int): Int = x * x sum(square, 2, 3)

Névtelen függvények

A fenti példában a square függvényt csak egyvalamire használtuk, hogy paramétere legyen a sumnak. Ha már ennél bonyolultabb magasabbrendű függvényeket is szeretnénk használni, akkor egy idő után fárasztó lesz a square-hez hasonló egyszer használatos függvényeknek nevet adni. A névtelen függvények, azaz a lambda kifejezések használatával ez a probléma orvosolható. Sokkal olvashatóbb és könnyebben is írható kódot eredményez, amellett hogy a funkcionális paradigmának alapköve a lambda kifejezések használata.
Scalában a következőképp írható fel egy ilyen függvény:

(x: Int, y: Int) => x * y

Egy ehhez hasonló kifejezésnek a felhasználásával viszont egy sorban is definiálható egy másik függvény pl. a korábbi példában szereplő sum segítségével. Érdemes kihasználni a típuskikövetkeztetést hogy a kódunk még olvashatóbb legyen:

def sumInts(a: Int, b: Int): Int = sum(x => x, a, b) def sumSquares(a: Int, b: Int): Int = sum(x => x * x, a, b)

Mint oly sok más jelölés a Scalában, a névtelen függvények jelölése is szintaktikus cukorka, ami a következőnek felel meg:

(x1 : T1 , ..., xn : Tn ) => E
Ekvivalens ezzel a blokkal:
{ def f (x1 : T1 , ..., xn : Tn ) = E ; f }
Az f láthatóságának köszönhetően a blokkon kívül nem is interferál semmivel, miután a blokk végén egyszer végrehajtjuk.

Curryzés

A sumInts és sumSquaresnél feltűnő hogy az a és b paraméterek látszólag csak foglalják a helyet, mivel azon kívül hogy közvetlen átadódnak a sum függvénynek nincsen semmi hasznuk. Curryzéssel tovább lehetne rövidíteni a két függvény felírását, ha a sum függvényt egy kicsit másképp definiáljuk:

def sum(f: Int => Int)(a: Int, b: Int) : Int = if (a > b) 0 else f(a) + sum(f)(a + 1, b)

Tehát Scalában egy függvény curryzhetőségét külön jelölni kell annak létrehozásakor. Most már ennek az új sumnak a segítségével

sumSquares így is felírható: def sumSquares = sum(x => x*x) _

A Scala fordítónak a '_' karakterrel lehet jelezni hogy oda ne várjon paramétert, hanem helyette kezelje a parciális függvényt értékként.
A sum(x => x*x)(1,2) kiértékelése a következőképpen történik: először a névtelen függvényt applikálódik a sumra, majd az így kapott parciális függvény kapja meg az (1,2) paramétereket. Látszik ahogy ezzel a módszerrel képesek vagyunk bármely többparaméteres függvényt sok egyparaméteres függvény applikációjának sorozatára felbontani.

Összetettebb példák

Aki programozott már Java-ban valószínűleg írt ehhez hasonló kódot:

//Java kód JButton button = new JButton("Push Me"); button.addActionListener(new ActionListener() { public void actionPerformed(ActionEvent e) { System.out.println("You pushed me!"); } }); add(button); //Java kód

Tegyük fel, hogy újraírhatnánk a Swing-et felhasználva a Scala minden előnyét. Ekkor megváltoztathatnánk az addActionListener() metódust magasabb rendű függvények használatával.

val button = new JButton("Push Me") button.addActionListener((e:ActionEvent) => { println("You pushed me!") }) add(button)

Egy névtelen metódust adunk át az előbbi függvénynek. Ez a metódus egyetlen ActionEvent típusú paramétert vár és meghívásakor egy egyszerű println()-t hajt végre. A hatása ugyanaz mint, az előbbi Java kódnak azonban jóval egyszerűbb és átláthatóbb.

Ezt a példát még akár tovább is egyszerűsíthetjük a felesleges zárójelek elhagyásával:

val button = new JButton("Push Me") button.addActionListener { e:ActionEvent => println("You pushed me!") } add(button)


Készítsünk egy elemzőt a következő nyelvtanhoz:

letter ::= /* all letters */ digit ::= /* all digits */ ident ::= letter {letter | digit} number ::= digit {digit} expr ::= expr1 {'+' expr1 | '-' expr1} expr1 ::= expr2 {'*' expr2 | '/' expr2} expr2 ::= ident | number | '(' expr ')'

Az elemzőket leírhatjuk az alapján, hogy mely inputokat fogadják el. Au &&& jel két elemző szekvenciális kompozícióját, a ||| pedig alternatívát jelent. Ezek a műveletek a Parser osztály metódusaiként lesznek megvalósítva. A következő elemzőkhöz definiálunk konstruktort:

empty The parser that accepts the empty string. fail The parser that accepts no string. chr The parser that accepts any character. chr(c: Char) The parser that accepts the single-character string "c". chrWith(p: (Char)Boolean) The parser that accepts single-character strings "c" for which p(c) is true.

Lesz két magasabb rendű függvény is. Ezek az opt és a rep. Az opt(p) egy olyan elemző, mely a p által elfogadott, vagy az üres stringet fogadja el. A rep(p) a p által elfogadott stringek tetszőleges sorozatát fogadja el.
A fentieket figyelembe véve a nyelvtanból a következőt kapjuk:

module ExprParser with { import Parse; def letter = chrWith(c => c.isLetter); def digit = chrWith(c => c.isDigit); def ident = letter &&& rep(letter ||| digit); def number = digit &&& rep(digit); def expr:Parserexpr1 &&& rep((chr('+') &&& expr1) ||| (chr('-') &&& expr1)); def expr1 = expr2 &&& rep((chr('*') &&& expr2) ||| (chr('/') &&& expr2)); def expr2 = ident ||| number ||| (chr('(') &&& expr &&& chr(')')); }

Most már csak az kérdés, hogy a fenti metódusokat hogyan valósítjuk meg. Ezeket egy modulban helyezzük el, az elemzők karakterek egy listáját kapják, abból állítják elő az eredményt:

module Parse with { type Result = Option[List[Char]]; abstract class Parser extends Function1[List[Char], Result] with {

Egy elemző vagy a None konstanst adja vissza, ami azt jelenti, hogy nem volt legális az input, vagy egy Some(in1) értéket, ahol in1 jelenti azt a részét az inputnak, amit az elemző nem tudott feldolgozni.
Az elemzők olyan függvények, melyek a List[Char] típusú adatból Parse.Result értéket állítanak elő. Ezt a Parser osztály fejezi ki, ami a megfelelő Function osztály leszármazottja. A Function osztályok definiálják az apply metódust, a Parser ezen kívül az &&&-t és a |||-t is:

def &&& (def p: Parser) = new Parser with { def apply(in: List[Char]) = outer.apply(in) match { case Some(in1) => p(in1) case n => n } } }

A primitív elemzők a következők:

def empty = new Parser with { def apply(in: List[Char]) = Some(in) } def fail = new Parser with { def apply(in: List[Char]) = None[List[Char]] } def chrWith(p: (Char)Boolean) = new Parser with { def apply(in: List[Char]) = in match { case [] => None[List[Char]] case (c :: in1) => if (p(c)) Some(in1) else None[List[Char]] } } def chr(c: Char): Parser = chrWith(d => d == c);

A magasabb rendű függvények:

def opt(p: Parser): Parser = p ||| empty; def rep(p: Parser): Parser = opt(rep1(p)); def rep1(p: Parser): Parser = p &&& rep(p);

Saját vezérlési struktúrák írása magasabb rendű függvényekkel

A magasabb rendű függvények felhasználhatók saját vezérlési absztrakció írására. Programozási nyelvekben alprogramok, illetve függvények hasznos segítsége, hogy az ismétlődő forráskód-részletet, azaz a függvény törzsét csak egyszer kell megírni, hiszen hívásonként megegyező utasításokat kell végrehajtani. Természetesen ennek következménye, hogy a függvényeknek van egy hívásonként különböző része is, a paraméterlista. Ha a paraméterlistában forráskód-részletet (függvényt) is megadhatunk, akkor hívásonként más utasításokat használhatunk. A Scala nyelvben használhatunk magasabb rendű függvényeket, amelyek extra lehetőséget nyújtanak a forráskódunk egyszerűsítésére és tömörebbé formálására, ezáltal csökkenthető a forráskód redundanciája. Nézzünk egy példát egy fájlböngésző alkalmazásban. A felhasználónak keresni/kiválasztani kell a fájlok között valamely kritériumok szerint. Ilyen lehet mondjuk a kiterjesztés vizsgálata. Nézzük meg először, hogyan lehet a feladatot megvalósítani Java-ban.

for(String szűrtFájlNév: new File("c:/windows").list( new FilenameFilter() { @Override public boolean accept(File dir, String name) { return name.toUpperCase().endsWith(".EXE"); } })) System.out.println(szűrtFájlNév);

Ahhoz, hogy ezt megvalósítsuk, implementálnunk kell a FilenameFilter interfészt, ismernünk kell működését, meg kell benne valósítani az accept() metódust, amelynek paraméterei automatikusan átadásra kerülnek. A paraméterként kapott String bejegyzésnévre meg kell fogalmazni az elfogadás feltételét (ezek hagyományos szövegműveletek lehetnek, például a név nagybetűssé alakítva, végződik-e valamire, illetve tartalmaz-e valamit). Mindezt úgy dolgozza fel a File osztály list() metódusa, hogy paraméterként egy olyan névtelen objektumot kap (belső osztályból létrehozva) amely implementálja a FilenameFilter interfészt. Mindezek megvalósítása magasabb absztrakciós szintet követel a programozótól, a forrásfájl nehezebben áttekinthető ráadásul fordításkor több forrásfájl keletkezik a belső osztályok miatt. Ezzel szemben Scala-ban ennek a feladatnak megvalósítása kevésbé absztrakt, szinte utasítás szintre viszi le a gondolkodást. Célszerű definiálni egy Scala singleton objektumban egy függvényt, amelynek paraméterül adhatjuk a kiterjesztést és a String osztály endsWith() függvényével könnyedén vizsgálhatjuk a fájlneveket.

object FileMatcher { private def filesHere = (new java.io.File(".")).listFiles def filesEnding(query: String) = for (file <- filesHere; if file.getName.endsWith(query)) yield file }

A további igény, hogy a felhasználók bármilyen fájlnév darabra kereshessenek ekkor, már a contains() függvényt kell használnunk.

def filesEnding(query: String) = for (file <- filesHere; if file.getName.contains(query)) yield file

A két függvény egyformán dolgozik, megvizsgálják a filesHere összes elemének nevét és visszaadják a fájlt, ha igaz a feltétel. Az egyetlen különbség csak a felhasznált logikai függvényben van. A következő igény, hogy reguláris kifejezésre is lehessen keresni, amelyhez a String osztály matches() függvényét használhatjuk. Három hasonló metódushoz célszerű lehet bevezetni egy magasabb rendű segéd függvényt, amely képes fogadni paraméterül egy függvényt is, hogy csökkentsük az ismétlések számát.

def filesMatching(query: String, matcher: (String, String) => Boolean) = { for (file <- filesHere; if matcher(file.getName, query)) yield file }

Itt az első paraméter egy String (query), a második egy logikai függvény (matcher()), amelynek két String paramétere van. A filesMatching() függvényben meghívott matcher() függvény egyetlen célja ellenőrizni a fájlnevet a külső függvényben megadott első paraméterrel. Az így megírt függvényt felhasználva a következő kereső függvényeket használhatjuk:

def filesEnding(query: String) = filesMatching(query, _.endsWith(_)) def filesContaining(query: String) = filesMatching(query, _.contains(_)) def filesRegex(query: String) = filesMatching(query, _.matches(_))

A filesMatching() belső függvény két String paramétert vár, ezért használható a _ helyettesítő paraméter, ahol nem kell megadni a paraméter típusát. A rövidebb szintaktika teljesen ekvivalens a következő két sorral:

(fileName: String, query: String) => fileName.endsWith(query) (fileName, query) => fileName.endsWith(query)