Blog

Hinweise zum Schreiben eines Scala 3 Compiler Plugins

Grigorios Athanasiadis

Grigorios Athanasiadis

Aktualisiert Oktober 15, 2025
12 Minuten

Anfang dieses Jahres habe ich an dem Compiler-Plugin für scourt gearbeitet, einem Projekt zur Implementierung von Coroutines in Scala neben Unwrapped, das in PRE-SIP: Suspended functions and continuations vorgeschlagen wurde. Da ich zum ersten Mal mit dem Compiler gearbeitet habe, habe ich mir Notizen zu Dingen gemacht, die ich für nützlich hielt. Und ich möchte sie gerne mit Ihnen teilen, um auch Ihnen das Leben zu erleichtern.

Intro

Zu Beginn wäre es hilfreich, ein paar Begriffe zu erklären, die wir später sehen werden.

  • Trees: oder Abstract Syntax Tree bezieht sich auf die Datenstruktur, die der Compiler zur Darstellung des Codes aufbaut. Diese Struktur wird umgewandelt, während sie Phases durchläuft. Zum Beispiel, für eine Variable und eine Methode wie
private val hello: String = "Hello"

def exampleMethod: Int = {    
  1 
}

sind die entsprechenden Bäume

// using ctx: Context
val valdef = tpd.ValDef(
  Symbols.newSymbol(ctx.owner, termName("hello"), Flags.Private, defn.StringType),
  tpd.Literal(Constant("Hello"))
)

val defdef =
  tpd.DefDef(
    Symbols.newSymbol(ctx.owner, termName("exampleMethod"), Flags.Method, defn.IntType),
    tpd.Block(List.empty, tpd.Literal(Constant(1)))
  )

Sie können auch den AST Explorer verwenden, um die entsprechenden Bäume eines Scala-Codes zu sehen.

  • Phasen: werden verwendet, um den Baum zu transformieren. Wenn wir uns die Phasen des Scala 3 Compilers ansehen, sehen wir zum Beispiel Inlining, das die Inline-Funktionalität unterstützt. Wenn wir ein Plugin erstellen, fügen wir dem Compiler eine neue Phase hinzu, um den Tree zu transformieren, wie in unserem Fall mit ContinuationsPhase.
  • Symbole: werden verwendet, um eine Entität wie eine Klasse, eine Methode oder einen Typ eindeutig zu identifizieren und enthalten unter anderem den Namen der Entität. Ein ClassSymbol kann verwendet werden, um eine Klasse, eine Eigenschaft oder ein Objekt zu beschreiben. In unserem vorherigen Beispiel sehen Sie, dass wir die Symbole für unsere Bäume mit der Methode Symbols.newSymbol erstellen und ihnen einen Namen, Flags und einen Typ geben.
  • Bezeichnungen: Symbole selbst haben keine Struktur. Deshalb sind sie mit Bezeichnungen verknüpft, die die Bedeutung eines Symbols in einer bestimmten Compilerphase darstellen. In unserem Code haben wir Denotationen verwendet, um Bäume zu vergleichen
    tree1.symbol.denot.matches(tree2.symbol)
  • Namen: sind gewickelte Zeichenketten, von denen nur eine Kopie gespeichert wird. Als Teil der Symbol entsprechen sie auch bestimmten Begriffen (Variablen, Methoden oder Klassenparameter) oder Typen (Klasse, Eigenschaft, Typparameter).
  • Flags: Sie definieren die Semantik eines Symbols (z.B. ob es veränderbar, eine Methode, eine implizite usw. ist). Im obigen Beispiel haben wir Flags verwendet, um zu sagen, dass ein Symbol den Zugriffsmodifikator private haben sollte oder in einer Methode verwendet wird.

Methoden und Vorschläge für Helfer

