Suche
Scala Schritt für Schritt: Teil 2

Scala Tutorial Teil 2: Funktionale Aspekte

Johannes Dienst

© Shutterstock.com / Aquir

Als Multiparadigmensprache besitzt Scala neben Objektorientierung auch ausgeprägte Merkmale einer funktionalen Sprache. Dadurch wird sie für viele Einsatzgebiete interessant. In diesem Tutorial lernen Sie Schritt für Schritt die Programmierung mit Scala und lernen so ihre Vorteile kennen – viel Spaß!

Das funktionale Programmierparadigma gewinnt in den letzten Jahren immer mehr Zuspruch. Scala ist von Grund auf bereits auf diese Art der Programmierung ausgelegt und wird von erfahrenen „Scalaisten“ so weit es nur geht funktional verwendet. Bevor jedoch ausgewählte Bereiche der funktionalen Programmierung besprochen werden, muss die Beispielapplikation Die Bibliothek auf neue Füße gestellt werden. Um sie aus dem Status eines Spielprojektes in den eines richtigen Projektes zu heben, benötigt es eine Umstrukturierung.
Ich werde das Simple Build Tool (sbt) vorstellen, das als konsolenbasiertes Build-Tool für Scala konzipiert ist. Die Struktur des Projekts wird nach dem Standardlayout eines sbt-Projekts umgebaut und die grundlegende Funktionsweise von Build-Skripten zusammen mit der Verwaltung von Abhängigkeiten zu Fremdbibliotheken und sbt-Plug-ins erläutert.
Dann geht es weiter mit der Implementierung einer XML-basierten Datenbank, die testgetrieben mit ScalaTest entwickelt wird. Neben der in Scala schon eingebauten XML-Unterstützung wird in diesem Zug auch das funktionale Programmierparadigma näher besprochen. Da es bei der genauen Definition von funktionaler Programmierung unterschiedliche Meinungen gibt, werde ich hier nur auf die aus meiner Sicht wichtigsten Aspekte eingehen: Unveränderlichkeit, Pattern Matching und Funktionen höherer Ordnung.

Installation von sbt und Integration in Eclipse

Unsere Bibliothek wurde als einfaches Scala-Projekt gestartet und soll immer weiter zu einer vollständigen Webapplikation ausgebaut werden. Dafür reicht die bisherige Projektstruktur nicht mehr aus. Es empfiehlt sich daher, ein Build-Tool zu integrieren. Für Scala steht mit dem Simple Build Tool (sbt) eine konsolenbasierte Variante bereit, die flexibel konfiguriert werden kann. Das Dependency-Management wird von Apache Ivy übernommen. Dadurch kann es sich zum Beispiel auch aus Maven-Repositories mit Abhängigkeiten versorgen.
Die Installation von sbt ist verhältnismäßig einfach. Unter Windows erledigt der Installer die nötige Arbeit. Er kann von der Homepage unter Downloads heruntergeladen werden. Unter Linux stehen für die gängigsten Linux-Ableger entsprechende Repositories zur Verfügung.

Nach erfolgreicher Installation kann unser Projekt Schritt für Schritt auf die neue Struktur umgebaut werden. Das hätte man zwar auch schon im ersten Teil des Tutorials machen können, aber dann wäre der Spaß und der Lerneffekt, der durch den Umbau entstanden wäre, nicht zur Geltung gekommen. Als Erstes muss eine Datei build.properties auf höchster Ebene des Projekts erstellt werden, die lediglich eine Zeile enthält: sbt.version=0.13.13. Als Nächstes geht es an das eigentliche Build-Skript build.sbt, ebenfalls auf höchster Ebene:

import Dependencies._

lazy val root = (project in file(".")).
  settings(
    inThisBuild(List( //Zeile 5
      organization := "de.bookstore",
      scalaVersion := "2.11.8",
      version      := "0.1.0-SNAPSHOT"
    )),
    name := "Scala_JaxEnter_Tutorial",
    logBuffered in Test := false,
    libraryDependencies ++= testDeps
  )

Als Erstes fällt auf, dass es sich bei der Syntax um Scala handelt, da mit den Schlüsselwörtern import und val gearbeitet wird. Das eröffnet interessante Möglichkeiten und führt dazu, dass man keine eigene Syntax für Build-Skripte erlernen muss. Gehen wir das Build-Skript Zeile für Zeile durch. Zeile 1 importiert den Inhalt der Datei Dependencies.scala aus dem Ordner project in unserem Projekt. Dort werden in einem Scala-Object alle Abhängigkeiten definiert, die gebraucht werden. Der genaue Inhalt wird in einem der nächsten Abschnitte beschrieben, denn zum jetzigen Zeitpunkt gibt es den Ordner project noch nicht, da er automatisch von sbt bei der Initialisierung des Projekts erstellt wird.

