Back to flin
flin

Pattern matching : de switch à match

Comment nous avons conçu le pattern matching de FLIN -- du simple filtrage par valeur à la vérification d'exhaustivité sur les unions étiquetées, et l'implémentation Rust qui alimente le tout.

Thales & Claude | March 30, 2026 11 min flin
EN/ FR/ ES
flinpattern-matchingmatchexhaustiveness

Chaque langage de programmation a un moyen de brancher sur une valeur. JavaScript a switch. Python a match (depuis la 3.10). Rust a match. Mais il y a une différence profonde entre une instruction switch et le vrai pattern matching, et cette différence est la raison pour laquelle les Sessions 145 à 157 ont été parmi les plus importantes dans le développement de FLIN.

Un switch compare une valeur à une liste de constantes. Le pattern matching décompose une valeur en ses parties constitutives, lie ces parties à des noms, et garantit que chaque forme possible de la valeur est gérée. C'est la différence entre « quel nombre est-ce ? » et « de quoi est fait cet objet, et ai-je pris en compte tout ce qu'il pourrait être ? »

Le point de départ : match sur les valeurs

L'expression match de FLIN a commencé simplement. Avant les Sessions 145-157, le langage avait déjà le filtrage par valeur issu de la spécification :

flinresult = match status {
    "active" -> "User is active"
    "pending" -> "Waiting for approval"
    "suspended" -> "Account suspended"
    _ -> "Unknown status"
}

C'est essentiellement une instruction switch avec une syntaxe plus propre. Le _ est un joker qui attrape tout ce qui n'est pas capturé par un bras précédent. Le -> sépare le motif du corps. L'ensemble du match est une expression -- il s'évalue en une valeur.

Mais le filtrage par valeur a des limitations sévères. On ne peut pas filtrer sur la structure d'une valeur. On ne peut pas lier des parties d'une valeur à des noms. Et on ne peut pas demander au compilateur de vérifier que chaque cas est géré. Les Sessions 145-157 ont résolu ces trois limitations.

Pattern matching sur les types

La première extension était le filtrage sur les types. Avec les types union dans le langage, les développeurs avaient besoin d'un moyen de gérer chaque membre d'une union :

flinvalue: int | text | bool = getData()

match value {
    int -> print("Got integer: " + text(value))
    text -> print("Got string: " + value)
    bool -> print("Got boolean: " + text(value))
}

Chaque bras filtre un type, et à l'intérieur du corps du bras, la valeur est rétrécie à ce type. C'est le même mécanisme de rétrécissement de type que l'opérateur is, mais appliqué systématiquement à travers toutes les branches.

Dans le compilateur, les bras de match basés sur les types sont représentés comme une nouvelle variante de motif :

rustpub enum Pattern {
    Literal(Literal),           // "active", 42, true
    Identifier(String),          // x (binds value to name)
    Wildcard,                    // _
    TypeCheck(Type),             // int, text, bool
    EnumVariant {                // Ok(value), None
        enum_name: String,
        variant: String,
        inner: Option<Box<Pattern>>,
    },
    Or(Vec<Pattern>),            // pattern1 | pattern2
    // ...
}

La variante TypeCheck porte le type à filtrer. Le vérificateur de types valide que chaque type dans le match est un membre de l'union sur laquelle on filtre.

Pattern matching sur les enums

Avec les unions étiquetées (Session 145), le pattern matching est devenu essentiel. Une variante d'enum peut porter des données associées, et la seule façon d'accéder à ces données est le pattern matching :

flinenum Result<T, E> {
    Ok(T),
    Err(E)
}

result: Result<int, text> = Ok(42)

match result {
    Ok(value) -> print("Success: " + text(value))
    Err(msg) -> print("Error: " + msg)
}

Le motif Ok(value) fait deux choses simultanément : il vérifie que le résultat est la variante Ok, et il lie les données associées au nom value. À l'intérieur du corps du bras, value a le type int (car Ok de Result<int, text> porte un T = int).

La représentation AST utilise Pattern::EnumVariant :

rustPattern::EnumVariant {
    enum_name: "Result".to_string(),
    variant: "Ok".to_string(),
    inner: Some(Box::new(Pattern::Identifier("value".to_string()))),
}

Le motif interne est récursif. On peut imbriquer des motifs à l'intérieur des variantes d'enum :

flinenum Nested {
    Pair(int, int),
    Single(int),
    Empty
}

match nested {
    Pair(x, y) -> x + y
    Single(x) -> x
    Empty -> 0
}

Vérification d'exhaustivité

La fonctionnalité la plus importante du pattern matching est quelque chose que le développeur ne voit jamais : la vérification d'exhaustivité. Le compilateur vérifie que chaque valeur possible est gérée par au moins un bras.

flinenum Color {
    Red,
    Green,
    Blue
}