Jetzt können wir mit einigen Vorschlägen beginnen, wo Sie Methoden finden, die Ihnen helfen, Trees für den Compiler zu schreiben.

  • Schauen Sie sich zunächst immer in der Codebasis des Compilers nach Hilfsmethoden um. Ein paar Orte, an denen Sie nach nützlichen Erweiterungsmethoden für Symbole, Typen, Namen oder generell suchen können, sind SymUtils, TypeUtils, NameOps und Decorators. Diese Methoden haben mir vor allem bei der Fehlersuche und dem Erkennen von Mustern geholfen.

  • Ein weiterer Ort, den Sie im Auge behalten sollten, ist StdNames und die StdNames.nme. Hier finden Sie gängige Methodennamen wie nme.asInstanceOf_, nme.CONSTRUCTOR, nme.OR, nme.apply oder sogar nme.x_0. Dies sind bereits Begriffe, so dass Sie sie auch nicht in termName einpacken müssen. Und wenn Sie einige fertige Definitionen von Symbolen oder Typen finden möchten, die Sie verwenden können, schauen Sie unter Definitionen nach oder verwenden Sie Symbols.defn, zum Beispiel defn.IntType, defn.AnyType.

  • Wenn Sie einen Tree erstellen oder bearbeiten, stellen Sie sicher, dass Sie die Hilfsmethoden von TreeOps verwenden. Zum Beispiel: tree.select, tree.appliedTo, tree.appliedToType gegen Trees.Select, Trees.Apply, Trees.TypeApply, usw., wobei tree ist der Name einer Variablen val tree: tpd.ValDef = ??? und Trees ist die Struktur. In tpd finden Sie alle Arten von nützlichen Trees wie den Unterstrich oder Hilfsmethoden wie isInstance.

  • Möchten Sie die Signatur des Baums sehen, mit dem Sie arbeiten? Dann ist genau das Richtige für Sie. Sie werden feststellen, dass es auch eine Methode gibt, aber diese beiden können unterschiedliche Signaturen mit unterschiedlichen Parametern aufweisen. Sie denken vielleicht, dass Sie mit der Methode von arbeiten und versuchen, die Methode mit diesen Parametern aufzurufen, aber der Compiler wird versuchen, die Methode so aufzurufen, wie sie im Symbol angezeigt wird, was zu einem Fehler führt. Beachten Sie, dass Sie bei diesen beiden Methoden die Flags des Baums nicht sehen können. Überprüfen Sie also auch die symbol.flags (auf etwas, das implizit, faul, privat usw. ist).

  • Um auf vorhandenen Code wie Klassen, Traits oder Objekte zu verweisen, können SieSymbols.requiredClass, Symbols.requiredModule, Symbols.requiredPackage, usw. verwenden.
    Zum Beispiel

    Symbols.requiredClassRef("scala.util.Either").appliedTo(defn.ThrowableType, defn.IntType)

    zu erstellen

    Either[Throwable, Int]
  • Wenn Sie einen Tree haben und ihn später in Ihrem Code referenzieren wollen, können Sie ref(tree.symbol) verwenden. Zum Beispiel, für

    val x = 1
    val y = x

    können Sie tun

    val x = tpd.Literal(Constant(1))
    val y = ref(x.symbol)
  • Möchten Sie sehen, ob Ihr Baum eine Methode ist? Dann ist alles, was Sie brauchen, tree.symbol.is(Flags.Method)). Übrigens, ein var ist einfach ein ValDef mit dem Flag Mutable.

  • Eine wirklich nützliche Klasse ist die TreeTypeMap, denn sie hilft dabei, einen Tree zu transformieren und nur ein Symbol innerhalb des Tree zu ändern.

  • Aber wie definiert man überhaupt einen konstanten Wert? Was Sie brauchen, ist tpd.Literal(Constant("Hello World")).

  • Versuchen Sie im Allgemeinen, tpd oder Trees für den Import von Dingen wie ValDef oder DefDef zu verwenden. Andernfalls kann es ein wenig schwierig werden, den Code zu lesen. Zugegeben, manchmal gibt es keine andere Möglichkeit, als sie zu mischen. Wenn Sie im gleichen Zusammenhang einen Mustervergleich mit einem Tree durchführen möchten, um seinen Typ zu sehen, aber keine Parameter verwenden möchten, können Sie auch einfach tpd verwenden,

    tree match { case t: tpd.DefDef => println(t.show) }

    anstelle von

    tree match { case t @ Trees.DefDef(_, _, _, _) => println(t.show) }

Dinge, auf die Sie achten sollten

Seien Sie vorsichtig, manche Dinge sind nicht so offensichtlich!

  • Denken Sie immer daran, die Symbole, die Sie mit symbol.entered oder einer der ähnlichen Methoden erstellen, einzugeben. Wenn ihr Besitzer eine Klasse ist, dann ist das Symbol im Geltungsbereich. Andernfalls ist es das nicht. Dies geht Hand in Hand mit der Verwendung von TreeTypeMap, um den Besitzer bei Bedarf zu ändern. Ein Symbol, das an einen alten Besitzer angehängt war, war eines der häufigsten Probleme, mit denen wir zu tun hatten, und leider war es nicht einfach, das Problem herauszufinden und herauszufinden, welches Symbol aktualisiert werden muss (normalerweise sehen Sie etwas wie java.lang.IllegalArgumentException: Could not find proxy:...).

  • Wenn Sie einen Baum durchqueren, sollten Sie berücksichtigen, dass der Compiler anonyme Funktionen für Fälle wie Lambdas def func(f: Int => Boolean), Kontextfunktionen usw. erstellt. In unserem Fall haben wir Codestücke extrahiert, um sie an anderer Stelle zu verwenden. Wir mussten also sicherstellen, dass wir diese synthetischen Funktionen identifizieren, den inneren Baum übernehmen und dann auch die Eigentümer ändern können.

  • Flags spielen eine wichtige Rolle. Stellen Sie also sicher, dass Sie wissen, welche Sie benötigen, da sie zur Transformation eines Baums verwendet werden können. Wenn Sie beispielsweise den Baum für eine Klasse mit einem Parameter erstellen, aber vergessen, die Flags.LocalParamAccessor hinzuzufügen (wie bei den Parametern in unserer synthetischen Klasse), dann wird dieser Parameter in der Konstruktorphase ignoriert. Danach fügt der Compiler möglicherweise seinen eigenen synthetischen Parameter hinzu (z.B. in dem Fall, dass unsere neue Klasse in einer anderen Klasse verschachtelt ist) und geht dann davon aus, dass sie nur einen Parameter hat (den vom Compiler hinzugefügten) und nicht zwei. Später wählt es diesen vermeintlich einen Parameter aus, aber in Wirklichkeit ist es unser Parameter und nicht der, den der Compiler hinzugefügt hat. Soweit ich weiß, wird beim Erstellen einer Klasse und beim Hinzufügen eines Konstruktors mit einem Parameter leider weder das Flag hinzugefügt noch das Symbol in den Geltungsbereich aufgenommen. Wir müssen diese Schritte später explizit durchführen, sobald der Konstruktor das Symbol für den Parameter erstellt hat.

  • Wenn Sie einem def Parameter hinzufügen und diese Parameter dann innerhalb des Körpers verwenden wollen, müssen Sie sie aus dem def selbst übernehmen und nicht die Anfangsvariable verwenden, die dem def nicht zugewiesen wurde. Zum Beispiel für

    val x = ???
    val defdef = DefDef(newSymbol(???), paramss = List(List(x.symbol)), ???)

    können wir den Wert x nicht innerhalb des Körpers defdef verwenden, sondern wir müssen die Parameter der Methode wie val paramsToUse = defdef.paramss oder val paramsToUse = defdef.termParamss abrufen und den Wertx finden, bevor wir ihn verwenden. Ich hoffe, ein Blick auf den Code macht mehr Sinn:

val param: Symbol = newSymbol(ctx.owner, termName("param"), Flags.LocalParam, defn.IntType)

val defdef: tpd.DefDef =
  tpd.DefDef(
    sym = newSymbol(
      ctx.owner,
      Names.termName("exampleMethod"),
      Flags.Method,
      MethodType.fromSymbols(List(param), defn.IntType)
    )
  )

val defdefParam: Symbol =
  defdef.termParamss.flatten.find(_.symbol.denot.matches(param)).get.symbol

val defdefWithBody =
  cpy.DefDef(defdef)(rhs = ref(defdefParam).select(defn.Int_+).appliedTo(tpd.Literal(Constant(1))))

tpd.Block(List(defdefWithBody), ref(defdefWithBody.symbol).appliedTo(tpd.Literal(Constant(1))))

dieser Baum erzeugt beim Kompilieren

{
  def exampleMethod(param: Int): Int = param.+(1)
  exampleMethod(1)
}

Wenn wir jedoch die defdefParam innerhalb des Methodenkörpers durch param

val defdefWithBody =
  cpy.DefDef(defdef)(rhs = ref(param).select(defn.Int_+).appliedTo(tpd.Literal(Constant(1))))

scheitert die Kompilierung mit Exception in thread "main" java.util.NoSuchElementException: val param. Sie können dieses Szenario hier in unserem Plugin sehen. In diesem Beispiel haben wir auch cpy verwendet, um einen vorhandenen Tree zu kopieren und nur eine seiner Optionen zu ändern, den Body oder rhs.

  • Wenn Sie Bäume transformieren oder traversieren möchten, können Sie Phasen wie transformDefDef oder Methoden wie filterSubTrees, shallowFold oder deepFold verwenden. Denken Sie daran, dass es sich dabei um Traversierungen in der Tiefe handelt, die der Reihenfolge des Baums folgen; der erste Zweig wird vollständig durchlaufen, bevor Sie mit dem nächsten fortfahren. Wenn wir zum Beispiel Folgendes haben
