Ceci est une rétrospective. Au cours des quatorze articles précédents, nous avons retracé l'évolution du système de types de FLIN, des types primitifs et de l'inférence aux types union, génériques, traits, unions étiquetées, filtrage par motifs, déstructuration, opérateurs pipeline, tuples, gardes de type, type never, bornes, boucles while-let, boucles étiquetées et motifs Or.
Chaque article racontait l'histoire d'une fonctionnalité isolément. Cet article raconte comment elles s'assemblent -- comment des fonctionnalités conçues en séquence forment un système cohérent, comment les décisions de conception de la session 97 ont rendu possibles les fonctionnalités de la session 155, et comment l'ensemble du système de types sert l'objectif fondamental de FLIN : rendre le développement d'applications simple, sûr et expressif.
La carte des fonctionnalités
Avant d'examiner les connexions, voici l'inventaire complet :
| Fonctionnalité | Session(s) | Catégorie | |
|---|---|---|---|
| Types primitifs (int, number, text, bool) | Core | Fondation | |
| Types spéciaux (time, money, file, semantic) | Core | Fondation | |
| Inférence de types (bidirectionnelle) | Core | Inférence | |
| Types optionnels (T?) | Core | Sécurité | |
| Types de collections ([T], [K:V]) | Core | Données | |
| Types d'entités | Core | Données + Persistance | |
| Coercition de types (int->number, etc.) | Core | Ergonomie | |
| Opérateur Elvis (?:) | 097 | Ergonomie | |
| Déstructuration (tableau, entité, imbriquée) | 097-098 | Ergonomie | |
| Types union (T \ | U) | 100 | Expressivité |
| Découpage (list[1:5:2]) | 100 | Ergonomie | |
| Types génériques ( | 101 | Polymorphisme | |
| Traits et blocs impl | 133-136 | Contraintes | |
| Unions étiquetées (enum avec données) | 145 | Expressivité | |
| Filtrage par motifs (match) | 145-157 | Flux de contrôle | |
| Vérification d'exhaustivité | 136, 147 | Sécurité | |
| Type Never | 136, 147 | Sécurité | |
| Opérateur pipeline (\ | >) | 150 | Ergonomie |
| Clauses where | 144, 150 | Contraintes | |
| Tuples | 142 | Données | |
| Gardes de type (is) | 120 | Sécurité | |
| Boucles while-let | 152 | Flux de contrôle | |
| Break avec valeur | 153 | Flux de contrôle | |
| Boucles étiquetées | 154 | Flux de contrôle | |
| Motifs Or | 155 | Ergonomie |
Vingt-cinq fonctionnalités en environ soixante sessions. Chacune conçue, implémentée, testée et documentée.
Le graphe de dépendances
Ces fonctionnalités ne sont pas indépendantes. Elles forment un graphe de dépendances où les fonctionnalités ultérieures reposent sur les précédentes :
Primitives + Inference
|
+-- Optional types
| |
| +-- Type guards (is)
| | |
| | +-- Type narrowing
| | |
| | +-- Exhaustiveness checking
| |
| +-- Elvis operator (?:)
| +-- While-let loops
|
+-- Entity types
| |
| +-- Destructuring
|
+-- Collection types
| |
| +-- Slicing
| +-- Destructuring
| +-- Pipeline operator
|
+-- Union types
| |
| +-- Type narrowing
| +-- Tagged unions
| | |
| | +-- Pattern matching
| | | |
| | | +-- Or-patterns
| | | +-- Exhaustiveness checking
| | | +-- While-let patterns
| | |
| | +-- Never type
| |
| +-- Generic types
| |
| +-- Traits
| | |
| | +-- Generic bounds
| | +-- Where clauses
| |
| +-- Tuples
|
+-- Control flow
|
+-- Break with value
+-- Labeled loops
+-- While-letChaque ligne dans ce graphe représente une dépendance de conception : la fonctionnalité en dessous ne pourrait pas exister sans celle au-dessus. Les unions étiquetées nécessitent les types union (parce qu'un enum générique comme Result<T, E> = T | E utilise l'infrastructure des types union). Le filtrage par motifs nécessite les unions étiquetées (parce que le filtrage sur les variants d'enum nécessite la représentation des données de variant). L'exhaustivité nécessite le filtrage par motifs et le type never.
Comment les décisions de conception se propagent
Certaines décisions de conception précoces ont eu des conséquences qui n'étaient pas apparentes avant beaucoup plus tard.
La décision Vec
À la session 100, nous avons représenté les types union comme Union(Vec<Type>) plutôt que Union(Box<Type>, Box<Type>). Cela semblait être un détail d'implémentation mineur à l'époque. Mais les effets en cascade ont été importants :
- Les motifs Or (session 155) ont réutilisé la même approche
Vec<Pattern>, parce que le motifA | B | Creflète le typeA | B | C. - La vérification d'exhaustivité (session 147) pouvait itérer sur les membres de l'union comme une liste plate, simplifiant l'algorithme.
- La soustraction de type -- retirer un type d'une union -- était une simple opération
retainsur le vecteur.
Si nous avions utilisé une représentation binaire, chacune de ces fonctionnalités aurait nécessité un traitement récursif des unions imbriquées.
La décision DestructuringDecl séparé
À la session 097, nous avons créé Stmt::DestructuringDecl comme un type d'instruction séparé plutôt que de modifier VarDecl. Cela semblait conservateur à l'époque -- une façon d'éviter de toucher à 190 sites d'appel.
Mais cela a porté ses fruits plus tard. Quand les boucles while-let (session 152) ont eu besoin de filtrage par motifs dans les conditions de boucle, elles pouvaient utiliser le même enum Pattern sans aucune interaction avec VarDecl. Quand la déstructuration dans les boucles for a été ajoutée, elle utilisait la même infrastructure Pattern. Le type séparé signifiait que les motifs étaient un concept autonome, utilisable dans n'importe quel contexte.
L'approche par désucrage
À la session 150, l'opérateur pipeline a été implémenté par désucrage en appels de fonction au moment de l'analyse syntaxique. Pas de nouveau noeud AST. Pas de nouvelles règles du vérificateur de types. Pas de nouveau bytecode.
Cette approche a ensuite été appliquée à d'autres fonctionnalités. While-let se désucre en une boucle avec une vérification de motif. Les motifs Or se désucrent en une série de vérifications avec un corps partagé. Break avec valeur se désucre en une séquence stockage-et-saut.
La philosophie de désucrage a gardé le coeur du compilateur petit. L'analyseur syntaxique gère la complexité ; les passes en aval gèrent la simplicité.
Le test de cohérence
Un système de types est cohérent si ses fonctionnalités interagissent de manière prévisible. Voici plusieurs interactions qui fonctionnent correctement grâce à une conception délibérée :
Générique + Union + Filtrage par motifs
flinenum Result<T, E> {
Ok(T),
Err(E)
}
fn handle<T: Printable, E: Printable>(result: Result<T, E>) -> text {
match result {
Ok(value) -> "Success: " + value.to_text()
Err(error) -> "Error: " + error.to_text()
}
}Cela utilise les génériques, les bornes de traits, les unions étiquetées et le filtrage par motifs simultanément. Le compilateur :
1. Résout les paramètres génériques T et E
2. Valide les bornes Printable sur les deux
3. Vérifie que le match est exhaustif (Ok et Err couvrent tous les variants)
4. Rétrécit T dans le bras Ok et E dans le bras Err
5. Vérifie que .to_text() est disponible (via la borne Printable)
Cinq fonctionnalités interagissant correctement.
Pipeline + Déstructuration + Gardes de type
flindata: [int | text] = [1, "hello", 2, "world", 3]
numbers = data
|> filter(x => x is int)
|> map(x => x * 2)
[first, second, ...rest] = numbersLe pipeline alimente les données à travers des transformations. La garde de type (is int) rétrécit le type union dans le filtre. La déstructuration déballe le résultat. Le type de first est int -- le compilateur a tracé le type à travers trois fonctionnalités.
While-Let + Union étiquetée + Break avec valeur
flinenum Token {
Number(int),
Text(text),
End
}
fn find_first_number(tokens: [Token]) -> int? {
index = 0
while let token = tokens[index] {
match token {
Number(n) -> break n
Text(_) -> { index++; continue }
End -> break
}
index++
}
}While-let itère. Le filtrage par motifs dispatche sur le variant du jeton. Break avec valeur retourne le nombre trouvé. Le type de résultat est int? parce que la boucle pourrait ne pas trouver de nombre.
Boucle étiquetée + Motif Or + Exhaustivité
flinenum Priority { Critical, High, Medium, Low }
'scan: for task in tasks {
match task.priority {
Critical | High -> {
urgent_tasks.push(task)
if urgent_tasks.len >= max {
break 'scan
}
}
Medium | Low -> continue
}
}Les motifs Or combinent les niveaux de priorité. Le break étiqueté sort quand suffisamment de tâches urgentes sont trouvées. Le match est exhaustif parce que Critical | High et Medium | Low couvrent les quatre variants.
Les chiffres de l'implémentation
À la fin de la session 157, l'implémentation du système de types de FLIN comprenait :
| Composant | Lignes approximatives |
|---|---|
Enum FlinType et opérations | 800 |
| Moteur d'inférence de types | 1 200 |
| Vérificateur de compatibilité de types | 600 |
| Vérification de types du filtrage par motifs | 500 |
| Vérificateur d'exhaustivité | 400 |
| Registre et validation des traits | 500 |
| Substitution de types génériques | 300 |
| Génération de messages d'erreur | 400 |
| Total système de types | ~4 700 |
La suite de tests a grandi proportionnellement :
| Catégorie | Nombre de tests |
|---|---|
| Tests d'inférence de types | ~200 |
| Tests de compatibilité de types | ~150 |
| Tests de filtrage par motifs | ~100 |
| Tests de types génériques | ~80 |
| Tests de bornes de traits | ~50 |
| Tests d'exhaustivité | ~40 |
| Tests d'intégration (bout en bout) | ~450 |
| Total | ~1 070 tests liés au système de types |
Chaque fonctionnalité a été testée au niveau unitaire (analyseur, vérificateur de types, génération de code indépendamment) et au niveau intégration (tests complets compilation-et-exécution).
Ce que nous ferions différemment
Aucune conception ne survit parfaitement au contact avec la réalité. Quelques éléments que nous reconsidérerions :
Les alias de types pourraient être plus puissants. Le type Option<T> = T? de FLIN est un simple alias. Il ne crée pas un nouveau type nominal. Cela signifie que Option<int> et int? sont interchangeables -- ce qui est pratique mais perd la distinction sémantique. Une version future pourrait supporter des alias de types nominaux qui créent des types véritablement distincts.
La composition de traits pourrait utiliser + dans plus d'endroits. Actuellement, T: A + B fonctionne dans les bornes, mais on ne peut pas écrire type Combined = A + B pour combiner des traits. C'est un manque qui nécessite parfois des contournements.
La récupération d'erreurs dans le vérificateur de types pourrait être meilleure. Quand le vérificateur de types rencontre une erreur, il arrête parfois de vérifier les expressions suivantes dans le même bloc. Continuer au-delà des erreurs et signaler plusieurs diagnostics donnerait aux développeurs un tableau plus complet.
La vérification de types incrémentale n'est pas implémentée. Chaque changement re-vérifie le fichier entier. Pour les petits fichiers, c'est instantané. Pour les grands programmes, cela pourrait devenir un goulot d'étranglement. La vérification incrémentale -- ne re-vérifier que les expressions affectées par un changement -- est une optimisation future.
Ce que nous avons bien fait
Plusieurs décisions précoces se sont avérées exactement justes :
L'inférence bidirectionnelle. L'inférence en avant (la valeur détermine le type) gère 90 % des cas. L'inférence en arrière (le contexte détermine le type) gère les 10 % restants. Ensemble, elles éliminent presque toutes les annotations de types explicites.
La hiérarchie de types avec int < number. Avoir int comme sous-type de number signifie que l'arithmétique fonctionne naturellement. Additionner un int et un number produit un number. Pas de conversion explicite nécessaire.
Les traits nominaux plutôt que les interfaces structurelles. Les blocs impl explicites rendent les relations de traits visibles et recherchables. Les messages d'erreur peuvent nommer le trait spécifique manquant. Le petit coût (écrire des blocs impl) se paie en clarté.
L'exhaustivité comme erreur, pas comme avertissement. Faire des matchs non exhaustifs une erreur dure attrape les bugs à la compilation qui autrement apparaîtraient en production. Chaque développeur qui ajoute un variant à un enum est guidé par le compilateur pour mettre à jour chaque match.
Désucrer au niveau de l'analyseur. Les opérateurs pipeline, while-let et motifs Or se désucrent tous en constructions plus simples avant que le vérificateur de types ne les voie. Cela garde le vérificateur de types concentré sur les opérations de types fondamentales et évite les cas spéciaux par fonctionnalité.
La philosophie en rétrospective
En regardant l'ensemble de l'arc du système de types, une philosophie émerge : rendre le système de types invisible quand il peut l'être, et visible quand il doit l'être.
Invisible : l'inférence de types signifie que les développeurs écrivent rarement des annotations de types. La coercition automatique signifie que la conversion int-en-number fonctionne simplement. La propagation optionnelle signifie que la sécurité null ne nécessite pas de cérémonie.
Visible : les types union déclarent explicitement ce qu'une valeur peut être. Les bornes de traits déclarent explicitement ce qu'un type générique doit supporter. La vérification d'exhaustivité exige explicitement de traiter chaque cas. Les messages d'erreur indiquent explicitement ce qui a mal tourné et comment le corriger.
Le point d'équilibre est différent pour différentes fonctionnalités. L'inférence devrait être invisible -- le développeur ne devrait pas penser aux types pour la plupart du code. L'exhaustivité devrait être visible -- le développeur devrait savoir qu'il a traité chaque cas.
Cet équilibre est ce qui rend le système de types de FLIN adapté aux développeurs d'applications. Il ne demande pas le niveau d'annotation de types que Rust ou Haskell demandent. Il n'accepte pas le niveau d'incertitude de types que JavaScript ou Python acceptent. Il se situe dans un terrain intermédiaire où les types sont présents mais discrets, où la sécurité est appliquée mais pas lourde.
Ce terrain intermédiaire était l'objectif depuis la première session. Cent cinquante sessions plus tard, nous l'avons atteint.
Ce qui vient ensuite
L'arc du système de types est complet. Le prochain arc de la série « How We Built FLIN » passe à un domaine différent : le modèle temporel de FLIN. Les requêtes de voyage dans le temps, l'historique des entités, l'opérateur @, les mots-clés temporels et l'infrastructure de base de données qui fait de « montre-moi cet enregistrement tel qu'il était mardi dernier » une opération en une seule ligne.
Le système de types réapparaîtra tout au long -- les opérations temporelles retournent des résultats typés, les requêtes d'historique produisent des listes typées, et l'opérateur @ est vérifié par le système de types comme n'importe quelle autre expression. Mais l'accent passe de comment FLIN comprend les types à comment FLIN comprend le temps.
Ceci est la partie 45 de la série « How We Built 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 : - [43] While-Let Loops and Break With Value - [44] Labeled Loops and Or-Patterns - [45] Advanced Type Features: The Complete Picture (vous êtes ici) - [46] FLIN's Temporal Model (prochainement)