In Zeile drei wird festgelegt, dass sich das Projekt auf der gleichen Ebene befindet wie das Build-Skript. Ab Zeile 5 finden sich einige der sogenannten Projekt-Metadaten (dazu hier mehr). So wird der Reverse-Domain-Name de.bookstore festgelegt, die Scala-Version auf 2.11.8 und die Version der Bibliothek auf 0.1.0-SNAPSHOT gesetzt. An dieser Stelle scheint auch durch, dass Maven teilweise als Vorlage dient. Anschließend kommen ab Zeile 10 noch ein paar weitere Einstellungen hinzu. Der Name wird auf Scala_JaxEnter_Tutorial gesetzt. Die letzten beiden Einstellungen sind für ScalaTest notwendig. Der Logbuffer wird ausgeschaltet und das Value testDeps, das wir über den Import aus der ersten Zeile bekommen haben, an die globalen libraryDependencies angehängt. Beim Value testDeps handelt es sich um eine Sequenz, wie wir gleich noch sehen werden.

Wechselt man in der Konsole in den Projektordner, kann mit dem Befehl sbt das Projekt initialisiert werden. Dabei wird dann auch der Ordner project erstellt. Falls sich nach der Eingabe des Befehls erst einmal eine ganze Weile nichts tut, ist das normal. Irgendwann sieht man in der Konsole, dass sbt im Hintergrund seine Arbeit verrichtet. Am Ende sollte eine Fehlermeldung erscheinen, denn es fehlen noch ein paar Dinge, damit sbt genutzt werden kann. Als Erstes will sbt in Eclipse integriert werden. Dafür nutzen wir das sbteclipse-Plug-in. Das Management von sbt-Plug-ins ist sehr einfach. Für lokale Plug-ins legt man dazu im gerade erstellten project-Ordner eine Datei plugins.sbt an. Dieser werden sowohl das sbteclipse-Plug-in hinzugefügt als auch die Supersafe Community Edition des Scala-Compilers, der für ScalaTest empfohlen wird:

addSbtPlugin("com.typesafe.sbteclipse" % "sbteclipse-plugin" % "5.0.1")
addSbtPlugin("com.artima.supersafe" % "sbtplugin" % "1.1.0")

Die erwähnte Dependencies.scala im Ordner project steht noch aus. Sie hat folgenden Inhalt:

import sbt._

object Dependencies {
  lazy val scalaTest = "org.scalactic" %% "scalactic" % "3.0.1"
  lazy val scalactic = "org.scalatest" %% "scalatest" % "3.0.1"

  val testDeps = Seq(scalaTest, scalactic)
}

Hier sieht man, woher das Value testDeps stammt, das in der build.sbt in der ersten Zeile importiert wird. Eine Kleinigkeit fehlt aber noch zum Glück. Für den Supersafe-Compiler muss noch ein Repository hinzugefügt werden. Diese Information muss global für sbt definiert werden und liegt systemunabhängig in der Datei <Benutzername>/.sbt/0.13/global.sbt. Ist diese noch nicht vorhanden, kann man sie einfach erstellen und mit einem Eintrag für das fehlende Repository füllen:

resolvers += "Artima Maven Repository" at "http://repo.artima.com/releases"

Befindet man sich in der Konsole im Projektordner seines Eclipse-Projekts, dann sollten noch die Dateien .project und .classpath gelöscht werden. Diese werden von sbteclipse neu erstellt. Anschließend startet der Befehl sbteclipse die Erzeugung eines Eclipse-Projekts. Die Ausgabe sollte dann bei Erfolg so enden (Linux):

…
[info] 	[SUCCESSFUL ] jline#jline;2.14.1!jline.jar(doc) (64ms)
[info] Successfully created Eclipse project files for project(s):
[info] Scala_JAXEnter_Tutorial
>

Hier befindet man sich dann auch gleich im interaktiven Modus von sbt, der bevorzugt verwendet werden sollte. Mit der Eingabe von help und einer anschließenden Bestätigung mit Enter zeigt die Ausgabe die Kommandos, die dort möglich sind. Anschließend erstellt man noch die Ordner src/{main, test}/scala und src/{main, test}/resources, verschiebt die bereits vorhandenen Scala-Klassen in src/main/scala und verlinkt die Ressourcen in Eclipse. Die Struktur des Projekts sollte dann dem nächsten Screenshot ähneln.

