Blog

Kompilierzeit-Auswertung in Scala mit Makros

Arnout Engelen

Arnout Engelen

Aktualisiert Oktober 22, 2025
5 Minuten

In vielen 'kompilierten' Sprachen gab es früher eine strikte Trennung zwischen dem, was zur 'Kompilierzeit' und dem, was zur 'Laufzeit' passiert. Diese Unterscheidung beginnt zu verblassen: Bei der JIT-Kompilierung wird ein Großteil der Kompilierphase in die Laufzeit verlagert, während umgekehrt verschiedene Arten von Optimierungen die 'Laufzeit'-Arbeit zur Kompilierzeit erledigen. Leistungsstarke Typsysteme ermöglichen den Ausdruck von Dingen, die zuvor nur zur Laufzeit überprüft wurden, insbesondere mit der jüngsten Renaissance der abhängigen Typen, die von Idris populär gemacht wurde. Dieser Beitrag zeigt ein sehr einfaches Beispiel für die Auswertung zur Kompilierzeit in Scala: Wir schreiben eine reguläre 'faktorielle' Funktion und verwenden Makros, um sie (auf Konstanten) zur Kompilierzeit anzuwenden.

Unser faktorieller

Beginnen wir mit unserer Faktorialfunktion: [code language="scala"] def normalFactorial(n: Int): Int = if (n == 0) 1 else n * Fakultät(n - 1) [/code] Dies ist ziemlich ereignislos, aber beachten Sie, dass dies eine einfache Scala-Funktion ist, nichts Ausgefallenes.

Definieren einer Makrofunktion

Wenn wir eine Makrofunktion definieren, geben wir eine Signatur in der regulären Scala-Syntax an. Die Implementierung besteht aus dem speziellen Schlüsselwort 'macro' und einem Verweis auf die Makroimplementierung. [code language="scala"] import scala.language.experimental.macros def factorial(n: Int): Int = macro factorial_impl import scala.reflect.macros.blackbox.Context def factorial_impl(c: Context)(n: c.Expr[Int]): c.Expr[Int] = ??? [/code] Die Makroimplementierung ist also auch eine normale Scala-Funktion. Der Unterschied besteht darin, dass sie statt 'normaler' Werte AST's empfängt und produziert.

Implementierung der Makrofunktion

Um unsere Kompilierzeit-Faktorisierung zu implementieren, müssen wir den AST entpacken, unsere Funktion anwenden und einen neuen AST erzeugen, der an der Aufrufstelle platziert wird. Da der AST in regulärem Scala-Code ausgedrückt ist, können wir einen Pattern-Match durchführen und zum Beispiel eine literalisierte Int-Konstante erkennen: [code language="scala"] import scala.reflect.macros.blackbox.Context def factorialimpl(c: Context)(n: c.Expr[Int]): c.Expr[Int] = { import c.universe. n match { case Expr(Literal(Constant(nValue: Int))) => val result = normalFactorial(nValue) c.Expr(Literal(Constant(result))) case _ => println("Yow!") ??? } } [/code] Denken Sie daran, dass dieser Code zur Kompilierzeit ausgeführt wird: Sogar unsere freche kleine Meldung wird ausgegeben, wenn der Compiler auf einen nicht passenden AST stößt, und die Kompilierung wird aufgrund des NotImplementedError fehlschlagen.

Das Endergebnis

Wenn Sie alle vorherigen Schritte kombinieren, sieht unsere makrobasierte Kompilierzeit-Faktor-Implementierung jetzt wie folgt aus: [code language="scala"] object CompileTimeFactorial { import scala.language.experimental.macros // Diese Funktion, die für Konsumenten sichtbar ist, hat einen normalen Scala-Typ: def factorial(n: Int): Int = // aber sie ist als Makro implementiert: macro CompileTimeFactorial.factorial_impl import scala.reflect.macros.blackbox.Context // Die Makroimplementierung erhält einen 'Context' und // die AST's der übergebenen Parameter: def factorialimpl(c: Context)(n: c.Expr[Int]): c.Expr[Int] = { import c.universe. // Wir können den AST nach Mustern durchsuchen: n match { case Expr(Literal(Constant(nValue: Int))) => // Wir führen die Berechnung durch: val result = normalFactorial(nValue) // Und erzeugen einen AST für das Ergebnis der Berechnung: c.Expr(Literal(Constant(result))) case other => // Ja, das wird zur Kompilierzeit ausgegeben: println("Yow!") ??? } } // Die eigentliche Implementierung ist normaler altmodischer Scala-Code: private def normalFactorial(n: Int): Int = if (n == 0) 1 else n * normalFactorial(n - 1) } [/code] Für diejenigen unter Ihnen, die zu Hause mitlesen, speichern Sie diese Implementierung in CompileTimeFactorial.scala.

Das Makro verwenden

Die Verwendung des Makros ist so einfach wie der Aufruf einer normalen Scala-Funktion: [code language="scala"] import CompileTimeFactorial._ object Test extends App { println(factorial(10)) // Unkommentiert führt dies zu einem Fehler bei der Kompilierung, da wir // nur einen Fall für ein Int-Literal, nicht aber für eine Variable implementiert haben: // val n = 10 // println(factorial(n)) } [/code] Es gibt ein paar Vorbehalte: Um das Makro beim Kompilieren von Test.scala auswerten zu können, muss CompileTimeFactorial.scala zuvor kompiliert worden sein. Die Kompilierung von Test.scala und CompileTimeFactorial.scala im selben scalac-Aufruf wird nicht zuverlässig funktionieren. Dieses Beispiel zeigt auch, dass factorial zwar eine reguläre Scala-Typdefinition hat, aber die Tatsache, dass Makros AST-Manipulationen durchführen, durchdringt. Der untere Aufruf ist zwar semantisch trivialerweise äquivalent zum oberen, lässt sich aber nicht kompilieren: Wir haben unser Makro nur für Int-Literale implementiert, nicht für Variablen.

Hat es funktioniert?

Unser Programm erzeugt in der Tat die richtige Ausgabe: [code] $ scalac CompileTimeFactorial.scala [/code] $ scalac Test.scala $ scala Test 3628800 $ Aber können wir beweisen, dass dieser Wert tatsächlich zur Kompilierzeit berechnet wurde? Mit dem Disassembler von javap, der mit der JVM geliefert wird, können wir das. Ich erspare Ihnen die komplette Ausgabe, aber der relevante Teil ist: [code] $ javap -c Test$ .... Code: 0: getstatic #61 // Feld scala/Predef$.MODULE$:Lscala/Predef$; 3: ldc #62 // int 3628800 5: invokestatic #68 // Methode scala/runtime/BoxesRunTime.boxToInteger:(I)Ljava/lang/Integer; 8: invokevirtual #72 // Methode scala/Predef$.println:(Ljava/lang/Object;)V 11: return [/code] Zeigt, dass die konstante Ganzzahl 3628800 geladen, geboxt und gedruckt wird.

Schlussfolgerungen

Dies ist natürlich nur ein Spielzeugbeispiel, das das Konzept der Scala-Makros veranschaulichen soll. Wenn Sie ernsthaftere AST-Anpassungen und -Manipulationen vornehmen, werden Sie sich sicherlich mit Quasiquotes und der zusätzlichen Leistung von Makrobündeln befassen wollen. Diese Art der Metaprogrammierung hat sich in verschiedenen Versionen des Compilers als schwer zu unterstützen erwiesen. Dies führte zur scala.meta-Initiative, um die Metaprogrammierung vom Compiler zu entkoppeln ⊢ obwohl es wahrscheinlich noch eine Weile dauern wird, bis sie zu Makros kommen. In der Zwischenzeit könnte es macro-compat einfacher machen, Makros zu schreiben, die mehrere Compiler-Versionen unterstützen. Zu den Anwendungen von Makros gehören:

Die Metaprogrammierung in Scala scheint noch in den Kinderschuhen zu stecken, aber das Potenzial ist erstaunlich.

Verfasst von

Arnout Engelen

Contact

Let’s discuss how we can support your journey.