Durchstarten mit Scala

Erste Schritte mit funktionaler Programmierung in Scala
Kommentare

Laut Runar Oli Bjarnason ist funktionale Programmierung das Programmieren mit Funktionen. Diese zwar korrekte, aber für sich alleine nicht besonders hilfreiche Definition aus seinem sehr empfehlenswerten Video „Functional Programming for Beginners“, bei dem übrigens Scala zum Einsatz kommt, führt uns zur Frage, was Funktionen sind.

Die „reinen“ Funktionen der funktionalen Programmierung haben große Ähnlichkeit zu mathematischen Funktionen und sind auf keinen Fall mit Unterprogrammen zu verwechseln. Im Prinzip sind Funktionen ein recht einfaches Konstrukt: Sie werden auf Argumente angewendet und liefern ein Ergebnis zurück. Sonst tun sie nichts, insbesondere ändern sie keinen Zustand wie zum Beispiel globale Variablen, was auch mit „frei von Seiteneffekten“ bezeichnet wird. Dadurch führen wiederholte  Funktionsaufrufe, die mit den gleichen Argumenten ausgeführt werden, stets zum gleichen Ergebnis.

So betrachtet können wir auch mit Java funktional programmieren, denn Methoden sind per Definition Funktionen, solange sie ein Ergebnis haben und keine Seiteneffekte ausüben. Allerdings fehlt bei unserer bisherigen Definition noch ein sehr wichtiges Feature, das funktionale Programmiersprachen üblicherweise anbieten: Funktionen höherer Ordnung. Das bedeutet, dass Funktionen anderen Funktionen als Argument übergeben werden können. Anders ausgedrückt sind Funktionen vollwertige Typen wie zum Beispiel numerische Typen oder Strings. Spätestens hier enden die Möglichkeiten von Java, weil Methoden bekanntlich keine „echten“ Objekte sind, die an andere Methoden übergeben werden können.

Scala hingegen unterstützt die funktionale Programmierung mit all ihren zentralen Features, insbesondere mit Funktionen höherer Ordnung. Allerdings wissen wir ja inzwischen schon, dass Scala durchaus veränderlichen Zustand erlaubt, und zwar in Form von vars. Daher ist Scala keine „reine“ funktionale Sprache, sondern eine objekt-funktionale oder post-funktionale. Was das in der Praxis bedeutet, das wollen wir in diesem Kapitel kennenlernen. Dabei werden wir zunächst auf die Scala Collection Library eingehen, weil Collections auf einfache Weise höchst anschauliche Einblicke in die funktionale Programmierung ermöglichen. Anschließend werden wir viel anhand von Beispielen experimentieren und ebenso unser Fallbeispiel um wichtige Funktionen erweitern.

Scala-Collections

Die überaus umfangreiche und sehr mächtige Scala Collection Library wäre an sich schon fast ein eigenes Buch wert. Zum Glück gibt es eine recht umfangreiche Online-Dokumentation, die wir sehr empfehlen können. Daher werden wir hier nur einen groben Überblick geben, um genügend zu wissen, damit wir unser Fallbeispiel bearbeiten können und – wie schon gesagt – um die funktionale Programmierung anhand anschaulicher Beispiele kennenzulernen.

Klassenhierarchie

Im Vorgriff auf Kapitel 9, wo wir Vererbung behandeln werden, stellen wir zunächst die Vererbungshierarchie der Scala-Collections vor. Abbildung 1 zeigt einen Auszug besonders wichtiger Vertreter, wobei kursiv gedruckte Namen für abstrakte Vertreter stehen und in normalem Schriftgrad gedruckte für konkrete. Alle Collections erben von Traversable, wo schon zahlreiche konkrete Methoden definiert werden, die letztendlich alle auf die einzige abstrakte Methode foreach zugreifen. Iterable ergänzt die Möglichkeit, einen Iterator zu beziehen, also seitens des Nutzers aktiv über die Elemente zu iterieren. Unterhalb davon stehen die drei klassischen Arten von Collections: Seq(ence), Set und Map. Diese erweitern Iterable um jeweils spezifische Features und überschreiben manche allgemeinen Methoden auf optimierte Art und Weise.