Ein erster Test mit ScalaTest

ScalaTest ist ein mächtiges Framework, das einer eigenen Artikelserie würdig ist. Für unsere Zwecke reicht ein Bruchteil der Funktionalität aber aus, um sinnvolle Unit-Tests zu schreiben. Das Konzept von ScalaTest beruht auf einer Reihe von Traits, die beliebig kombiniert werden können. Im User Guide wird empfohlen, abstrakte Basisklassen zu implementieren, die von den entsprechenden Traits erben. Listing 1 zeigt unsere Basisklasse UnitTest, die den Trait FlatSpec implementiert.

Listing 1 – Basisklasse UnitTest

import org.scalatest._

class UnitTest(component: String) extends FlatSpec {}

Der Trait FlatSpec lässt uns die Testbeispiele verhaltensgetrieben erstellen. Die Basisklasse wird herangezogen, um einen ersten Test unserer Book-Klasse zu erstellen, der die richtige Ausgabe der Methode getDigit überprüft (siehe Listing 2).

Listing 2 – Test der Methode getDigit

  "A Book" should "print the right digit" in {
    val book = new Book("Clean Code", "Uncle Bob", 3826655486L)
    assert(book.getDigit === "6")
  }
}

Um den Test auszuführen, starten wir den interaktiven Modus von sbt und führen dort das Kommando test aus. Die Ausgabe zeigt, dass der Test erfolgreich durchlaufen wurde:

>test
...
[info] Book_Test:
[info] A Book
[info] - should print the right digit
[info] Run completed in 174 milliseconds.
[info] Total number of tests run: 1
[info] Suites: completed 1, aborted 0
[info] Tests: succeeded 1, failed 0, canceled 0, ignored 0, pending 0
[info] All tests passed.
[success] Total time: 6 s, completed 23.01.2017 11:13:14

Die Datenbank als XML

Die Bibliothek bezieht ihre Daten aus einem Objekt, das den allgemein gehaltenen Trait Database implementiert und dadurch verschiedene Technologien als Grundlage besitzen kann. Der Einfachheit halber legen wir die Datenbank als XML-Datei an. Diese ist unter src/main/resources/database.xml beheimatet. Der Inhalt ist in Listing 3 zu sehen. Für die Tests legen wir sie auch gleich noch unter src/test/resources/database.xml ab, damit stabile Testdaten vorliegen:

Listing 3 – Datenbank im XML-Format

<books>
  <book>
    <title>Clean Code</title>
    <author>Uncle Bob</author>
    <isbn10>1234567890</isbn10>
  </book>
  <book>
    <title>Clean Coder</title>
    <author>Robert Cecil Martin</author>
    <isbn10>0137081073</isbn10>
  </book>
  <book>
    <title>Code Complete 2</title>
    <author>Steve McConnell</author>
    <isbn10>0735619670</isbn10>
  </book>
  <book>
    <title>The Pragmatic Programmer</title>
    <author>Andrew Hunt</author>
    <isbn10>3446223096</isbn10>
    <formats>epub</formats>
  </book>
  <book>
    <title>The Mythical Man-Month</title>
    <author>Frederik P. Brooks</author>
    <isbn10>0201835959</isbn10>
    <formats>epub,pdf</formats>
  </book>
</books>

Scala bietet native Unterstützung für XML. Es kann dadurch mit XML-Elementen ohne zusätzliche Bibliotheken umgehen und diese z. B. mit einer an XPath angelehnten Syntax durchsuchen. Diese Möglichkeit machen wir uns zunutze, um Objekte vom Typ Book/EBook zu serialisieren oder zu deserialisieren. Listing 4 zeigt die zusätzlichen Tests, die dafür zuerst in der Klasse Book_Test implementiert wurden.

Listing 4 – Tests für Serialisierung/Deseralisierung von Book/Ebook

"A Book" should "output a valid XML" in {
  val book = new Book("Clean Code", "Uncle Bob", 3826655486L)
  val bookXML: Elem = book.toXml
  val title: NodeSeq = bookXML \\ "title"
  val author = bookXML \\ "author"
  val isbn10 = bookXML \\ "isbn10"
  assert(title.text === "Clean Code")
  assert(author.text === "Uncle Bob")
  assert(isbn10.text === "3826655486")
}

