Goose : un langage système qui se passe de tas
Goose est un langage système sans tas, sans GC ni annotations de durée de vie. Ce que sa conception tout-pile révèle sur Rust et les régions.

Voici une affirmation qui va agacer au moins deux communautés de langages : le borrow checker était un détour. Pas une erreur. Le modèle d'ownership de Rust a résolu de vrais problèmes et a fait avancer toute l'industrie, mais il reste un détour par rapport à une idée plus ancienne et plus simple, que nous redécouvrons aujourd'hui. Cette idée, ce sont les régions : de la mémoire allouée dans de grandes arènes façon pile, libérée d'un bloc à la fin d'un scope. Le langage Goose, un nouveau langage système qui fait parler de lui cette semaine, est le pari moderne le plus radical sur cette idée que j'aie vu, et son design pose une question inconfortable : qu'est-ce qu'on supporte dans C++ et Rust qui est réellement porteur, et qu'est-ce qui relève simplement du tas ?
Goose n'a pas d'allocateur de tas. Pas de ramasse-miettes, pas de comptage de références, pas de destructeurs, pas d'annotations de durée de vie, pas de unsafe. Chaque valeur dynamique vit en ligne sur l'une des quelques piles de données que le compilateur assigne statiquement. Une croissance consiste simplement à incrémenter un pointeur. Libérer une structure imbriquée d'un million d'éléments revient à une seule écriture au sommet de la pile. L'auteur annonce des benchmarks plus rapides que du C++ idiomatique (3,3x) et que du Rust sûr (1,12x), avec moins de mémoire. La réaction publique de John Carmack a pointé précisément la partie qui me fascine le plus : les références relatives qui font qu'une structure de données devient son propre format de fichier. Que Goose finisse ou non par servir à des logiciels de production, c'est une expérience de pensée sérieuse sur ce à quoi ressemble un langage système quand on supprime le tas et qu'on refuse de s'en excuser.
Le modèle mémoire est tout le langage
La plupart des fonctionnalités d'un langage sont négociables. La syntaxe, les systèmes de modules, même la gestion des erreurs : on peut imaginer deux langages raisonnables faisant des choix opposés. Les modèles mémoire ne fonctionnent pas comme ça. Vous en choisissez un et tout le reste en découle : vos structures de données, votre gestion de la concurrence, votre FFI, vos messages d'erreur. Goose en a conscience et s'y engage à fond.
La règle est simple à énoncer : un programme dispose de la pile d'appels native, de données statiques et de N piles de données, N étant calculé par le compilateur. Au plus une valeur redimensionnable est vivante par pile, et elle est toujours au sommet. Cet invariant unique rapporte énormément. Comme la valeur extensible ne bouge jamais, car rien au-dessus ne doit être déplacé, les références vers un tableau qui grandit restent valides pour toujours. En C++, un std::vector<T> qui réalloue invalide chaque pointeur et itérateur vers lui, ce qui explique pourquoi des générations de programmeurs C++ se sont fait piéger en conservant une référence à travers un push_back. Rust sûr contourne le problème en le rendant inexprimable : on utilise alors des indices u32, au prix de vérifications de bornes et d'ergonomie. Goose, lui, vous laisse simplement garder la référence. Les règles de types et de durées de vie sont vérifiées à la compilation, le compilateur déduisant à quoi chaque référence est « enracinée » et rejetant celles qui pourraient survivre à leur propriétaire, mais sans annotations, sans règles d'aliasing, sans exclusivité.
struct Item { id: i32, weight: f32 }
var items: Item[>..] = [];
let first .= items.push(Item { 1, 0.5 });
for i: i32 in 2..1000001 {
items.push(Item { i, 0.0 });
}
first.weight = 99.5; // still valid, a million pushes later
Si vous avez passé du temps avec le borrow checker de Rust, cela ressemble à un tour de magie. Où sont passées les durées de vie ? La réponse honnête : dans une restriction. Le système d'ownership de Rust peut prouver la sûreté de schémas de partage arbitraires, comme les graphes, les cycles ou les structures mutables partagées entre scopes. Goose prouve la sûreté pour un ensemble de schémas beaucoup plus étroit et refuse tout le reste. Ce compromis, expressivité contre simplicité, est le plus ancien de la conception des langages, et c'est le fil que je vais continuer à tirer ici. Si vous avez lu notre article sur ce que la soundness signifie réellement dans le borrow checker de Rust, Goose en est le revers : la soundness obtenue non pas par une analyse plus puissante, mais par un langage plus petit.
Tout en ligne : la disposition des données comme fonctionnalité de premier plan
La deuxième grande idée, c'est qu'il n'existe pas de type tableau unique. Goose propose une famille de tableaux qui ne diffèrent que par leur comportement vis-à-vis de la taille : fixe à la compilation, figé après construction, extensible dans une capacité en ligne, croissance seule, croissance et décroissance. Tous suivent la forme [métadonnées][éléments...] : contigus, compacts, jamais un pointeur vers un bloc d'éléments. Les chaînes ne sont que des tableaux de u8, si bien qu'une structure peut contenir des parties de taille variable placées en ligne, dans l'ordre de déclaration. Un enregistrement avec un champ chaîne de longueur variable et une liste de sous-enregistrements de longueur variable forme un seul bloc d'octets, sans aucune indirection.
Les chiffres annoncés sont impressionnants : un enregistrement de commande de 29 octets et zéro allocation dans Goose, contre 160 octets et une allocation en std::string + std::vector<Item> en C++, ou 153 octets et quatre allocations en Rust. Cela dit, une correction juste est remontée presque immédiatement dans la discussion : l'exemple du tutoriel de Goose, « un vecteur de chaînes », ignore l'optimisation des petites chaînes (SSO), que toutes les STL grand public implémentent. En C++, les chaînes courtes n'allouent pas. Cela ne tue pas l'argument, car le SSO aide pour les chaînes d'environ 15 à 22 octets et ne change rien au cas général des données imbriquées de taille variable. Mais cela rappelle que les tableaux de benchmarks sur la page d'accueil d'un langage relèvent du marketing, même quand le langage est bon. Les défaites sont aussi présentes, à l'honneur de l'auteur, ce qui est plus que ce que publient la plupart des projets.
Le coût est réel, et Goose ne le cache pas : un tableau d'éléments de taille variable est séquentiel. On peut le parcourir, mais pas l'indexer, car l'élément k se trouve là où tombent les octets. Pour un accès aléatoire, on conserve un second tableau de références comme index. C'est le type de compromis que les adeptes de la conception orientée données font à la main depuis des décennies (c'est essentiellement le fonctionnement des formats en colonnes et des dispositions de type FlatBuffers), mais ici c'est le comportement par défaut, et en sortir demande du travail supplémentaire. Que ce soit une fonctionnalité ou une camisole dépend entièrement de vos schémas d'accès.
Références relatives : votre structure de données est son format de fichier
Mon idée préférée dans Goose est la référence relative, celle sur laquelle Carmack s'est aussi concentré. Une référence classique est une adresse machine : huit octets, absolue, dénuée de sens en dehors du processus. Une référence relative stocke le même lien sous forme de décalage sur 1, 2 ou 4 octets, mesuré depuis le champ lui-même ou depuis la base d'un pool nommé. Les structures construites à partir de liens auto-relatifs sont indépendantes de leur position. Elles ont le même sens où qu'elles se trouvent en mémoire. Cela signifie que sauvegarder une structure de données revient à un write(), et la charger à un read() suivi d'une passe de vérification.
Cette dernière clause compte plus qu'il n'y paraît. La version naïve de cette astuce, qui consiste à mapper en mémoire un fichier plein de pointeurs, est un désastre de sécurité classique. La version de Goose valide : from_bytes vérifie le cadrage, chaque tag, chaque longueur et chaque lien avant que les octets ne deviennent une valeur. Ainsi, un fichier corrompu ou malveillant renvoie false, et non un pointeur sauvage. On obtient la rapidité des structures mappées en mémoire, avec quelque chose qui ressemble à la sûreté d'un parseur.
Le constat de Carmack était exactement juste : le parsing des données de fichiers et du réseau est souvent 100 fois plus lent qu'il ne devrait l'être. Quiconque a profilé un chemin critique de désérialisation le sait. La réponse de l'industrie jusqu'ici, ce sont les formats zero-copy pilotés par schéma comme Cap'n Proto et FlatBuffers, qui fonctionnent mais imposent un compilateur de schémas et une bibliothèque d'exécution à votre projet. La réponse de Goose : la disposition en mémoire et la disposition sur le fil sont la même chose par construction, parce que le langage n'a jamais eu de pointeurs vers des adresses absolues. L'exemple d'arbre binaire de recherche est presque provocant : des nœuds de 12 octets, liens compris, l'insertion se résume à un push et une écriture, et tout l'arbre persiste en écrivant ses octets. Les adeptes de Lisp reconnaîtront le geste philosophique, qui consiste à effacer une distinction que tout le monde croyait fondamentale. C'est une partie de pourquoi Lisp compte toujours comme référence de conception, soixante ans plus tard.
On a déjà vu tout ça : régions, arènes et ML Kit
La réponse historiquement informée à Goose est arrivée à point nommé dans les commentaires : « Je crois me souvenir que les gens de ML sont passés par là. Ils appelaient ça des régions. » Exact. La gestion de mémoire par régions remonte aux travaux ML Kit des années 1990. L'inférence de régions de Tofte et Talpin permettait à un compilateur ML de remplacer le ramasse-miettes par des régions façon pile déterminées statiquement, allouant et libérant des régions entières sans comptabilité par objet. Le verdict historique honnête : c'était considéré comme une impasse. L'inférence de régions fonctionnait, mais les programmes réels montraient une croissance mémoire pathologique suffisante, avec des régions maintenant en vie des données mortes beaucoup trop longtemps, pour que la recherche passe majoritairement à autre chose. Les ramasse-miettes ont gardé leur couronne.
Mais l'idée n'est jamais vraiment morte ; elle a simplement changé de métier. L'allocation par arène est une pratique standard dans les moteurs de jeu, les compilateurs et les serveurs haute performance. Les pools mémoire d'Apache, les arènes par requête des serveurs web, le motif « réinitialiser le tampon de travail à chaque frame » dans les boucles de jeu : ce sont des régions sous un autre nom, gérées à la main parce que le langage ne vous aide pas. Ce que Goose fait différemment de ML Kit mérite d'être énoncé précisément : au lieu d'inférer des régions pour un langage fonctionnel généraliste (difficile, imprévisible), il expose directement le modèle de pile et fait de l'intention du programmeur une partie du type. T[>..] signifie « ceci grandit, les références restent valides ». Le rôle du compilateur est de vérifier une discipline déclarée, et non d'en découvrir une. C'est un problème beaucoup plus facile, et c'est la même raison pour laquelle Rust a réussi là où les anciens schémas de durée de vie automatique ont peiné : les programmeurs acceptent un contrat qu'ils peuvent voir.
Il y a une leçon plus large sur la façon dont les langages de production absorbent la recherche. L'inférence de régions comme remplacement automatique complet du GC : échec. Les régions comme motif système manuel : omniprésentes. Le borrow checking : un immense succès qui emprunte lui-même beaucoup aux idées de régions et aux types linéaires. Les processus sans partage à la Erlang : validés, et les threads de Goose sont littéralement cela. Ce sont des workers compilés en programmes séparés avec leur propre mémoire, les valeurs traversant des files typées sous forme de memcpy, et les data races rendues non représentables. Personne n'a la vision complète du premier coup. Les idées circulent pendant des décennies avant de prendre une forme qui fonctionne.
Là où le modèle montre ses limites
J'ai dit que j'ouvrirais avec une affirmation à contre-courant, puis que je concéderais où elle se brise. Voici la concession. Le borrow checker n'est pas un détour si les schémas de partage de votre programme sont réellement arbitraires, et une énorme catégorie de programmes a exactement cette forme. La question la plus pointue de la discussion sur Goose était aussi la plus simple : comment construire une table de hachage associant des clés à des chaînes, quand le programme insère et supprime des chaînes aléatoires à des moments aléatoires ? La suppression est le point fatal. Une pile à croissance seule ne peut pas récupérer un trou au milieu. Les contournements proposés (une pile par classe de taille, compactage périodique, un pool avec liste libre où « libérer » un emplacement signifie seulement qu'il pourra être réutilisé) fonctionnent tous, et le design à base de pool est vraiment malin : un emplacement périmé lit une autre valeur du bon type plutôt que de corrompre la mémoire. Mais chacun revient à ce que le programmeur réimplémente à la main un morceau de gestionnaire mémoire, pour cette seule structure de données.
De même pour l'usage mémoire. Sans GC, une pile qui grandit conserve tout ce qu'elle a jamais contenu jusqu'à la fin du scope. Les programmes longue durée aux durées de vie irrégulières, comme la question des documents JSON soulevée dans le fil, les caches ou l'état de session, entrent soit dans le schéma discipliné par scope, soit non. Dans ce dernier cas, aucune élégance ne vous sauve. Le mode d'échec de ML Kit (données mortes épinglées dans des régions vivantes) ne disparaît pas avec Goose ; il devient visible et manuel, ce qui est sans doute préférable, mais il est toujours là. Un commentateur a formulé la bonne posture : Goose est un langage pour un usage restreint, et c'est très bien. Les charges orientées données, les compilateurs, les systèmes d'entités de jeu, tout ce qui est en forme de pipeline (lire, transformer, émettre, réinitialiser) s'y prête à merveille. Un graphe d'objets mutable à longue durée de vie, non.
Une dernière réserve sur les chiffres. Les suites de benchmarks auto-hébergées sont un genre aux modes d'échec connus : vous comparez votre meilleur Goose au C++ « idiomatique » de quelqu'un d'autre, et c'est sur les idiomes que les bagarres commencent. La bévue sur le SSO montre que le code de comparaison n'a pas été optimisé de façon adverse. Le chiffre de 1,12x sur Rust sûr est plus intéressant que le 3,3x, précisément parce qu'il est modeste : il dit que le modèle mémoire gagne sur la disposition et la suppression d'allocations, non par magie, et qu'une version Rust réglée avec des arènes et des indices comblerait une bonne part de l'écart. C'est justement le sujet : on peut écrire du Rust à base d'arènes, mais le langage le rend maladroit, et la maladresse détermine le code qui finit réellement écrit.

