A Scala programozási nyelv

Nyelvi elemek és vezérlési elemek



Azonosítók

Az azonosítóknak kétféle formája van. Az elsőben egy azonosító betűvel kezdődik, majd tetszőleges számú betű és szám áll. A másodikban az azonosító speciális karakterrel kezdődik, és utána speciális karakterek sorozata van. Mindig a lehetséges leghosszabb egyezést használjuk, például a big_bob++=z3 sorozat 3 azonosítót jelöl. A $ karakter nem használható az azonosítókban.

Literálok

Vannak egész szám, lebegőpontos szám, boolean, karakter és string literálok. Ezek formája megfelel a Java szintaxisának.

// egész 1234L 077 0x9ABA // lebegőpontos 2.4e61f 6.11 .9d // boolean true false // karakter 'A' '\101' '\u0044' // string "lol" "\"" "\\\"\'"

Ezenkívül vannak még ún. raw string literálok, amelyekben semmilyen karaktert nem kell escape karakterekkel ellátnunk, így tetszőleges karaktert tartalmazhatnak:

// raw string """A raw string may contain any ~!@#$%^&*()_+ characters `|":}{[],./? > < except 3 quotes in a row"""

Illetve vannak még szimbólum literálok is:

// symbol 'scala

A szimbólumok singleton stringként viselkednek, mindig csak 1 példány létezik belőlük, így a következő mindig teljesül:

// always true 'scala == 'scala

Ez viszont nem determinisztikus:

// true!? "scala" == "scala"

És persze a funkcionális eszközökhöz nélkülözhetetlen függvény literál:

// function literal (a: Int, b: Int) => a * b

A függvényliterálok teszik lehetővé például, hogy egy lista elemein egyszerűen végezhessünk tetszőleges műveleteket:

val t = List(1,2,3) t.foreach(n => print(2*n + " ")) //Output: 2 4 6

Megjegyzések

A megjegyzések szintén a Java megjegyzéseihez hasonló formájúak.

// a single line comment /* a multi line comment */ /** * Scaladoc comment */

Nevek

Nevek azonosítják a típusokat, az értékeket, a függvényeket és az osztályokat. Ezeket együtt entitásoknak nevezzük. A neveket definícióval, deklarációval és importálással vezethetjük be. Két külön névterület van a típusok és az egyéb termek számára. A környezet mindig egyértelműen eldönti, hogy típusra vagy másra vonatkozik a név.

Típusok

Vannak egyszerű típusjelölők (Int), paraméterezett típusok (List[Boolean]), n-esek, függvénytípusok, metódus típusok (def b (x : Int): Boolean), polimorfikus metódus típusok (def empty[a]: List[a]) és túlterhelt típusok (println).

Érték deklarációk és definíciók

A val kulcsszó vezeti be, definíció esetén egy kifejezést kell megadnunk, amellyel a változó egyenlő lesz.

Lusta értékek

Az egyes értékek (konstans változók) inicializálását tudjuk késleltetni a lusta értékek (lazy val) használatával. Ez pl. akkor lehet hasznos, ha olyan adatokkal dolgozunk, amikre nem biztos, hogy szükség lesz, és költséges a kiszámításuk:

case class Employee(id: Int, name: String, managerId: Int) { val manager: Employee = Db.get(managerId) val team: List[Employee] = Db.team(id) }

Itt az Employee osztály mohó módon inicializálja az összes mezőt, betöltve ezzel a teljes Employee táblát a memóriába. Ezt lehet optimalizálni a mezők "lustává" tételével, így az adatbázis-lekérdezés csak akkor történik meg, ha (és amennyiben) szükség lesz rá:

case class Employee(id: Int, name: String, managerId: Int) { lazy val manager: Employee = Db.get(managerId) lazy val team: List[Employee] = Db.team(id) }

