Die Entwicklung einer eigenen Programmiersprache ist eine anspruchsvolle und spannende Herausforderung. Für viele Entwickler stellt sich schon in den ersten Schritten die Frage, wie man überhaupt sicherstellen kann, dass die Sprache auch richtig funktioniert – also dass Programme genau so ausgeführt werden, wie es der Sprachentwurf vorsieht. Dabei ist die Überprüfung der eigenen Implementierung oft die größte Hürde. Die Idee, die Programmiersprache gleich zweimal zu implementieren, bietet einen profunderen Weg, dieses Problem zu lösen, als sich nur auf eine einzelne Implementierung oder eine vage informelle Spezifikation zu verlassen. Die meisten Programmierer verifizieren ihre Programme, indem sie sie einfach ausführen und beobachten, ob die Ergebnisse den Erwartungen entsprechen.
Die Programmiersprache selbst – ob Interpreter oder Compiler – fungiert dabei gewissermaßen als „Wahrheitsquelle“ für die Bedeutung von Programmen. Doch wenn Sie selbst die Sprache implementieren, fallen diese einfachen Lösungen weg: Wie können Sie sicher sein, dass Ihre Implementierung korrekt ist, wenn es keine externe Referenz gibt? Ein naheliegender, aber wenig hilfreicher Ansatz wäre, die Sprache schlicht als das zu definieren, was die Implementierung tut. Doch selbst Sprachen mit weitgehend implementation-defined Verhalten, wie etwa Python, legen implizite Kriterien an ihre Korrektheit an. Zum Beispiel darf der Interpreter nicht einfach abstürzen, und es existiert meist eine Dokumentation oder zumindest eine vage Spezifikation darüber, wie bestimmte Konstrukte zu interpretieren sind. Allerdings sind umfassend formale Sprachspezifikationen nach wie vor die Ausnahme – ein bedeutendes Beispiel dafür ist die Definition von Standard ML aus dem Jahr 1997.
Die meisten industriellen Programmiersprachen verfügen nur über informelle oder semi-formale Spezifikationen, oft auch nur für Teilbereiche der Sprache. Gerade für unabhängige Entwickler oder Hobbyprojekte ist das Verfassen und Pflegen einer solchen offiziellen Spezifikation extrem aufwendig. Gerade in den Anfangsstadien ist eine Sprache meist dynamisch im Wandel – neue Konzepte fügen sich hinzu, andere werden verworfen oder angepasst. Ohne präzise Spezifikation gibt es zwangsläufig Unsicherheiten bei der Interpretation von Grenzfällen oder bei Randbedingungen, beispielsweise wie sich Spezialfälle wie etwa leere Arrays verhalten sollten, wie es im Beispiel der Programmiersprache Futhark üblich ist. In Futhark hat das Team die Erfahrung gemacht, wie problematisch optimierende Compiler sein können, wenn sie das beobachtbare Programmverhalten verändern.
Compileroptimierungen wie Monomorphisierung oder Defunktionalisierung sind oft unverzichtbar, um die Sprache überhaupt effizient umsetzen zu können. Dennoch dürfen diese Transformationsschritte das Ergebnis der Programmausführung nicht im Sinne der Semantik verändern. Deshalb ist eine Möglichkeit, die Korrektheit der Implementierung zu überprüfen, eine weitere, vollständig getrennte Implementierung der Semantik zu schaffen. Ein solcher Interpretationsansatz, der direkt den abstrakten Syntaxbaum (AST) abläuft, verzichtet auf jegliche Vorverarbeitung über das reine Typ-Checking hinaus. Damit spiegelt die Interpretations-Engine die Sprache in ihrer reinsten Form wider.
Die Implementierung der Futhark-Interpreter ist bewusst einfach und direkt gehalten. Sie ist nicht performant, sondern vor allem als Referenz gedacht – um die Korrektheit zu sichern. Diese Ausrichtung führt dazu, dass eventuelle Ungereimtheiten der Sprache sich direkt in der Interpreter-Implementierung widerspiegeln, was das Auffinden und beheben von inkonsistentem Verhalten erleichtert. Für alle, die tief in die Sprachentwicklung einsteigen wollen, steckt in diesem dualen Implementierungsansatz ein essenzieller Vorteil: die Möglichkeit, beide Implementierungen gegeneinander zu verifizieren. Unterschiedliche Ergebnisse markieren potentielle Fehler.
In der Praxis wird meist die Interpreter-Variante als Referenz angesehen, da sie intuitiver und weniger komplex ist. Aber natürlich können auch beide Seiten Fehler enthalten, so dass der Abgleich beider Implementierungen Hinweise darauf gibt, wo Anpassungen nötig sind. Es gibt Szenarien, in denen die Implementierungen bewusst unterschiedlich agieren dürfen, zum Beispiel bei Programmen, die bewusst undefiniertes oder inkorrektes Verhalten aufweisen. Beispielsweise verwandeln Optimierer manchmal bewusst nonterminale Programmteile durch Dead-Code-Eliminierung, was ein langsamer Interpreter hingegen nicht tut. Dieses bewusste Auseinanderklaffen ist aber gut kontrollierbar und dokumentiert.
Die doppelte Implementierung macht zudem die Spezifikation praktisch greifbar, weil sie eine Form der semantischen Dokumentation darstellt. Eine formale Spezifikation an sich ist keineswegs nutzlos, aber oft zu aufwändig und wenig flexibel im Entwicklungsprozess. Ein schlanker Referenzinterpreter bietet hingegen eine lebendige und anpassbare Grundlage zur Klärung von Sprachfragen – sowohl für Entwickler als auch für Nutzer. Manche mögen argumentieren, dass die doppelte Implementierung viel Arbeit bedeute und vielleicht gar zu komplex sein könne. Tatsächlich legt ein solcher Ansatz eine gewisse Disziplin nahe und zwingt dazu, die Sprache möglichst einfach und klar zu gestalten.
Sprachen, die so komplex sind, dass ein Referenzinterpreter nur schwer zu realisieren ist, haben womöglich selbst ein Problem mit der Verständlichkeit und Wartbarkeit ihrer Semantik. Für Hobbyisten und unabhängige Sprachentwickler ist dieser Ansatz besonders empfehlenswert. Meist wird in der Anfangsphase ohnehin ein Interpreter gebaut, um neue Sprachideen zu testen. Warum diesen nicht als dauerhafte Referenz weiter pflegen? Dieser Referenzinterpreter dient dann als Kontrollinstrument, wenn nach und nach ein leistungsfähigerer Compiler entsteht. Auf diese Weise wächst die Sprache organisch und zuverlässig und bleibt generell beherrschbar.
In der Praxis hat sich bei Futhark gezeigt, dass die gemeinsame Nutzung von Frontend-Komponenten wie Parsing und Typ-Checking zwischen Interpreter und Compiler sinnvoll ist. So entsteht weniger Redundanz bei der Entwicklung. Gerade die frontalen Aufgabenbereiche lassen sich durch klar strukturierte, möglichst einfach gehaltene Module abdecken. Das Risiko für Fehler ist dort vergleichsweise gering, sodass die kritischen Unterschiede vor allem in der Semantik und den Optimierungen liegen. Bei allen Vorteilen darf nicht vergessen werden, dass ein Referenzinterpreter nicht perfekt ist.
Er ist langsam und kann nicht immer alle Eigenschaften der Sprache in der Praxis demonstrieren. Der Fokus liegt vielmehr auf korrekter, nachvollziehbarer Semantik. Wenn der Compiler dann parallel optimiert, ist das Ergebnis stets durch den Interpreter überprüfbar. Insgesamt lässt sich festhalten, dass die doppelte Implementierung einer Programmiersprache enorme Vorteile für die Qualitätssicherung bietet. Sie fördert eine verständliche, einfache und elegante Sprachsemantik und erlaubt eine praktische Verifikation in Abwesenheit einer exakten formalen Spezifikation.
Sie macht offensichtliche Inkonsistenzen und Designfehler auf, die andernfalls nur schwer erkennbar wären. Für die wachsende Szene der unabhängigen, experimentellen Programmiersprachen ist das eine goldene Regel: Implementieren Sie Ihre Sprache zweimal – mit einem klaren, einfachen Interpretations-Referenzmodell und einer separaten, optimierenden Produktivumsetzung. Somit leisten Sie nicht nur einen wichtigen Beitrag zur Stabilität und Zuverlässigkeit Ihrer Sprache, sondern erhöhen auch das Vertrauen der Nutzer und Entwickler in die Korrektheit der Programme, die mit Ihrer Sprache entstehen. Die Zeit, die Sie in eine solche doppelte Implementierung investieren, zahlt sich vielfach aus – sowohl in der Entwicklung als auch beim langfristigen Betrieb und der Weiterentwicklung.