match color {
    Red -> "#FF0000"
    Green -> "#00FF00"
    // ERROR: non-exhaustive match -- missing variant: Blue
}

Sans le bras Blue, le compilateur rejette le match. Ce n'est pas un avertissement. C'est une erreur. Chaque match sur un enum doit couvrir chaque variante.

Le vérificateur d'exhaustivité fonctionne en calculant l'ensemble des motifs non couverts :

rustfn check_exhaustiveness(
    &self,
    matched_type: &FlinType,
    arms: &[MatchArm],
    span: Span,
) {
    match matched_type {
        FlinType::Enum { variants, .. } => {
            let mut uncovered: Vec<String> = variants
                .iter()
                .map(|(name, _)| name.clone())
                .collect();

            for arm in arms {
                match &arm.pattern {
                    Pattern::EnumVariant { variant, .. } => {
                        uncovered.retain(|v| v != variant);
                    }
                    Pattern::Wildcard => {
                        uncovered.clear();
                    }
                    _ => {}
                }
            }

            if !uncovered.is_empty() {
                self.report_error(&format!(
                    "non-exhaustive match: missing variant(s): {}",
                    uncovered.join(", ")
                ));
            }
        }
        FlinType::Bool => {
            let has_true = arms.iter().any(|a| matches!(&a.pattern, Pattern::Literal(Literal::Bool(true))));
            let has_false = arms.iter().any(|a| matches!(&a.pattern, Pattern::Literal(Literal::Bool(false))));
            let has_wildcard = arms.iter().any(|a| matches!(&a.pattern, Pattern::Wildcard));

            if !has_wildcard && (!has_true || !has_false) {
                self.report_error("non-exhaustive match on bool");
            }
        }
        _ => {
            // For non-enum types, require a wildcard arm
            let has_wildcard = arms.iter().any(|a| matches!(&a.pattern, Pattern::Wildcard));
            if !has_wildcard {
                self.report_error("match requires a wildcard '_' arm for this type");
            }
        }
    }
}

L'algorithme est direct pour les enums : commencer avec toutes les variantes, retirer chacune qui a un bras correspondant, et signaler celles qui restent. Pour les booléens, vérifier que true et false sont couverts. Pour tous les autres types (entiers, chaînes), exiger un bras joker car l'ensemble des valeurs possibles est infini.

Motifs Or

Parfois, plusieurs motifs doivent exécuter le même code. Les motifs Or combinent plusieurs motifs avec | :

flinmatch status {
    "active" | "enabled" -> showActive()
    "pending" | "waiting" -> showPending()
    _ -> showUnknown()
}

Les motifs Or fonctionnent aussi avec les variantes d'enum :

flinenum Shape {
    Circle(number),
    Square(number),
    Rectangle(number, number),
    Point
}

match shape {
    Circle(r) | Square(r) -> print("Size: " + text(r))
    Rectangle(w, h) -> print("Area: " + text(w * h))
    Point -> print("Point")
}

Le motif Or Circle(r) | Square(r) filtre l'une ou l'autre variante et lie la valeur interne à r. Le vérificateur de types vérifie que toutes les alternatives dans un motif Or lient les mêmes noms avec des types compatibles.

L'implémentation ajoute la variante Pattern::Or :

rustPattern::Or(Vec<Pattern>)

Lors de la vérification d'exhaustivité, un motif Or compte comme couvrant plusieurs variantes. Lors de la génération de code, il génère une vérification pour chaque alternative avec un corps partagé.

Match comme expression

Dans FLIN, match est une expression. Il s'évalue en une valeur :

flincolor_code = match color {
    Red -> "#FF0000"
    Green -> "#00FF00"
    Blue -> "#0000FF"
}

Cela signifie que le vérificateur de types doit vérifier que chaque bras produit une valeur du même type (ou de types compatibles) :

rustfn check_match_expression(&mut self, arms: &[MatchArm]) -> FlinType {
    let mut result_type: Option<FlinType> = None;

    for arm in arms {
        let arm_type = self.infer_type(&arm.body);

        match &result_type {
            None => result_type = Some(arm_type),
            Some(existing) => {
                if !self.types_compatible(existing, &arm_type) {
                    // Try to find a common type
                    let unified = self.unify(existing, &arm_type);
                    result_type = Some(unified);
                }
            }
        }
    }

    result_type.unwrap_or(FlinType::Unknown)
}

Si un bras retourne int et un autre retourne text, le type résultat est int | text. Le type de l'expression match est l'union de tous les types de bras. Cela interagit naturellement avec le système de types union de la Session 100.

Clauses de garde

Le pattern matching supporte aussi les clauses de garde -- des conditions booléennes supplémentaires sur un bras de match :

flinmatch value {
    x if x > 0 -> "positive"
    x if x < 0 -> "negative"
    _ -> "zero"
}

Les clauses de garde fournissent un filtrage que les motifs seuls ne peuvent pas exprimer. Un motif filtre sur la structure ; une garde filtre sur les contraintes de valeur. Ensemble, ils couvrent tous les besoins de branchement.

La représentation AST ajoute une garde optionnelle aux bras de match :

rustpub struct MatchArm {
    pub pattern: Pattern,
    pub guard: Option<Expr>,
    pub body: Expr,
    pub span: Span,
}

Le vérificateur de types évalue la garde comme une expression booléenne et ne compte pas les bras gardés comme exhaustifs (car la garde pourrait être fausse même quand le motif correspond).

Corps en bloc

Quand un bras de match a besoin de plusieurs instructions, il utilise un bloc :

flinmatch result {
    Ok(value) -> {
        processed = transform(value)
        save processed
        processed.id
    }
    Err(msg) -> {
        log("Error: " + msg)
        -1
    }
}

La dernière expression du bloc est la valeur du bras. C'est cohérent avec la règle générale de FLIN selon laquelle les blocs s'évaluent à leur dernière expression.

Génération de code

Les expressions match se compilent en une série de vérifications conditionnelles dans le bytecode :

rustfn emit_match(&mut self, subject: &Expr, arms: &[MatchArm]) {
    // Evaluate the subject once
    self.emit_expr(subject);
    let subject_local = self.allocate_temp();
    self.emit_store(subject_local);

    let mut end_jumps = vec![];

    for arm in arms {
        // Load subject and check pattern
        self.emit_load(subject_local);
        let skip_jump = self.emit_pattern_check(&arm.pattern);

        // If guard exists, check it
        if let Some(guard) = &arm.guard {
            let guard_skip = self.emit_guard_check(guard);
            // Bind pattern variables and emit body
            self.emit_pattern_bindings(&arm.pattern, subject_local);
            self.emit_expr(&arm.body);
            end_jumps.push(self.emit_jump_forward());
            self.patch_jump(guard_skip);
        } else {
            // Bind pattern variables and emit body
            self.emit_pattern_bindings(&arm.pattern, subject_local);
            self.emit_expr(&arm.body);
            end_jumps.push(self.emit_jump_forward());
        }

        self.patch_jump(skip_jump);
    }

    // Patch all end jumps to after the match
    for jump in end_jumps {
        self.patch_jump(jump);
    }
}

Le bytecode généré évalue le sujet une fois, puis vérifie chaque motif dans l'ordre. Quand un motif correspond (et que la garde passe, si présente), les liaisons du motif sont établies et le corps du bras s'exécute. Un saut en avant passe à la fin du match.

L'évolution à travers les sessions

Le pattern matching n'a pas été construit en une session. Il a évolué à travers les Sessions 145-157 :

  • Session 145 : Unions étiquetées et pattern matching basique sur les enums
  • Session 147 : Vérification d'exhaustivité pour les enums et les booléens
  • Session 149 : Clauses de garde sur les bras de match
  • Session 152-153 : Motifs while-let (pattern matching en boucle)
  • Session 154-155 : Motifs Or et boucles étiquetées
  • Session 157 : Raffinements finaux et gestion des cas limites

Chaque session a ajouté une couche de capacité. À la Session 157, le pattern matching de FLIN était fonctionnellement complet : motifs de valeur, motifs de type, motifs de variante d'enum avec déstructuration, motifs Or, clauses de garde, vérification d'exhaustivité, et match-comme-expression.

Pourquoi le pattern matching est important

Le pattern matching n'est pas juste une syntaxe plus jolie pour les instructions switch. C'est un outil fondamental pour écrire du code correct.

Quand on filtre sur une union étiquetée avec vérification d'exhaustivité, le compilateur garantit que chaque cas possible est géré. Quand on ajoute une nouvelle variante à un enum, chaque expression match dans la base de code qui touche cet enum ne compilera plus jusqu'à ce qu'on ajoute le nouveau cas. Cela transforme « ai-je mis à jour chaque instruction switch ? » d'une tâche de revue manuelle en une vérification automatisée du compilateur.

Pour le public cible de FLIN -- des développeurs construisant des applications full-stack -- le pattern matching fournit la sécurité sans la cérémonie. On n'a pas besoin de réfléchir aux cas qu'on a peut-être oubliés. Le compilateur s'en souvient pour vous.

Le prochain article explore la fonctionnalité qui rend le pattern matching vraiment puissant : les unions étiquetées et les types de données algébriques.


Ceci est la partie 35 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 : - [33] Les types génériques dans FLIN - [34] Traits et interfaces - [35] Pattern matching : de switch à match (vous êtes ici) - [36] Types union étiquetés et types de données algébriques - [37] La déstructuration partout

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles