Erstellen verwendbarer JVM-Sprachen: Ein Überblick
Veröffentlicht: 2022-03-11Es gibt mehrere mögliche Gründe für die Erstellung einer Sprache, von denen einige nicht sofort offensichtlich sind. Ich möchte sie zusammen mit einem Ansatz vorstellen, um eine Sprache für die Java Virtual Machine (JVM) zu erstellen, die vorhandene Tools so weit wie möglich wiederverwendet. Auf diese Weise reduzieren wir den Entwicklungsaufwand und stellen eine dem Anwender vertraute Toolchain zur Verfügung, die den Einstieg in unsere neue Programmiersprache erleichtert.
In diesem Artikel, dem ersten der Serie, werde ich einen Überblick über die Strategie und verschiedene Tools geben, die bei der Erstellung unserer eigenen Programmiersprache für die JVM beteiligt sind. In zukünftigen Artikeln werden wir uns mit den Implementierungsdetails befassen.
Warum eine eigene JVM-Sprache erstellen?
Es gibt bereits unendlich viele Programmiersprachen. Warum sich also die Mühe machen, eine neue zu erstellen? Darauf gibt es viele mögliche Antworten.
Zunächst einmal gibt es viele verschiedene Arten von Sprachen: Möchten Sie eine Programmiersprache für allgemeine Zwecke (GPL) oder eine domänenspezifische erstellen? Zur ersten Art gehören Sprachen wie Java oder Scala: Sprachen, die dazu bestimmt sind, anständige Lösungen für eine große Menge von Problemen zu schreiben. Domänenspezifische Sprachen (DSL) konzentrieren sich stattdessen darauf, eine bestimmte Reihe von Problemen sehr gut zu lösen. Denken Sie an HTML oder Latex: Sie könnten auf dem Bildschirm zeichnen oder Dokumente in Java erstellen, aber es wäre umständlich, mit diesen DSLs können Sie stattdessen sehr einfach Dokumente erstellen, aber sie sind auf diese bestimmte Domäne beschränkt.
Vielleicht gibt es also eine Reihe von Problemen, an denen Sie sehr oft arbeiten und für die es sinnvoll sein könnte, eine DSL zu erstellen. Eine Sprache, die Sie sehr produktiv macht, während Sie immer wieder die gleichen Probleme lösen.
Vielleicht möchten Sie stattdessen eine GPL erstellen, weil Sie einige neue Ideen hatten, zum Beispiel Beziehungen als Bürger erster Klasse darzustellen oder Kontext darzustellen.
Schließlich möchten Sie vielleicht eine neue Sprache erstellen, weil sie Spaß macht, cool ist und weil Sie dabei viel lernen werden.
Tatsache ist, dass man mit reduziertem Aufwand eine brauchbare Sprache erhält, wenn man die JVM anvisiert, denn:
- Sie müssen nur Bytecode generieren und Ihr Code ist auf allen Plattformen verfügbar, auf denen es eine JVM gibt
- Sie können alle für die JVM vorhandenen Bibliotheken und Frameworks nutzen
Die Kosten für die Entwicklung einer Sprache werden also auf der JVM stark reduziert, und es könnte sinnvoll sein, neue Sprachen in Szenarien zu erstellen, die außerhalb der JVM unwirtschaftlich wären.
Was brauchen Sie, um es nutzbar zu machen?
Es gibt einige Tools, die Sie unbedingt benötigen, um Ihre Sprache zu verwenden - ein Parser und ein Compiler (oder ein Interpreter) gehören zu diesen Tools. Dies ist jedoch nicht genug. Um Ihre Sprache in der Praxis wirklich brauchbar zu machen, müssen Sie viele andere Komponenten der Werkzeugkette bereitstellen, die möglicherweise in vorhandene Werkzeuge integriert werden können.
Idealerweise können Sie:
- Verwalten Sie Verweise auf Code, der für die JVM aus anderen Sprachen kompiliert wurde
- Bearbeiten Sie Quelldateien in Ihrer bevorzugten IDE mit Syntaxhervorhebung, Fehlererkennung und automatischer Vervollständigung
- Sie möchten in der Lage sein, Dateien mit Ihrem bevorzugten Build-System zu kompilieren: Maven, Gradle oder andere
- Sie möchten in der Lage sein, Tests zu schreiben und diese als Teil Ihrer Continuous-Integration-Lösung auszuführen
Wenn Sie das können, wird es viel einfacher sein, Ihre Sprache anzunehmen.
Wie können wir das erreichen? Im Rest des Beitrags untersuchen wir die verschiedenen Teile, die wir benötigen, um dies zu ermöglichen.
Analysieren und Kompilieren
Das erste, was Sie tun müssen, um Ihre Quelldateien in ein Programm umzuwandeln, ist, sie zu parsen und eine Abstract-Syntax-Tree (AST)-Darstellung der im Code enthaltenen Informationen zu erhalten. An diesem Punkt müssen Sie den Code validieren: Gibt es syntaktische Fehler? Semantische Fehler? Sie müssen alle finden und dem Benutzer melden. Wenn alles glatt geht, müssen Sie noch Symbole auflösen. Bezieht sich „List“ beispielsweise auf java.util.List oder java.awt.List ? Welche Methode rufen Sie auf, wenn Sie eine überladene Methode aufrufen? Schließlich müssen Sie Bytecode für Ihr Programm generieren.
Vom Quellcode bis zum kompilierten Bytecode gibt es also drei Hauptphasen:
- Aufbau eines AST
- Analyse und Transformation des AST
- Erzeugen des Bytecodes aus dem AST
Sehen wir uns diese Phasen im Detail an.
Aufbau eines AST : Parsing ist eine Art gelöstes Problem. Es gibt viele Frameworks da draußen, aber ich schlage vor, dass Sie ANTLR verwenden. Es ist bekannt, gut gewartet und hat einige Funktionen, die es einfacher machen, Grammatiken zu spezifizieren (es behandelt weniger rekursive Regeln - Sie müssen das nicht verstehen, aber seien Sie dankbar, dass es das tut!).
Analysieren und Transformieren des AST : Schreiben eines Typsystems, Validierung und Symbolauflösung können eine Herausforderung sein und ziemlich viel Arbeit erfordern. Allein dieses Thema würde einen eigenen Beitrag erfordern. Bedenken Sie vorerst, dass dies der Teil Ihres Compilers ist, für den Sie die meiste Mühe aufwenden werden.
Den Bytecode aus dem AST erzeugen : Diese letzte Phase ist eigentlich nicht so schwierig. Sie sollten in der vorherigen Phase Symbole aufgelöst und das Terrain so vorbereitet haben, dass Sie im Grunde einzelne Knoten Ihres transformierten AST in Anweisungen mit einem oder wenigen Bytecodes übersetzen können. Kontrollstrukturen könnten etwas zusätzliche Arbeit erfordern, da Sie Ihre for-Schleifen, Schalter, ifs und so weiter in eine Folge von bedingten und unbedingten Sprüngen übersetzen werden (ja, unter Ihrer schönen Sprache gibt es immer noch eine Menge Gotos). Sie müssen lernen, wie die JVM intern funktioniert, aber die eigentliche Implementierung ist nicht so schwierig.
Integration mit anderen Sprachen
Wenn Sie die Weltherrschaft für Ihre Sprache erlangt haben, wird der gesamte Code ausschließlich mit dieser Sprache geschrieben. Als Zwischenschritt wird Ihre Sprache jedoch wahrscheinlich zusammen mit anderen JVM-Sprachen verwendet. Vielleicht fängt jemand an, innerhalb eines größeren Projekts ein paar Kurse oder kleine Module in Ihrer Sprache zu schreiben. Es ist vernünftig zu erwarten, dass mehrere JVM-Sprachen gemischt werden können. Wie wirkt sich das auf Ihre Sprachwerkzeuge aus?
Sie müssen zwei verschiedene Szenarien berücksichtigen:
- Ihre Sprache und die anderen leben in separat zusammengestellten Modulen
- Ihre Sprache und die anderen leben in denselben Modulen und werden zusammen kompiliert
Im ersten Szenario muss Ihr Code nur kompilierten Code verwenden, der in anderen Sprachen geschrieben ist. Beispielsweise können einige Abhängigkeiten wie Guava oder Module im selben Projekt separat kompiliert werden. Diese Art der Integration erfordert zwei Dinge: Erstens sollten Sie in der Lage sein, von anderen Sprachen erstellte Klassendateien zu interpretieren, um Symbole in sie aufzulösen und den Bytecode zum Aufrufen dieser Klassen zu generieren. Der zweite Punkt ist spiegelverkehrt zum ersten: Andere Module möchten vielleicht den in Ihrer Sprache geschriebenen Code wiederverwenden, nachdem er kompiliert wurde. Nun, normalerweise ist das kein Problem, da Java mit den meisten Klassendateien interagieren kann. Sie könnten jedoch immer noch Klassendateien schreiben, die für die JVM gültig sind, aber nicht von Java aus aufgerufen werden können (z. B. weil Sie Bezeichner verwenden, die in Java nicht gültig sind).