Ce que les langages de production vont réellement emprunter à Goose
Presque personne n'écrira de Goose en production dans les prochaines années : les langages développés par une seule personne et compilés via un unique backend C n'y arrivent pas vite, et l'auteur le reconnaît lui-même au sujet de leur statut. Ce n'est pas la question intéressante. La question intéressante est de savoir quelles idées s'échappent vers les langages que nous utilisons déjà, car c'est toujours ainsi que ça fonctionne. Mes prédictions, par ordre de confiance :
- Structures indépendantes de leur position avec chargement vérifié. La sérialisation zero-copy avec une passe de sûreté est trop bonne pour rester de niche. Attendez-vous à davantage de versions intégrées au langage de ce que FlatBuffers fait maladroitement, surtout à mesure que les services gourmands en données continuent de se heurter aux coûts de parsing.
- Des tableaux comme famille, pas comme type unique. « Que devient la taille ? » est une meilleure question d'organisation que « pile ou tas ? ». Fixe, figé, à capacité bornée, croissance seule : ces distinctions existent dans chaque base de code sous forme de conventions informelles. En faire des types ne coûte presque rien et attrape de vrais bugs.
- Des valeurs de fonction résolues à la compilation. Goose traite les valeurs de fonction comme des entités de compilation, si bien que
xs.filter() { it > 0 }se compile en la boucle qu'il semble être. Les templates C++ et la monomorphisation de Rust font déjà cela. La différence, c'est que Goose en fait la seule manière de faire, éliminant complètement le piège des pointeurs de fonction à l'exécution. - Retours multi-frames vérifiés statiquement.
return err from loadretourne depuis une fonction N frames plus haut, vérifié à la compilation, sans unwinder, sans pyramide deResult, sans?à chaque appel. Les systèmes d'effets tournent autour de cette même idée par l'autre bout. L'un d'eux finira par atterrir dans un langage grand public au cours de cette décennie.
Le modèle de threads sans partage mérite aussi une mention, surtout parce que c'est le moins original et le plus validé : ce sont les processus d'Erlang, les isolates de Dart, le modèle acteur sans l'idéologie. Qu'on le réinvente sans cesse montre que le consensus de l'industrie sur l'état mutable partagé est réglé en théorie et ignoré en pratique. Goose, au moins, rend cette ignorance impossible.
Le vrai argument : la simplicité par la restriction
Prenez du recul et le design de Goose tient en un geste répété : réduire l'ensemble des programmes représentables jusqu'à ce que la sûreté devienne bon marché à prouver. Pas de tas, donc pas de pointeurs de tas pendants. Des piles à croissance seule, donc les références ne peuvent pas être invalidées. Des threads-workers séparés, donc les courses ne peuvent pas exister. Une construction sans copie, donc pas de temporaires accidentels. Chaque restriction supprime une catégorie entière de bugs et une catégorie entière de mécanismes du langage (les règles d'aliasing du borrow checker, le destructeur, l'unwinder) qui existaient pour les gérer.
C'est l'argument « less is exponentially more » de Rob Pike, sans gants, et il touche la même tension que le Go de Pike : les restrictions paraissent libératrices jusqu'au moment où votre programme est celui qu'elles excluent, et là elles ressemblent à un mur. Go a exclu les génériques pendant une décennie et a survécu ; il a aussi exclu beaucoup de programmes corrects. Goose en exclut bien davantage. La raison de s'y intéresser malgré tout, c'est que les anciennes règles de simplicité de Pike ont bien vieilli, précisément parce que les budgets de complexité sont réels : chaque garantie qu'un langage offre automatiquement est une garantie que chaque programmeur qui l'utilise n'a pas à garder en tête.
Alors, le borrow checker était-il un détour ? Pour la programmation système généraliste, non : l'expressivité de Rust est réellement nécessaire pour les cas de partage arbitraire, et son succès a donné à l'industrie un vocabulaire pour parler de l'ownership tout court. Mais pour la grande majorité silencieuse du code système en forme de pipeline, d'arène ou de requête ? Goose prouve qu'on aurait pu avoir sûreté mémoire, vitesse et petits binaires avec une fraction du cérémonial, si nous avions accepté de dire « non » à plus de programmes. La conception de langage est l'art de choisir quels programmes interdire. Goose interdit d'autres programmes que ceux auxquels nous sommes habitués, et le résultat est l'esquisse de langage système la plus intéressante que j'aie lue depuis des années. Je ne compte pas le livrer. Je compte continuer à le piller.
Le pari de Goose : un langage peut interdire davantage de programmes que Rust et rester utile. Les benchmarks suggèrent qu'il vaut la peine d'être pris au sérieux, pertes comprises.