"A Book" should "should be deserialisable from a valid XML" in {
  val book = new Book("Clean Code", "Uncle Bob", 3826655486L)
  val bookXml = "<book>" + 
          "<title>Clean Code</title>" +
          "<author>Uncle Bob</author>" +
          "<isbn10>3826655486</isbn10>" +
      "</book>"
  val elem = scala.xml.XML.loadString(bookXml)
  assert(Book.fromXml(elem).toString() === book.toString())
}

Hier sieht man im ersten Test, dass die Methode toXml aus Book ein Objekt vom Typ Elem zurückliefert. Auf diesem wird dann in der nächsten Zeile, ähnlich wie mit XPath, eine Liste aller Knoten mit dem Tag title herausgefiltert. Da immer nur ein Element mit dem jeweiligen Tag vorhanden ist, reicht es beim anschließenden assert, den Text als String zu extrahieren.
Im zweiten Test wird zuerst ein vollständiges book-Element über das von Scala bereitgestellte XML-Object mit XML.loadString(String) erstellt. Anschließend soll die Methode Book.fromXml(Elem) ein Objekt vom Typ Book zurückliefern, das die gleichen Attribute besitzt.
Mit den Tests im Gepäck kann es an die Implementierung von toXml in der Klasse Book und fromXml(scala.xml.Node) im Companion-Object gehen. In Listing 5 widmen wir uns zuerst der Letzteren, die wenig spektakulär wieder mit der XPath-ähnlichen Syntax die nötigen Attribute aus einem Node extrahiert und daraus ein neues Objekt vom Typ Book erstellt.

Listing 5 – Deseralisierung eines Book

def fromXml(node: scala.xml.Node): Book = {
  val title = (node \ "title").text
  val author = (node \ "author").text
  val isbn10 = (node \ "isbn10").text.toLong
  new Book(title, author, isbn10)
}

Die Serialisierung in Listing 6 mit toXml sieht zuerst unauffällig aus, enthält aber gleich zwei interessante Aspekte. Der Erste ist, dass XML-Elemente nativ als Ausdruck geschrieben werden können. Es ist kein Aufruf eines Konstruktors oder einer Transformation über XML.loadString(…) nötig, wie weiter oben im Test. Zum Zweiten wird hier eine Technik namens String-Interpolation verwendet. Die in geschweiften Klammern geschriebenen Klassenvariablen title, author und isbn10 werden dynamisch bei der Auswertung des Ausdrucks ersetzt. Wer sich vor der Erklärung von String-Interpolation noch weiter über die Serialisierung/Deserialisierung von XML informieren will, findet hier einen guten Einstiegspunkt.

Listing 6 – Serialisierung eines Book

def toXml = {
    <book>
        <title>{title}</title>
        <author>{author}</author>
        <isbn10>{isbn10}</isbn10>
    </book>
}

Die String-Interpolation eröffnet neue Möglichkeiten, Strings dynamisch zu erzeugen. Als ein Beispiel hält die Methode toString der Klasse EBook her, die wir naiv so implementieren könnten: override def toString = title + „;“ + author + „;“ + isbn10 + „;“ + formats. Es gibt aber eine ausdrucksstärkere Methode, um das gleiche Ergebnis zu erreichen, indem wir den String-Interpolator s benutzen. Bei s handelt es sich tatsächlich um eine Methode, die unter anderem Variablen in einem String am vorangestellten $ erkennt und durch ihren Wert ersetzt. Das sieht dann so aus:

// Prints for example: Learning Scala;John;-1;List()
override def toString = s"$title;$author;$isbn10;$formats"

Abgesehen von unserem konkreten Beispiel der toString-Methode gibt es noch den String-Interpolator f, der sich wie printf in anderen Sprachen verhält:

val price = 200.0
println(f"Price: $price") // Prints: Price: 200.0
println(f"Price: $price%.0f") // Prints: 200

Erwähnenswert ist auch noch raw, der kein Escaping vornimmt:

println(raw"no line\nbreak")

Außerdem gibt es noch die Möglichkeit, eigene Interpolatoren zu implementieren. Ein paar anschauliche Beispiele, wie so etwas aussehen kann, gibt es hier.

Laden und Speichern der Datenbank