Das zweite Szenario ist komplizierter: Angenommen, Sie haben eine in Java-Code definierte Klasse A und eine in Ihrer Sprache geschriebene Klasse B. Angenommen, die beiden Klassen beziehen sich aufeinander (z. B. könnte A B erweitern und B könnte A als Parameter für dieselbe Methode akzeptieren). Der Punkt ist nun, dass der Java-Compiler den Code in Ihrer Sprache nicht verarbeiten kann, also müssen Sie ihm eine Klassendatei für Klasse B bereitstellen. Um jedoch Klasse B zu kompilieren, müssen Sie Verweise auf Klasse A einfügen. Sie müssen also Folgendes tun eine Art partiellen Java-Compiler zu haben, der eine gegebene Java-Quelldatei interpretieren und ein Modell davon erstellen kann, mit dem Sie Ihre Klasse B kompilieren können. Beachten Sie, dass Sie dazu in der Lage sein müssen, Java-Code zu analysieren (mit so etwas wie JavaParser) und Symbole lösen. Wenn Sie keine Ahnung haben, wo Sie anfangen sollen, werfen Sie einen Blick auf java-symbol-solver.
Tools: Gradle, Maven, Testframeworks, CI
Die gute Nachricht ist, dass Sie die Tatsache, dass sie ein in Ihrer Sprache geschriebenes Modul verwenden, für den Benutzer völlig transparent machen können, indem Sie ein Plugin für Gradle oder Maven entwickeln. Sie können das Build-System anweisen, Dateien in Ihrer Programmiersprache zu kompilieren. Der Benutzer wird weiterhin mvn compile oder gradle assemble ausführen und keinen Unterschied feststellen.
Die schlechte Nachricht ist, dass das Schreiben von Maven-Plugins nicht einfach ist: Die Dokumentation ist sehr schlecht, nicht verständlich und meist veraltet oder einfach falsch . Ja, es klingt nicht beruhigend. Ich habe noch keine Gradle-Plugins geschrieben, aber es scheint viel einfacher zu sein.
Beachten Sie, dass Sie auch überlegen sollten, wie Tests mit dem Build-System ausgeführt werden können. Zur Unterstützung von Tests sollten Sie sich ein sehr einfaches Framework für Unit-Tests ausdenken und es in das Build-System integrieren, damit das Ausführen von maven test nach Tests in Ihrer Sprache sucht, diese kompiliert und ausführt und die Ausgabe an den Benutzer meldet.
Mein Rat ist, sich die verfügbaren Beispiele anzusehen: Eines davon ist das Maven-Plugin für die Programmiersprache Turin.
Sobald Sie es implementiert haben, sollte jeder in der Lage sein, in Ihrer Sprache geschriebene Quelldateien einfach zu kompilieren und diese in Continuous-Integration-Diensten wie Travis zu verwenden.
IDE-Plugin
Ein Plugin für eine IDE wird das sichtbarste Werkzeug für Ihre Benutzer sein und etwas, das die Wahrnehmung Ihrer Sprache stark beeinflussen wird. Ein gutes Plugin kann dem Benutzer helfen, die Sprache zu lernen, indem es intelligente automatische Vervollständigung, kontextbezogene Fehler und vorgeschlagene Refactorings bereitstellt.
Die gängigste Strategie besteht nun darin, eine IDE (typischerweise Eclipse oder IntelliJ IDEA) auszuwählen und ein spezifisches Plugin dafür zu entwickeln. Dies ist wahrscheinlich das komplexeste Stück Ihrer Toolchain. Dies ist aus mehreren Gründen der Fall: Erstens können Sie die Arbeit, die Sie für die Entwicklung Ihres Plugins für eine IDE aufwenden, nicht sinnvoll für die anderen wiederverwenden. Ihr Eclipse und Ihr IntelliJ-Plugin werden völlig getrennt sein. Der zweite Punkt ist, dass die Entwicklung von IDE-Plug-ins nicht sehr verbreitet ist, daher gibt es nicht viel Dokumentation und die Community ist klein. Das bedeutet, dass Sie viel Zeit damit verbringen müssen, Dinge für sich selbst herauszufinden. Ich habe persönlich Plugins für Eclipse und für IntelliJ IDEA entwickelt. Meine Fragen in den Eclipse-Foren blieben monate- oder jahrelang unbeantwortet. In den IntelliJ-Foren hatte ich mehr Glück, und manchmal bekam ich eine Antwort von den Entwicklern. Die Benutzerbasis von Plugin-Entwicklern ist jedoch kleiner und die API ist sehr byzantinisch. Bereite dich darauf vor zu leiden.
Es gibt eine Alternative zu all dem, und zwar die Verwendung von Xtext. Xtext ist ein Framework zur Entwicklung von Plugins für Eclipse, IntelliJ IDEA und das Web. Es wurde auf Eclipse geboren und erst kürzlich erweitert, um die anderen Plattformen zu unterstützen, daher gibt es nicht so viel Erfahrung damit, aber es könnte eine Alternative sein, die es wert ist, in Betracht gezogen zu werden. Lassen Sie mich das klarstellen: Der einzige Weg, ein sehr gutes Plugin zu entwickeln, besteht darin, es mit der nativen API jeder IDE zu entwickeln. Mit Xtext können Sie jedoch mit einem Bruchteil des Aufwands etwas einigermaßen Anständiges erreichen - Sie geben es einfach an die Syntax Ihrer Sprache an und erhalten Syntaxfehler / -vervollständigung kostenlos. Dennoch müssen Sie die Symbolauflösung und die schwierigen Teile implementieren, aber dies ist ein sehr interessanter Ausgangspunkt; Die schwierigen Punkte sind jedoch die Integration mit den plattformspezifischen Bibliotheken zum Lösen von Java-Symbolen, sodass dies nicht wirklich alle Ihre Probleme lösen wird.
Schlussfolgerungen
Es gibt viele Möglichkeiten, potenzielle Benutzer zu verlieren, die Interesse an Ihrer Sprache gezeigt haben. Eine neue Sprache anzunehmen ist eine Herausforderung, weil sie das Erlernen und Anpassen unserer Entwicklungsgewohnheiten erfordert. Indem Sie die Abnutzung so weit wie möglich reduzieren und das Ihren Benutzern bereits bekannte Ökosystem nutzen, können Sie verhindern, dass Benutzer aufgeben, bevor sie Ihre Sprache gelernt und sich in sie verliebt haben.
Im Idealfall könnte Ihr Benutzer ein einfaches Projekt klonen, das in Ihrer Sprache geschrieben ist, und es mit den Standardwerkzeugen (Maven oder Gradle) erstellen, ohne einen Unterschied zu bemerken. Wenn er das Projekt bearbeiten möchte, kann er es in seinem bevorzugten Editor öffnen und das Plugin hilft ihm dabei, auf Fehler hinzuweisen und intelligente Vervollständigungen bereitzustellen. Dies ist ein ganz anderes Szenario, als herauszufinden, wie Sie Ihren Compiler aufrufen und Dateien mit Notepad bearbeiten. Das Ökosystem rund um Ihre Sprache kann wirklich den Unterschied ausmachen, und heutzutage kann es mit vertretbarem Aufwand aufgebaut werden.
Mein Rat ist, in Ihrer Sprache kreativ zu sein, aber nicht in Ihren Werkzeugen. Reduzieren Sie die anfänglichen Schwierigkeiten, mit denen Menschen konfrontiert werden, um Ihre Sprache zu übernehmen, indem Sie vertraute Standards verwenden.
Viel Spaß beim Sprachdesign!
Weiterführende Literatur im Toptal Engineering Blog:
- Wie man einen Dolmetscher von Grund auf neu schreibt
