Blog

Parsen von Text mit Scala

Jan Vermeir

Jan Vermeir

Aktualisiert Oktober 23, 2025
8 Minuten

Bei meinen Bemühungen, mir Scala beizubringen, habe ich versucht, ein Problem zu lösen, das ich in verschiedenen Sprachen angegangen bin, darunter 6510 Assembler (bin nicht weit gekommen...), pl/sql, Java (mit und ohne Drools) und Groovy. In der Regel verzettle ich mich in irgendeinem Detail der Sprache, so dass ich im Alltag nie wirklich von meinen Bemühungen profitieren kann. Der Vorteil dieser nicht enden wollenden Aufgabe ist, dass ich mich mittlerweile nicht mehr mit der Definition eines Problems beschäftigen muss, sondern sofort mit dem Programmieren beginnen kann. Diese Geschichte handelt also davon, wie man Text in Scala parst und ist Teil DER Software, die automatisch ein Menü für eine Woche und die Einkaufsliste für dieses Menü zusammen mit allem anderen, was meine Familie in dieser Woche braucht, generiert und an www.albert.nl sendet und mir die Lebensmittel nach Hause liefern lässt.

Ich habe groß gedacht und klein angefangen und mich daran gemacht, (wieder) eine Reihe von Rezepten zu parsen, um die Liste der Zutaten für die Einkaufsliste zu extrahieren. Da mein gesamtes praktisches Wissen über Scala aus dem Buch von Martin Odersky stammt, hat sich mein Code etwas weiterentwickelt. Ich werde versuchen, Sie mit einigen der klobigen Apparate zu unterhalten, die ich auf dem Weg geschaffen habe, während ich versuchte, alte Gewohnheiten abzuschütteln. Das Ziel ist es, eine Liste von Rezepten wie die unten stehende zu analysieren: [text] Name ZucchiniMitHackfleisch Jahreszeit Sommer Kategorie einfach Kochzeit 45 Minuten Ofen auf 180 Grad vorheizen 500 Gramm Hackfleisch[/text] anbraten 1 Stück Zucchini anbraten 1 Dose Tomatenpüree hinzufügen 1 Beutel geriebenenKäse hinzufügen 30 Minuten backen und daraus eine Liste von Lebensmitteln wie diese machen: [text] Hackfleisch 500 Gramm Zucchini 1 Stück pürierteTomaten 1 Dose gemahlenerKäse 1 Beutel [/text] Scala bietet einen Parser für Text namens StandardTokenParsers im Paket scala.util.parsing.combinator.syntactical. Die Idee ist, dass ein bestimmter Parser StandardTokenParsers erweitert. Der Parser sollte Folgendes bieten

  • Eine Liste von Begrenzungszeichen
  • Eine Liste der reservierten Wörter
  • Eine 'main'-Methode, die eine Texteingabe akzeptiert und den Parser startet
  • Eine Reihe von Produktionsregeln, die einen Teil der Eingabe parsen und dabei einen Objektgraphen erstellen. lexical.delimiters ist die Liste der Zeichen, die zum Trennen von Token in der Eingabe verwendet werden. lexical.reserved definiert eine Reihe von Schlüsselwörtern, d.h. die while, if, for usw. Ihrer Sprache. Der folgende Ausschnitt zeigt meine Definitionen (er ist Teil von com.xebia.cooking.CookBookDSL.scala, das Sie in der an diesen Beitrag angehängten Zip-Datei finden). [scala] lexical.delimiters ++= List("(", ")", ",", " ", "n") lexical.reserved ++= List("name", "season", "fry", "pack", "can", "bag", "piece", "at", "for", "bottle", "gram", "oven", "preheat", "bake", "serve", "with", "add", "degrees", "category", "cookingtime") [/scala] lexical ist vom Typ Lexical und ist ein geerbtes Attribut von StandardTokenParsers. Der Parser wird auf diese Weise gestartet: [scala] def parseCookBook(cookBook:String):CookBook = { cookbook(new lexical.Scanner(cookBook)) match { case Success(recipeList, _) => new CookBook(recipeList) case Failure(msg, _) => throw new ParseErrorException(msg) case Error(msg, _) => throw new ParseErrorException(msg) [/scala] } } Die Eingabe ist eine String-Darstellung eines Kochbuchs. parseCookBook gibt ein Objekt vom Typ CookBook zurück, auf das wir später noch eingehen werden. Das Parsen wird durch den Aufruf der Methode cookbook mit einer Instanz eines Scanners als Parameter eingeleitet. Der Scanner gibt Token aus, die mit den Regeln der Grammatik übereinstimmen, wie wir weiter unten sehen werden. Unter Verwendung des Case-Konstrukts von Scala wird das Ergebnis des Parsers entweder in eine CookBook-Instanz übersetzt oder führt zu einer Ausnahme. Der Parser besteht aus einem Satz von Regeln in Form von Methodendefinitionen, die wie die folgende aussehen: [scala] def recipeName: Parser[String] = "name" ~ ident ^^ { case "name" ~ name => name } [/scala] Die Regel definiert den Namen eines Rezepts als String. Der Text links vom Operator ^^ definiert die Token, die in der Eingabe vorkommen müssen, damit die Regel zutrifft. In diesem Fall erwarten wir die Zeichenkette "name" gefolgt von einem Bezeichner. ident ist in StdTokenParsers definiert (zusammen mit numericLit, das ich später verwenden werde) und passt auf eine Zeichenkette aus alphanumerischen Zeichen. Die so geparsten Token werden verwendet, um eine Aktion rechts vom ^^-Zeichen auszuwählen. "name" ~ name führt eine Variable namens name ein, die mit dem Wert des ident-Tokens initialisiert wird. name wird dann als Ergebnis der Regel zurückgegeben, indem es rechts vom => Zeichen platziert wird. Ein etwas komplexeres Beispiel wird unten gezeigt: [scala] def ingredient: Parser[Ingredient] = opt(amount) ~ ident ^^ { case Some(amount) ~ ingredient => (new Ingredient(ingredient,amount)) case _ ~ ingredient => {new Ingredient(ingredient)} } [/scala] Diese Regel passt auf Text wie "500 Gramm Hackfleisch", "1 Stück Zucchini" aber auch "Salat" oder "Tomaten". Der Teil "Menge" in der Definition ist optional. Statt nur einer Fallklausel wie in der ersten Regel, hat diese Regel zwei Fälle. Der erste Fall entspricht einer Menge gefolgt von einer Zutat, der zweite Fall entspricht nur einer Zutat ohne den Teil Menge. Eine Regel kann auf den Ergebnissen anderer Regeln aufbauen, wie unten dargestellt: [scala] def step: Parser[Step] = (fryStep | preHeatStep | serveWithStep | addStep | bakeStep) ^^ { case step => step } [/scala] Ein Schritt in einem Rezept ist etwas wie "Ofen auf 180 Grad vorheizen" oder "500 Gramm Hackfleisch braten". Die Klauseln auf der linken Seite sind Alternativen, die als separate Regeln definiert sind. Das Ergebnis ist immer eine Step-Instanz. Schritte sind wichtig, weil einige von ihnen Zutaten definieren, die in einer hypothetischen zukünftigen Version meines Hauptwerks zu einer Einkaufsliste führen werden (höre ich da etwa Gelächter?). Eine letzte Beispielregel zeigt sich wiederholende Elemente: [scala] def listOfSeasons: Parser[List[Seasons]] = repsep(season ,",") ^^ { listOfSeasons: List[Seasons] => listOfSeasons } } [/scala] Diese Regel passt auf ein oder mehrere durch ein Komma getrennte Vorkommen einer Jahreszeit unter Verwendung der repsep-Klausel. Das Ergebnis ist eine Liste von Objekten des Typs Seasons. Der Parser-Code verwendet Klassen, die Konzepte in meiner Domäne darstellen, wie Ingredient, Step und Recipe. Ich habe diese Klassen in einer separaten Quelldatei namens Recipe.scala definiert. Die meisten der Klassen sind leer und dienen nur der Definition von Konzepten. Vielleicht werden sie später interessanter (Sie wissen schon, wenn ich dieses wunderbare..., usw...) implementiere. Andere, wie StepWithAnIngredient, dienen dazu, Zutaten zu sammeln, die zur Erstellung einer Einkaufsliste verwendet werden.Wie auch immer, eine Sache, die ich beim Schreiben der Klassen in der Datei Recipe.scala gelernt habe, ist die Verwendung der Methode mkString. Mit meinem Java-Hintergrund habe ich toString-Methoden wie diese geschrieben: [scala] override def toString = { val result:StringBuilder = new StringBuilder("Steps: ") list.foreach(step => result.append(step).append("n")) result.toString() } [/scala] Ich war ziemlich glücklich darüber (Sie wissen schon, daran zu denken, die foreach-Methode zu verwenden), bis mir klar wurde, dass so etwas viel einfacher zu implementieren ist: [scala] override def toString = "Schritte: " + steps.mkString("n") [/scala] Sie können mkString für jede beliebige Instanz aufrufen und es gibt eine String-Darstellung zurück, was besonders bei iterierbaren Objekten praktisch ist. Eine weitere leistungsstarke Scala-Funktion ist die Reihe von Methoden, die für Listen definiert sind. Eine dieser Methoden ist filter, die eine Schließung akzeptiert, die auf jedem Element der Liste ausgeführt wird. Wenn die Bedingung wahr ist, wird das Element zum Ergebnis hinzugefügt. Im folgenden Beispiel steht _ für ein Rezept, d.h. ein Element der Liste der Rezepte. [scala] def findRecipesBySeason(season:Seasons):Seq[Recipe] = { recipes.filter(_.seasons.contains(season)) } [/scala] Eine weitere Sache, die mich anfangs sehr verwirrt hat, war die Syntax für Konstruktorparameter. Das folgende Beispiel zeigt die Variablen amount und unitName, die in der Klasse Amount definiert sind, auf die Java-Art: [java] public class Amount { private int amount; private String unitName; public Amount(int amount, String unitName) { this.amount=amount; this.unitName=unitName; } // Getter und Setter der Kürze halber weggelassen. } [/java] Und in Scala: [scala] class Betrag (val Betrag:Int, val Einheitsname:String) { override def toString = Betrag+": "+Einheitsname } [/scala] Die Parameter nach dem Namen der Klasse werden in öffentliche val's umgewandelt. Mit meinem Java-Hintergrund und meinem Zwang, in Eclipse Strg-1 zu drücken, habe ich eine Weile gebraucht, um zu erkennen, wie einfach und prägnant die Scala-Syntax ist. Beachten Sie, dass die Datei Recipe.scala eine Liste von Klassendefinitionen enthält. In Scala gibt es keine Eins-zu-Eins-Zuordnung von öffentlichen Klassen zu Quelldateien. Auf diese Weise konnte ich eine Liste verwandter Konzepte in einer einzigen Quelldatei zusammenfassen.Schließlich habe ich festgestellt, dass man Parser am besten entwickelt, indem man immer nur kleine Schritte macht. Wenn man zu viel auf einmal ändert, kann man ziemlich leicht alles durcheinander bringen.Dieser Scala-Parser ist bei weitem die vollständigste Version, die ich bisher gebaut habe. Ich fange an, die Sprache zu mögen, auch wenn mir meine mangelnde Erfahrung mindestens 15 Mal pro Stunde in die Quere kommt. Wer weiß, vielleicht schaffe ich dieses Mal tatsächlich etwas Nützliches...

Verfasst von

Jan Vermeir

Developing software and infrastructure in teams, doing whatever it takes to get stable, safe and efficient systems in production.

Contact

Let’s discuss how we can support your journey.