Scala Pattern Matching am Beispiel des Bowlinggame-Katas

07.03.2018

Alle zwei Wochen treffen sich einige //er zum Coding Dojo. Dort lösen wir alle 2 Wochen gemeinsam Katas und andere Aufgaben, um voneinander zu lernen und uns stetig zu verbessern. Bei einem solchen Treffen ist die Idee für diesen Blogbeitrag entstanden. Wir haben zusammen das Bowlinggame-Kata in verschiedenen Sprachen (Java, Scala und Kotlin) gelöst. Die Lösung in Scala ist am kürzesten und unterscheidet sich am meisten im Vergleich zu den anderen beiden. Deshalb stellt dieser Beitrag unsere gefundene Scala-Lösung und das Scala Pattern Matching vor.

Das Scala Pattern Matching

Das Pattern Matching von Scala wird auf der offiziellen Homepage folgendermaßen beschrieben:

Pattern matching is a mechanism for checking a value against a pattern. A successful match can also deconstruct a value into its constituent parts. It is a more powerful version of the switch statement in Java and it can likewise be used in place of a series of if/else statements.

Vergleich mit der Switch-Anweisung in Java

Was macht das Pattern Matching  „more powerful“ als die Switch-Anweisung in Java? Im Gegensatz zur Switch-Anweisung in Java handelt es sich beim Scala Pattern Matching um einen Ausdruck (expression) und nicht um eine Anweisung (statement). Somit kann das Pattern Matching im Gegensatz zur Switch-Anweisung Werte zurückgeben. Dadurch ist es nicht mehr nötig, das Ergebnis in einer Zwischenvariablen abzulegen. Außerdem unterstützt das Pattern Matching kein fall-through, wodurch kein extra Keyword am Ende von jedem Fall benötigt wird. Bei der Switch-Anweisung wird dafür das Keywort break verwendet. Für den Standardfall gibt es beim Scala Pattern Matching ebenfalls kein eigenes Keyword – default in Java – und wird mit dem folgenden Fall abgehandelt: „case _“. Der Unterstrich ist ein Wildcard und somit gilt dieser Fall für jegliche Eingabewerte. Ist dieser Fall nicht angegeben und es wird ein Eingabewert ohne passenden Fall übergeben, so wird ein MatchError geworfen.

Eine limitierende Eigenschaft der Switch-Anweisung ist die eingeschränkte Typenunterstützung. Es werden lediglich primitive Datentypen, Strings, Enums sowie bestimmte Wrappertypen (bspw. Byte, Character,…)) unterstützt. Dadurch werden die Verwendungszwecke der Switch-Anweisung extrem eingeschränkt. Umgangssprachlich könnte man sagen, dass das Pattern Matching bei diesen Typen erst warm wird, da es beliebige Typen unterstützt.

 

Anwendung des Pattern Matchings

case class Person(firstName: String, lastName: String)
class Pet(name: String) {
  def getName = name
}

def matcher(x: Any) = x match {
  case 1 => println("match 1")
  case "match" => println("match string")
  case Person(_, "Mustermann") => println("Lastname Mustermann")
  case Person(firstName, _) => println("Person with name: " + firstName)
  case p: Pet if p.getName == "Charlie" => println("Found Charlie")
  case p: Pet => println("Pet with name: " + p.getName)
  case _ => println("Nothing")
}

matcher(1)  					// match 1
matcher("match") 				// match string
matcher(Person("Tom", "Double")) 		// Person with name: Tom
matcher(Person("Max", "Mustermann")) 		// Lastname Mustermann
matcher(new Pet("Snoopy")) 			// Pet with name: Snoopy
matcher(new Pet("Charlie")) 			// Found Charlie

Die rudimentärsten Funktionen des Pattern Matching sind die Möglichkeiten, den Eingabewert auf einen konkreten Wert oder den Datentyp zu überprüfen. Diese Funktionalität wird in dem Beispiel 1 in Zeile 7, 8 und 12 beispielhaft dargestellt.
Eine weitere wichtige Eigenschaft des Pattern-Matchings wird im zweiten Satz der Erklärung der Scala-Homepage genannt:

A successful match can also deconstruct a value into its constituent parts“.

Diese Eigenschaft ist besonders in Verbindung mit „case classes“ oder unveränderlichen Listen nützlich. Beispielsweise wird bei „case classes“ eine ähnliche – in der Funktion erweiterte – Syntax verwendet wie bei der Erstellung einer Instanz der Klasse. Dies wird in den Zeilen 9 und 10 von Beispiel 1 dargestellt.
Die erweiterte Syntax ermöglicht, anstatt den Datentyp zu prüfen und über das Objekt auf Felder zuzugreifen, die Werte der Felder in temporäre Variablen zu schreiben. Diese temporären Variablen können ausschließlich im zutreffenden Fall verwendet werden.
Alternativ gibt es auch die Möglichkeit, einen konkreten Wert zu hinterlegen und damit den Wert nicht in eine temporäre Variable abzulegen. Das ermöglicht es, den Fall weiter einzuschränken. In Zeile 9 werden durch dieses Vorgehen alle Personen mit dem Nachnamen „Mustermann“ herausgefiltert.
Als dritte Möglichkeit kann ein Unterstrich angegeben werden. Dieser dient als Wildcard, somit wird keine Einschränkung vorgenommen und der Wert wird in keine temporäre Variable abgelegt.

Um einen Fall im Pattern Matching noch weiter einzuschränken gibt es die Möglichkeit, einen Guard zu verwenden. Ein Guard ist nichts anderes als ein if, das einen booleschen Ausdruck auswertet. Dieser boolesche Ausdruck kann ebenfalls aus vielen booleschen Ausdrücken bestehen. Mit einem Guard wird in Zeile 11 das Tier mit dem Namen „Charlie“ herausgefiltert.

Beispiel 1 zeigt in der Funktion matcher(x: Any) die Syntax des Pattern Matchings. Hier erkennt man, dass immer zuerst die spezifischeren Fälle vor den weniger spezifischen Fällen stehen. Das ist notwendig, da das Pattern Matching immer den ersten zutreffenden Fall ausführt.

 

Pattern Matching mit Listen­

def matcher(x: List[Int]) = x match {
  case List(1,2,3) => println("Match 1,2,3")
  case 2 :: rest => println("Match 2 at the beginnig and rest: " + rest)
  case 3 :: 4 :: _ => println("Match 3,4 at the beginning")
  case _ => println("Nothing")
}

matcher(List(1,2,3)) 		// Match 1,2,3
matcher(1 :: 2 :: 3 :: Nil) 	// Match 1,2,3
matcher(2 :: 3 :: 4 :: Nil) 	// Match 2 at the beginnig and rest: List(3, 4)
matcher(3 :: 4 :: 5 :: Nil)	// Match 3,4 at the beginning
matcher(4 :: Nil) 		// Nothing

Wie bereits erwähnt, unterstützt das Pattern Matching unveränderliche Listen sehr umfangreich. Um den Inhalt einer Liste (mit den Werten 1, 2 und 3) zu prüfen gibt es zwei Optionen. Beispielsweise könnte der folgende Fall verwendet werden: case List(1,2,3) => doSomething(). Derselbe Fall könnte auch so angegeben werden: case 1 :: 2 :: 3 :: Nil => doSomething(). Die zweite Möglichkeit verwendet eine alternative Schreibweise für unveränderlichen Liste. Dabei ist zu beachten, dass eine Liste in Scala immer mit Nil endet. Durch diese alternative Schreibweise ergeben sich viele mögliche Optionen, einen komplexen Fall sehr einfach zu beschreiben. Beispielsweise ist es möglich, nur nach dem ersten Element zu filtern, und die restliche Liste in einer temporären Variable abzulegen. Somit kann die restliche Liste in dem Fall weiter verarbeitet werden. Dies wird in Zeile 3 des zweiten Beispiels dargestellt. Dabei ist dieses Verhalten nicht auf das erste Element eingeschränkt, sondern es können beliebig vielen Elemente verwendet werden. Dies wird für die ersten beiden Elemente beispielhaft in Zeile 4 dargestellt. Außerdem wird in derselben Zeile die restliche Liste nicht in einer temporären Variable abgelegt.

 

Das Bowlinggame-Kata

Das Bowlinggame-Kata von „Mr. Clean Code“ Robert C. Martin, auch bekannt als „Uncle Bob“, hat das Ziel, die Punkte eines Bowlinggames zu berechnen. Die Komplexität des Katas liegt in der Berechnung der Zusatzpunkte.
Ein Bowlinggame besteht aus 10 Frames. Pro Frame hat ein Spieler zwei Würfe, außer er wirft einen Strike. Bei einem Strike wirft der Spieler beim ersten Wurf alle 10 Kegel um. Neben dem Strike und einem normalen Frame gibt es auch noch den Spare – der Spieler hat nach den beiden Würfen alle Kegel umgeworfen. Nach einem Strike oder einem Spare erhält der Spieler Zusatzpunkte. Bei einem Strike werden die Punkte der nächsten zwei Würfe addiert, bei einem Spare die Punkte des nächsten Wurfs. Wichtig: Es geht bei den Zusatzpunkten um die Würfe und nicht um die Frames. Aufgrund dieser Vergabe von Zusatzpunkten stellt das letzte Frame einen Sonderfall dar. Wird im letzten Frame ein Strike oder Spare geworfen, darf der Spieler in diesem insgesamt dreimal werfen. Die maximale Punktzahl für einen Frame liegt bei 30 Punkten. Dies kann ein Spieler erreichen, indem er 3 Strikes hintereinander wirft.

 

Die Pattern Matching Lösung

import scala.collection.mutable.ListBuffer

class BowlingGame {
  private val rolls = ListBuffer[Int]()
  private val spare = 10
  private val Strike = 10 // is a stable identifier -> stable identifier start with uppercase

  def roll(pins: Int) = rolls += pins
  def score: Int = score(rolls.toList)

  private def score(rolls: List[Int]): Int = rolls match {
    case Strike :: nextRoll :: afterNextRoll :: _ => Strike + nextRoll + afterNextRoll + score(rolls.tail) // Strike
    case firstRoll :: secondRoll :: rest if firstRoll + secondRoll < spare => firstRoll + secondRoll + score(rest) // Normal roll
    case firstRoll :: secondRoll :: nextRoll :: _ if firstRoll + secondRoll == spare => spare + nextRoll + score(rolls.tail.tail) // spare
    case _ => 0
  }
}

Interessant an der Lösung ist die private Methode score(rolls: List[Int]), die das Ergebnis berechnet. Als Eingabeparameter erwartet diese eine unveränderbare Liste, die mit Integers befüllt ist. Beim ersten Aufruf wird dafür die veränderbare List rolls in eine unveränderbare Liste konvertiert. Inhalt dieser Liste ist die Anzahl der gefallenen Kegel pro Wurf.

Der erste Fall (Zeile 12) im Pattern Matching fängt alle Strikes ab. Dafür wird überprüft ob das erste Element der Liste 10 ist. Zudem werden die nächsten beiden Würfe jeweils in temporären Variablen abgelegt. In diesem Fall werden die beiden temporären Variablen zusammen mit der 10 für den Strike und dem rekursiven Aufruf der Methode summiert. Beim rekursiven Aufruf wird der erste Wurf aus der Liste entfernt. Dies wird mit der Methode tail durchgeführt. tail ist eine Methode der Klasse List.

Der zweite Fall (Zeile 13) verarbeitet alle normalen Frames. Dabei werden die ersten beiden Elemente in temporäre Variablen geschrieben und diese mit Hilfe eines Guards überprüft. Der Guard kontrolliert, ob die Summe der beiden Werte kleiner ist als 10, da es sich sonst um einen Spare handeln würde. In diesem Fall werden die beiden Würfe, die durch die temporären Variablen dargestellt werden, zusammen mit dem rekursiven Aufruf addiert. Im rekursiven Aufruf wird einfach die restliche Liste, die von der temporären Variable rest abgebildet wird, übergeben.

Der dritte Fall (Zeile 14) deckt Spares ab. In diesem Fall werden die ersten drei Würfe in temporäre Variablen geschrieben. Die ersten beiden werden im Guard verwendet, um sicherzustellen, dass es sich um ein Spare handelt. Die dritte temporäre Variable nextRoll wird zusammen mit einer 10, die den Wert des Spares repräsentiert, und dem rekursiven Aufruf summiert. Für den rekursiven Aufruf werden die ersten beiden Würfe der Liste entfernt, dafür wird die Methode tail zweimal aufgerufen.

Der letzte Fall (Zeile 15) wird für das Ende der Rekursion benötigt und gibt einfach 0 zurück, da es sich bei der 0 um das neutrale Element einer Addition handelt.

 

Vergleich zu anderen Lösungsmöglichkeiten

Die Lösung von „Uncle Bob“ umfasst 46 Zeilen Code. Die Pattern-Matching-Lösung besteht aus lediglich 17 Zeilen Code. Alleine beim Vergleich der Anzahl der Codezeilen, zeigt das Pattern Matching seine Möglichkeiten. Ebenfalls fällt bei der Lösung mit dem Pattern Matching der Umgang mit Indexen vollständig weg und verringert dadurch die Komplexität. Würde in der Lösung von „Uncle Bob“ eine Liste statt eines Array verwendet werden, wären noch weitere Abfragen nötig, um keine NullPointerExceptions zu bekommen. Dieser Check kann bei der Pattern-Matching-Lösung ebenfalls entfallen.

In Zukunft können sich vermutlich auch Java-Programmierer freuen. JEP-305 beschreibt bereits ein Pattern Matching, das – wenn auch nicht so mächtig wie das von Scala – in eine künftige Java-Version Einzug halten könnte.

Zurück zur Übersicht

Kommentar verfassen

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert

*Pflichtfelder

*