Der Titel dieses Artikels spiegelt eine Frage wider, die ich immer wieder in Foren oder in E-Mails, die ich erhalte, höre.
Ich glaube, jeder neugierige Entwickler hat sich diese Frage mindestens einmal gestellt. Es ist normal, davon fasziniert zu sein, wie Programmiersprachen funktionieren. Leider sind die meisten Antworten, die wir lesen, sehr akademisch oder theoretisch. Einige andere enthalten zu viele Implementierungsdetails. Nachdem wir sie gelesen haben, fragen wir uns immer noch, wie die Dinge in der Praxis funktionieren.
So werden wir sie beantworten. Ja, wir werden sehen, wie man eine eigene vollständige Sprache mit einem Compiler dafür erstellt und was nicht.
Der Überblick
Die meisten Leute, die lernen wollen, wie man „eine Programmiersprache erstellt“, suchen eigentlich nach Informationen darüber, wie man einen Compiler baut. Sie wollen die Mechanismen verstehen, die die Ausführung einer neuen Programmiersprache ermöglichen.
Ein Compiler ist ein grundlegendes Teil des Puzzles, aber die Entwicklung einer neuen Programmiersprache erfordert mehr als das:
1) Eine Sprache muss entworfen werden: Der Sprachentwickler muss einige grundlegende Entscheidungen über die zu verwendenden Paradigmen und die Syntax der Sprache treffen
2) Ein Compiler muss erstellt werden
3) Eine Standardbibliothek muss implementiert werden
4) Unterstützende Werkzeuge wie Editoren und Build-Systeme müssen bereitgestellt werden
Lassen Sie uns im Detail sehen, was jeder dieser Punkte mit sich bringt.
Entwerfen einer Programmiersprache
Wenn du nur deinen eigenen Compiler schreiben willst, um zu lernen, wie diese Dinge funktionieren, kannst du diese Phase überspringen. Du kannst einfach eine Teilmenge einer bestehenden Sprache nehmen oder eine einfache Variante davon entwickeln und loslegen. Wenn Sie jedoch vorhaben, Ihre eigene Programmiersprache zu entwickeln, müssen Sie darüber nachdenken.
Ich denke, dass der Entwurf einer Programmiersprache in zwei Phasen unterteilt ist:
- Die Phase des großen Ganzen
- Die Verfeinerungsphase
In der ersten Phase beantworten wir die grundlegenden Fragen über unsere Sprache.
- Welches Ausführungsparadigma wollen wir verwenden? Wird es imperativ oder funktional sein? Oder vielleicht basierend auf Zustandsautomaten oder Geschäftsregeln?
- Wollen wir statische Typisierung oder dynamische Typisierung?
- Für welche Art von Programmen wird diese Sprache am besten geeignet sein? Soll sie für kleine Skripte oder große Systeme verwendet werden?
- Was ist für uns am wichtigsten: Leistung? Lesbarkeit?
- Wollen wir, dass sie einer bestehenden Programmiersprache ähnlich ist? Soll sie sich an C-Entwickler richten oder leicht zu erlernen sein für diejenigen, die von Python kommen?
- Wollen wir, dass sie auf einer bestimmten Plattform funktioniert (JVM, CLR)?
- Welche Art von Metaprogrammierfähigkeiten wollen wir unterstützen, wenn überhaupt? Makros? Templates? Reflection?
In der zweiten Phase werden wir die Sprache weiterentwickeln, während wir sie benutzen. Wir werden auf Probleme stoßen, auf Dinge, die sehr schwierig oder unmöglich in unserer Sprache auszudrücken sind, und wir werden sie schließlich weiterentwickeln. Die zweite Phase ist vielleicht nicht so glamourös wie die erste, aber es ist die Phase, in der wir unsere Sprache immer weiter optimieren, um sie in der Praxis brauchbar zu machen, also sollten wir sie nicht unterschätzen.
Bau eines Compilers
Der Bau eines Compilers ist der aufregendste Schritt bei der Entwicklung einer Programmiersprache. Sobald wir einen Compiler haben, können wir unsere Sprache tatsächlich zum Leben erwecken. Ein Compiler erlaubt es uns, mit der Sprache zu spielen, sie zu benutzen und herauszufinden, was wir im ursprünglichen Entwurf vermissen. Er ermöglicht es, die ersten Ergebnisse zu sehen. Es ist schwer, die Freude zu übertreffen, das erste Programm in unserer brandneuen Programmiersprache auszuführen, egal wie einfach dieses Programm sein mag.
Aber wie bauen wir einen Compiler?
Wie alles Komplexe tun wir das in Schritten:
- Wir bauen einen Parser: Der Parser ist der Teil unseres Compilers, der den Text unserer Programme nimmt und versteht, welche Befehle sie ausdrücken. Er erkennt die Ausdrücke, die Anweisungen, die Klassen und erstellt interne Datenstrukturen, um sie zu repräsentieren. Der Rest des Parsers arbeitet mit diesen Datenstrukturen, nicht mit dem Originaltext
- (optional) Wir übersetzen den Parse-Baum in einen Abstract Syntax Tree. Typischerweise sind die vom Parser erzeugten Datenstrukturen ein wenig niedrig, da sie viele Details enthalten, die für unseren Compiler nicht entscheidend sind. Aus diesem Grund wollen wir die Datenstrukturen häufig in etwas höherem Niveau anordnen
- Wir lösen Symbole auf. Im Code schreiben wir Dinge wie
a + 1
. Unser Compiler muss herausfinden, worauf sicha
bezieht. Ist es ein Feld? Ist es eine Variable? Ist es ein Methodenparameter? Wir untersuchen den Code, um diese Frage zu beantworten - Wir validieren den Baum. Wir müssen überprüfen, ob der Programmierer keine Fehler gemacht hat. Versucht er, einen Boolean und einen Int zu summieren? Oder greift er auf ein nicht vorhandenes Feld zu? Wir müssen entsprechende Fehlermeldungen erzeugen
- Wir generieren den Maschinencode. An diesem Punkt übersetzen wir den Code in etwas, das die Maschine ausführen kann. Es könnte richtiger Maschinencode oder Bytecode für eine virtuelle Maschine sein
- (optional) Wir führen die Verknüpfung durch. In einigen Fällen müssen wir den für unsere Programme erzeugten Maschinencode mit dem Code statischer Bibliotheken, die wir einbinden wollen, kombinieren, um eine einzige ausführbare Datei zu erzeugen
Brauchen wir immer einen Compiler? Nein. Wir können ihn durch andere Mittel zur Ausführung des Codes ersetzen:
- Wir können einen Interpreter schreiben: ein Interpreter ist im Wesentlichen ein Programm, das die Schritte 1-4 eines Compilers ausführt und dann direkt das ausführt, was durch den abstrakten Syntaxbaum spezifiziert ist
- Wir können einen Transpiler schreiben: Ein Transpiler führt das aus, was in den Schritten 1-4 spezifiziert ist, und gibt dann einen Code in einer Sprache aus, für die wir bereits einen Compiler haben (z.B. C++ oder Java)
Diese beiden Alternativen sind durchaus valide, und häufig ist es sinnvoll, sich für eine der beiden zu entscheiden, weil der Aufwand in der Regel geringer ist.
Wir haben einen Artikel geschrieben, der erklärt, wie man einen Transpiler schreibt. Werfen Sie einen Blick darauf, wenn Sie ein praktisches Beispiel mit Code sehen wollen.
In diesem Artikel erklären wir genauer den Unterschied zwischen einem Compiler und einem Interpreter.
Eine Standardbibliothek für deine Programmiersprache
Jede Programmiersprache muss ein paar Dinge tun:
- Ausdrucken auf dem Bildschirm
- Zugriff auf das Dateisystem
- Netzwerkverbindungen nutzen
- Erstellen von GUIs
Dies sind die grundlegenden Funktionalitäten, um mit dem Rest des Systems zu interagieren. Ohne sie ist eine Sprache im Grunde nutzlos. Wie stellen wir diese Funktionalitäten zur Verfügung? Durch die Erstellung einer Standardbibliothek. Dabei handelt es sich um eine Reihe von Funktionen oder Klassen, die in den Programmen aufgerufen werden können, die in unserer Programmiersprache geschrieben sind, aber in einer anderen Sprache geschrieben werden. Viele Sprachen haben zum Beispiel Standardbibliotheken, die zumindest teilweise in C geschrieben sind.
Eine Standardbibliothek kann dann viel mehr enthalten. Zum Beispiel Klassen, um die wichtigsten Sammlungen wie Listen und Maps darzustellen, oder um gängige Formate wie JSON oder XML zu verarbeiten. Oft enthält sie erweiterte Funktionen zur Verarbeitung von Zeichenketten und regulären Ausdrücken.
Mit anderen Worten: Eine Standardbibliothek zu schreiben ist eine Menge Arbeit. Es ist nicht glamourös, es ist konzeptionell nicht so interessant wie das Schreiben eines Compilers, aber es ist immer noch eine grundlegende Komponente, um eine Programmiersprache lebensfähig zu machen.
Es gibt Möglichkeiten, diese Anforderung zu umgehen. Eine davon ist, die Sprache auf einer bestimmten Plattform laufen zu lassen und es zu ermöglichen, die Standardbibliothek einer anderen Sprache wiederzuverwenden. Zum Beispiel können alle Sprachen, die auf der JVM laufen, einfach die Java-Standardbibliothek wiederverwenden.
Unterstützende Werkzeuge für eine neue Programmiersprache
Um eine Sprache in der Praxis nutzbar zu machen, müssen wir häufig einige unterstützende Werkzeuge schreiben.
Das offensichtlichste ist ein Editor. Ein spezialisierter Editor mit Syntaxhervorhebung, Inline-Fehlerprüfung und Autovervollständigung ist heutzutage ein Muss für jeden Entwickler, um produktiv zu sein.
Aber heute sind Entwickler verwöhnt und erwarten alle möglichen anderen Hilfsmittel. Zum Beispiel könnte ein Debugger sehr nützlich sein, um einen bösen Fehler zu beheben. Oder ein Build-System, ähnlich wie Maven oder Gradle, könnte etwas sein, das die Benutzer später verlangen werden.
Am Anfang könnte ein Editor ausreichen, aber wenn die Benutzerbasis wächst, wird auch die Komplexität der Projekte zunehmen und mehr unterstützende Werkzeuge werden benötigt. Hoffentlich gibt es zu diesem Zeitpunkt eine Gemeinschaft, die bereit ist, bei der Erstellung dieser Werkzeuge zu helfen.
Zusammenfassung
Die Entwicklung einer Programmiersprache ist ein Prozess, der vielen Entwicklern rätselhaft erscheint. In diesem Artikel haben wir versucht zu zeigen, dass es nur ein Prozess ist. Es ist faszinierend und nicht einfach, aber es ist machbar.
Sie möchten vielleicht eine Programmiersprache aus verschiedenen Gründen entwickeln. Ein guter Grund ist, dass es Spaß macht, ein anderer, dass man lernen will, wie Compiler funktionieren. Ihre Sprache kann sehr nützlich sein oder auch nicht, das hängt von vielen Faktoren ab. Wenn Sie jedoch Spaß haben und/oder beim Erstellen lernen, dann lohnt es sich, etwas Zeit zu investieren.
Und natürlich können Sie mit Ihren Entwicklerkollegen prahlen.
Wenn Sie mehr über das Erstellen einer Sprache erfahren möchten, schauen Sie sich die anderen Ressourcen an, die wir erstellt haben: Lernen, wie man Sprachen erstellt.
Sie könnten auch an einigen unserer Artikel interessiert sein: