Il y a une tension fondamentale dans tout langage statiquement typé qui interagit avec le monde réel. Le système de types connaît des choses au moment de la compilation. Le monde réel révèle des choses à l'exécution. Une réponse JSON pourrait contenir un nombre ou une chaîne. Une fonction pourrait retourner des types différents selon son entrée. Une variable typée par union pourrait être n'importe lequel de ses membres.
Les gardes de type sont le pont. Ce sont des vérifications à l'exécution que le compilateur comprend, lui permettant de rétrécir un type d'une possibilité large à une certitude spécifique. Dans FLIN, l'opérateur is est ce pont.
L'opérateur is
L'opérateur is vérifie si une valeur a un type spécifique à l'exécution :
flinvalue: int | text | bool = getData()
if value is int {
// Ici, value est int -- pas int | text | bool
result = value + 1
}
if value is text {
// Ici, value est text
upper = value.upper
}
if value is bool {
// Ici, value est bool
negated = !value
}L'opérateur is fait deux choses simultanément :
- Exécution : vérifie le type réel de la valeur et produit un résultat booléen
- Compilation : rétrécit le type dans la branche vraie de la condition
Ce double rôle est ce qui rend les gardes de type puissants. Le développeur écrit une seule vérification, et à la fois l'exécution et le compilateur en bénéficient.
Comment fonctionne le rétrécissement
Le vérificateur de types maintient un environnement de types -- une correspondance des noms de variables aux types. Quand il entre dans un bloc if dont la condition est une garde de type, il crée une nouvelle portée avec le type rétréci :
rustfn check_if_with_type_guard(
&mut self,
variable: &str,
checked_type: &FlinType,
original_type: &FlinType,
then_block: &Block,
else_block: &Option<Block>,
) {
// Branche then : rétrécir au type vérifié
self.push_scope();
self.env.insert(variable.to_string(), checked_type.clone());
self.check_block(then_block);
self.pop_scope();
// Branche else : exclure le type vérifié de l'union
if let Some(else_block) = else_block {
self.push_scope();
let remaining = self.subtract_type(original_type, checked_type);
self.env.insert(variable.to_string(), remaining);
self.check_block(else_block);
self.pop_scope();
}
}Soustraction de type
La branche else effectue la soustraction de type : retirer le type vérifié de l'union. Si une variable est int | text | bool et que vous vérifiez value is int, la branche else sait que la valeur est text | bool :
rustfn subtract_type(&self, base: &FlinType, removed: &FlinType) -> FlinType {
match base {
FlinType::Union(members) => {
let remaining: Vec<FlinType> = members
.iter()
.filter(|m| m != removed)
.cloned()
.collect();
match remaining.len() {
0 => FlinType::Never,
1 => remaining.into_iter().next().unwrap(),
_ => FlinType::Union(remaining),
}
}
_ => {
if base == removed {
FlinType::Never
} else {
base.clone()
}
}
}
}Si tous les types sont retirés, le résultat est Never -- un type sans valeurs, indiquant du code inatteignable. Si un type reste, l'union se réduit à ce type unique.
Gardes de type chaînées
Les gardes de type se chaînent à travers les blocs if-else if-else, rétrécissant progressivement le type :
flinvalue: int | text | bool | number = getData()
if value is int {
// value: int
print("Integer: " + text(value))
} else if value is text {
// value: text (était text | bool | number, rétréci par is text)
print("Text: " + value)
} else if value is bool {
// value: bool (était bool | number, rétréci par is bool)
print("Boolean: " + text(value))
} else {
// value: number (la seule possibilité restante)
print("Number: " + text(value))
}Chaque else if voit un type progressivement plus étroit. La branche else finale a le type qui reste après toutes les vérifications. Le compilateur suit cela automatiquement.
Gardes de type sur les entités
L'opérateur is fonctionne avec les types d'entités :
flinentity Dog { name: text, breed: text }
entity Cat { name: text, indoor: bool }
animal: Dog | Cat = getAnimal()
if animal is Dog {
print(animal.breed) // sûr -- Dog a breed
}
if animal is Cat {
print(animal.indoor) // sûr -- Cat a indoor
}Rétrécissement optionnel
Vérifier si une valeur optionnelle est présente la rétrécit de T? à T :
flinuser: User? = User.find(id)
if user is User {
print(user.name)
}
// Ou la forme courte via la vérification de véracité :
if user {
print(user.name)
}Les deux formes produisent le même rétrécissement.
Combiner les gardes avec les opérateurs logiques
flinvalue: int | text = getData()
if value is int && value > 0 {
// value: int (rétréci par is int)
// La vérification > 0 est valide parce que value est connu comme int
print("Positive integer")
}L'opérateur && applique le rétrécissement de gauche à droite. L'opérande gauche value is int rétrécit le type, et l'opérande droit value > 0 est vérifié avec le type rétréci.
L'opérateur || ne rétrécit pas parce que l'un ou l'autre côté pourrait être vrai :
flinif value is int || value is text {
// value est toujours int | text -- pas de rétrécissement
}Patterns pratiques
Accès sûr aux entités
flinfn get_display_name(entity: User | Organization) -> text {
if entity is User {
return entity.first_name + " " + entity.last_name
}
// entity est Organization ici
return entity.company_name
}Gestion de réponses API
flinresponse: Success | NotFound | ServerError = fetch("/api/data")
if response is Success {
render(response.data)
} else if response is NotFound {
show_404()
} else {
// response: ServerError
log(response.message)
show_error_page()
}Traitement de collections
flinitems: [int | text] = [1, "hello", 2, "world", 3]
numbers = items.where(x => x is int) // [int]
strings = items.where(x => x is text) // [text]La méthode where avec une garde de type produit un type de liste rétréci. Le compilateur infère que filtrer par is int produit [int], pas [int | text].
Pourquoi le rétrécissement à l'exécution compte
Les gardes de type résolvent un vrai problème. Les systèmes de types statiques sont conservateurs -- ils suivent ce qu'une valeur pourrait être, pas ce qu'elle est. Sans gardes de type, le développeur ne peut pas effectuer en toute sécurité des opérations spécifiques à un type.
L'alternative est le casting explicite, qui est à la fois verbeux et dangereux :
flin// Mauvais : cast explicite (pas de sécurité au moment de la compilation)
value = getData() as int // plante si value est en fait text
// Bon : garde de type (sécurité compilation et exécution)
if value is int {
// le compilateur vérifie que toutes les opérations sont valides pour int
}Les gardes de type fournissent la sécurité des vérifications explicites avec l'ergonomie du rétrécissement implicite. Le développeur écrit une seule vérification is, et le compilateur propage l'information de type à travers le bloc entier.
C'est la promesse fondamentale du système de types de FLIN : une sécurité que vous obtenez toujours. Pas une sécurité pour laquelle vous devez vous battre. Pas une sécurité qui nécessite des annotations verbeuses. Une sécurité qui coule naturellement de la façon dont vous écrivez le code.
Ceci est la partie 40 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 : - [38] L'opérateur pipeline : composition fonctionnelle dans FLIN - [39] Tuples, enums et structs - [40] Gardes de type et rétrécissement de type à l'exécution (vous êtes ici) - [41] Le type Never et la vérification d'exhaustivité - [42] Contraintes génériques et clauses where