Le langage de programmation Jam
(rapha.land)- Jam est un langage encore avant la v1.0 qui vise à combiner sûreté, faible courbe d’apprentissage et hautes performances, tout en conservant la prise en main immédiate des langages de la famille C et sans GC
- Son cœur repose sur les mutable value semantics et un système de drop à la Rust, où le compilateur gère ownership, borrow et nettoyage automatique sans exposer de références ni de syntaxe de lifetime dans le code utilisateur
- Son modèle d’initialisation évite à la fois
undefinedet l’initialisation implicite à zéro ; l’initialisation différée et les out-parameters sont gérés avecMaybe(T)et l’analyse deunsafeAssumeInit() exportexpose les fonctions Jam via une ABI C et les struct Jam sont conçues avec un layout compatible C, afin de réduire le besoin de shim unsafe séparé ou d’annotationsrepr- Le compilateur est encore au stade bootstrap, implémenté en C++, non public, avec un plan d’open source après avoir construit 108 projets distincts en Jam
Positionnement du langage Jam
- Jam est encore avant la v1.0 et, même si les mécanismes décrits fonctionnent dans le compilateur, certains détails peuvent encore changer avant stabilisation
- L’objectif est de créer un langage sûr qui garde une sensation de langage de la famille C facile à comprendre immédiatement, comme Go, Zig ou le C moderne, tout en réduisant les classes de bugs du C
- La conception s’articule autour de deux axes
- les Mutable value semantics de Racordon, Abrahams et al. 2022
- le drop system de Rust
- Le point de départ est l’idée que, dans une équipe réelle aux niveaux d’expérience variés, les membres les moins expérimentés risquent davantage de faire des erreurs ; le langage doit donc bloquer plus de fautes avant même la revue de code
Différences avec Rust, Zig et C++
- Rust a une philosophie de sûreté forte, mais l’écart entre « savoir un peu utiliser Rust » et « être productif avec Rust » peut être important, ce qui rend la courbe d’apprentissage lourde pour une équipe
- Zig offre un petit langage très proche de C, avec une surface réduite et un modèle mental immédiat, mais ce n’est pas un langage sûr au niveau du langage lui-même
- la lecture de mémoire non initialisée, le nettoyage manuel et la prévention des use-after-free ne sont pas imposés au niveau du langage
- les gros projets de production en Zig ou en C++ dépendent fortement d’outils de vérification comme Valgrind, AddressSanitizer ou le fuzzing
- À l’ère de l’IA, une grande partie du code de production est écrite ou ébauchée non par des humains mais par des outils, et le goulot d’étranglement se déplace de l’écriture du code vers la revue de code
- le volume de code augmente tandis que la surface de revue reste plate, donc le compilateur doit attraper davantage de bugs
Système de drop automatique
- Dans Jam, un binding possède sa valeur, et quand un binding d’un type ayant un drop sort de sa portée, le compilateur synthétise l’appel au drop
- Dans l’exemple du type
File, on déclarefn drop(self: mut File)et, dansuseFile(), on écrit seulementconst f: File = { fd: 7 };- pas de nettoyage explicite, pas de
defer, pas de marqueur de fin de lifetime - dans l’IR LLVM, un
call void @__drop_File(ptr %1)est généré juste avantret
- pas de nettoyage explicite, pas de
- Le nom manglé
__drop_Fileévite les collisions entre fonctions de drop de plusieurs types au niveau LLVM self: mut Fileest abaissé en paramètre pointeur, et le site d’appel transmet directement l’adresse du binding- En Zig, il faut écrire explicitement
defer f.deinit()pour le même nettoyage- si cette ligne est supprimée, l’appel
deinitdisparaît aussi de l’IR - une fuite de descripteur de fichier survient quand le programmeur oublie le nettoyage
- si cette ligne est supprimée, l’appel
- Le RAII de C++ exécute aussi automatiquement le destructeur en sortie de portée, mais Jam adopte le modèle de drop plus simple de Rust
- l’idée est d’éviter la complexité de C++ autour des règles of 0/3/5, des destructeurs virtuels, des exceptions dans les constructeurs, des exceptions dans les destructeurs, de
std::exit,std::abort,longjmpou des signaux - Jam a une seule fonction de drop par type, exécutée à chaque sortie de portée
- l’idée est d’éviter la complexité de C++ autour des règles of 0/3/5, des destructeurs virtuels, des exceptions dans les constructeurs, des exceptions dans les destructeurs, de
Initialisation et Maybe(T)
- Dans Jam, il n’existe pas de valeur
undefined, et on ne peut pas déclarer un binding sans valeur- chaque
varet chaqueconstdoivent avoir un véritable initializer - pour une struct, on calcule d’abord les valeurs des champs, puis on construit le littéral de struct avant de l’affecter au binding
- chaque
- Zig autorise
var f: File = undefined; return f.fd;, ce qui peut lire des déchets de pile à l’exécution- en mode Debug, un remplissage
0xaaest utilisé pour rendre les erreurs visibles - en mode Release, cela devient des octets arbitraires
- en mode Debug, un remplissage
- Go initialise tous les
varà zéro pour éviter ces lectures de déchets, mais cela a un coût même pour les champs aussitôt réécrits - Jam évite à la fois
undefinedet le zéro implicite - Pour l’initialisation différée et les out-parameters, Jam utilise
Maybe(T)empty()crée un slot dont le contenu n’a pas encore de senswrite()remplit ce slotunsafeAssumeInit()extrait la valeur
- Un passage de lint suit si le slot a été écrit, et si l’analyseur ne peut pas prouver l’initialisation, un appel à
unsafeAssumeInit()est rejeté avec une erreur de compilation- le préfixe
unsafereste un point d’ancrage que les relecteurs humains et les outils IA peuvent retrouver viagrep
- le préfixe
Sortie de portée, return, break, continue
- Le compilateur suit une pile de portées de drop et pousse une nouvelle portée à chaque frontière de bloc lexical
- À la fin d’un bloc ou juste avant d’en sortir via une branche, il émet les drops des bindings de cette portée
- les bindings dans
if,else, les branches dematch, le corps dewhileou deforsont drop à la fin de leur bloc - un
returndans un bloc imbriqué drop d’abord toutes les portées actives, de la plus interne à la plus externe, avant lereteffectif breaketcontinuedrop les portées ouvertes dans le corps de boucle avant de sortir de la boucle ou de passer à l’itération suivante
- les bindings dans
- Dans l’exemple de
breakimbriqué,outerest drop à la fin de l’itération 0 et, sur le chemin dubreakà l’itération 1,innerest drop puisouter
Modes de paramètres et suppression des références first-class
- Lors d’un appel de fonction, c’est le mode de paramètre qui détermine si un binding est drop
- Le mode par défaut est un borrow en lecture seule
- le callee lit la valeur et le binding du caller reste initialisé
- aucun drop n’a lieu au retour de l’appel
mutest un borrow exclusif en lecture-écriture- le binding du caller reste initialisé après l’appel
- Seul
moveconsomme la valeur- le callee reçoit la possession et le drop a lieu à la fin du callee
- le binding du caller passe en état Uninit après l’appel, et toute lecture devient une erreur de compilation
- Il n’y a pas de marqueur au site d’appel :
f(x)a la même forme dans tous les modes - Jam n’a pas de type référence first-class
- on ne peut pas stocker un borrow dans une variable, le retourner, ni le conserver dans un champ de struct
- les borrows de paramètres n’existent que pendant la frame d’appel et expirent au retour
- c’est pourquoi il n’y a pas besoin d’annotations de lifetime : il n’y a aucun lifetime à y attacher
- L’API des collections reste elle aussi orientée valeur
v[i] = xest désucré env.setAt(i, x)let y = v[i]appelle le getterv.at(i)qui renvoie l’élément par valeur
- La vérification d’exclusivité au site d’appel inspecte le recouvrement des chemins dans l’ensemble des borrows créés par les arguments
swap(p.x, p.y)est autorisé car ce sont deux sous-chemins disjointsmoveX(p, p.x)produit une erreur carpetp.xse recouvrent
ABI C et FFI
- L’ABI native de Rust est instable, donc dès qu’on franchit une frontière de distribution, il faut réencoder sous forme C
- le déréférencement de pointeur brut est
unsafe - l’ownership est transféré manuellement avec
Box::into_rawetBox::from_raw - pour passer une struct par valeur, il faut une annotation supplémentaire comme
#[repr(C)] - des outils comme
cbindgenetabi_stableexistent pour réduire le travail manuel à cette frontière
- le déréférencement de pointeur brut est
- Jam considère qu’en l’absence de références first-class, de lifetime et de layout niche-packed, une valeur Jam reste value-shaped all the way down
- les struct Jam sont déjà conçues avec un layout compatible C
exportexpose une fonction Jam avec un nom simple non manglé selon la convention d’appel Cexport fn counterAdd(c: mut Counter, n: i64) i64peut être appelé depuis C commeint64_t counterAdd(Counter *c, int64_t n);- le paramètre
mut Counterest abaissé enCounter *pointant vers un stockage possédé par le caller
- Le corps de fonction côté Jam reste du Jam ordinaire, donc les règles de drop, l’analyse d’initialisation et l’exclusivité au site d’appel continuent de s’appliquer
- Dans le sens vers C, on déclare la signature C avec
extern- une fonction
externsuit littéralement l’ABI C - la mécanique des modes de paramètres ne s’applique pas au-delà de cette frontière
- on peut passer un buffer à C via un pointeur brut, et Jam ne vérifie pas ce que C fait de ce pointeur
- une fonction
- Ce que Jam cherche à offrir, c’est un côté Jam safe by default tout en permettant d’exposer une bibliothèque Jam via une ABI C sans devoir créer une API miroir unsafe ou une couche de shim séparée
Pattern matching
- Le
matchde Jam utilise la formePattern Blocket n’emploie pas=>- la valeur testée utilise des parenthèses, comme dans
match (opcode) _est la branche catch-all- les branches sont évaluées de haut en bas, premier match gagné, sans fallthrough implicite
- la valeur testée utilise des parenthèses, comme dans
- Le principal cas d’usage est le dispatcher d’opcodes d’un émulateur Game Boy
- il dispatch 256 opcodes de base et 256 opcodes préfixés
- Le langage prend aussi en charge le matching sur payload d’enum
- le pattern de variant matche le tag et lie les champs du payload à de nouvelles variables locales dans la branche
- le compilateur vérifie l’exhaustivité sur l’ensemble des variants
- si on ajoute un nouveau variant, chaque site de
matchqui ne le gère pas échoue à la compilation
matchfonctionne aussi comme expression- chaque bloc de branche produit la valeur de son expression finale
- toutes les branches doivent produire le même type
- le match doit être exhaustif
- En interne, chaque
matchpasse par un pipeline d’arbre de décision fondé sur Luc Maranget 2008- une cascade de littéraux entiers peut être repliée par LLVM
simplifycfgenswitchet table de saut quand c’est rentable
- une cascade de littéraux entiers peut être repliée par LLVM
Conception du compile time
- Le pipeline de compilation de Rust passe par plusieurs IR et étapes d’analyse
tokens → AST → HIR → THIR → MIR → monomorphization → LLVM IR → machine code- la résolution de traits est un problème de recherche, et le borrow checking est une analyse de régions à l’échelle de la fonction
- la monomorphization augmente le volume de code avant LLVM
- Le pipeline de Jam est conçu pour être plus court
tokens → AST → AstGen → JIR → codegen → LLVM IR → machine code- il n’utilise qu’un seul IR typé : JIR
- JIR est typé dès sa création par AstGen
- Jam considère ne pas avoir de comptime-as-values imposant un lowering non typé
- le placement des drops, la vérification init-before-use et la règle d’exclusivité au site d’appel sont effectués comme des passes locales de dataflow sur JIR
- Comme chaque binding a son annotation de type, la charge liée à l’inférence de types globale et à la recherche ouverte de traits est plus faible
- L’AST et JIR sont des structures de données plates
- de petits nœuds à taille fixe sont rangés dans des tableaux contigus
- on utilise des index plutôt que des pointeurs, et les payloads trop volumineux sont stockés dans des side pools
- le compilateur parcourt ainsi des tableaux cache-friendly au lieu de suivre des arbres alloués sur le heap
- Côté backend, c’est LLVM qui domine encore le temps d’optimisation en build release
- un split est prévu avec Cranelift pour les builds debug et LLVM pour les builds release
- Cranelift est sur la roadmap mais n’est pas encore terminé
- Le compilateur actuel est encore une implémentation C++ en phase de bootstrap du langage, et il n’existe pas encore de benchmark de temps de build digne d’être cité
- les affirmations sur le compile time relèvent donc de choix de conception, pas de mesures publiées
Performances runtime et exemples
- L’objectif est que Jam atteigne les performances de Rust et Zig
- Jam n’a ni GC, ni runtime de mémoire managée, ni header par allocation
- le codegen produit un IR LLVM direct
- Le projet ne considère pas encore avoir atteint le niveau de Rust et Zig
- Rust et Zig bénéficient d’un long travail sur les intrinsèques spécifiques aux cibles dans la bibliothèque standard, les indices d’auto-vectorisation, les conteneurs sensibles à l’allocator, l’optimisation des hot paths et le réglage des passes LLVM
- Jam devra faire le même type de travail pour combler les 10 à 30 % finaux
- Sur les workloads mesurés jusqu’ici, l’écart semble relever d’un petit facteur constant plutôt que d’une autre classe de performance
- Une démo de Tetris en terminal a été écrite en Jam
Plan de publication et travaux restants
- Jam n’est pas encore public
- le compilateur existe et fonctionne, mais n’a pas encore été diffusé plus largement
- Pour un usage quotidien, les travaux suivants sont en cours
- surface stable
- package manager
- LSP
- formatter
- le reste du tooling
- Plusieurs sujets feront l’objet d’articles séparés
- le système de modes de paramètres
- la règle d’exclusivité
- les génériques
- le comptime de Jam
- la bibliothèque standard
- les systèmes d’allocator
- le modèle de panic
- l’exploration de MLIR pour une pipeline de génération de code GPU
- le travail sur l’ABI Rust pour la FFI
- Cranelift
- la voie vers un compilateur self-hosted
- Le plan open source est de publier Jam après avoir créé 108 projets distincts avec
- le nombre 108 est un jalon arbitraire inspiré des 108 Stars of Destiny de Suikoden 2
- pour l’instant, le langage est diffusé à un petit groupe d’utilisateurs, avec l’idée d’élargir l’accès à mesure que le tooling suivra
- Un accès anticipé est disponible via la beta list sur jamlang.org
1 commentaires
Avis sur Lobste.rs
Ce genre de texte généré par LLM fait quelque chose dont les ingénieurs, en particulier les jeunes, devraient se méfier : remplacer des données quantitatives par une prose qualitative et plausible.
Convaincre par un récit est plus facile, pour l’auteur comme pour le lecteur, que de collecter et d’analyser des chiffres solides. Le cerveau humain aime les histoires, et elles fonctionnent d’autant mieux qu’elles sont simples et nettes. Les données réelles reflètent souvent un monde complexe, plein de nuances dès qu’on prend la peine de les examiner.
Il suffit de comparer avec ce billet quantitatif sur le profilage du compilateur Rust, écrit par un contributeur de rustc.
Un bon texte technique peut, et devrait, contenir les deux lorsque c’est pertinent, mais il ne doit pas manquer ce qu’il faut vraiment transmettre. Après avoir dirigé une grosse organisation d’assurance qualité, j’ai compris à quel point la rédaction technique est difficile, et il faut se méfier de la manière dont ce problème peut encore empirer avec la facilité d’accès aux LLM.
La différence essentielle avec Zig, c’est qu’il y a
drop, et qu’il n’y a pasundefined, ce composant particulier si facile à mal utiliser ?Il n’y a pas
undefinedet toutes les valeurs doivent être initialisées, maisMaybe(T).empty()renvoie une valeur dont le contenu n’a « pas encore de sens », et si on appelle juste aprèsunsafeAssumeInit(), cela semble pouvoir renvoyer une valeur poubelle. Ce n’est donc pas de la sûreté au sens où Rust traiteunsafecomme une contamination nécessitant ununsafe { .. }explicite.L’exemple qui montre la sûreté et la fonctionnalité
dropest ce code :Si je ne me trompe pas, ce n’est pas sûr, non ? Même en mettant de côté l’allocation manuelle du descripteur de fichier, cela appelle
close(7)puis renvoie7. Comme il n’y a pas de suivi des durées de vie, l’utilisateur n’a aucun moyen d’exprimer que la durée de vie du descripteur de fichier se termine avant le retour deuseFile().Dans l’exemple d’ABI, quand
export fn counterAdd(c: mut Counter, n: i64) i64 { .. }devientint64_t counterAdd(Counter *c, int64_t n);, comment exprime-t-on sicpeut êtreNULLou non ? Rust a une ABI définie pour cela, et on peut écrire aussi bienextern "C" fn counterAdd(c: &mut Counter, n: i64) -> i64queextern "C" fn counterAdd(c: Option<&mut Counter>, n: i64) -> i64.La version Rust n’a pas non plus besoin de
unsafe. On peut définir l’API avec des références. Ironiquement, le seul endroit qui pourrait nécessiterunsafedans Rust moderne est#[no_mangle], devenu#[unsafe(no_mangle)], mais l’exemple est curieusement construit pour utiliser des pointeurs bruts côté Rust.Cet autre exemple, plus loin :
Il ne devrait pas y avoir un
unsafequelque part ici ? Commesnprintfreçoit des pointeurs bruts, si l’on suit la règle évoquée plus haut selon laquelle les opérationsunsafedoivent pouvoir être repérées par leur nom, il devrait y avoir quelque chose commeunsafeSnprintfet une redéfinition du symbole.« Un indice honnête : sur une ligne
extern, on parle à C, et les règles de C l’emportent », hmm.as_raw_fd()de Rust, qui présente le même problème de sûreté.C’est une mauvaise compréhension de la stabilité FFI de la bibliothèque standard Rust. Les références partagées, les références mutables,
Boxet leurs variantesOptionont toutes une ABI définie et stable. Toute la procédureBox::into_raw/from_rawde l’exemple est donc inutile.Les durées de vie n’existent tout simplement pas au niveau binaire. Si l’on choisit de définir une ABI stable pour une énumération, l’optimisation par niche est désactivée.
La raison pour laquelle la plupart des types ne définissent pas d’ABI stable, c’est que souvent on n’en veut pas, car cela empêcherait de modifier leur structure interne.
Je ne comprends pas ce choix. Il y a une grande différence entre « publier » quelque chose d’incomplet et simplement ouvrir le code source. Si c’est de toute façon prévu plus tard, quel mal y a-t-il à le rendre public pendant la construction du projet ?
L’avantage, c’est que les personnes à qui la direction plaît peuvent l’essayer elles-mêmes et peut-être même contribuer. Bien sûr, à « l’ère de l’IA », il n’est pas évident que ces contributions soient un bénéfice net. Cela permet aussi de mieux comprendre ce qui est en cours de construction et d’évaluer les arguments expliquant pourquoi c’est formidable. Sans cela, le projet devient beaucoup moins intéressant.
En plus, il y a des gens qui n’utilisent aucun de ces outils. Dans mon équipe actuelle, nous n’arrivons même pas à nous mettre d’accord sur l’adoption d’un formateur automatique, mais à part cela elle est excellente. Retarder l’ouverture pendant qu’on construit ces outils ne change donc pas grand-chose.
Les gens essaient sans cesse de créer un « Rust sans durées de vie pénibles », et échouent sans cesse. Un autre commentaire a abordé l’un des modes d’échec : le problème de renvoyer une partie d’une valeur qui a été
dropvient du fait qu’on ne peut pas renvoyer de référence. Un autre problème classique est celui-ci :Il y a trois réponses :
Chacune de ces trois options a ses bonnes raisons, mais Jam semble vouloir être dans le cas 1 comme Rust, tout en étant en réalité dans le cas 2 à cause de sa sémantique de valeur. Si cela signifie que tout est copié, cela risque d’empêcher d’écrire des structures de données à la fois sûres et efficaces
En particulier, si l’on abandonne le vérificateur d’emprunts, il devient beaucoup plus difficile de prendre en charge des types alloués sur la pile sans introduire plusieurs indices. Par exemple, on peut copier lors de l’emprunt ; Inko comme Swift font tous deux cela
En regardant la référence du langage, il n’y a pas de références, mais il existe des pointeurs
mutetconst, et je n’ai rien trouvé sur leur sûretéL’un des grands éléments qui font que Zig est Zig, c’est l’absence de RAII, et Rust, c’est le vérificateur d’emprunts. Or le point où aboutissent ces choix de conception, à savoir « RAII sans références », je ne vois pas vraiment qui en a besoin
Cela dit, je pense qu’il y a de la place pour expérimenter dans cette niche, et je vois ces tentatives d’un bon œil. Simplement, cette approche ne me semble pas être la bonne
La direction à laquelle je pense beaucoup ces derniers temps est une combinaison du
comptimede Zig, de permissions de référence à la Pony, du traitement des durées de vie comme des valeurs au moment de la compilation, et du branding des durées de vie sur les allocateursCe que j’en espère, c’est ajouter la sûreté des références à la stratégie d’allocateurs de Zig, et obtenir des durées de vie qui nécessitent très peu d’annotations
Les nouveaux langages, c’est bien, mais je n’aime pas que tout devienne un frontend pour LLVM. Je sais que les backends sont difficiles, mais j’aimerais parfois qu’il y ait aussi d’autres options
Ça sonne presque comme Swift