Abbildung 1: Wichtige Scala-Collections

Seqs haben eine wohldefinierte Reihenfolge, Sets enthalten keine zwei gleichen Elemente und Maps bilden Schlüssel auf Werte ab. Für alle drei Arten gibt es Spezialisierungen, aber wir erwähnen hier nur exemplarisch spezielle Seq-Vertreter. Diese unterscheiden sich in lineare, d.h. verkettete, die darauf optimiert sind, „vom Kopf her“ manipuliert zu werden, und indizierte, d.h. solche mit wahlfreiem Zugriff. Für jede Art ist eine konkrete Implementierung dargestellt, und zwar List als das Paradebeispiel einer linearen Seq und Vector für Seqs mit wahlfreien Zugriff.

Collection-Instanzen erzeugen

Wenn wir eine Collection-Instanz erzeugen wollen, dann können wir das auf die denkbar einfachste Art und Weise bewerkstelligen. Wir schreiben den Namen der Collection, also zum Beispiel List, und in runden Klammern und mit Komma getrennt die Elemente, welche die Collection enthalten soll. Dabei spielt es auch keine Rolle, ob wir eine abstrakte Seq oder eine konkrete List erzeugen wollen:

scala> Seq(1, 2, 3)
res0: Seq[Int] = List(1, 2, 3)
scala> IndexedSeq(1, 2, 3)
res1: IndexedSeq[Int] = Vector(1, 2, 3)
scala> List("a", "b", "c")
res2: List[java.lang.String] = List(a, b, c)

Wie funktioniert das? Wieso können wir hinter einen Namen wie zum Beispiel Seq eine Liste von Argumenten schreiben, also quasi einen Aufruf machen. So etwas kennen wir bisher nur von Methoden! Da Scala eine „schlanke“ Sprache ist, müssen wir gar nichts Neues lernen. Denn letztendlich führt Seq(1, 2, 3) zu einem ganz normaler Methodenaufruf. Der Scala-Compiler macht nämlich daraus folgendes: Seq.apply(1, 2, 3). Zum Beweis schauen wir uns das in der REPL an:

scala> Seq(1, 2, 3)
res0: Seq[Int] = List(1, 2, 3)
scala> Seq.apply(1, 2, 3)
res1: Seq[Int] = List(1, 2, 3)

Das sieht vom Ergebnis her identisch aus und ist in der Tat auch dasselbe. Mit anderen Worten ist die erste Variante nur syntaktischer Zucker für die zweite. Das funktioniert übrigens überall: Sobald wir ein Objekt behandeln, als sei es eine Methode, d.h. wir schreiben hinter dem Objekt eine Liste von Argumenten, dann übersetzt der Scala-Compiler dies in einen Aufruf der apply-Methode. Zur Erinnerung sei an die Case Classes verwiesen, bei denen die apply-Methode des Companion Object zur Erzeugung einer Instanz dient.

So weit, so gut, aber eine Frage bleibt noch offen. Was ist denn dieses Seq? Offenbar müssen wir es direkt ansprechen können und es muss eine apply-Methode haben. Um es direkt ansprechen zu können, muss es ein Singleton Object sein. Ein Blick in die ScalaDoc-Dokumentation der Scala-Standardbibliothek zeigt uns, dass es tatsächlich ein solches gibt und dass dieses eine apply-Methode hat:

def apply[A](elems: A*): Seq[A]

Hier sehen wir zwar eine Menge an Dingen, die wir noch gar nicht besprochen haben, zum Beispiel Typ-Parameter und Repeated Parameters. Aber wenn wir uns davon nicht verwirren lassen, dann erkennen wir, dass diese Methode offenbar Argumente eines bestimmten Typs erwartet und dann eine Seq zurückgibt. Und genau das passiert ja, wenn wir Seq(1, 2, 3) ausführen: Wir erhalten eine Seq-Instanz.

Typ-Parameter

