La Session 097 a commencé par une décision qui a donné le ton de toute l'implémentation de la déstructuration : modifie-t-on l'instruction VarDecl existante, ou crée-t-on un nouveau type d'instruction ?
La réponse a façonné non seulement la fonctionnalité, mais la méthodologie de développement. Nous avons choisi un type d'instruction séparé, une approche d'implémentation stub-first, et un déploiement incrémental sur deux sessions. Le résultat a été un système de déstructuration qui gère les tableaux, les entités, les motifs imbriqués, la collecte des restes, et les valeurs par défaut -- sans casser un seul test existant.
Le problème que la déstructuration résout
Sans déstructuration, extraire des valeurs de données structurées nécessite un code verbeux et répétitif :
flinpoint = [10, 20, 30]
x = point[0]
y = point[1]
z = point[2]
user = getUser()
name = user.name
email = user.email
role = user.roleTrois lignes pour déballer une liste. Trois lignes pour extraire des champs d'entité. Dans un langage qui valorise la simplicité et l'expressivité, cette verbosité est un défaut de conception.
Avec la déstructuration :
flin[x, y, z] = [10, 20, 30]
{ name, email, role } = getUser()Deux lignes au lieu de six. La structure du côté gauche reflète la structure du côté droit, et le compilateur gère l'extraction.
La décision de conception : type d'instruction séparé
L'AST de FLIN avait Stmt::VarDecl utilisé à environ 190 endroits dans la base de code. Le modifier pour supporter des côtés gauches basés sur des motifs aurait nécessité de toucher chacun de ces 190 endroits -- dans le parser, le vérificateur de types, le générateur de code, le formateur et les tests.
À la place, nous avons créé Stmt::DestructuringDecl :
rustStmt::DestructuringDecl {
pattern: Pattern,
mutability: Mutability,
value: Expr,
span: Span,
}Cela coexiste avec VarDecl. Les déclarations de variables simples continuent d'utiliser VarDecl exactement comme avant. Les déclarations de déstructuration utilisent le nouveau type d'instruction. Zéro changement aux chemins de code existants.
Le compromis est une légère augmentation de la complexité de l'AST -- deux variantes d'instruction là où une seule pourrait suffire. Mais en pratique, cette séparation garde chaque variante simple et son traitement ciblé. Le parser sait quelle variante produire. Le vérificateur de types sait quelle variante vérifier. Ni l'un ni l'autre n'a besoin de brancher sur « est-ce une variable simple ou un motif ? »
L'enum Pattern
Le cœur de la déstructuration est l'enum Pattern :
rustpub enum Pattern {
Identifier { name: String, span: Span },
List { patterns: Vec<Pattern>, span: Span },
Rest { name: String, span: Span },
Map { entries: Vec<(String, Pattern)>, span: Span },
WithDefault { pattern: Box<Pattern>, default: Expr, span: Span },
}Cinq variantes, chacune composable avec les autres :
Identifier -- le motif le plus simple. Lie la valeur à un nom.
List -- correspond à une collection ordonnée et déstructure par position.
Rest -- collecte les éléments restants dans une liste. Préfixé par ....
Map -- correspond à une collection clé-valeur (ou entité) et déstructure par nom.
WithDefault -- enveloppe n'importe quel motif avec une valeur de repli.
Ceux-ci se composent récursivement. Un motif de liste peut contenir des identifiants, d'autres motifs de liste, des motifs rest, et des motifs avec valeurs par défaut :
flin// Déstructuration imbriquée
[first, [inner_a, inner_b], ...rest] = [[1, 2], [3, 4], [5, 6], [7, 8]]
// Avec valeurs par défaut
[x, y, z = 0] = [10, 20]
// x = 10, y = 20, z = 0 (défaut)L'approche stub-first
La Session 097 n'a pas implémenté la déstructuration en un seul passage. Au lieu de cela, elle a utilisé une approche stub-first :
- Phase 1 : enum Pattern. Ajouter l'enum
Patternà l'AST. Ajouter les implémentationsDisplaypour le débogage. - Phase 2 : instruction DestructuringDecl. Ajouter le nouveau type d'instruction. Ajouter des stubs dans chaque passe du compilateur.
- Phase 3 : Parser. Implémenter l'analyse des motifs et la détection des affectations de déstructuration.
- Phase 4 : Génération de code. Implémenter l'émission de bytecode pour la déstructuration de motifs.
- Phase 5 : Tests. Ajouter des tests complets pour tous les types de motifs.
La propriété critique : après chaque phase, la suite de tests complète passe. La Phase 1 ajoute des types que rien n'utilise encore -- aucun test ne casse. La Phase 2 ajoute des stubs qui compilent mais ne font rien -- aucun test ne casse. Les Phases 3-5 ajoutent de nouvelles fonctionnalités avec de nouveaux tests -- les tests existants passent toujours.
Cette approche mérite d'être décrite car c'est la méthodologie qui a rendu le développement de FLIN viable sur plus de 150 sessions. Chaque changement est additif. Rien ne casse. Le compilateur est toujours dans un état fonctionnel.
Support du vérificateur de types
Le vérificateur de types gère la déstructuration en parcourant l'arbre de motifs et en liant chaque identifiant à son type inféré :
rustfn check_pattern(&mut self, pattern: &Pattern, value_type: &FlinType, span: Span) {
match pattern {
Pattern::Identifier { name, .. } => {
self.env.insert(name.clone(), value_type.clone());
}
Pattern::List { patterns, .. } => {
let elem_type = match value_type {
FlinType::List(inner) => inner.as_ref().clone(),
_ => {
self.report_error("cannot destructure non-list as list");
FlinType::Unknown
}
};
for (i, pat) in patterns.iter().enumerate() {
match pat {
Pattern::Rest { name, .. } => {
self.env.insert(name.clone(), FlinType::List(Box::new(elem_type.clone())));
}
_ => {
self.check_pattern(pat, &elem_type, span);
}
}
}
}
Pattern::Map { entries, .. } => {
for (key, pat) in entries {
let field_type = match value_type {
FlinType::Entity(name) => self.get_entity_field_type(name, key),
FlinType::Map(_, v) => v.as_ref().clone(),
_ => {
self.report_error("cannot destructure as map/entity");
FlinType::Unknown
}
};
self.check_pattern(pat, &field_type, span);
}
}
Pattern::WithDefault { pattern, default, .. } => {
let default_type = self.infer_type(default);
self.check_pattern(pattern, value_type, span);
}
}
}La structure récursive reflète l'enum Pattern. Les motifs de liste extraient le type d'élément du type de liste. Les motifs map recherchent les types de champs dans les définitions d'entité. Les motifs rest lient un type de liste. Les motifs par défaut sont vérifiés avec le type de la valeur mais utilisent le type du défaut à l'exécution.
Génération de code
L'émetteur de bytecode génère un accès par index pour la déstructuration de liste et un accès par champ pour la déstructuration map/entité :
rustfn emit_destructuring_pattern(&mut self, pattern: &Pattern, source_local: usize) {
match pattern {
Pattern::Identifier { name, .. } => {
self.emit_load(source_local);
let local = self.declare_local(name);
self.emit_store(local);
}
Pattern::List { patterns, .. } => {
for (i, pat) in patterns.iter().enumerate() {
match pat {
Pattern::Rest { name, .. } => {
self.emit_load(source_local);
self.emit_const(Value::Int(i as i64));
self.emit_op(OpCode::SliceFrom);
let local = self.declare_local(name);
self.emit_store(local);
}
_ => {
self.emit_load(source_local);
self.emit_const(Value::Int(i as i64));
self.emit_op(OpCode::Index);
let temp = self.allocate_temp();
self.emit_store(temp);
self.emit_destructuring_pattern(pat, temp);
}
}
}
}
Pattern::Map { entries, .. } => {
for (key, pat) in entries {
self.emit_load(source_local);
self.emit_const(Value::Text(key.clone()));
self.emit_op(OpCode::GetField);
let temp = self.allocate_temp();
self.emit_store(temp);
self.emit_destructuring_pattern(pat, temp);
}
}
Pattern::WithDefault { pattern, default, .. } => {
self.emit_load(source_local);
self.emit_op(OpCode::Dup);
let jump = self.emit_jump_if_not_none();
self.emit_op(OpCode::Pop);
self.emit_expr(default);
let temp = self.allocate_temp();
self.emit_store(temp);
self.patch_jump(jump);
self.emit_destructuring_pattern(pattern, temp);
}
}
}La déstructuration de liste se compile en une séquence d'opérations d'index. L'élément 0 va au premier motif, l'élément 1 au second, et ainsi de suite. Les motifs rest utilisent une opération de découpe pour capturer tout depuis l'index actuel jusqu'à la fin.
La déstructuration map se compile en une séquence d'opérations d'accès par champ. Chaque clé est utilisée pour chercher la valeur correspondante, qui est ensuite liée au motif.
Les motifs par défaut se compilent en un conditionnel : vérifier si la valeur est none, et si c'est le cas, évaluer et utiliser l'expression par défaut.
Déstructuration de tableau en détail
flin// Basique
[a, b, c] = [1, 2, 3]
// a = 1, b = 2, c = 3
// Avec rest
[first, ...rest] = [1, 2, 3, 4, 5]
// first = 1, rest = [2, 3, 4, 5]
// Avec valeurs par défaut
[x, y, z = 0] = [10, 20]
// x = 10, y = 20, z = 0
// Imbriquée
[a, [b, c]] = [1, [2, 3]]
// a = 1, b = 2, c = 3
// Ignorer des éléments
[_, _, third] = [10, 20, 30]
// third = 30Le motif joker _ jette une valeur sans la lier. C'est le même _ utilisé dans les expressions match -- un marqueur universel « cette valeur ne m'intéresse pas ».
Déstructuration d'entité
flinentity Point {
x: int
y: int
z: int = 0
}
point = Point { x: 10, y: 20, z: 30 }
// Extraire des champs par nom
{ x, y } = point
// x = 10, y = 20
// Renommer pendant l'extraction
{ x: horizontal, y: vertical } = point
// horizontal = 10, vertical = 20
// Avec rest (collecte les champs restants)
{ x, ...other } = point
// x = 10, other = { y: 20, z: 30 }La déstructuration d'entité utilise les noms de champs plutôt que les positions. C'est plus robuste que la déstructuration de tableau -- si l'entité ajoute un nouveau champ, les motifs de déstructuration existants continuent de fonctionner.
Déstructuration dans les boucles for
La déstructuration s'étend naturellement aux boucles for :
flinpoints = [[1, 2], [3, 4], [5, 6]]
for [x, y] in points {
print("x: " + text(x) + " y: " + text(y))
}Chaque itération déstructure l'élément courant. Le motif [x, y] est appliqué à chaque élément de points, liant x et y pour le corps de la boucle.
Déstructuration des paramètres de fonction
Les fonctions peuvent déstructurer leurs paramètres :
flinfn distance([x1, y1], [x2, y2]) -> number {
dx = x2 - x1
dy = y2 - y1
return (dx * dx + dy * dy) ** 0.5
}
distance([0, 0], [3, 4]) // 5.0La fonction prend deux arguments de type liste et les déstructure dans la liste de paramètres. L'appelant passe des listes ; le corps de la fonction travaille avec des valeurs nommées.
L'opérateur Elvis : un gain rapide
La Session 097 a aussi implémenté l'opérateur Elvis (?:) en parallèle de la fondation de la déstructuration. C'était une décision de planification délibérée -- l'opérateur Elvis était une fonctionnalité autonome de 45 minutes qui pouvait être terminée et livrée pendant que la fondation de la déstructuration était en cours de construction.
flinname = user.name ?: "Anonymous"
displayName = firstName ?: lastName ?: "Guest"L'opérateur Elvis retourne la première valeur truthy. Il diffère de la coalescence nulle (??) en ce qu'il traite les valeurs falsy (0, false, chaîne vide) comme absentes :
| Expression | Résultat `??` | Résultat `?:` |
|---|---|---|
0 ?? 42 | 0 | 42 |
false ?? true | false | true |
"" ?? "default" | "" | "default" |
none ?? "default" | "default" | "default" |
L'implémentation a réutilisé le bytecode de l'opérateur Or -- les deux court-circuitent sur la première valeur truthy. La seule différence est la représentation AST et la distinction sémantique pour les développeurs.
Statistiques de la session
La Session 097 a produit :
- 4 commits, environ 710 lignes
- 2 nouveaux tests (tests du lexer et du parser pour Elvis)
- 16 fichiers modifiés
- Les 1 017 tests existants passant tout au long
L'implémentation de la déstructuration était à 70 % complète en fin de session -- l'enum Pattern, le type d'instruction, et tous les stubs étaient en place. Les 30 % restants (intégration du parser et génération de code complète) ont été achevés lors de la Session 098.
Pourquoi la déstructuration est importante
La déstructuration est du sucre syntaxique. Tout ce qu'elle fait peut être accompli avec l'accès par index et l'accès par champ. Mais le sucre syntaxique compte énormément pour l'expérience développeur.
Considérons une fonction qui traite une liste de paires de coordonnées :
flin// Sans déstructuration
for point in points {
x = point[0]
y = point[1]
distance = (x * x + y * y) ** 0.5
print(distance)
}
// Avec déstructuration
for [x, y] in points {
distance = (x * x + y * y) ** 0.5
print(distance)
}La version avec déstructuration n'est pas juste plus courte. Elle est plus claire. Le motif [x, y] déclare la forme attendue de chaque point. Si un point a trois éléments, le développeur sait utiliser [x, y, z]. Le motif est de la documentation.
C'est pourquoi nous avons intitulé l'article « La déstructuration partout » -- la fonctionnalité apparaît dans les déclarations de variables, les boucles for, les paramètres de fonction et les bras de match. Chaque endroit où une valeur est liée à un nom, la déstructuration est disponible. Ce n'est pas un cas spécial. C'est le cas général.
Le prochain article couvre l'opérateur pipeline -- une autre fonctionnalité ergonomique qui transforme la façon dont les développeurs composent des opérations dans FLIN.
Ceci est la partie 37 de la série « Comment nous avons construit FLIN », documentant comment un CEO à Abidjan et un CTO IA ont conçu et implémenté un langage de programmation à partir de zéro.
Navigation de la série : - [35] Pattern matching : de switch à match - [36] Types union étiquetés et types de données algébriques - [37] La déstructuration partout (vous êtes ici) - [38] L'opérateur pipeline : composition fonctionnelle dans FLIN - [39] Tuples, enums et structs