Nachdem die Grundbausteine für das Laden und wieder Abspeichern unserer Datenbank im vorherigen Abschnitt gelegt wurden, geht es jetzt ans Eingemachte. Da zuerst noch keine Zugriffsmethoden implementiert werden sollen, entfällt die Implementierung von Tests. Stattdessen wird die Klasse DatabaseXML (Listing 6) angelegt, die einen Parameter dbPath erhält, der den Pfad zur Datenbank enthält. Die Klasse erweitert zudem den Trait Database, der etwas angepasst wurde, damit er auch den tatsächlichen Anwendungsfall abdeckt (siehe dazu Listing 7).

Listing 7 – DatabaseXML

class DatabaseXML(dbPath: String = "src/main/resources/database.xml")
  extends Database {

  val books: ListBuffer[Book] = readFromFile()

  private def readFromFile() = {
    val root: Elem = XML.loadFile(dbPath)
    val books = ListBuffer[Book]()
    for (n <- (root \\ "book") if (!(n \ "formats").isEmpty)) {
      books += EBook.fromXml(n)
    }
    for (n <- (root \\ "book") if ((n \ "formats").isEmpty)) {
      books += Book.fromXml(n)
    }
    books
  }

  def update(book: Book) = {
    this.books += book
  }

  def save(filePath: String = "src/main/resources/database.xml" ) = {
    val root = <books>{books.map(b => b.toXml)}</books>
    XML.save(filePath, root)
  }
}

Listing 8 – Angepasster Trait Database

trait Database {
  def save(filePath: String)
  def update(book: Book)
  def findBooks(title: String, bookType: String): List[Book]
}

In Listing 6 sehen wir die Klassenvariable books, die alle aus der Datenbank ausgelesenen Books/EBooks enthält. Die Methode readFromFile() liest die übergebene XML-Datei über XML.loadFile(dbPath) als Elem aus, das wieder mit der XPath-ähnlichen Syntax analysiert werden kann. In den beiden for-Schleifen wird zweimal über alle XML-Elemente book iteriert und jeweils mit der sogenannten for-Comprehension gefiltert. So wird ein EBook deserialisiert, wenn es ein Element formats gibt, ansonsten ein Book. Die Vorgehensweise ist eher imperativ als funktional. Bei der Methode findBooks(…) werden wir bessere Techniken kennenlernen, um das Ergebnis in kompakteren Code zu erreichen.

Die Methode update(Book) macht nichts Weiteres, als dem ListBuffer books ein neues Objekt vom Typ Book anzuhängen. Die Methode save(String), die die Datenbank in eine Datei schreibt, ist kurz und verwendet zum einen die String-Interpolation, um das root-Element mit book-Elementen zu füllen. Hier sieht man, dass statt Variablenersetzung auch die Ausführung von Ausdrücken möglich ist. Zum Anderen wird auch eine erste Anwendung von einer Funktion höherer Ordnung verwendet. Funktionen höherer Ordnung nehmen als Parameter selbst wieder Funktionsobjekte entgegen. In diesem Fall erwartet die Funktion map(Book => B), dass ihr eine Funktion übergeben wird, die ein Objekt vom Typen Book als Parameter bekommt und einen beliebigen Rückgabewert B hat. Wir bedienen das, indem wir eine anonyme Funktion b => b.toXml übergeben. Damit ergibt die Anwendung eine Liste von Objekten des Typs Elem, die dann in den finalen String eingebaut wird. Schon ist aus der Klassenvariable ein XML-String erzeugt.

Zugriffsmethoden

Im Moment liegen die Daten als ListBuffer in unserer Datenbank. Im nächsten Schritt soll darauf eine Suchmethode realisiert werden, die auf eine funktionale Art implementiert wird.
Da es im Allgemeinen keine scharfe Definition von funktionaler Programmierung gibt, wird sich dabei auf die wichtigsten Konzepte bezogen: Unveränderlichkeit, Pattern Matching und Funktionen höherer Ordnung.

Zum Start wird eine neue Klasse DatabaseXML_Test angelegt, die genau einen Test enthält:

"A Database" should "output all books" in {
  val db = new DatabaseXML("src/test/resources/database.xml")
  assert(db.findBooks().size === 5)
}

Die Methode, die den Test grün werden lässt, sieht so aus:

def findBooks(title: String = "", bookType: String = ""): List[Book] = {
  var retVar = books.clone()
  retVar.toList
}

Es fehlt natürlich noch Funktionalität, sie zeigt aber schon Dinge, die man sich schnell angewöhnt, wenn man mit Scala arbeitet. Zuerst wird eine Kopie der Klassenvariable gemacht, mit der die Methode arbeitet. Am Ende wird ein Objekt des Typs List zurückgegeben. Das Konzept der Unveränderlichkeit ist in Scala die implizite Voreinstellung. So wird grundsätzlich immer der Typ List beim Anlegen von Listen verwendet und nur durch expliziten Import von ListBuffer kann eine veränderliche Liste erzeugt werden. Das heißt, dass diese Entscheidung bewusst vom Entwickler getroffen wird und er sich darüber deswegen automatisch Gedanken macht. Jetzt hauchen wir der Methode findBooks(title: String, bookType: String) Leben ein, indem wir die Funktionalität spezifizieren, die erwartet wird:

"A Database" should "output all non ebooks" in {
  val db = new DatabaseXML("src/test/resources/database.xml")
  assert(db.findBooks(bookType = "book").size === 3)
}

"A Database" should "output all ebooks" in {
  val db = new DatabaseXML("src/test/resources/database.xml")
  assert(db.findBooks(bookType = "ebook").size === 2)
}

"A Database" should "find all books with 'Clean Coder' in title" in {
  val db = new DatabaseXML("src/test/resources/database.xml")
  assert(db.findBooks(title = "Clean Coder")(0).title === "Clean Coder")
}

Um die Liste retVar nach einem Titel zu filtern, wenden wir eine Technik an, die man manchmal auch als Switch-Statement on steroids bezeichnet: Pattern Matching. Pattern Matching kann man als Switch-Statement sehen, das nicht nur auf Integern funktioniert. Man kann auf beliebige Typen matchen und darüber hinaus sogar auf strukturelle Eigenschaften. Letzteres machen wir uns später zunutze. Zuerst wollen wir alle Bücher finden, deren Titel den Parameter title enthalten. Wurde kein title angegeben (leerer String), dann sollen einfach alle zurückgegeben werden. Ein Pattern-Matching-Ausdruck, der diese Eigenschaften erfüllt, sieht so aus:

def titleMatch(title: String): ListBuffer[Book] = title match {
  case "" => retVar
  case _ => retVar.filter(book => book.title.contains(title))
}

In der ersten Zeile definieren wir die Funktion titleMatch(title: String), die den gesuchten Titel übergeben bekommt und einen ListBuffer[Book] mit dem Ergebnis zurückliefert. Das eigentliche Pattern Matching passiert nach dem =. Es wird auf den übergebenen Parameter title gematcht und falls dieser leer ist, dann geben wir retVar unverändert zurück. Ansonsten filtern wir den ListBuffer nach Objekten vom Typ Book, deren Titel den Parameter title enthält. Die Methode filter ist wie map eine Funktion höherer Ordnung, da sie für das Filtern wieder eine Funktion übergeben bekommt. Die übergebene anonyme Funktion wird auf jedes Objekt des ListBuffers retVar angewendet. Liefert sie true, dann bleibt das Objekt im ListBuffer, bei false wird es aussortiert. Um das Ergebnis herzustellen, ruft man dann titleMatch(title: String) einfach auf:

retVar = titleMatch(title)

Ähnlich sieht auch die Filterung nach Buchtyp aus. Hier wurde am Ende mit case _ auf alle Strings gematcht, die weder den Wert book noch den Wert ebook besitzen. Die Wildcard _ steht hier, wie bei Scala üblich, für ein beliebiges Objekt:

def bookTypeMatch(bookType: String): ListBuffer[Book] = bookType match {
  case "book" => retVar.filter(book => !book.isInstanceOf[EBook])
  case "ebook" => retVar.filter(book => book.isInstanceOf[EBook])
  case _ => retVar
}

Mit dem Einbau von retVar = bookTypeMatch(bookType) ist unsere Funktion findBooks(String: title, String: bookType) fertig.

Strukturelles Pattern Matching

Im letzten Abschnitt wurde gezeigt, wie mächtig Pattern Matching sein kann. Tatsächlich kann man die Funktion von oben noch weiter verkürzen, durch strukturelles Pattern Matching. Dazu brauchen wir erneut einen Test:

"A Database" should "output a list with 1 element for title 'Clean Coder' and bookType 'book" in {
  val db = new DatabaseXML("src/test/resources/database.xml")
  val books = db.findBooksAdvanced("Clean Coder", "book")
  assert(books.length === 1)
}

Außerdem sollte das Konzept von Case-Klassen erläutert werden. Case-Klassen sind spezielle Klassen, die wie in Listing 8 definiert werden. Sie sind per Definition unveränderlich, werden aufgrund ihrer Struktur verglichen und ihre Klassenvariablen sind öffentlich (umfassende Erläuterung hier).

