Back to flin
flin

Bornes génériques et clauses Where

Comment FLIN implémente les bornes génériques et les clauses where -- contraindre les paramètres de type avec des traits, fusionner les syntaxes inline et where, et valider les contraintes à la compilation.

Thales & Claude | March 30, 2026 10 min flin
EN/ FR/ ES
flingenericsboundswhere-clausesconstraints

Les types génériques sans bornes sont comme des promesses sans garanties. Une fonction qui prétend fonctionner avec « n'importe quel type T » ne peut faire que des choses valides pour littéralement tous les types : l'assigner, le passer, peut-être vérifier s'il est none. Elle ne peut pas comparer des valeurs, ne peut pas les convertir en texte, ne peut appeler aucune méthode dessus.

Les bornes contraignent un paramètre de type générique aux types qui satisfont des exigences spécifiques. T: Comparable signifie « n'importe quel type T, tant que T implémente le trait Comparable ». Avec cette borne, la fonction peut appeler des méthodes de comparaison sur les valeurs de T. Sans elle, le compilateur rejette la comparaison.

FLIN supporte deux syntaxes pour les bornes : les bornes inline et les clauses where. La session 144 a implémenté les deux, et la session 150 a fait en sorte que le compilateur les valide réellement.

Bornes inline

La syntaxe inline place les bornes directement sur le paramètre de type :

flinfn max<T: Comparable>(a: T, b: T) -> T {
    if a > b { return a }
    return b
}

fn to_strings<T: Printable>(items: [T]) -> [text] {
    return items.map(x => x.to_text())
}

La borne T: Comparable apparaît dans les chevrons, immédiatement après le nom du paramètre de type. Cette syntaxe est concise et fonctionne bien pour les contraintes simples.

Les bornes multiples utilisent + :

flinfn sort_and_display<T: Comparable + Printable>(items: [T]) -> [text] {
    sorted = sort(items)
    return sorted.map(x => x.to_text())
}

T: Comparable + Printable exige que T implémente les deux traits.

Clauses Where

Pour les contraintes complexes, la clause where offre une alternative plus lisible :

flinfn complex_operation<T, U>(input: T, transformer: (T) -> U) -> [U]
    where T: Serializable + Comparable,
          U: Printable
{
    // ...
}

La clause where vient après la liste des paramètres et avant le corps de la fonction. Chaque contrainte est sur sa propre ligne (par convention), rendant les signatures complexes lisibles.

Les clauses where sont particulièrement utiles quand : - Il y a plusieurs paramètres de type avec plusieurs bornes - Les bornes référencent des types complexes - La syntaxe inline rendrait la signature difficile à lire

flin// Hard to read with inline bounds:
fn merge<T: Comparable + Serializable + Printable, U: Comparable + Serializable>(a: [T], b: [U]) -> [(T, U)] { ... }

// Clear with where clause:
fn merge<T, U>(a: [T], b: [U]) -> [(T, U)]
    where T: Comparable + Serializable + Printable,
          U: Comparable + Serializable
{
    // ...
}

La représentation AST

Les paramètres de type avec contraintes sont représentés à l'aide du struct TypeParam :

rustpub struct TypeParam {
    pub name: String,
    pub constraints: Vec<String>,  // trait names from inline bounds
    pub span: Span,
}

Les clauses where sont un champ séparé sur les déclarations de fonction :

rustStmt::FnDecl {
    name: String,
    type_params: Vec<TypeParam>,
    params: Vec<Param>,
    return_type: Option<Type>,
    where_clauses: Vec<WhereClause>,  // where T: Trait
    body: Block,
    span: Span,
}

pub struct WhereClause {
    pub type_param: String,
    pub bounds: Vec<String>,
    pub span: Span,
}

Cette double représentation -- contraintes sur TypeParam et sur WhereClause -- reflète les deux syntaxes. Le vérificateur de types les fusionne avant la validation.

Analyse syntaxique des clauses Where

L'analyseur syntaxique reconnaît le mot-clé where après la liste des paramètres :

rustfn parse_where_clauses(&mut self) -> Result<Vec<WhereClause>, ParseError> {
    if !self.check_keyword("where") {
        return Ok(vec![]);
    }
    self.advance(); // consume "where"

    let mut clauses = vec![];
    loop {
        let type_param = self.expect_identifier()?;
        self.expect(&Token::Colon)?;

        let mut bounds = vec![];
        loop {
            bounds.push(self.expect_identifier()?);
            if !self.match_token(&Token::Plus) {
                break;
            }
        }

        clauses.push(WhereClause {
            type_param,
            bounds,
            span: self.current_span(),
        });

        if !self.match_token(&Token::Comma) {
            break;
        }
    }

    Ok(clauses)
}

L'analyseur collecte chaque entrée TypeParam: Bound1 + Bound2, séparée par des virgules. Le vecteur résultant est attaché à la déclaration de fonction.

Fusion des contraintes

Le vérificateur de types fusionne les bornes inline et les bornes des clauses where en une seule carte de contraintes :

rustfn merge_constraints(
    &self,
    type_params: &[TypeParam],
    where_clauses: &[WhereClause],
) -> HashMap<String, Vec<String>> {
    let mut constraints: HashMap<String, Vec<String>> = HashMap::new();

    // Collect inline constraints: <T: Comparable>
    for param in type_params {
        if !param.constraints.is_empty() {
            constraints.insert(
                param.name.clone(),
                param.constraints.clone(),
            );
        }
    }

    // Merge where clause constraints: where T: Serializable
    for clause in where_clauses {
        let entry = constraints
            .entry(clause.type_param.clone())
            .or_insert_with(Vec::new);

        for bound in &clause.bounds {
            if !entry.contains(bound) {
                entry.push(bound.clone());
            }
        }
    }

    constraints
}

Si un paramètre de type a à la fois des bornes inline et des bornes de clause where, elles sont combinées :

flinfn example<T: Comparable>(value: T) where T: Printable {
    // T must implement both Comparable AND Printable
}

La carte de contraintes fusionnée pour T est ["Comparable", "Printable"]. Les deux sont validées aux sites d'appel.

Validation des contraintes aux sites d'appel

Avant la session 150, les contraintes étaient analysées mais pas validées. Un développeur pouvait écrire T: Comparable puis appeler la fonction avec un type qui n'implémente pas Comparable, et le compilateur ne se plaignait pas. La session 150 a corrigé cela.

Quand le vérificateur de types rencontre un appel à une fonction générique, il :

  1. Infère les types concrets pour chaque paramètre de type à partir des arguments
  2. Cherche les contraintes fusionnées pour chaque paramètre de type
  3. Vérifie que le type concret satisfait chaque contrainte
rustfn check_generic_function_call(
    &mut self,
    func: &FnDef,
    args: &[Expr],
    span: Span,
) -> FlinType {
    // Step 1: Infer type arguments from arguments
    let type_args = self.infer_type_args(func, args);

    // Step 2: Merge constraints
    let constraints = self.merge_constraints(
        &func.type_params,
        &func.where_clauses,
    );

    // Step 3: Validate each constraint
    for (i, param) in func.type_params.iter().enumerate() {
        let concrete_type = &type_args[i];
        if let Some(bounds) = constraints.get(&param.name) {
            for bound in bounds {
                if !self.type_satisfies_trait(concrete_type, bound) {
                    self.diagnostics.push(Diagnostic {
                        level: DiagnosticLevel::Error,
                        code: "E0010",
                        message: format!(
                            "type {} does not satisfy bound {}: {}",
                            concrete_type.display_name(),
                            param.name,
                            bound
                        ),
                        span,
                        notes: vec![format!(
                            "required by constraint {} on {} in fn {}",
                            bound, param.name, func.name
                        )],
                        hints: vec![format!(
                            "add an implementation: impl {} for {} {{ ... }}",
                            bound, concrete_type.display_name()
                        )],
                    });
                }
            }
        }
    }

    // Step 4: Substitute type args in return type
    self.substitute_type_params(&func.return_type, &type_args)
}

Le message d'erreur inclut la borne spécifique qui a échoué, quelle fonction la requiert, et comment la corriger. Ce niveau de détail est possible parce que les contraintes sont explicites et nommées.

Satisfaction intégrée des traits

Les types primitifs de FLIN satisfont automatiquement certains traits :

rustfn type_satisfies_trait(&self, flin_type: &FlinType, trait_name: &str) -> bool {
    // Check explicit implementations first
    if self.trait_registry.has_impl(trait_name, flin_type) {
        return true;
    }

    // Check built-in implementations
    match (flin_type, trait_name) {
        (FlinType::Int, "Comparable") => true,
        (FlinType::Number, "Comparable") => true,
        (FlinType::Text, "Comparable") => true,
        (FlinType::Int, "Printable") => true,
        (FlinType::Number, "Printable") => true,
        (FlinType::Text, "Printable") => true,
        (FlinType::Bool, "Printable") => true,
        (FlinType::Int, "Numeric") => true,
        (FlinType::Number, "Numeric") => true,
        _ => false,
    }
}

La fonction vérifie d'abord le registre de traits pour les blocs impl explicites. Puis elle se replie sur la satisfaction intégrée des traits pour les types primitifs. Cela signifie que max(5, 3) fonctionne directement parce que int satisfait Comparable nativement.

Bornes sur les enums

Les bornes fonctionnent aussi sur les enums génériques :

flinenum Container<T: Comparable> {
    Empty,
    Single(T),
    Multiple([T])
}

Lors de la construction d'un Container, l'argument de type doit satisfaire Comparable :

flinContainer.Single(42)           // OK -- int satisfies Comparable
Container.Multiple(["a", "b"]) // OK -- text satisfies Comparable

La même logique de validation s'applique. Le vérificateur de types infère T = int à partir de l'argument et vérifie la borne Comparable.

Patterns de contraintes complexes

Paramètres multiples avec bornes indépendantes

flinfn zip_with<T, U, V>(
    list_a: [T],
    list_b: [U],
    combine: (T, U) -> V
) -> [V]
    where T: Comparable,
          V: Printable
{
    // T is Comparable, V is Printable, U is unconstrained
}

Chaque paramètre de type a ses propres contraintes indépendantes. Le compilateur valide chacune séparément par rapport à son type concret.

Bornes sur les paramètres de type d'entité

flinfn find_best<T: Comparable>(items: [T]) -> T? {
    if items.len == 0 { return none }
    best = items[0]
    for item in items[1:] {
        if item > best {
            best = item
        }
    }
    return best
}

L'opérateur > est valide parce que T: Comparable. Sans la borne, le compilateur rejetterait la comparaison avec « cannot compare values of unknown type T ».

Bornes génériques imbriquées

flinfn flatten_and_sort<T: Comparable>(nested: [[T]]) -> [T] {
    flat: [T] = []
    for inner in nested {
        flat = flat + inner
    }
    return sort(flat)
}

La borne sur T se propage à travers la structure imbriquée. sort requiert Comparable, et T: Comparable satisfait cette exigence.

Suite de tests

La session 150 a ajouté quatre tests spécifiques pour la validation des clauses where :

  1. test_e2e_where_clause_valid_constraint -- fn max<T>(a: T, b: T) where T: Comparable appelée avec int
  2. test_e2e_where_clause_numeric_constraint -- clause where avec le trait Numeric
  3. test_e2e_where_clause_multi_constraint_valid -- bornes multiples sur le même paramètre
  4. test_e2e_where_clause_merge_with_inline -- inline <T: Comparable> combiné avec where T: Printable

Ces tests vérifient à la fois le cas positif (contrainte satisfaite) et l'interaction entre les deux syntaxes.

La philosophie de conception

Les bornes et les clauses where représentent une philosophie particulière de la programmation générique : les contraintes doivent être explicites et vérifiées par le compilateur.

Certains langages (comme TypeScript) utilisent le typage structurel pour les génériques -- si une valeur a la bonne forme, ça fonctionne. FLIN utilise des bornes nominales -- un type doit déclarer explicitement (via impl) qu'il satisfait un trait. Cela signifie :

  • Les messages d'erreur nomment le trait spécifique manquant
  • Les développeurs peuvent chercher impl Comparable for X pour trouver tous les types comparables
  • Ajouter un trait à un type est un acte intentionnel, pas une correspondance de forme accidentelle

Cette explicité coûte une petite cérémonie (écrire des blocs impl) mais achète une grande clarté. Quand une fonction générique requiert T: Comparable, le développeur sait exactement ce que T doit fournir. Quand la contrainte échoue, le message d'erreur lui dit exactement quoi implémenter.

Pour le public de FLIN -- des développeurs qui construisent des applications, pas des auteurs de bibliothèques écrivant du code maximalement générique -- ce compromis est correct. La plupart du code générique en FLIN utilise un petit nombre de traits bien connus (Comparable, Printable, Serializable). Les bornes sont prévisibles, les contraintes sont familières, et les messages d'erreur sont exploitables.


Ceci est la partie 42 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 : - [40] Type Guards and Runtime Type Narrowing - [41] The Never Type and Exhaustiveness Checking - [42] Generic Bounds and Where Clauses (vous êtes ici) - [43] While-Let Loops and Break With Value - [44] Labeled Loops and Or-Patterns

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles