TypeScript ist überraschend brauchbar – sogar für Compiler-Implementierungen
(matklad.github.io)- Die Implementierungssprache für compilerartige Werkzeuge war traditionell zwischen OCaml und C++ aufgeteilt, aber für kleine Sprachexperimente kann TypeScript eine leichtgewichtige Option sein, ähnlich wie eine ML-Familiensprache.
- Rust kombiniert die Stärken von ML und C++ und bietet sogar sicheres Multithreading, doch weil man das physische Layout der Daten modellieren muss, kann es für kleine Prototypen zu aufwendig sein.
- Deno ermöglicht mit Single Binary, integriertem Linting und Formatting, ohne Kompilierungsschritt sowie mit Task Runner und Watch-Modus einen schnellen Start für TypeScript-Sprachexperimente.
- Der Beispiel-Typechecker kombiniert generische ASTs, Tagged Unions, Visitor und Bottom-up-Transformationen, um
Expr<void>inExpr<Type>zu verwandeln, und verwendetTypeErrorals Typwert, um Kaskadenfehler zu reduzieren. - TypeScript kann dank Autovervollständigung, flexiblem Typsystem und Laufzeiteigenschaften, die bei Bedarf dynamische Ansätze erlauben, ein produktives Werkzeug für kleine Sprach-Hacks sein.
Die bisherige Lage bei der Wahl der Implementierungssprache
- Beim Bau compilerartiger Werkzeuge gibt es bei der Wahl der Implementierungssprache grob zwei Strömungen.
- Für sprachzentrierte Arbeiten wie formale Spezifikationen oder Hobby-Spielzeugsprachen eignet sich OCaml gut.
- Beispiele sind plzoo und der WebAssembly reference interpreter.
- Für implementierungsorientierte Arbeiten mit Bedarf an Produktionsreife wird häufig C++ gewählt.
- LLVM, clang, v8 und HotSpot basieren alle auf C++.
- Rust ist direkt von ML und C++ beeinflusst, kombiniert die Stärken beider Sprachen und bringt zusätzlich eigene Vorteile wie sicheres Multithreading mit.
- Im Spektrum ist es allerdings stärker in Richtung Produktionsreife geneigt.
- Ein Build-System, das „einfach funktioniert“, hilft zwar auch beim Prototyping, bringt aber die zusätzliche Komplexität mit sich, das physische Layout der Daten modellieren zu müssen.
Die Vorteile des Rust-Index-Ansatzes und die Last für kleinen Code
- Ein häufiger Rat beim Schreiben von Compilern in Rust ist, Pointer zu vermeiden und stattdessen Indizes zu verwenden.
- Indizes haben in großen Codebasen mehrere Vorteile.
- Side Tables können im zugehörigen Modul bleiben, was die Kopplung senkt.
- Indizes sind
u32und fördern ein Struct-of-Arrays-Layout, was der Performance zugutekommt. - Sie lassen sich leicht serialisieren oder an Frameworks für inkrementelles Kompilieren anbinden, wodurch die Berechnungsstrategie flexibler wird.
- Bei kleineren Programmeinheiten wird der Index-Ansatz selbst jedoch umständlich, und bei Hobby-Experimenten kann diese Last entscheidend sein.
- OCaml wirkt ein wenig altmodisch, und in diesem Kontext wird geprüft, ob TypeScript als eine Art ML-Alternative dienen kann.
Deno und TypeScript als Umgebung für schnelle Experimente
- Als Startumgebung wird deno verwendet.
- Es bietet eine Out-of-the-box-Erfahrung, bei der TypeScript sofort nutzbar ist.
- In OCaml ist genau dieser Punkt oft schmerzhaft, und Rust ist hier zwar besser als OCaml oder C++, aber Deno liefert noch eine einfachere Erfahrung als Rust.
- Das Entwicklererlebnis mit Deno passt gut zu kleinen PLT-Hacks.
- Es ist ein Single Binary.
- Linting und Formatting sind eingebaut.
- Es gibt keinen separaten Kompilierungsschritt.
- Es gibt einen integrierten Task Runner und einen Watch-Modus.
- TypeScript selbst bietet ein Typsystem, das ausreichend flexibel ist und zugleich eine geringe syntaktische Hürde hat.
TypeScript-Muster im kleinen Typechecker-Beispiel
- Der AST beginnt mit Ausdrücken, die Positionsinformationen aus der Datei enthalten.
Locationenthältfile,lineundcolumn.- In TypeScript sind Zeichenketten einfach
stringund Zahlen einfachnumber, sodass man nicht über Unterscheidungen wieusizeundu32nachdenken muss.
- Ausdrücke trennen Position und Kind und verallgemeinern die zugehörigen Daten später in der Form
Expr<T>.- Direkt nach dem Parsen haben Ausdrücke Daten vom Typ
void. - Vom Typechecker verarbeitete Ausdrücke haben Daten vom Typ
Type. - Die Typinferenzfunktion nimmt
ast.Expr<void>entgegen und gibtast.Expr<Type>zurück.
- Direkt nach dem Parsen haben Ausdrücke Daten vom Typ
- TypeScript fügt Laufzeitverhalten nicht automatisch hinzu, daher muss man für das Matching auf Union-Typen selbst Laufzeit-Typinformationen einbauen.
- Felder wie
tag: "binary"undtag: "if"übernehmen diese Rolle. tag: "binary"bedeutet zur Laufzeit, dass der Wert nur die Zeichenkette"binary"sein kann.
- Felder wie
- Boolean-Literale und Integer-Literale haben fast dieselbe Form und werden daher als
ExprLiteral<T, V, Tag>abstrahiert.ExprBool<T>istExprLiteral<T, boolean, "bool">.ExprInt<T>istExprLiteral<T, number, "int">.
- Typwerte sind in
TypeBoolundTypeIntaufgeteilt, Singleton-Werte werden unter denselben Namen ebenfalls bereitgestellt.- TypeScript löscht Typen vollständig, deshalb existieren typbezogene und wertbezogene Namen in getrennten Namespaces.
- Diese Eigenschaft erlaubt es, Typen und Werte mit demselben Namen zu definieren.
Visitor, Transformation, Fehlertypen und Desugaring
- Da
switchin TypeScript kein Ausdruck, sondern eine Anweisung ist, wird zur bequemen Verarbeitung der Ausdrucksarten ein Visitor definiert.- Die Methoden
bool,int,binaryundifverarbeiten jeweils das entsprechende Kind. - Die Autovervollständigung des Editors hilft sowohl bei
switch-Cases als auch bei der Visitor-Implementierung.
- Die Methoden
transform<U, V>ist eine verallgemeinerte Traversierungsfunktion, dieExpr<U>inExpr<V>umwandelt.- Die Transformation läuft bottom-up ab.
- Beim Besuch innerer Knoten sind die Unterausdrücke bereits transformiert, daher hat der Visitor den Typ
Visitor<V, V>und nichtVisitor<U, V>.
- Weil TypeScript auch eine dynamisch typisierte Sprache ist, könnte man mit
Object.keyseine allgemeinere Traversierung bauen.- Dabei kann die statische Funktionssignatur trotzdem erhalten bleiben.
- Im Beispiel ist das nicht unbedingt nötig, aber bei Bedarf gibt es die Möglichkeit, in einen dynamischen Ansatz auszuweichen.
- Typfehler werden nicht als Seiteneffekt in einem Array gesammelt, sondern als Typ
TypeErrordargestellt.Typewird zuTypeBool | TypeInt | TypeError.TypeErrorbesitzttag: "Error",locationundmessage.type_equalgibttruezurück, wenn eine der beiden SeitenErrorist, und verhindert so Kaskadenfehler.
- Der finale Typechecker prüft
binary- undif-Ausdrücke.- Bei einem
binary-Ausdruck wird bei unterschiedlichen Typen der linken und rechten Operanden der Fehler"binary expression operands have different types"zurückgegeben. - Bei einem
if-Ausdruck wird der Fehler"if condition is not a boolean"zurückgegeben, wenn die Bedingung kein Boolean ist. - Unterscheiden sich die Typen von
then- undelse-Branch, wird"if branches have different types"zurückgegeben.
- Bei einem
- Das Ergebnis erfordert zwar ein gewisses Maß an Typannotationen, doch die Autovervollständigung gleicht viel davon aus, das Gefühl eines Kampfs mit der Sprache ist geringer, und es passt sich natürlich an die Problemform an.
- Warum TypeScript als Werkzeug für kleine Sprach-Hacks produktiv sein kann, wird in drei Punkten zusammengefasst.
- Deno ist eine kleine, geschlossene, leistungsfähige und auf einen effektiven Entwicklungs-Workflow optimierte Scripting Runtime.
- Die TypeScript-Tooling ist dank nützlicher IDE-Unterstützung produktiv, und mit Deno ist keine Konfiguration nötig.
- Die Sprache ist sowohl zur Laufzeit als auch zur Compile-Zeit mächtig, erlaubt recht ausgefeilte Ausdrücke über Typen und bietet dennoch die Möglichkeit, bei Bedarf dynamisch vorzugehen.
- Als weitere Idee ist auch ein typsicheres Desugaring vieler Sprachzucker-Konstrukte möglich.
ExprundExprKindwerden nicht über zugehörige Daten, sondern rekursiv über das gesamteExprKindparametrisiert.ExprKindCoresteht für die Menge der Kernausdrücke.ExprKindSugarenthält entweder Kernausdrücke oder Ausdrücke, die sich in Kernausdrücke desugaren lassen.desugar(expr: ExprSugar): ExprCorereduziert Ausdrucksformen mit Sprachzucker auf Kernausdrücke.desugar_one(expr: ExprKindSugar<ExprCore>): ExprKindCore<ExprCore>führt eine einzelne Transformation aus, wenn die Unterausdrücke bereits desugart wurden.
1 Kommentare
Meinungen auf Hacker News
TypeScript ist insgesamt eine hervorragende Sprache, und es wird unterschätzt, dass Funktionen Objekte sind, die Properties/Methoden haben können.
Man kann ein Array von Funktionen wie Befehle ausführen und später Beschreibungen wie
helpanhängen, oder über Closures/Partial Application Zustand hinzufügen, sodass man nicht vorschnell Klassen wieCommanddefinieren muss.In der Objektorientierung sorgt es oft für Konflikte, Dingen zu früh Namen zu geben; natürlicher sei es, die benötigten Werte an eine Funktion zu übergeben, statt Strukturen wie
VideoCompressor#compress()zu verwenden.Andere Sprachen, die Objekte unterstützen, die sich wie Funktionen verhalten, sind unter https://en.wikipedia.org/wiki/Function_object zusammengefasst.
Wie bei den Handlern in
net/httpkann eine Struct eineserve-Methode implementieren, oder eine Handler-Funktion kann sich selbst aufrufen und so ein Interface erfüllen.In Clojure kann man ähnlich vorgehen, indem man an ein Funktions-
varMetadaten hängt; und da es Lisp ist, ist mit Makros fast alles möglich.Außerdem trennen die CSP-Channels aus
core/asyncAusführung und Kommunikation und helfen so, das Problem der Funktionsfarben wie bei Callbacks/Promises/async/awaitzu vermeiden; Befehle können sich dabei wie Producer verhalten, die Ergebnisse in Channels senden.Unabhängig vom Methodennamen kann man sie in einem Funktionskontext verwenden, ohne auf den konkreten Methodennamen verweisen zu müssen.
Allerdings bevorzuge ich es nicht, wenn eine Funktion Properties besitzt, Zustand in sich trägt und ihr Verhalten sich trotz Aufruf mit denselben Argumenten ändern kann. Ein großer Vorteil funktionaler Programmierung liegt meiner Ansicht nach darin, sich von objektorientiertem Zustand zu lösen.
Func, Funktionen ohne Rückgabewert alsAction.Die Lambda-Ausdrücke von JavaScript ähneln denen von C# ziemlich stark, und auch die Funktionssignaturen von TypeScript und C# sind recht ähnlich.
Es gibt auch ein kleines Repository, das die Ähnlichkeiten zwischen JavaScript, TypeScript und C# zeigt: https://github.com/CharlieDigital/js-ts-csharp
Screenshot, der dieselbe Logik nebeneinander in JS/TS/C# zeigt: https://github.com/CharlieDigital/js-ts-csharp/blob/main/js-...
So überraschend ist das nicht. TypeScript ist letztlich auch nur eine weitere Sprache, die viele Funktionen aus der ML-Familie mühsam übernommen hat.
Weil echtes Pattern Matching fehlt, ist sie unbequemer als OCaml, aber verglichen mit Sprachen wie C#, Swift, Dart oder Kotlin liegt sie durchaus im Rahmen.
TypeScript hat zwar ein starkes Typsystem, aber die zugrunde liegende Standardbibliothek und die Sprache selbst sind enttäuschend; Pattern Matching/Switch-Expressions fehlen.
Dart hat ein geschlossenes Objektmodell, bietet also weniger dynamische Freiheit, Typsystem und Expressions sind schwächer, und es gibt kaum Metaprogramming-Fähigkeiten, sodass man sich auf Java-artigen Boilerplate und Codegeneratoren stützen muss.
C# kommt von den genannten Sprachen den ML-Funktionen am nächsten, aber im Gegensatz zu TypeScript fehlen Sum Types, wodurch vieles umständlicher wird.
Zum Beispiel kann man einen Parser, der
{ ... }erkennt, mit anderen Parsern kombinieren, und eine Anweisung kann als eines von Kontrollfluss/Deklaration/Zuweisung definiert werden.ML-artige Listenverarbeitung und Pattern Matching waren beim Umgang mit Zwischenrepräsentationen sehr ausdrucksstark.
Auch im Artikel musste man auf das Visitor Pattern ausweichen, weil
switchkeine Expression ist, und die Iterator-Unterstützung von JavaScript ist merkwürdig unzureichend..map()gibt es zwar, aber es funktioniert nur auf Arrays und lässt sich nicht direkt auf allgemeine Iteratoren anwenden.Es gibt viele Unterschiede, aber der Wechsel ist nicht schwierig; persönlich bevorzuge ich Kotlin, kann aber beide verwenden.
Ich frage mich, wie es wäre, wenn TypeScript sich von der JavaScript-Kompatibilität lösen und nach WASM kompilieren würde. Kotlin fügt gerade einen WASM-Compiler hinzu und hat bereits einen JS-Transpiler; derselbe Code ist in WASM kleiner und lädt schneller.
Browser-JavaScript ist kein gutes Compile Target, und da neue Projekte immer häufiger von Anfang an mit TypeScript starten, wird auch das Argument, man müsse leicht von bestehendem JavaScript migrieren können, zunehmend schwächer.
Wenn man zwischen Rust und TypeScript wechselt, sieht man sehr deutlich, wie sehr Funktionen wie Tagged Enums fehlen.
Der Vorschlag für ADT-Enums scheint stehengeblieben zu sein; ich frage mich, ob es andere Bemühungen gibt: https://github.com/Jack-Works/proposal-enum/discussions/19
Das Typsystem von TypeScript ist interessant, aber man fragt sich, wie viel schneller es wäre, wenn der Compiler in einer kompilierten Sprache geschrieben wäre
Dafür braucht es natürlich die große Voraussetzung einer „guten Implementierung“
swcundesbuildsind keine guten Vergleichsmaßstäbe. Ein erheblicher Teil des Geschwindigkeitsgewinns kommt daher, dass TypeScript-spezifische Syntax entfernt und JavaScript erzeugt wirdtscist vor allem nur beim ersten Lauf langsam; mit demincremental-Flag oder einem Watch-Modus mit--transpile-onlysinkt die Kompilierzeit meist unter 100 ms, sodass sich zu SWC oder ESBuild kaum noch ein spürbarer Unterschied ergibtWenn die Typprüfung eines mittelgroßen Programms 20 Sekunden dauert, liegt das meist nicht daran, dass es JS ist, sondern daran, dass die Typen eine kombinatorische Explosion verursachen
Andere Runtimes könnten bei Parallelität oder Startzeit Vorteile bringen, aber CPU-zentrierte Benchmarks, die eine insgesamt 20-fache Beschleunigung stützen, habe man nicht gesehen
Und nachdem man dann einen ganzen Tag lang 5 Zeilen Typ-Logik debuggt hat, fragt man sich, wie man überhaupt so weit gekommen ist
Ein in Rust geschriebenes Projekt eines der Hauptentwickler von SWC; SWC kompiliert TS zu JS und STC prüft TS-Typen
Der kommende isolated declaration mode soll die Kompilierzeit um bis zu 75 % reduzieren können: https://github.com/microsoft/TypeScript/pull/53463#issuecomm...
Wer gerade erst anfängt, Compiler zu lernen, dem empfehle ich dieses Buch: https://keleshev.com/compiling-to-assembly-from-scratch/
Der Autor baut darin einen Compiler von einer TypeScript-Teilmenge zu 32-Bit-ARM-Assembly und erklärt, dass es fast wie Pseudocode wirkt und dadurch gut zugänglich ist
Das Buch ist in zwei Teile gegliedert: Im ersten wird in Java ein Interpreter für eine Sprache gebaut, im zweiten wird dieselbe Sprache zu Bytecode kompiliert und anschließend in C eine Bytecode-VM implementiert
Jede Codezeile der Implementierung wird im Buch referenziert
Wenn man das Visitor Pattern vermeiden will, kann man mit einer
run-Utility-Funktion einen IIFE-artigen switch verwendenMan schreibt also ein
switchin eine sofort ausgeführte Funktion und lässt den Rückgabetyp ableitenWenn man IIFEs wegen des abschließenden
()vermeiden möchte, kann man die Funktion separat definieren und dann aufrufenIch schreibe gerade einen Compiler in TypeScript und stimme zu, dass es besser ist als erwartet
Anfangs habe ich wie der Autor mit Deno angefangen, bin aber letztlich zu Bun gewechselt; trotz einiger rauer Kanten gefällt es mir besser als Deno und ist sehr schnell
Als Frontend mit Standard-Parsergenerator ist Ohm-js ziemlich angenehm: https://ohmjs.org/
Der offizielle
tsc-Compiler ist viel zu groß, daher würde ich ihn nicht zum Lesen empfehlen; um zu sehen, wietscfunktioniert, ist mini-typescript besser: https://github.com/sandersn/mini-typescript/Besonders der Branch
centi-typescriptist hilfreich: https://github.com/sandersn/mini-typescript/tree/centi-types...Ich freue mich darauf, dass in WASM GC und DOM-Zugriff möglich werden
Ich hätte wegen der Interfaces bei TypeScript mehr zusätzlichen Overhead erwartet, das ist überraschend
Da fragt man sich, ob sich das auch auf andere Bereiche übertragen lässt, etwa ob es auch für Sprachparsing taugt
Sie verschwinden beim Kompilieren vollständig
Das Ergebnis ist nicht allzu überraschend. Schließlich ist der TypeScript-Compiler selbst in TypeScript geschrieben
TypeScript als ML wird bereits täglich in schweren Produktionsumgebungen erprobt
Ich habe einmal einen Compiler in C# geschrieben; was hier besonders wirkt, sind eigentlich nur die Union Types
Persönlich habe ich mich entschieden, die Ausführlichkeit des Visitor Patterns zu vermeiden, und warte auf geschlossene Enums, um zur Kompilierzeit Vollständigkeitsprüfungen zu bekommen
Die üblichen Alternativen sind unbeholfen: Entweder hat man in einer Klasse zur Darstellung eines Summentyps N nullable Properties und verlässt sich auf die dokumentierte Bedingung „immer genau eines ist non-null“, oder man lässt mehrere Klassen von einer gemeinsamen Klasse erben; beides fühlt sich schwergewichtig an
Wenn man mehrere überlappende Union Types erstellen will, braucht man bei beiden Ansätzen Duplikation oder clevere Kombinationen