Alle Collections in Scala sind parametrisiert, d.h. es gibt keine Raw Types wie in Java, sondern sozusagen nur Generics. Die Notation ist zunächst ähnlich wie in Java, nur dass der oder die Typ-Parameter in eckigen Klammern geschrieben werden, statt in spitzen. Das bedeutet, dass Traversable, Iterable, Seq etc. gar keine „richtigen“ Typen sind, sondern sogenannte Typ-Konstruktoren, denn erst durch die Anwendung eines Typ-Arguments wird daraus ein Typ. Wie wir oben gesehen haben, müssen wir beim Erzeugen von Collections das Typ-Argument gar nicht explizit angeben, denn der Scala-Compiler kann diesen inferieren. Das funktioniert nicht nur, wenn alle Elemente denselben Typ haben, sondern auch mit unterschiedlichen. Dann wird einfach der spezifischste Super-Typ verwendet:

scala> List(1, "a")
res0: List[Any] = List(1, a)

Der Typ Any stellt hierbei die Wurzel der Scala-Typhierarchie dar, doch dazu mehr in Kapitel 8. Wenn wir möchten, dann können wir den Typ-Parameter auch explizit angeben, indem wir das Typ-Argument in eckigen Klammern hinter den Typ-Konstruktor schreiben. Das ist insbesondere dann erforderlich, wenn wir eine leere Collection anlegen und der Scala-Compiler keine sonstigen Informationen über den Typ-Parameter hat. Die zweite und dritte Liste im folgenden Beispiel sind leer. Dabei legen wir bei der zweiten den Typ explizit fest, wohingegen der Compiler bei der dritten den Typ Nothing inferiert, der ein Sub-Typ aller anderen Typen ist, aber auch dazu noch mehr in Kapitel 8.

scala> List[Int](1, 2, 3)
res0: List[Int] = List(1, 2, 3)
scala> List[Int]()       
res1: List[Int] = List()
scala> List()     
res2: List[Nothing] = List()

Nicht nur Collections sind parametrisiert, sondern auch viele andere Vertreter aus der Scala-Standardbibliothek. Wir können natürlich auch eigene parametrisierte Typen schreiben und auch parametrisierte Methoden, doch dazu mehr in Kapitel 12.

Tupel

Nun betrachten wir mit den Tupeln ganz spezielle Vertreter aus der Scala-Standardbibliothek. Diese sind gar keine Collections, ähneln diesen jedoch zu einem gewissen Grad. Darüber hinaus benötigen wir sie unter anderem auch für die Initialisierung von Maps. Tupel fassen eine feste Anzahl von Werten bzw. Objekten zusammen, wobei die jeweiligen Werte zwar wohldefinierte, aber unterschiedliche Typen haben dürfen. Darüber hinaus gibt es noch eine vereinfachte Schreibweise, um Tupel anzulegen und deren Typ zu bezeichnen, also quasi wiederum syntaktischen Zucker. Ein Beispiel:

scala> val pair = (1, "a")
pair: (Int, java.lang.String) = (1,a)
scala> Tuple2(1, "a")
res1: (Int, java.lang.String) = (1,a)

In der oberen Zeile schreiben wir in Klammern und mit Komma getrennt die einzelnen Werte, aus denen unser Tupel, in diesem Fall ein Tuple2, bestehen soll. Die Antwort der REPL enthält eine Typangabe, die ganz ähnlich aussieht wie die Initialisierung. Diese enthält nämlich in Klammern und mit Komma getrennt die einzelnen Typen. Anstelle dieser Notation können wir auch, ähnlich wie bei den Collections, ein „Tupel Singleton Object“ schreiben, gefolgt von der Argumente-Liste. Dies sehen wir am Beispiel von Tuple2 in der unteren Zeile. Als Typangabe könnte die REPL hier auch Tuple2[Int, String] ausgeben, was gleichbedeutend zum (Int, String) ist, aber zum Glück verwendet die REPL die intuitivere Klammer-Schreibweise. Wozu dienen eigentlich Tupel, die es in der Scala-Standardbibliothek von Tuple2 bis Tuple22 gibt? Zum Beispiel kann eine Methode damit mehrere Werte zurückgeben. Dafür verwendet man zwar oft eine extra Klasse, aber manchmal ist ein Tupel durchaus angebracht. Wir werden das gleich anhand unseres Fallbeispiels praktisch zeigen.