val block = tpd.Block(
  List(
    tpd.Block(List(tpd.Literal(Constant("A"))), tpd.Literal(Constant(1))),
    tpd.Block(List(tpd.Literal(Constant("B"))), tpd.Literal(Constant(2)))
  ),
  tpd.Literal(Constant(3))
)

TreeTypeMap(treeMap = tree => {
  println(tree.show)
  tree
})(block)

das Ergebnis wird sein

{ { "A" 1 } { "B" 2 }  3 } -> the whole tree
{ "A" 1 } 
"A"
1
{ "B" 2 }
"B"
2
3

Schauen Sie sich unbedingt Jacks Vortrag an, um zu sehen, wie Sie dies ganz einfach selbst testen können.

Es gibt jedoch einige Sonderfälle, die mich zunächst überrascht haben. Wenn Sie eine Trees.Inlined haben, dann wendet die Verwendung von shallowFold die Transformation auf den erweiterten Tree an, also auf den Tree, den Sie erhalten, nachdem die Inline angewendet wurde, und nicht auf den ursprünglichen Tree, der ersetzt werden soll. Wenn Sie also einen Inlined finden und diesen bestehenden Tree ändern wollen, müssen Sie dies explizit tun, einen Mustervergleich durchführen und den call von Inlined(call,...) übernehmen, wie hier zu sehen. Das Gleiche gilt für Klassenkonstruktorparameter und anonyme Funktionen, da sie nicht Teil des Traverses sind. In ähnlicher Weise können wir einen expliziten Mustervergleich mit tpd.Apply durchführen oder die Methoden transformParams, transformParamss und transformDefDef verwenden.

Tricks

Lassen Sie uns zum Schluss noch einige Beispiele betrachten, die hauptsächlich aus der Compiler-Codebasis stammen, aber nicht immer ganz offensichtlich sind.

  • Nehmen wir an, Sie möchten einen Tree erstellen, der eine Ausnahme mit einer bestimmten Meldung auslöst. Das hört sich nach einer einfachen Aufgabe an, aber tatsächlich müssen Sie diesem Muster hier oder dem Code unseres Plugins folgen, um die Ausnahme auch hier auszulösen
val IllegalArgumentExceptionClass = requiredClass("java.lang.IllegalArgumentException") // or defn.IllegalArgumentExceptionClass

val IllegalArgumentExceptionClass_stringConstructor: TermSymbol =
  IllegalArgumentExceptionClass
    .info
    .member(nme.CONSTRUCTOR)
    .suchThat(_.info.firstParamTypes match {
      case List(pt) => pt.stripNull.isRef(defn.StringClass)
      case _ => false
    })
    .symbol
    .asTerm

val throwException = tpd.Throw(
  tpd.New(
    IllegalArgumentExceptionClass.typeRef, // or defn.IllegalArgumentExceptionType
    IllegalArgumentExceptionClass_stringConstructor,
    List(tpd.Literal(Constant("wrong argument")))
  )
)

println(throwException.show) // throw new IllegalArgumentException("wrong argument")

Später haben wir jedoch festgestellt, dass Sie auch überladene Member disambiguieren können, indem Sie select mit Symbol => Boolean als Disambiguierungshandler verwenden

val IllegalArgumentExceptionClass_stringConstructor: TermSymbol =
  ref(IllegalArgumentExceptionClass).select(
    nme.CONSTRUCTOR,
    _.info.firstParamTypes match {
      case List(pt) => pt.stripNull.isRef(defn.StringClass)
      case _ => false
    }).symbol.asTerm
  • Wenn Sie nun den Tree für eine Addition wie 1 + 1 erstellen möchten, gibt es einige Möglichkeiten, einschließlich des Versuchs, den Typ Int zu verlangen und dann nach der Plus-Methode zu suchen. Aber das haben wir bereits für Sie getan. Sie können also versuchen

    tpd.Literal(Constant(1)).select(defn.Int_+).appliedTo(tpd.Literal(Constant(1)))

    Aber was ist, wenn Sie eine ODER-Verknüpfung wie 9 | 10 vornehmen möchten? Dies ist nicht verfügbar, aber das Muster ist das gleiche. Wir brauchen

    val Int_| = (defn.IntClass.requiredMethod(nme.OR, List(defn.IntType)))
  • Die nächste Frage ist, wie wir so etwas wie den Minimalwert einer ganzen Zahl Int.MinValue erhalten können, und dafür können wir

    val Int_Min =ref(requiredModuleRef("scala.Int").select(Names.termName("MinValue")).symbol)
  • Eine weitere interessante Aufgabe, die wir zu lösen hatten, war der Aufruf des Konstruktors einer Klasse, z.B. new AClass[Int](2). Wir haben das gesehen, als wir eine Exception geworfen haben, aber nur der Klarheit halber, hier ist der Code:

    tpd.New(ref(AClassSymbol)).select(nme.CONSTRUCTOR).appliedToType(defn.IntType).appliedTo(tpd.Literal(Constant(2)))
  • In diesem Szenario möchten wir einen Mustervergleich mit dem Typ value match { case x: Int => ??? } durchführen und die Frage ist, wie wir den Teil x: Int definieren können. In Trees.scala sehen wir, dass es Bind oder Typed gibt. Ein Blick in den Compiler-Code zeigt jedoch, dass die Verwendung von BindTyped vorgeschlagen wird. Insgesamt werden die Bäume für value match { case x$0: Int => ??? } wie folgt aussehen

    val caseParam = newSymbol(owner, nme.x_0, Flags.Case | Flags.CaseAccessor, defn.IntType)
    tpd.Match(ref(value.symbol), List(tpd.CaseDef(tpd.BindTyped(caseParam, caseParam.info), ???, ???)))

    und für value { match case x$0 => ??? } haben wir

    val caseParam = newSymbol(owner, nme.x_0, Flags.Case | Flags.CaseAccessor, defn.IntType)
    tpd.Match(ref(value.symbol), List(tpd.CaseDef(tpd.Bind(caseParam, tpd.EmptyTree), ???, ???)))

    In unseren Plugin-Transformationen haben wir auch eine Kombination aus Bind und Typed

    val caseParam = newSymbol(owner, nme.x_0, Flags.Case | Flags.CaseAccessor, defn.IntType)
    tpd.Match(ref(value.symbol), List(tpd.CaseDef(tpd.Bind(caseParam, tpd.Typed(ref(param), ref(defn.IntType))), ???, ???)))

    die Sie auch hier sehen können.

Fazit

Die Arbeit mit dem Compiler ist nicht einfach, oder vielleicht ist sie einfach anders als die Entwicklung von HTTP-APIs und Streaming-Plattformen. Code wird extrahiert, angehoben, nach außen oder nach innen verschoben oder sogar gelöscht in verschiedenen Phases. Dinge spielen eine Rolle, auch wenn es nicht so aussieht. Vielleicht haben Sie ein neues Symbol nicht registriert oder ein zusätzliches Flag verwendet und der Compiler behandelt es auf eine Weise, die Sie nicht erwartet haben.

Aber insgesamt war es eine interessante Erfahrung. Ich glaube, ich habe viel gelernt, auch wie man sich in der Codebasis des Scala 3 Compilers zurechtfindet, und am Ende war ich wirklich froh, dass unser Plugin funktioniert.

Wenn Sie daran interessiert sind, selbst ein Plugin zu schreiben, würde ich sagen, dass es sich lohnt, sich in der Codebasis des Compilers umzuschauen und sich auch einige vorhandene Plugins anzusehen. Wenn die Zeit gekommen ist, werden Sie in der Lage sein, die Muster zu erkennen, die Sie brauchen.

Weitere Ideen, die Ihnen helfen werden, ein testbares Compiler-Plugin zu schreiben, finden Sie in Jack Viers' Vortrag von den Scala Days Testbare Compiler-Plugin-Entwicklung in Scala 3. (HINWEIS: Wir werden diesen Beitrag mit dem direkten Link zum Video aktualisieren, sobald die Präsentationen der Scala Days 2023 veröffentlicht worden sind. Sie können auch den Scala Days YouTube-Kanal abonnieren, um benachrichtigt zu werden, wenn Videos veröffentlicht werden).

Nützliche Links

Dotty Docs
Compiler-Plugin-Entwicklung in Scala 3 | Lassen Sie uns über Scala 3 sprechen
Scala 3 Compiler Akademie YouTube Kanal
Scala Center Sprees
Compiler sind Datenbanken

Verfasst von

Grigorios Athanasiadis

Contact

Let’s discuss how we can support your journey.