Itt a manager és a team változók akkor fognak inicializálódni, amikor az első hivatkozás történik rájuk. Hasonlóképpen lehetőség van lusta metódus megadására is a "lazy def" használatával. Fontos különbség azonban, hogy a metódussal szemben a változó sosem értékelődik ki egynél többször. Sőt, valójában a háttérben az történik, hogy a lusta változó első kiértékelésekor a futtatókörnyezet a változó értékét eltárolja, és további használatkor nem számolja ki újra, hanem az eltárolt értéket adja vissza ("val" változóról lévén szó az értéke nem változik meg a futás során).
Megjegyzendő az összefüggés a "lusta" funkcionális nyelvekkel (pl. Haskell), ahol minden érték és paraméter lusta módon kerül inicializálásra.

Változó deklarációk és definíciók

A var kulcsszó vezeti be, ekvivalens egy lekérdező és egy beállító függvény definíciójával. Definíció esetén kezdőértéket is megadhatunk.

Függvény deklarációk és definíciók

A def kulcsszó vezeti be, definíció esetén a függvény törzsét is meg kell adni.

Lokális függvények

A funkcionális paradigma fontos alapelve, hogy a programokat felbontjuk sok kis függvényre, amelyek mindegyike elvégez egy-egy jól definiált feladatot. Az előnye ennek a stílusnak, hogy sok kis építőkockát kínál a fejlesztőnek, amelyek rugalmasan használhatók összetettebb programok írásához.
A hátránya, hogy a sok segédfüggvény bevezetésével a program névterét elárasztjuk sok függvénynévvel. Interpreter használatakor ez nem jelent nagy gondot, azonban mikor újrafelhasználható osztályokat és objektumokat hozunk létre, ezeket tanácsos elrejteni a felhasználó elől. Java-ban erre a privát metódus használatát javasolják, ami természetesen Scala-ban is megoldást nyújt. A Scala azonban kínál egy másik alternatívát: a lokális függvényeket. Ezek csak a befoglaló függvényen belül érhetők el.

def processFile(filename: String, width: Int) { def processLine(filename: String, width: Int, line: String) { if (line.length > width) print(filename +": "+ line) } val source = Source.fromFile(filename) for (line <- source.getLines) { processLine(filename, width, line) } }

Vegyük észre, hogy a lokális függvénynek is megadtuk azokat a formális paramétereket, amelyeket a befoglaló függvénynek. Ezek megadása szükségtelen, ugyanis a lokális függvénynek van hozzáférése a befoglaló függvény formális paramétereihez (és lokális változóihoz). Tehát a következő is egy helyes (és egyszerűbb) megadása a fenti lokális függvénynek: def processLine(line: String) { … }

Helyettesítő szintaxis

A Scala-ban lehetőség van függvényliterálok tömörebb felírására, ha egy vagy több paraméter helyett "_"-t használunk. A "_" tekinthető úgy, mint egy "üres mező", amit fel kell tölteni egy argumentummal akárhányszor meghívjuk a függvényt.

scala> val someNumbers = List(-11, -10, -5, 0, 5, 10) someNumbers: List[Int] = List(-11, -10, -5, 0, 5, 10) scala> someNumbers.filter(_ > 0) res9: List[Int] = List(5, 10)

Bizonyos esetekben a fordítónak nem áll rendelkezésére elég információ ahhoz, hogy meghatározza a helyettesített argumentumok típusát, ilyenkor explicit módon jelölnünk kell:

scala> val f = _ + _ :4: error: missing parameter type for expanded function ((x$1, x$2) => x$1.$plus(x$2)) val f = _ + _
scala> val f = (_: Int) + (_: Int) f: (Int, Int) => Int = scala> f(5, 10) res11: Int = 15

Részlegesen alkalmazott függvények

Az előző példában egy paraméterre alkalmaztuk a helyettesítő szintaxist, azonban lehetőség van teljes paraméterlista helyettesítésére is:

someNumbers.foreach(println _)

Ismétlődő paraméterek

Scala-ban lehetőség van arra, hogy egy függvény utolsó paramétere tetszőleges számban ismétlődjön. Ehhez az utolsó formális paraméter mögé egy '*'-ot kell tenni. Függvényhíváskor akárhány (akár nulla) ilyen típusú parameter átadható:

scala> def echo(args: String*) = for (arg <- args) println(arg) echo: (String*)Unit scala> echo() scala> echo("one") one scala> echo("hello", "world!") hello world!

Érdemes megfigyelni az echo függvény argumentumának típusát: String típusú elemek tömbje, vagyis valójában Array[String]. Ennek ellenére, ha Array[String] típusú aktuális paramétert próbálunk átadni neki, fordítási hibát kapunk:

scala> val arr = Array("Hello", "only", "world") arr: Array[java.lang.String] = Array(Hello, only, world) scala> echo(arr) :7: error: type mismatch; found : Array[java.lang.String] required: String echo(arr)

Rekurzió

A while-ciklus alternatívájaként használhatunk rekurzív függvényhívásokat is. Felmerülhet a kérdés: az imperatív jellegű while-ciklus, vagy a funkcionális jellegű rekurzió a hatékonyabb? Első ránézésre a rekurzió sokkal költségesebbnek tűnik, hiszen egy rekurzív függvényhívás esetében sokkal több művelet hajtódik végre egy egyszerű ugrásnál a ciklus végéről az elejére. Valójában azonban (pl. az alábbi, legnagyobb közös osztót számoló függvény esetében) Scala-ban hatékonyság szempontjából majdnem megegyezik a két módszer, ez pedig a Scala fordító által alkalmazott optimalizációknak köszönhető. Vegyük észre, hogy a függvény végén szerepel a rekurzió! A Scala fordító ezt felismeri, és fordítás közben helyettesíti egy függvény elejére történő ugrással (és a függvényváltozók frissítésével).

def gcd(a: Int, b: Int): Int = if (b == 0) a else gcd(b, a % b)

Argumentum nélküli metódusok

class Complex(real: Double, imaginary: Double) { def re() = real def im() = imaginary }
object ComplexNumbers { def main(args: Array[String]) { val c = new Complex(1.2, 3.4) println("imaginary part: " + c.im()) } }

A fenti példában létrehoztuk a Complex osztályt és két getter metódust hozzá, melyek visszatérnek a real és imaginary adattagokkal. Egy apró probléma ezekkel a metódusokkal, hogy annak ellenére, hogy nem várnak paramétert, minden alkalommal mikor meghívjuk őket ki kell tennünk feleslegesen egy nyitó, csukó zárójelet. Kényelmesebb lenne, ha használhatnánk őket úgy, mintha változók lennének.

Scala-ban erre is van lehetőségünk, szimplán definiáljuk őket argumentum nélküli metódusokként:

class Complex(real: Double, imaginary: Double) { def re = real def im = imaginary }

Névtelen függvények

Gyakran adódhat olyan alkalom, amikor egy függvényt csak egyszer, egyetlen helyen szeretnénk használni. Ilyenkor feleslegesnek tűnhet a függvény külön, névvel való létrehozása, jobb lenne helyben létrehozni őket ott, ahol a hívás történik. A Scala erre is lehetőséget ad a névtelen függvények segítségével.

object TimerAnonymous { def oncePerSecond(callback: () => Unit) { while (true) { callback(); Thread sleep 1000 } } def main(args: Array[String]) { oncePerSecond(() => println("time flies like an arrow...")) } }

A névtelen függvény a jobbra nyílnál látható, melynek bal oldalán a függvény paraméterlistája, jobb oldalán pedig maga a függvénytörzs van.

Értékadás

Az x = e értékadás nem más, mint a változó beállító függvényének meghívása az e paraméterrel.

Feltételes kifejezés

Az if (e1) e2 else e3 kifejezés nem más, mint az e1.ifThenElse(e2)(e3) kifejezés egy rövidebb formája. Hasonló ifThen metódus is létezik. Természetesen a Boolean típus tartalmazza ezeket a metódusokat.

Láthatósági szintek

A Scala-ban a Java-val ellentétben nem négy, hanem csak három láthatósági szint van: private, protected és public. Az alapértelmezett láthatósági szint a public, ha mást nem mondunk, néhány kivételt leszámítva minden public hozzáférésű. A public láthatósági szint abban (is) különbözik a másik kettőtől, hogy nem tartozik hozzá kulcsszó, azaz a kódban expliciten nem jelenhet meg az, hogy valami public hozzáférésű, a többi módosítószó hiánya jeleni ezt.

A private láthatósági szint a Java-hoz hasonlóan osztályszintű, nem pedig példányszintű, mint például C++-ban. A protected entitások pedig nem érhetők el az adott csomagban, csak a leszármazottakban.

Az eddigi, úgynevezett access modifiereken kívül a Scala-ban léteznek access qualifierek is, ezek segítségével sokkal részletesebben lehet szabályozni, hogy mi honnan legyen elérhető. Az alábbi példa szemlélteti a használatukat:

package kulso { package belso { class A { class B { private[A] var c = 0 protected[kulso] var y = 0 } } } }
Itt a private utáni "A" és a protected utáni "kulso" egy úgynevezett access qualifier. Ilyen pozícióban csomagnév, osztály vagy objektumnév és a this állhat. Annyi megszorítás vonatkozik még rájuk, hogy az alkalmazás helyén az access qualifier mindenképp "enclosing identifier" kell legyen, azaz csak a tartalmazó entitásokra hivatkozhatunk.

A példában a private[A] jelentése, hogy c elérhető az A osztályból és az összes tartalmazott osztályából és objektumából, de máshonnan nem. A protected[kulso] pedig azt jelenti, hogy a kulso csomagon belül mindenhol hozzá lehet férni az y-hoz. Annyiban különbözik ez a private[kulso]-től, hogy ha a kulso csomagon kívül származtatunk B-ből, y-t ott is el fogjuk érni.

Ahogy azt korábban láttuk, a Scala-ban nincs példányszintű private és package private hozzáférés. Ez nem teljesen igaz, ugyanis access qualifierek használatával pontosan ugyanezt a hatást el lehet érni. Példányszintű privát hozzáférést kapunk a private[this] használatával, a private[csomagnév] pedig tulajdonképpen a Java-ból ismert package private láthatósági szint.

Strukturális típusdefiníció

"Ha egy madár úgy jár, mint egy kacsa, úgy úszik, mint egy kacsa és úgy hápog, mint egy kacsa, akkor az egy kacsa."

Az angol nyelvű szakirodalomban "duck typing"-ként is emlegetik a strukturális definíciót: azt a típusmegadási módot, ahol nem altípusosság segítségével szorítjuk meg az egy adott helyen használható típusokat, hanem a tulajdonságaik és a viselkedésük alapján.

def takeOff(r: {def fly()}) = { r.fly() }

Ez azt jelenti, hogy a takeOff metódus olyan típusú r paramétert vár, amire definiált egy fly() művelet. Ez azt fejezik ki, hogy ami repül, az fel is tud szállni. A strukturális definíció különösen akkor hasznos, ha típusok uniójára lenne szükség. Ha például az osztályainkat úgy szerveztük, hogy a Kacsa és a Repülőgép osztálynak nincs közös ősosztálya, és már nem is tudjuk módosítani a kódot (mert például egy zárt forráskódú, harmadik féltől származó könyvtárról van szó), de mindkettő rendelkezik repul() metódussal, akkor ha egy függvényben semmi mást nem akarunk kihasználni róluk a repul()-ön kívül, a strukturális típusdefiníció kapóra jöhet. Hátrány lehet, hogy így minden más (még esetleg nem is létező) típus is, amire definiált a repul(), használható lesz. Azonban ha helyes volt az absztrakciónk, ez nem okozhat problémát, hiszen csak a repul() meglétét használtuk (és használhattuk) ki, amivel pedig a többi típus is rendelkezik.

Beépített vezérlési elemek

A Scala nyelv nem tartalmaz sok vezérlési szerkezetet: ezek az if, while, for, try, match. A funkcionális szemléletből adódóan a Scala számos vezérlési eleme visszatérési értékkel rendelkezik. Ez lehetőséget ad arra, hogy a forráskód egyszerűbb, olvashatóbb legyen számos esetben elkerülhető, hogy lokális változókat deklaráljunk, melyeknek csak az a szerepe, hogy visszatérési értéket tároljanak. Tekintsük át ezeket az alapvető vezérlési elemeket:

If feltételes kifejezések

Hasonlóan működik, mint a c alapú nyelvek feltételes kifejezése (?:). Egy nagyon egyszerű példán szemléltetjük a kifejezés előnyét.

val filename = if (!args.isEmpty) args(0) else "default.txt"

Ennek az egyszerű funkcionális szemléletű kódrészletnek az előnye, hogy val deklarációt használhatunk, azaz konstansként hozzuk léte a filename értéket. A második előnye, ha az if szerkezet visszatérési értékkel rendelkezik, hogy számos esetben gyorsabb a kiértékelése, bárhová ahová a forráskódba írható változónév, oda írható helyette kifejezés is.

println(if (!args.isEmpty) args(0) else "default.txt")

While és do while ciklusok

A Scala elöl- és hátultesztelő ciklusa nem különbözik a C alapú nyelvek szintaktikájától, de van egy érdekes, fontos tulajdonságuk. Ciklusnak nevezhetők, mert nincs figyelembe veendő visszatérési értékük, de ha a ciklus belépési feltétele Unit-tól különböző, azaz meghatározó logikai értéket ad akkor az elöltesztelő ciklusba beléphetünk, illetve a hátultesztelő ciklusban bennmaradhatunk.

var line = "" while ((line = readLine()) != "") // This doesn’t work! println("Read: "+ line)

Ez azért lehetséges, mert a feltételben található (line = readLine()) Unit értéket ad vissza amely soha nem lesz megegyező a ""-gel.

Match kifejezés

Szintaktikailag nagyban hasonlít a Java switch utasításához, de van számos különbség. A Java-ban 1.6-os JDK-ig kizárólag primitív típusú értékekre lehetett alkalmazni a többágú elágazást. Az 1.7-es JDK támogatja a String típust is. Scala-ban nem csak egész és felsorolt típusokra használható, hanem bármilyen konstansra is. Scala-ban nem kell break utasítás az esetek végére. A másik különbség, hogy a default ág helyett az _ karaktert, mint jokert kell használni az egyéb esetekre. Ennek használata kötelező, hiszen a match is egy kifejezés, tehát visszatérési értéke van. Ezen tulajdonságok előnye, hogy a forráskód rövidebb és könnyebben áttekinthető. Scala-ban nincs break és continue utasítás (foglalt szóként), ha mégis szükség volna a break utasításra, akkor a scala.util.control.Break osztály break metódusa használható. Erre nincs szükség, hiszen ugró utasítások nélkül az összes programozási tétel megvalósítható.

Interakció a JAVA-val

A Scala nyelv egyik legnagyobb erőssége, hogy használhatjuk benne a már meglévő teljes JAVA kódbázist. A java.lang csomag minden osztálya alapértelmezetten importálva van, azonban ez nem köti meg a kezünket, bármely más csomagot is felhasználhatunk miután explicit módon importáltuk.
Nézzünk erre egy példát: szeretnénk elérni és megformázni az aktuális dátumot.

Mivel a JAVA-ban erre már rendelkezésre állnak a megfelelő osztályok (Date, DateFormat), nincs szükség arra, hogy nekiálljunk egy saját, ekvivalens Scala osztályt implementálni. Egyszerűen csak beimportáljuk a JAVA megfelelő osztályait

import java.util.{Date, Locale} import java.text.DateFormat import java.text.DateFormat._ object FrenchDate { def main(args: Array[String]) { val now = new Date val df = getDateInstance(LONG, Locale.HUNGARY) println(df format now) } }

A fenti kódban a df format now ekvivalens a df.format(now) kifejezéssel.

A Scala-ban használt import szinte teljesen megegyezik a JAVA-ban használatossal, azonban annál egy kicsit többet nyújt. Lehetőség van több osztály importálására is egyetlen sorban. Ehhez az osztályok neveit tegyük kapcsos zárójelbe, vesszővel elválasztva. Egy adott csomag teljes importálásakor * helyett _-t kel használnunk, ugyanis a * Scala-ban egy elfogadott azonosító.