Listing 8 – Zwei Case-Klassen mit Oberklasse Person

abstract class Person()
case class Programmer(name: String, coffeinated: Boolean) extends Person
case class Accountant(name: String, coffeinated: Boolean) extends Person

Durch ihre Besonderheiten können sie beim Pattern Matching auf ihre Struktur verglichen werden. Das Beispiel in Listing 9 gibt auf der Konsole für alle Objekte vom Typ Programmer, die kein Koffein in sich haben, eine Warnmeldung aus. Während für Objekte vom Typ Accountant die Meldung ausgegeben wird, dass alles in Ordnung ist.

Listing 9 – Beispiel strukturelles Pattern Matching

def printNotification(person: Person) = person match {
  case (p @ Programmer(_, false)) => println(s"${p.name} you need more coffee!")
  case other => println("Everything is fine")
}

val programmer = Programmer("John Doe", false)
val accountant = Accountant("Jane Dane", false)

printNotification(programmer) // John Doe you need more coffee
printNotification(accountant) // Everything is fine

Wie funktioniert das Ganze? Das Matching basiert auf einem Objekt des Typs Person, wie man in der ersten Zeile erkennen kann. Im Falle eines Objekts vom Typ Programmer, dessen Wert von coffeinated false ist und dessen Wert des ersten Arguments egal ist (erkennbar am _) trifft das erste case-Statement zu. Um das gematchte Objekt auf der rechten Seite des Statements zur Verfügung zu haben, wird noch ein Alias p erstellt, indem p gefolgt von einem @ eingefügt wird. Deswegen kann der Funktion println dann mit p.name die Variable name des gematchten Objekts übergeben werden. Zurück zu unserem Beispiel: Hier wollen wir das strukturelle Pattern Matching auf die Klasse Book anwenden und zusätzlich zum Titel auch die Filterung auf den Buchtyp in das Matching einbauen. Listing 10 zeigt den dazugehörigen Ausdruck.

Listing 10 – Strukturelles Pattern Matching auf Titel und Buchtyp

def aliasMatchTitleAuthor(book: Book, title: String, booktype: String): Book =
 (book, booktype) match {
  case ((Book(`title`, _)), "book") => { if (!book.isInstanceOf[EBook]) book else null }
  case ((Book(`title`, _)), "ebook") => { if (book.isInstanceOf[EBook]) book else null }
  case _ => null
}

Das Matching basiert hier auf zwei Variablen. Dem übergebenen Objekt vom Typ Book und dem Parameter bookType. Entsprechend sehen auch die case-Statements aus, die ein Tupel (Book, String) darstellen. Da wir auf den Titel matchen wollen, wird dieser strukturell adressiert. Da title bereits als Parameter in der Methodensignatur steht, muss er in Backticks gesetzt werden. Als Zweites matchen wir auf den Parameter bookType. Die rechte Seite der case-Statements stellen jeweils einen Ausdruck dar. Dieser gibt, je nachdem ob das Objekt den richtigen Typ hat (zum Beispiel Book), das Objekt zurück, oder eben null. Das stellt die Filterung auf den richtigen Typ sicher.

Es gibt aber ein Problem mit diesem Code: Strukturelles Pattern Matching funktioniert nur mit Case-Klassen. Da Book keine Case-Klasse ist, meckert der Compiler. Zum Glück können normale Objekte einer Klasse zur Laufzeit in Case-Klassen konvertiert werden. Scala erwartet dafür im Companion-Object von Book eine Methode unapply(book: Book):

def unapply(book: Book) = Some(book.title, book.isbn10)

Bei der Rückgabe der Instanz der Case-Klasse Some handelt es sich um einen Kniff, damit unapply(book: Book) eine Instanz einer Case-Klasse zurückliefert. Eine genaue Betrachtung dieser Klasse und welche besondere Funktion sie in Scala hat, wird im nächsten Teil des Tutorials behandelt. Um die Methode fertigzustellen, wird noch eine partiell ausgewertete Version des Matchings aliasMatchTitleAuthor erstellt. Bei dieser ist nur der Parameter book variabel. Der Parameter title und bookType werden auf die Werte festgezurrt, die der Methode übergeben worden sind:

val aliasMatchPartial = aliasMatchTitleAuthor(_: Book, title, bookType)

Das dient dazu, dass aliasMatchPartial als Funktion an map von retVar übergeben werden kann. Da es dabei auch zu null-Einträgen kommen kann, werden diese anschließend herausgefiltert:

retVar = retVar.map(aliasMatchPartial)
               .filter(b => b != null)

Fazit

Der zweite Teil des Tutorials beschäftigte sich zu Anfang mit dem grundlegenden Aufbau eines Scala-Projektes. Dabei wurde das Simple Build Tool (sbt) als Build-Tool vorgestellt. Für das Dependency Management benutzt es intern Apache Ivy. Dadurch kann es auf verschiedene Arten Abhängigkeiten auflösen, zum Beispiel auch aus Maven-Repositories. Durch die konsolenbasierte Bedienung entsteht das bei funktionalen Sprachen übliche Entwicklungsgefühl. Nach dem Umbau der Bibliothek auf die neue Projektstruktur, die sehr einem Aufbau nach Maven-Standard-Layout aussieht, wurde die Syntax der Build-Dateien besprochen. Bei dieser handelt es sich im Grunde genommen um Scala. Fremdbibliotheken werden über eine globale Variable definiert. Der Aufbau einer Build-Datei kann durch die Verwendung von Scala modular gestaltet werden, was anhand der Einbindung der Bibliothek ScalaTest gezeigt wurde. Auch sbt-Plug-ins lassen sich über einen Eintrag in eine Datei problemlos hinzufügen, was am Beispiel des sbteclipse-Plug-ins anschaulich erklärt wurde.

Im Zusammenspiel mit dem mächtigen Testframework ScalaTest ging es dann an die Implementierung der Datenbankfunktionalität der Bibliothek. Es wurde dabei auf testgetriebene Entwicklung mit dem Paradigma BDD gesetzt, das ScalaTest durch einen Trait FlatSpec unterstützt. ScalaTest empfiehlt dabei Basisklassen zu definieren, welche die vom Framework bereitgestellten Traits implementieren.

Anschließend wurden eine Reihe von Tests definiert und mit diesen spezifiziert, wie Objekte vom Typ Book zu XML serialisiert und deserialisiert werden sollen. Da Scala XML nativ unterstützt und eine stark an XPath angelehnte Abfragesprache besitzt, konnte das ganz ohne Fremdbibliotheken vonstatten gehen. Im Zuge der Implementierung kamen die von Scala bereitgestellten Klassen Elem, NodeSeq und das Object XML zum Einsatz, das unter anderem XML aus Dateien auslesen kann. Um XML zu serialisieren kann String-Interpolation verwendet werden. String-Interpolation wird benutzt, um Variablen und Ausdrücke in Strings zu interpretieren.

Zum Abschluss wurden die wichtigsten Eigenschaften der funktionalen Programmierung vorgestellt, die in Scala angeboten werden. Anhand der Datenbankklasse DatabaseXML wurde gezeigt, welche Vorteile die Unveränderlichkeit von Objekten bietet und wie Scala diese Eigenschaft zum Standard macht. Um die weiteren Vorteile einer funktionalen Vorgehensweise deutlich zu machen, habe ich mich verschiedenen Methoden der Datenbank und Problemen von der imperativen Seite mit for-Comprehension genähert. Schrittweise über Funktionen höherer Ordnung, die als Parameter selbst wieder Funktionen erwarten, ging es dann zum Pattern Matching. Das ist eine Methode, die gerne als Switch-Statement on steroids bezeichnet wird, da sie auf beliebige Typen matchen kann. In diesem Zuge wurde zuerst ein einfaches Matching geschrieben, das nur auf einen Parameter prüft. Im nächsten Schritt wurden Case-Klassen vorgestellt, die speziell für das strukturelle Pattern Matching gedacht sind. Mit Ihnen ist es möglich, ein Objekt auf die Werte von bestimmten Klassenvariablen zu matchen. Zum Abschluss wurden die gesammelten Erfahrungen dazu benutzt eine (fast rein) funktionale Methode zum Auffinden von Büchern nach Titel und Buchtyp zu implementieren.

Das Projekt ist auf GitHub verfügbar. Mit git checkout tags/part-two erhält man den abschließenden Stand dieses Tutorials.

Verwandte Themen:

Geschrieben von
Johannes Dienst
Johannes Dienst
Johannes Dienst ist Clean Coder aus Leidenschaft bei der MULTA MEDIO Informationssysteme AG. Seine Tätigkeitsschwerpunkte sind die Wartung und Gestaltung von serverseitigen Java, Python und JavaScript-Applikationen. Twitter: @JohannesDienst
Kommentare

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht.