Gyűjtemények Scalában

Kategória: Scala.

A gyűjtemények (collections) kezelését is jelentősen tovább gondolták Scala-ban.

Gyűjtemény típusok

Tömb

A tömbök alapvető gyűjtemények szinte minden programozási nyelvben; még azokban is jelen van, amelyekben nincs gyűjtemény könyvtár. A Scala-ban is alapvető; gondoljunk csak a main függvény paraméterére.

A tömb egy fajta elemet tartalmazhat, amit létrehozáskor meg kell adnunk. A tömb méretét utána zárójelben adhatjuk meg. A tömbök értékeit utólag meg tudjuk változtatni. A címzés szintén zárójellel történik, ezzel is azt illusztrálva, hogy itt valójában a funkcionális programozás jegyében függvényhívás történik; a tömb bizonyos sorszámú elemét "meghívjuk". A sorszámozás 0-tól indul. Pl.:

val fruits = new Array[String](3)
fruits(0) = "apple"
fruits(1) = "peach"
fruits(2) = "banana"

fruits(1)

A tömb elemeit ennél egyszerűbben, felsorolással is megadhatjuk:

val fruits = Array("apple", "peach", "banana")

Tömböt Scalaban a range függvény segítségével is megadhatunk, pl. az Array.range(1, 5) az Array(1, 2, 3, 4) tömböt adja vissza (a range második paramétere által meghatározott elem tehát már nem része az eredménynek). Opcionális paraméterként megadhatjuk azt is, hogy hányasával lépjen, pl. az Array.range(2, 10, 2) eredménye Array(2, 4, 6, 8). Szakaszt egyszerűbben a ciklusnál már bemutatott to és until segítségével is létrehozhatunk, pl. 1 to 5 (ebben benne van az 5) ill. 1 until 5 (ebben nincs benne azt 5). A toArray függvény segítségével tömbbé tudjuk alakítani (pl. (1 to 5).toArray), de tipikusan ezt a for ciklusban szoktuk használni.

Több dimenziós tömbös is létrehozhatunk az Array.ofDim() segítségével, pl.:

val matrix = Array.ofDim[Int](2, 3)
matrix(1)(2) = 5

Ebben a szakaszban a tömbök felszínét épp hogy csak karcoljuk; érdemes egy pillantást vetni az API-ra: https://www.scala-lang.org/api/current/scala/Array.html.

Lista

A lista a Scala-ban láncolt listát jelent. A címzés tehát lassú művelet. További jelentős eltérés a tömb és a lista között az, hogy a lista elemei alapértelmezésben nem
megváltoztathatóak, bár létezik külön változtatható lista típus is.

A lista elemeit megadhatjuk felsorolással, hasonlóan a tömbökhöz:

val fruits: List[String] = List("apple", "orange", "banana")

A típust itt is elhagyhatjuk:

val fruits = List("apple", "orange", "banana")

Az egyes elemeket a :: (cons) operátorral is összefűzhetjük; ez egyébként igen gyakori:

val fruits = "apple" :: "orange" :: "banana" :: Nil

Ha már összefűzés: két listát a ::: operátorral fűzhetünk össze:

List("apple", "orange", "banana") ::: List("orange", "pear") // List(apple, orange, banana, orange, pear)

Elemek ismétlését a fill függvénnyel adhatjuk meg:

List.fill(3)("apple")

A tabulate függvény végrehajtását teszi lehetővé inicializáláskor:

List.tabulate(6)(n => n * n)

Több dimenzióban is működik a tabulate

List.tabulate(3, 4)( _ * _ )

Listából is lehet több dimenziósat létrehozni:

val matrixList = List(
  List(1, 4, 8),
  List(2, 6, 3),
  List(9, 2, 4)
)

A listák listáját a flatten eljárással tudjuk "kiegyenesíteni", azaz egyetlen listává változtatni, pl.:

matrixList.flatten

Ennek eredménye List(1, 4, 8, 2, 6, 3, 9, 2, 4). Ennek a függvénynek létezik flattenLeft és flattenRight változata, amellyel azt mondjuk meg, hogy az összevonás sorrendje balról jobba (alapértelmezett) vagy jobbról balra történjen (az eredmény ugyanaz).

Halmaz

A halmaz a műveleteit tekintve döntőrészt megegyezik a listákkal. Leglényegesebb eltérés az, hogy az elemek nem ismétlődnek benne. Pl.

val fruits1 = Set("apple", "banana", "cherry")
val fruits2 = Set("orange", "apple", "peach")
fruits1 ++ fruits2 // Set(peach, banana, orange, cherry, apple)

Ez egyben példa arra is, hogy hogyan adhatjuk meg a halmazt, és hogyan képezhetjük két halmaz unióját (++). Valamint azt is láthatjuk, hogy az eredményben a sorrend nem garantált.

Asszociatív tömb

Az asszociatív tömbök kulcs-érték párokat tartalmaznak, pl.

val callNumbers = Map("Hungary" -> 36, "Germany" -> 49, "Serbia" -> 381)

Az apply művelet itt paraméterül a kulcsot várja, ami az értéket adja vissza. A keys a kulcsokat adja vissza, a values pedig az értékeket. Pl.

callNumbers("Hungary") // 36
callNumbers.keys // Set(Hungary, Germany, Serbia)

Tuple

Gyakran előforduló probléma, hogy elem ketteseket, hármasokat stb. szeretnénk létrehozni, pl. függvények esetén kettő vagy több értékkel visszatérni. A Java-ban ezt leginkább külön osztállyal tudjuk megoldani, amit deklarálni kell, minden szükségest megvalósítani (konstruktor, getter, setter, equals, hashCode, toString stb.), ráadásul külön fájlba - működik, de macerás. Ellenben a Scala tartalmaz Tuple típust, amivel ezt közvetlenül meg tudjuk oldani. Zárójelben soroljuk fel az értékeket, melyek különbözőek is lehetnek. Az elemeket az _1, _2 stb.-vel tudjuk elérni:

def f() = ("apple", 3)

val t = f()
t._1
t._2

Option

Kezdjük a sztorit messziről! Tegyük fel, hogy van egy tetszőleges típusunk, pl. egész vagy szöveges. Hogyan jelezzük azt, hogy egy adat hiányzik? Olyan technikákat alkalmaztak kezdetben, hogy egész esetén a hiányzó adatot a 0 érték jelzi szöveg esetén az üres string ("") stb. De mi van akkor, ha ezek érvényes adatok? Pl. valaminek az értéke tényleg lehet nulla vagy üres string? Erre programtól függően kiválasztottak egyéb értékeket, pl. -1, vagy egy olyan stringet, ami nagyon valószínűtlen, pl "$$$empty$$$".

Ez egyrészt nem szép, másrészt meg továbbra is problémát okoz az, hogy mi van akkor, ha az összes létező érték előfordulhat? Kezdetben bevezettek erre külön jelölőbiteket, amelyek tovább komplikálták a kódot.

Jelentős előrelépést jelentett az objektumorientált világban az objektumok megjelenése, ahol a null érték jelzi az adatok hiányát. A primitív típusok esetén ezt ne lehetett használni, de (valószínűleg pont emiatt) bevezették a csomagoló (wrapper) osztályokat. Pl. egy Integer típus értéke lehet 5, de lehet null is.

Megoldódni látszott tehát a hiányzó adat problematikája, de ezzel is van egy bökkenő. A null ellenőrzés nem szép, elcsúfítja a kódot. Ráadásul nem tudhatjuk, hogy a null érték az adat tudatos hiányából fakad, vagy szoftverhibáról van szó. Pl. függvény esetén szerver oldalon nem tudjuk explicit jelezni, hogy egy adott paraméter opcionális-e. (Persze erre is van megoldás: ha egy paraméter opcionális, akkor két fejlécet hozunk létre, és a szűkebb alapértelmezett értékkel hívja a bővebbet. Működik, de nem szép.)

A Scala erre vezette be az opcionális típust: itt explicit megadhatjuk, ha egy adat hiányzik. Így null ellenőrzésre sincs szükség, mert ha minden olyan esetben, ahol az érték lehet üres, konzekvensen az opcionális típust használjuk, akkor 100%, hogy a null érték szoftverhibára utal.

Az opcionális típus neve Scala-ban az Option. Ez generikus típus, szögletes zárójelben a tényleges típust adhatjuk meg, majd az érték vagy None, vagy pedig Some(…), ahol paraméterként ajduk meg az értéket. Pl.

val o: Option[String] = Some("apple")

A típus elhagyható, ez esetben viszont létrehozáskor az Option-t kell használnunk:

val o = Option("apple")

Az isDefined függvénnyel tudjuk lekérdezni azt, hogy tartalmaz-e értéket, és az értéket a get függvénnyel kapjuk meg:

if (o.isDefined) o.get else "empty"

Ez olyan gyakori művelet, hogy a beépített getOrElse pontosan ezt csinálja:

o.getOrElse("empty")

Kicsit komplikáltabb kódot eredményez a mintaillesztés, ám bonyolultabb esetekben (azaz ha több műveletet hajtunk végre akkor is, ha definiált az érték és akkor is, ha nem) ez a preferált módszer:

o match {
  case Some(fruit) => fruit
  case None => "empty"
}

Valószínűleg a Scala visszahatásaként jelent meg az opcionális típus a Java 8-ban.

Bejárások

Számos módon tudjuk bejárni az elemeket. A példákban a listákat fogjuk használni, mivel azokat használjuk leggyakrabban.

for ciklus

A for ciklussal végig tudunk iterálni egy gyűjteményen, pl.

for (fruit <- fruits) println(fruit)

foreach

A gyűjteményeknek van foreach metódusuk, aminek segítségével szintén végig tudunk lépkedni annak elemein, pl. (az alábbi három ekvivalens):

fruits.foreach(x => println(x))
fruits.foreach(println(_))
fruits.foreach(println)

mkstring

A gyűjtemények egy igen hasznos metódusa a mkString, melynek segítségével egyetlen stringgé tudjuk formázni a gyűjtemény összes elemét:

fruits.mkString(", ")

head :: tail

A gyűjtemények első elemét a head függvénnyel érjük el, a tail pedig a további részét adja vissza. Az isEmpty azt mondja meg, hogy a gyűtemény üres-e.

fruits.head // apple
fruits.tail // List(orange, banana)
fruits.isEmpty // false

Erre építhetünk mintaillesztést:

def printElementsRecursively(fruits: List[String]): Unit = fruits match {
  case head :: tail =>
    println(head)
    printElementsRecursively(tail)
  case Nil =>
}

printElementsRecursively(fruits)

Fontos megjegyezni, hogy a case head :: tail => sorban a head és a tail nem kulcsszavak; azt is írhattuk volna, hogy case fej :: farok =>.

Ez persze jelen esetben elbonyolította a kódot, ám általános esetben igen gyakran használják ezt a megoldást, így érdemes megismerni.

Átalakítás és szűrés

A függvények többsége folyam (stream) jellegű, melyek közül néhányat most megnézünk. Elemeket átalakítani a map függvénnyel tudunk, amely sorra veszi a lista elemeit, és kiszámol egy újabb értéket, pl.:

List.range(1, 10).map(x => x * x) // List(1, 4, 9, 16, 25, 36, 49, 64, 81)

Szűrni a filter függvénnyel tudunk:

val fruits = List("apple", "peach", "banana")
fruits.filter(x => x.contains('n')) // List(orange, banana)

vagy egyszerűbben:

fruits.filter(_.contains('n'))

Listák esetén gyakori a műveletek egymásután fűzése (ezt angolul fluent API-nak hívjuk), pl.:

List.range(1, 10).filter(x => x % 2 == 1).map(x => x * x) // List(1, 9, 25, 49, 81)

Ha csak arra vagyunk kíváncsiak, hogy van-e a szűrőfeltételnek megfelelő elem, ill. mindegyik elemre teljesül-e, az exists és a forall függvényeket használhatjuk. Az exists akkor ad igazat, ha a paraméterül átadott predikátum legalább egy elemre igaz, míg a forall akkor, ha mindre, pl.:

fruits.exists(_.contains('n')) // true
fruits.forall(_.contains('n')) // false

Összevonások

Összevonások során sorba vesszük kettesével az elemeket, és összevonjuk őket. Ne feledjük: a Scala lista megvalósítása láncolt lista, sorba venni az elemeket tehát olcsó művelet! Az eső iyen művelet a reduce:

List.range(1, 10).reduce((a, b) => a + b)

Ill. egyszerűbben:

List.range(1, 10).reduce(_ + _)

Ez összeadja a számokat 1-től kilencig, a következőképpen: először kiszámolja az 1+2-t, utána az eredményhez hozzáad 3-at és így tovább; az eredmény 45 lesz. A reduce tehát balról hajtja végre a műveletet, amit jelezhetünk is:

List.range(1, 10).reduceLeft(_ + _)

Ennek amiatt van értelme, mert létezik a reduceRight is, ami jobbról hajtja végre a műveletet:

List.range(1, 10).reduceRight(_ + _)

Itt tehát először a 8-at és a 9-et adja össze, az eredmény 17, majd a 7+17-et számolja ki, és így tovább. A végeredmény ugyanaz mint a reduceLeft esetén, de ez amiatt van, mert a példában szereplő művelet, azaz az összeadás kommutatív. Kivonás esetén már nem lenne ugyanaz az eredmény. (Zárójeles megjegyzés listák esetén: attól, hogy a lista láncolt, a reduceRight ugyanúgy hatékony, ugyanis a láncolás lehet oda-vissza láncolt. Így pl. a reverse művelet (ami megfordítja a listát) is gyors művelet.)

A reduce függvényhez hasonló a fold. A reduce hiányossága az, hogy a legelső elemen igazából nem hajtódik végre a művelet; ezt tudjuk megadni az első paraméterlistában a fold esetén:

List.range(1, 10).fold(0)(_ + _)

A reduce-hoz hasonlóan a fold esetén is megadhatjuk azt, hogy balról vagy jobbról történjen az összevonás; az alapértelmezett a bal:

List.range(1, 10).foldLeft(0)(_ + _)
List.range(1, 10).foldRight(0)(_ + _)

A fold-nak a reduce-hoz képest a fenti példában a kezdeti paraméter megadásának nincs jelentősége, de nézzünk egy másik példát! Tegyük fel, hogy a gyümölcsöket össze szeretnénk vonni kötőjellel:

fruits.reduce((a, b) => a + "-" + b)
fruits.fold("")((a, b) => a + "-" + b)

Az első eredménye apple-orange-banana, a másodiké pedig -apple-orange-banana. Ebben az esetben valószínűleg az elsőt szeretnénk elérni, általános esetben viszont valószínűbb, hogy a másodikat, azaz ugyanazt a műveletet végre szeretnénk hajtani mindegyik elemen, már az első elemen is.

Erre egyébként külön operátort hoztak létre: /: a bal, és :\ a jobb irány számára. Az alábbi két sor a fenti foldLeft és foldRight példával egyenértékű:

List.range(1, 10)./:(0)(_ + _)
List.range(1, 10).:\(0)(_ + _)

A személyes véleményem egyébként erről az, hogy az ilyen szintű tömörítés már az olvashatóság rovására megy. Pl. ha az l egy lista, akkor a fenti példa ez lenne: l./:(0)(_+_), aminek az olvashatósága vetekszik a Brainfuck nevű egzotikus programozási nyelv kódjának olvashatóságával. Lehet, hogy tömöríti a kódot, és a programozó számára élményt nyújt az, hogy ilyen bonyolult dolgokat is képes leprogramozni, mégis, a fold kiírását szerencsésebbnek tartom kód olvashatóság szempontjából.

A harmadik, viszonylag ritkán használt összevonó művelet a scan, ami hasonlóan működik, mint a fold, azzal a különbséggel, hogy a részeredményeket is visszaadja. A scan eredménye tehát egy lista:

List.range(1, 10).scan(0)(_ + _) // List(0, 1, 3, 6, 10, 15, 21, 28, 36, 45)

A scan esetén is beszélhetünk bal és jobb oldali scan-ről, és itt látványos is az eredmény:

List.range(1, 10).scanLeft(0)(_ + _) // List(0, 1, 3, 6, 10, 15, 21, 28, 36, 45)
List.range(1, 10).scanRight(0)(_ + _) // List(45, 44, 42, 39, 35, 30, 24, 17, 9, 0)

Gyűjtemények elemeinek összekapcsolása

Elem párokat a zip függvénycsaláddal tudunk képezni. Lássunk egy példát!

val fruits = List("apple", "orange", "banana")
val prices = List(3.5, 7.3, 4.0)
val zippedWithPrice = fruits.zip(prices) // List((apple,3.5), (orange,7.3), (banana,4.0))

Igen gyakori művelet az indexszel történő kombinálás. Ez a fenti módon is megoldható, de olvashatóbb kódot eredményez a zipWithIndex:

val fruits = List("apple", "orange", "banana")
val zippedWithIndex = fruits.zipWithIndex // List((apple,0), (orange,1), (banana,2))

Különböző hosszúságú gyűjteményeket is össze lehet kapcsolni a zipAll függvénnyel:

val fruits = List("apple", "orange", "banana")
val prices = List(3.5, 7.3, 4.0, 5.6)
val zippedWithPrice = fruits.zipAll(prices, "", 0.0) // List((apple,3.5), (orange,7.3), (banana,4.0), ("",5.6))

A zipAll függvénynek 3 paramétere van: a másik gyűjtemény, az első gyűjtemény alapértelmezett értéke arra az esetre, ha az a rövidebb, ugyanez a második gyűjteményre.

Bejáráskor az eredeti gyűjtemény elemeit a ._1, míg a paraméterül kapottat a ._2 segítségével érhetjük el:

zippedWithIndex.foreach(x => println(x._2 + ". " + x._1))

Kibontani az unzip függvénnyel tudjuk:

zippedWithIndex.unzip // (List(apple, orange, banana),List(0, 1, 2))

Az eredményből itt is a ._1 ill. ._2 segítségével érhetjük el az eredeti listát ill. a paraméterül kapottat (vagy az indexeket):

zippedWithIndex.unzip._1 // List(apple, orange, banana)
zippedWithIndex.unzip._2 // List(0, 1, 2)

Iterátor

Iterátorok segítségével végig tudunk lépkedni gyűjteményeken. Létrehozhatjuk közvetlenül a következőképpen:

val fruitsIterator = Iterator("apple", "banana", "orange")

Illetve tetszőleges gyűjteményből képezhetünk iterátort:

val fruitsIterator = fruits.iterator

Az iterátor két fő függvénye a hasNext() és a next, amely lekérdezi, hogy van-e az iterátornak következő eleme, ill. visszaadja azt, a következőképpen:

while (fruitsIterator.hasNext) println(fruitsIterator.next)

Fontos tudnunk, hogy egy iterátoron csak egyszer lehet végighaladni, ha másodszor is megpróbálnánk lefuttatni, akkor az eredmény üres lenne. A következő példát így célszerű egy újabb iterátoron végrehajtani (akár úgy, hogy ismét létrehozzuk). A for és foreach módszerekkel is végig tudunk lépkedni az elemeken:

for (fruit <- fruitsIterator) println(fruit)

ill.

fruitsIterator.foreach(println _)

Módosítható gyűjtemények

A fent felsorolt gyűjtemények egyike sem módosítható. A módosítható gyűjtemények (amelyekhez tehát tudunk értéket hozzáadni, megváltoztatni ill. törölni belőle) a scala.collection.mutable csomagban találhatóak. Néhány példa:

  • ArrayBuffer: módosítható tömb
  • ListBuffer: módosítható láncolt lista
  • Queue: módosítható sor
  • Map: módosítható asszociatív tömb

Részletes leírást találunk erről a https://docs.scala-lang.org/overviews/collections/concrete-mutable-collection-classes.html oldalon.

val fruits = scala.collection.mutable.ArrayBuffer("apple", "peach", "banana")
fruits += "orange"
fruits -= "peach"
fruits(0) = "lemon"
fruits.mkString(", ") // lemon, banana, orange

val callNumbers = scala.collection.mutable.Map("Hungary" -> 36, "Germany" -> 49, "Yugoslavia" -> 38)
callNumbers -= "Yugoslavia"    // Map(Germany -> 49, Hungary -> 36)
callNumbers += "Serbia" -> 381 // Map(Germany -> 49, Hungary -> 36, Serbia -> 381)

A lazy és a folyamok

A lazy kulcsszó segítségével azt adjuk meg, hogy az érték kiszámítása ne azonnal történjen, hanem csak akkor, ha először használjuk. Ennek a gyakorlati haszna a következő: tegyük fel, hogy egy érték kiszámolása erőforrás igényes, és nem is biztos, hogy fel fogjuk használni.

Példaként vegyük a következő programot!

println("Begin program")
lazy val l = {
  println("Calculating value...")
  Thread.sleep(1000)
  println("Returning value")
  15
}
println("Before print")
println(l)
println("End program")

A "Before print" kiírása megelőzi a "Calculating value…" kiírását. Ha nem lenne lazy, akkor fordítva történne. Ill. ha kitöröljük a println(l) sort, akkor ki sem értékelődik.

E kis kitérő fontos volt a folyamok (stream) tárgyalásához, ugyanis a folyam nem más, mint lazy lista. Tehát a lista elemeit csak akkor határozzuk meg, ha az szükséges. Először lássuk, hogy hogyan tudunk folyamot létrehozni, majd utána hogyan tudjuk az elemeket elérni. (Megjegyzés: a Stream deprecated lett, helyette a LazyList használata javasolt.)

A legegyszerűbb és talán legritkább megadási mód az elemek felsorolása, melyet a #:: operátorral tudjuk elválasztani.

val stream = 1 #:: 2 #:: 3 #:: 4 #:: Stream.empty // LazyList.empty

Létező listából is készíthetünk folyamot:

val lazyFruits = fruits.toStream // fruits.to(LazyList)

Igazán a végtelen folyamok az izgalmasak. A következő pl. 0-tól legenerálja a természetes számokat:

val infinite = Stream.from(0)
infinite    // Stream(0, ?)
infinite(5) // 5
infinite    // Stream(0, 1, 2, 3, 4, 5, ?)

A példában az első kiíráskor azt látjuk, hogy csak az első elem van kiértékelve (azaz a 0), a többi nincs. Amikor kiíratjuk az 5. elemet (0-tól sorszámozva valójában a hatodikat), akkor addig kiértékelődik, ami látszódik is a következő kiírásból.

A következő a Fibonacci számokat generálja le elvben a végtelenségig.

lazy val fibonacci: Stream[Int] = 0 #:: 1 #:: fibonacci.zip(fibonacci.tail).map(n => n._1 + n._2) // Stream -> LazyList

(A megértéséhez érdemes eljátszani először normál lista verzióval: val l = List(2, 3, 5, 8), l.zip(l.tail) stb.)

Lássuk a bejárásokat! Először vegyük az első elemet, pl.

fibonacci.head

Ennek az eredménye 0. A tail eredménye a következő:

fibonacci.tail
// scala.collection.immutable.Stream[Int] = Stream(1, ?)

A következőnek:

fibonacci.tail.tail.tail.tail.tail.head

az eredménye 5, majd ha kiírjuk ismét a fibonacci változó értékét, akkor ezt kapjuk:

Stream[Int] = Stream(0, 1, 1, 2, 3, 5, ?)

Ez azt jelenti, hogy a végtelen folyam első 6 eleme ezzel kiértékelődött.

A take(n) függvény segítségével az első n elemet dolgozza fel, pl.

fibonacci.take(10).mkString(", ") // 0, 1, 1, 2, 3, 5, 8, 13, 21, 34

Végtelen folyamok esetén értelemszerűen nincs értelme azoknak a függvényeknek, amelyeknél végig kell iterálni az összes elemen, pl. min, max stb. Véges folyamok esetén ezt is kiszámolja, de ekkor legenerálja az összes elemet.

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