Um auf die einzelnen Werte eines Tupels zuzugreifen, verwenden wir wie bei Feldern oder Methoden einen Punkt „.“ gefolgt von einem Unterstrich „_“ und dem bei Eins (!) beginnenden Index des Wertes innerhalb des Tupels. Das sieht in unserem Beispiel so aus:

scala> pair._1
res2: Int = 1
scala> pair._2
res3: java.lang.String = a

Eine andere wichtige Bedeutung kommt Tupeln bei den Maps zu, denn diese sind ja Abbildungen von Schlüsseln auf Werte oder mit anderen Worten Collections von Tuple2s. Daher können wir Maps folgendermaßen anlegen:

scala> Map((1, "a"), (2, "b"))
res0: scala...Map[Int,java.lang.String] = Map((1,a), (2,b))

Tuple2s können wir auch noch einfacher schreiben. Dazu kommen wieder einmal die mächtigen Implicit Conversions ins Spiel, auf die wir in Kapitel 11 eingehen werden. Hier sei bloß so viel verraten, dass dadurch auf beliebigen Objekten der Operator -> mit einem beliebigen Argument aufgerufen werden kann, der ein Tuple2 erzeugt. Damit ergibt sich für obiges Beispiel die sehr eingängige Schreibweise:

scala> Map(1 -> "a", 2 -> "b")
res0: scala...Map[Int,java.lang.String] = Map((1,a), (2,b))

Unveränderliche und veränderliche Collections

Die Scala-Collections spalten sich letztendlich in unveränderliche und veränderliche auf, was wir auch anhand der Package-Struktur der Scala-Standardbibliothek erkennen können. Dort gibt es unter anderem die folgenden Packages:

  • scala.collection
  • scala.collection.immutable
  • scala.collection.mutable

Das oberste Package enthält abstrakte Basis-Collections, die in den beiden Sub-Packages entweder unveränderlich oder veränderlich spezialisiert werden. Das führt zu der Frage, was genau (un-)veränderlich bedeutet. Die einfache Antwort lautet, dass unveränderliche Collections zwar durchaus Methoden zum Verändern bieten, diese aber eine neue Collection zurückgeben und die alte unverändert lassen. Ein Beispiel:

scala> val numbers = Vector(1, 2, 3)
numbers: ...Vector[Int] = Vector(1, 2, 3)
scala> numbers :+ 4
res0: ...Vector[Int] = Vector(1, 2, 3, 4)
scala> numbers
res1: ...Vector[Int] = Vector(1, 2, 3)

Hier haben wir den Operator :+ verwendet, der ein Element an eine Seq hinten anfügt. Wie wir sehen, bleibt die ursprüngliche Liste numbers unverändert und wir bekommen eine neue Liste res0 mit dem zusätzlichen Element.

Einschub: Assoziativität von Operatoren

Wie könnten wir an unsere numbers ein Element vorne anfügen? Dafür gibt es den Operator +:, der zum gerade verwendeten spiegelverkehrt geschrieben wird. Wäre es nicht gut verständlich, wenn wir erst das vorne anzufügende Element schreiben könnten und dann den Operator gefolgt von der Seq? Da der Operator +: mit einem Doppelpunkt „:“ endet, können bzw. müssen wir das tun. Denn in Scala, wo grundsätzlich alles linksassoziativ ist, sind Operatoren, die mit einem Doppelpunkt enden, rechtsassoziativ. Damit können wir obiges Beispiel leicht abwandeln:

scala> val numbers = Vector(1, 2, 3)
numbers: ...Vector[Int] = Vector(1, 2, 3)
scala> 0 +: numbers
res0: ...Vector[Int] = Vector(0, 1, 2, 3)

Unveränderlich ist der Standard

Wir können wichtige Collections nutzen, ohne diese explizit zu importieren. Das haben wir uns die ganze Zeit schon zunutze gemacht, schließlich haben wir bisher nirgendwo in diesem Kapitel einen Import verwendet oder einen voll qualifizierten Namen. Wie funktioniert das? Auch hier gilt, dass Scala „schlank“ bleibt und sich vorhandener Mechanismen bedient. Wir haben bereits über das Singleton Objekt Predef gesprochen, dessen Member vom Compiler automatisch importiert werden. Dort finden wir zwei sogenannte Typ-Aliase für Set und Map, die auf die jeweiligen unveränderlichen Collections verweisen. Da wir im Rahmen dieses Buches selbst keine Typ-Aliase verwenden werden, zeigen wir den entsprechenden Auszug ohne weiteren Kommentar:

type Map[A, +B] = collection.immutable.Map[A, B]
type Set[A] = collection.immutable.Set[A]

Dann kommt noch ein weitere Mechanismus zur Anwendung, der mit Scala 2.8 eingeführt wurde. Im sogenannten Package Object scala befinden sich weitere Typ-Aliase. Wir werden in Kapitel 13 auf Package Objects eingeben, sodass wir diese hier einfach nur als Möglichkeit begreifen wollen, unter anderem Typ-Aliase zur Verfügung zu stellen. Da alle Member aus dem scala-Package überall sichtbar sind, können wir die dort definierten Typ-Aliase ohne Import nutzen. Hier ein kleiner Auszug:

type Seq[+A] = scala.collection.Seq[A]
type List[+A] = scala.collection.immutable.List[A]
type Vector[+A] = scala.collection.immutable.Vector[A]

Da Scala uns dazu ermutigen möchte, funktional zu Programmieren, sind diese „frei verfügbaren“ Collections allesamt unveränderlich. Als einzige Ausnahme referenziert Seq das allgemeine Collection-Package, um eine möglichst reibungslose Integration mit Arrays zu ermöglichen. Aber jedenfalls ist auch das „frei verfügbare“ Seq keine veränderliche Collection. Dennoch werden wir in den meisten Fällen stets explizit Seq aus dem Package scala.collection.immutable importieren.

Collections in ScalaTrain

Nachdem wir jetzt die wichtigsten Grundlagen über Scala-Collections kennen, wollen wir an unserem Fallbeispiel weiter arbeiten. Als nächsten Schritt werden wir unsere Züge um einen Fahrplan erweitern. Dieser soll zunächst einfach eine Collection von Bahnhöfen sein, um die Abfahrtszeiten kümmern wir uns in Kapitel 7.2.2. Also brauchen wir als erstes die Klasse Station, die wir wegen der engen Verbindung zu Train und der geringen Größe der beiden Klassen in dieselbe Quelldatei Train.scala geben. Wir verwenden wieder eine Case Class und geben einer Station den Parameter name, der nicht null sein darf. Somit haben wir:

case class Station(name: String) {
  require(name != null, "name must not be null!")
}

In der Quelldatei TrainSpec.scala schreiben wir die dazu gehörige Test-Spezifikation für Station:

class StationSpec extends Specification {
  "Creating a Station" should {
    "throw an IllegalArgumentException for a null name" in {
      Station(null) must throwA[IllegalArgumentException]
      ...

Da Züge im Rahmen eines Fahrplans die Bahnhöfe in wohldefinierter Reihenfolge anfahren, ergänzen wir die Train-Klasse um den Parameter schedule vom Typ Seq[Station]. Selbstverständlich darf auch dieser Wert nicht null sein. Weiterhin macht ein Fahrplan nur dann Sinn, wenn er aus mindestens zwei Bahnhöfen besteht. Daher müssen wir die Größe von schedule ermitteln, was wir mit der Methode size bewerkstelligen können, die es für jede Collection gibt.

case class Train(kind: String, number: String, schedule: Seq[Station]) {
  require(kind != null, "kind must not be null!")
  require(number != null, "number must not be null!")
  require(schedule != null, "schedule must not be null!")
  require(schedule.size >= 2, "schedule must have at least two stops!")
}

Nun müssen wir natürlich TrainSpec an die geänderte Signatur des Konstruktors anpassen und die neuen Preconditions überprüfen, was wir hier jedoch nicht zeigen. Vielmehr wollen wir nun in die Welt der funktionalen Programmierung eintauchen.

Aufmacherbild: Gears with binary code von Shutterstock / Urheberrecht: Bruce Rolff

Unsere Redaktion empfiehlt:

Relevante Beiträge

Meinungen zu diesem Beitrag

X
- Gib Deinen Standort ein -
- or -