Die Programmiersprache Jam
(rapha.land)- Jam ist eine Sprache vor Version 1.0, die die unmittelbare Nutzbarkeit von Sprachen der C-Familie beibehalten und zugleich Sicherheit, eine niedrige Lernkurve und hohe Performance ohne GC anstreben will
- Im Kern stehen mutable value semantics und ein Rust-ähnliches Drop-System, bei dem der Compiler Ownership, Borrowing und automatische Bereinigung übernimmt, ohne Referenzen oder Lifetime-Syntax im Benutzercode offenzulegen
- Das Initialisierungsmodell vermeidet sowohl
undefinedals auch implizite Nullinitialisierung; verzögerte Initialisierung und Out-Parameter werden mitMaybe(T)und der Analyse vonunsafeAssumeInit()behandelt exportlegt Jam-Funktionen mit C ABI offen, und Jam-Structs sind mit C-kompatiblem Layout entworfen, um den Aufwand für separate unsafe Shims oderrepr-Annotationen zu verringern- Der Compiler befindet sich noch in der Bootstrap-Phase und ist in C++ implementiert, noch nicht öffentlich, und soll nach 108 unterschiedlichen Projekten in Jam als Open Source veröffentlicht werden
Welche Sprachposition Jam anvisiert
- Jam ist noch vor v1.0, und die derzeit beschriebenen Mechanismen funktionieren zwar im Compiler, Details können sich vor der Stabilisierung aber noch ändern
- Ziel ist eine sichere Sprache, die die Anmutung der C-Familie bewahrt, wie man sie aus Go, Zig oder modernem C kennt, dabei aber Bug-Klassen von C reduziert
- Im Zentrum des Designs stehen zwei Achsen
- Mutable value semantics nach Racordon, Abrahams et al. 2022
- Das Drop-System von Rust
- Ausgangspunkt ist die Einsicht, dass Teams in der Praxis gemischte Erfahrungsstände haben und weniger erfahrene Mitglieder eher Fehler machen; die Sprache soll deshalb schon vor dem Review mehr Fehler verhindern
Unterschiede zu Rust, Zig und C++
- Rust hat eine starke Sicherheitsphilosophie, aber der Abstand zwischen „Rust einigermaßen benutzen können“ und „mit Rust produktiv sein“ ist groß, was die Lernkurve im Team zur Belastung machen kann
- Zig bietet eine kleine, C-ähnliche Oberfläche und ein unmittelbares mentales Modell, ist aber auf Sprachebene keine sichere Sprache
- uninitialized read, manuelle Bereinigung und die Verhinderung von use-after-free werden nicht auf Sprachebene erzwungen
- Große Produktionsprojekte in Zig oder C++ verlassen sich stark auf Prüfwerkzeuge wie Valgrind, AddressSanitizer und Fuzzing
- Im Zeitalter von KI wird ein großer Teil von Produktionscode nicht mehr von Menschen, sondern von Tools geschrieben oder vorformuliert; der Flaschenhals verschiebt sich daher vom Code schreiben zum Code Review
- Das Codevolumen steigt, die Review-Oberfläche bleibt aber flach, daher muss der Compiler mehr Bugs abfangen
Automatisches Drop-System
- Ein Binding in Jam besitzt den Wert, und wenn ein Binding eines drop-tragenden Typs den Scope verlässt, erzeugt der Compiler einen Drop-Aufruf
- Der Beispieltyp
Filedeklariertfn drop(self: mut File), und inuseFile()steht nurconst f: File = { fd: 7 };- Es gibt keine explizite Bereinigung, kein
deferund keine Markierung für das Ende der Lifetime - Im LLVM-IR wird direkt vor
reteincall void @__drop_File(ptr %1)erzeugt
- Es gibt keine explizite Bereinigung, kein
- Der gemangelte Name
__drop_Fileverhindert, dass Drop-Funktionen verschiedener Typen auf LLVM-Ebene kollidieren self: mut Filewird auf einen Pointer-Parameter abgesenkt, und die Aufrufstelle übergibt direkt die Adresse des Bindings- In Zig müsste dieselbe Bereinigung mit
defer f.deinit()explizit angegeben werden- Entfernt man diese Zeile, verschwindet auch der deinit-Aufruf im IR
- Ein Leak von File Descriptors entsteht, wenn der Programmierer die Bereinigung vergisst
- Auch C++-RAII führt Destruktoren beim Verlassen des Scope automatisch aus, aber Jam übernimmt das einfache Drop-Modell von Rust
- Komplexität wie die rule of 0/3/5 in C++, virtuelle Destruktoren, Exceptions in Konstruktoren oder Destruktoren,
std::exit,std::abort,longjmpoder Signale soll vermieden werden - Jam hat pro Typ genau eine Drop-Funktion, die bei jedem Scope-Exit ausgeführt wird
- Komplexität wie die rule of 0/3/5 in C++, virtuelle Destruktoren, Exceptions in Konstruktoren oder Destruktoren,
Initialisierung und Maybe(T)
- In Jam gibt es keine
undefined-Werte, und ein Binding kann nicht ohne Wert deklariert werden- Jedes
varundconstverlangt einen echten Initializer - Bei Structs werden zuerst die Feldwerte berechnet, dann wird per Struct-Literal erzeugt und anschließend gebunden
- Jedes
- Zig erlaubt
var f: File = undefined; return f.fd;, was zur Laufzeit Stack-Müll lesen kann- Im Debug-Modus wird zur Sichtbarmachung von Fehlgebrauch mit
0xaaaufgefüllt - Im Release-Modus sind es beliebige Bytes
- Im Debug-Modus wird zur Sichtbarmachung von Fehlgebrauch mit
- Go nullinitialisiert alle
var, um Garbage-Reads zu verhindern, zahlt dafür aber auch bei Feldern, die gleich überschrieben werden, die Kosten des Zero-Patterns - Jam vermeidet sowohl
undefinedals auch implizites Nullsetzen - Für verzögerte Initialisierung und Out-Parameter verwendet Jam
Maybe(T)empty()erzeugt einen Slot mit noch bedeutungslosem Inhaltwrite()füllt den SlotunsafeAssumeInit()extrahiert den Wert
- Ein Lint-Pass verfolgt, ob der Slot beschrieben wurde, und lehnt
unsafeAssumeInit()-Aufrufe, deren Initialisierung der Analyzer nicht beweisen kann, als Compile Error ab- Das Präfix
unsafebleibt als per grep auffindbarer Anker für menschliche und KI-Reviewer erhalten
- Das Präfix
Scope-Exit, return, break, continue
- Der Compiler verfolgt einen Drop-Scope-Stack und pusht an jeder lexikalischen Blockgrenze einen neuen Scope
- Wenn ein Block endet oder per Branch verlassen wird, erzeugt er unmittelbar davor die Drops für die Bindings dieses Scope
- Bindings in
if,else,match-Armen,while- undfor-Bodies werden am Ende des jeweiligen Blocks gedroppt - Ein
returnin einem verschachtelten Block droppt aktive Scopes vom innersten nach außen, bevor tatsächlichreterfolgt breakundcontinuedroppen erst die im Loop-Body geöffneten Scopes und springen dann zum Loop-Exit oder zur nächsten Iteration
- Bindings in
- Im Beispiel mit verschachteltem
breakwirdouteram Ende von Iteration 0 gedroppt, und auf dembreak-Pfad in Iteration 1 zuerstinner, dannouter
Parameter-Modi und das Entfernen erstklassiger Referenzen
- Ob beim Funktionsaufruf ein Binding gedroppt wird, entscheidet der Parameter-Modus
- Der Standardmodus ist ein schreibgeschütztes Borrow
- Der Callee liest den Wert, und das Binding des Callers bleibt initialisiert
- Bei der Rückkehr des Aufrufs findet kein Drop statt
mutist ein exklusives Lese-/Schreib-Borrow- Das Binding des Callers bleibt auch nach dem Aufruf initialisiert
- Nur
movekonsumiert den Wert- Der Callee erhält Ownership und droppt am Ende des Callee
- Das Binding des Callers wird nach dem Aufruf zu Uninit, und ein Lesezugriff darauf ist ein Compile Error
- Es gibt keinen Marker an der Aufrufstelle;
f(x)sieht in allen Modi gleich aus - Jam hat keinen erstklassigen Referenztyp
- Borrows können nicht in Variablen gespeichert, zurückgegeben oder in Struct-Feldern abgelegt werden
- Parameter-Borrows existieren nur während des Call-Frames und verfallen beim Return
- Lifetime-Annotationen sind nicht nötig, weil es keine Lifetimes gibt, die man daran hängen könnte
- Auch Collection-APIs behalten eine wertförmige Gestalt
v[i] = xwird zuv.setAt(i, x)desugaredlet y = v[i]ruft den Getterv.at(i)auf, der das Element als Wert zurückgibt
- Die Exklusivitätsprüfung an der Aufrufstelle untersucht Pfadüberlappungen in den durch Argumente erzeugten Borrow-Sets
swap(p.x, p.y)ist wegen disjunkter Unterpfade erlaubtmoveX(p, p.x)ist ein Fehler, weilpundp.xsich überlappen
C ABI und FFI
- Das native ABI von Rust ist instabil, daher müssen Werte über Distributionsgrenzen hinweg wieder in C-Form codiert werden
- Das Dereferenzieren roher Pointer ist
unsafe - Ownership wird manuell mit
Box::into_rawundBox::from_rawübertragen - Wer Structs by value übergibt, braucht zusätzliche Annotationen wie
#[repr(C)] - Werkzeuge wie
cbindgenundabi_stableexistieren, um die Handarbeit an dieser Grenze zu verringern
- Das Dereferenzieren roher Pointer ist
- Jam geht davon aus, dass Jam-Werte durchgehend value-shaped sind, weil es keine erstklassigen Referenzen, Lifetimes oder niche-packed Layouts gibt
- Jam-Structs sind bereits mit C-kompatiblem Layout entworfen
exportlegt Jam-Funktionen mit dem C-Calling-Convention und einem einfachen unmangled Namen offenexport fn counterAdd(c: mut Counter, n: i64) i64kann von C alsint64_t counterAdd(Counter *c, int64_t n);aufgerufen werden- Der Parameter
mut Counterwird zu einemCounter *auf caller-owned storage abgesenkt
- Der Funktionsrumpf auf Jam-Seite bleibt gewöhnlicher Jam-Code, daher gelten Drop, Initialisierungsanalyse und Exklusivitätsregeln an der Aufrufstelle weiterhin
- In Richtung C werden C-Signaturen mit
externdeklariert- Eine
extern-Funktion folgt dem C ABI wortwörtlich - Die Parameter-Modus-Mechanik gilt außerhalb dieser Grenze nicht
- Übergibt man C einen Buffer per rohem Pointer, verifiziert Jam nicht, was C mit dem Pointer macht
- Eine
- Was Jam bieten will, ist, dass die Jam-Seite standardmäßig safe bleibt und man beim Export einer Jam-Bibliothek über C ABI keine separate unsafe API-Spiegelung oder Shim-Schicht bauen muss
Pattern Matching
matchin Jam hat die FormPattern Blockund verwendet kein=>- Das Scrutinee wird wie in
match (opcode)in Klammern gesetzt _ist ein Catch-all-Arm- Die Arme werden von oben nach unten in first-match-Reihenfolge geprüft, ohne implizites Fallthrough
- Das Scrutinee wird wie in
- Ein zentraler Anwendungsfall ist der Opcode-Dispatcher eines Game-Boy-Emulators
- Dabei werden 256 Basis-Opcodes und 256 Prefix-Opcodes verteilt
- Auch Matching auf Enum-Payloads wird unterstützt
- Das Variantenmuster matched auf den Tag und bindet Payload-Felder innerhalb des Arms an frische lokale Variablen
- Der Compiler prüft die Exhaustiveness der Variantenmenge
- Fügt man eine neue Variante hinzu, schlagen alle
match-Stellen beim Kompilieren fehl, die diese Variante nicht behandeln
matchfunktioniert auch als Expression- Jeder Arm-Block erzeugt den Wert seiner abschließenden Expression
- Alle Arme müssen denselben Typ erzeugen
- Ein
matchmuss exhaustiv sein
- Intern wird jedes
matchüber eine Entscheidungsbaum-Pipeline auf Basis von Luc Maranget 2008 kompiliert- Eine Kaskade von Integer-Literalen faltet LLVM
simplifycfgbei Rentabilität zuswitchund Jump Tables zusammen
- Eine Kaskade von Integer-Literalen faltet LLVM
Compile-Time-Design
- Die Compile-Pipeline von Rust durchläuft mehrere IRs und Analysephasen
tokens → AST → HIR → THIR → MIR → monomorphization → LLVM IR → machine code- Trait-Solving ist ein Suchproblem, Borrow Checking eine regionsbasierte Analyse über die gesamte Funktion
- Monomorphisierung vergrößert das Codevolumen schon vor LLVM
- Die Pipeline von Jam ist kürzer ausgelegt
tokens → AST → AstGen → JIR → codegen → LLVM IR → machine code- Es gibt nur ein typisiertes IR: JIR
- JIR ist bereits beim Erzeugen durch AstGen typisiert
- Jam geht davon aus, dass es kein comptime-as-values gibt, das untypisierte Lowerings erzwingen würde
- Drop-Platzierung, Init-before-use-Prüfung und Exklusivitätsregeln an der Aufrufstelle laufen als lokale Dataflow-Pässe über JIR
- Weil jedes Binding eine Typannotation hat, soll der Aufwand für globale Typinferenz und offene Trait-Suche geringer sein
- AST und JIR sind flache Datenstrukturen
- Kleine Knoten fester Größe werden in zusammenhängende Arrays gepackt
- Statt Pointern werden Indizes verwendet, übergroße Payloads landen in Side-Pools
- Der Compiler soll cache-freundliche Arrays traversieren, statt Heap-allozierte Bäume zu verfolgen
- Im Backend dominiert LLVM die Optimierungszeit von Release-Builds
- Geplant ist eine Aufteilung mit Cranelift für Debug-Builds und LLVM für Release-Builds
- Cranelift steht auf der Roadmap und ist noch nicht fertig
- Der aktuelle Compiler ist noch eine C++-Implementierung in der Bootstrap-Phase, und es gibt noch keine zitierfähigen Benchmarks zur Build-Zeit
- Aussagen zur Compile-Time sind Design-Behauptungen, keine Messergebnisse
Laufzeit-Performance und Beispiele
- Ziel ist, dass Jam bei der Performance zu Rust und Zig aufschließt
- Jam hat weder GC noch ein Managed-Memory-Runtime noch per-Allocation-Header
- Das Codegen besteht aus geradlinigem LLVM-IR
- Derzeit wird nicht behauptet, dass Jam bereits das Niveau von Rust und Zig erreicht
- Rust und Zig haben über lange Zeit viel Arbeit in target-spezifische Intrinsics der Standardbibliothek, Auto-Vectorization-Hinweise, allocator-bewusste Container, Tuning von Hot Paths und LLVM-Pass-Tuning gesteckt
- Auch Jam wird für die letzten 10–30 % dieselbe Art von Arbeit brauchen
- Bei den bisher gemessenen Workloads liegt die Lücke nach eigener Einschätzung nicht in einer „anderen Klasse“, sondern innerhalb eines kleinen konstanten Faktors
- Eine im Terminal laufende Tetris-Demo wurde in Jam geschrieben
Veröffentlichungsplan und offene Arbeit
- Jam ist noch nicht öffentlich
- Der Compiler existiert und funktioniert, befindet sich aber noch vor einer breiteren Freigabe
- Für die Alltagstauglichkeit wird derzeit an Folgendem gearbeitet
- stabile Oberfläche
- Package Manager
- LSP
- Formatter
- übriges Tooling
- Es gibt noch Themen, die in eigenen Beiträgen behandelt werden sollen
- Parameter-Modus-System
- Exklusivitätsregel
- Generics
- Jam-Comptime
- Standardbibliothek
- Allocator-Systeme
- Panic-Modell
- MLIR-Exploration für eine GPU-Codegen-Pipeline
- Rust-ABI-Arbeit für FFI
- Cranelift
- Pfad zu einem self-hosted Compiler
- Geplant ist die Open-Source-Veröffentlichung erst nach 108 unterschiedlichen Projekten in Jam
- Die Zahl 108 ist ein willkürliches Milestone, abgeleitet von den 108 Stars of Destiny aus Suikoden 2
- Aktuell ist Jam bei einer kleinen Nutzergruppe im Einsatz; mit reiferem Tooling soll der Kreis erweitert werden
- Early Access ist über die Beta-Liste auf jamlang.org möglich
1 Kommentare
Meinungen auf Lobste.rs
Solche von LLMs erzeugten Texte tun etwas, wovor sich Engineers, insbesondere junge Engineers, in Acht nehmen sollten: Sie ersetzen quantitative Daten durch qualitative, plausibel klingende Prosa.
Mit einer Geschichte zu überzeugen ist für Autor und Leser einfacher, als belastbare Zahlen zu sammeln und zu analysieren. Das menschliche Gehirn mag Geschichten, und Geschichten funktionieren am besten, wenn sie einfach und sauber sind. Reale Daten spiegeln oft eine komplexe Welt wider, die so nuanciert ist, wie man bereit ist, hinzuschauen.
Man kann das mit diesem quantitativen Blogbeitrag zum Profiling des Rust-Compilers eines rustc-Contributors vergleichen.
Gute technische Texte können und sollten, wenn es passt, beides enthalten, dürfen aber nicht das verfehlen, was wirklich vermittelt werden muss. Nachdem ich eine größere Organisation für Assurance-Arbeit betrieben habe, ist mir klar geworden, wie schwierig technisches Schreiben ist; und mit der leichteren Zugänglichkeit von LLMs muss man vorsichtig sein, wie viel schlimmer dieses Problem werden kann.
Der zentrale Unterschied zu Zig ist, dass es
dropgibt und dass eine bestimmte, leicht falsch zu verwendende Komponente, nämlichundefined, fehlt?Es gibt kein
undefined, und alle Werte müssen initialisiert werden, aberMaybe(T).empty()gibt offenbar einen Wert zurück, dessen Inhalt „noch bedeutungslos“ ist, und wenn man direkt danachunsafeAssumeInit()aufruft, dürfte ein Müllwert zurückkommen. Damit ist es nicht sicher in dem Sinn wie Rust, wo der Compilerunsafeals Verunreinigung behandelt, die ein explizitesunsafe { .. }erfordert.Das Beispiel, das Sicherheit und die
drop-Funktion zeigen soll, ist dieser Code:Wenn ich das nicht falsch sehe, ist das unsicher, oder? Selbst wenn man die manuelle Zuweisung des File Descriptors beiseitelässt, wird
close(7)aufgerufen und danach7zurückgegeben. Ohne Lifetime-Tracking hat der Nutzer keine Möglichkeit auszudrücken, dass die Lebensdauer des File Descriptors vor der Rückgabe vonuseFile()endet.Wenn im ABI-Beispiel
export fn counterAdd(c: mut Counter, n: i64) i64 { .. }zuint64_t counterAdd(Counter *c, int64_t n);wird: Wie drückt man aus, obcNULLsein darf oder nicht? In Rust gibt es dafür eine definierte ABI, und sowohlextern "C" fn counterAdd(c: &mut Counter, n: i64) -> i64als auchextern "C" fn counterAdd(c: Option<&mut Counter>, n: i64) -> i64sind möglich.Auch die Rust-Version braucht kein
unsafe. Man kann die API über Referenzen definieren. Ironischerweise ist die einzige Stelle, an derunsafein aktuellem Rust nötig sein könnte, ungefähr#[no_mangle], das inzwischen#[unsafe(no_mangle)]ist; das Beispiel ist jedoch aus irgendeinem Grund so aufgebaut, dass auf der Rust-Seite Raw Pointer verwendet werden.Auch dieses spätere Beispiel:
Müsste hier nicht irgendwo
unsafestehen? DasnprintfRaw Pointer entgegennimmt, müsste es gemäß der zuvor genannten Richtlinie, dassunsafe-Operationen am Namen erkennbar sein sollten, wohl so etwas wieunsafeSnprintfund eine Symbol-Neudefinition geben.„Ein ehrlicher Hinweis: In der
extern-Zeile spricht man mit C, und die Regeln von C gewinnen“ – hm.as_raw_fd(), und dort gibt es dasselbe Sicherheitsproblem.Das missversteht die FFI-Stabilität der Rust-Standardbibliothek. Shared References, Mutable References,
Boxund ihre jeweiligenOption-Varianten haben alle eine definierte und stabile ABI. Daher ist der gesamteBox::into_raw/from_raw-Ablauf im Beispiel unnötig.Lifetimes existieren auf Binärebene überhaupt nicht. Wenn man sich dafür entscheidet, für ein Enum eine stabile ABI zu definieren, wird die Niche-Optimierung deaktiviert.
Der Grund, warum die meisten Typen keine stabile ABI definieren, ist, dass man häufig gar keine stabile ABI will, weil man sonst das Innere des Typs nicht mehr ändern kann.
Diese Entscheidung verstehe ich nicht. Es gibt einen großen Unterschied zwischen etwas Unvollständiges zu „releasen“ und einfach den Source Code öffentlich zu machen. Wenn man es später ohnehin tun will, was schadet es, das Projekt schon während der Entwicklung öffentlich zu machen?
Der Vorteil wäre, dass Leute, denen die Richtung gefällt, es selbst ausprobieren und vielleicht sogar beitragen können. Natürlich ist im „Zeitalter der KI“ nicht klar, ob solche Beiträge netto ein Gewinn wären. Außerdem würde es den Leuten ermöglichen, besser zu verstehen, was gebaut wird, und die Behauptungen darüber zu bewerten, warum es großartig ist. Ohne das wird das Projekt deutlich weniger interessant.
Außerdem gibt es auch Leute, die keines dieser Tools verwenden. Mein aktuelles Team kann sich nicht einmal darauf einigen, einen automatischen Formatter einzuführen, ist ansonsten aber großartig. Daher macht es kaum einen Unterschied, die Veröffentlichung aufzuschieben, während man solche Tools baut.
Die Leute versuchen immer wieder, „Rust ohne nervige Lifetimes“ zu bauen, und scheitern immer wieder. Ein anderer Kommentar hat bereits eine Art des Scheiterns behandelt: Das Problem, einen Teil eines
dropten Werts zurückzugeben, entsteht, weil man keine Referenz zurückgeben kann. Ein anderes klassisches Problem ist dieses:Es gibt drei Antworten:
Für jede dieser drei Optionen gibt es gute Gründe, aber Jam scheint wie Rust Option 1 sein zu wollen, ist in der Praxis wegen der Wertsemantik aber eher Option 2. Wenn das bedeutet, dass alles kopiert wird, dürfte es verhindern, sichere und zugleich effiziente Datenstrukturen zu schreiben
Insbesondere wird es, wenn man den Borrow-Checker weglässt, deutlich schwieriger, stack-alloziierte Typen zu unterstützen, ohne mehrere Annotationen einzuführen. Ein Beispiel ist Kopieren beim Borrowing; sowohl Inko als auch Swift machen das so
In der Sprachreferenz habe ich gesehen, dass es zwar keine Referenzen gibt, aber
mut- undconst-Pointer; zu deren Sicherheit konnte ich jedoch nichts findenEin großer Teil dessen, was Zig zu Zig macht, ist, dass es kein RAII gibt, und bei Rust ist es der Borrow-Checker. Aber bei dem Punkt, an dem diese Designentscheidungen hier ankommen – „RAII ohne Referenzen“ –, bin ich mir nicht sicher, wer das tatsächlich braucht
Trotzdem denke ich, dass es in dieser Nische Raum zum Experimentieren gibt, und solche Versuche sehe ich positiv. Nur dieser Ansatz scheint es mir nicht zu sein
Die Richtung, über die ich in letzter Zeit ständig nachdenke, ist eine Kombination aus Zigs
comptime, Pony-ähnlichen Referenz-Capabilities, Lifetimes als Compile-Time-Werte und dem Branding von Lifetimes an AllocatorsWas ich mir davon erhoffe, ist, Zigs Allocator-Strategie um Referenzsicherheit zu ergänzen und Lifetimes zu bekommen, die kaum annotiert werden müssen
Neue Sprachen sind gut, aber ich mag es nicht, wenn alles nur ein LLVM-Frontend wird. Ich weiß, dass Backends schwierig sind, aber manchmal hätte ich gern auch andere Optionen
Klingt fast wie Swift