Pattern Matching

Pattern Matching

Le pattern matching permet de faire correspondre des valeurs à des patterns de manière déclarative et sûre, en regroupant tous les cas au même endroit.

Héritage Lisp/ML : rendre la forme explicite clarifie le matching.

Syntaxe de base

match valeur {
    pattern1 => { action1 }
    pattern2 => { action2 }
    _ => { action_par_defaut }
}

Correspondance de valeurs littérales

code_http = 404

match code_http {
    200 => { print("OK") }
    404 => { print("Non trouvé") }
    500 => { print("Erreur serveur") }
    _ => { print("Code inconnu") }
}

Capture de variable

# Capturer la valeur dans une variable
nombre = 42

match nombre {
    0 => { print("Zéro") }
    n => { print("Le nombre est:", n) }
}

Wildcard (joker)

match x {
    1 => { print("Un") }
    2 => { print("Deux") }
    _ => { print("Autre chose") }  # Correspond à tout
}

Guards (conditions)

age = 25

match age {
    n if n < 18 => { print("Mineur") }
    n if n < 65 => { print("Adulte") }
    n => { print("Senior") }
}

# Plusieurs conditions
score = 85

match score {
    n if n >= 90 => { print("Excellent") }
    n if n >= 75 => { print("Bien") }
    n if n >= 60 => { print("Passable") }
    n => { print("Insuffisant") }
}

Pattern OR (alternatives)

jour = 6

match jour {
    1 | 2 | 3 | 4 | 5 => { print("Jour de semaine") }
    6 | 7 => { print("Weekend") }
    _ => { print("Jour invalide") }
}

# Avec capture
symbole = "+"

match symbole {
    "+" | "-" => { print("Opérateur additif") }
    "*" | "/" => { print("Opérateur multiplicatif") }
    op => { print("Opérateur inconnu:", op) }
}

Match non exhaustif (erreur)

Un match doit être total. Sans wildcard, une valeur non couverte déclenche une erreur.

jour = 9

match jour {
    1 => { "lundi" }
    2 => { "mardi" }
}
# CatnipRuntimeError: No matching pattern

Erreurs possibles en match

Ordre des patterns (le premier qui matche gagne) :

value = 2

match value {
    _ => { "fallback" }
    2 => { "two" }
}
# Toujours "fallback"

Guards trop larges (masquent les cas spécifiques) :

value = 10

match value {
    n if n > 0 => { "positive" }
    10 => { "ten" }
}
# "ten" ne sera jamais atteint

Match complexe

# Classification de nombres
classifier = (n) => {
    match n {
        0 => { "zéro" }
        n if n < 0 => { "négatif" }
        n if n < 10 => { "petit positif" }
        n if n < 100 => { "moyen positif" }
        n => { "grand positif" }
    }
}

print(classifier(-5))  # → "négatif"
print(classifier(7))  # → "petit positif"
print(classifier(150))  # → "grand positif"

Pattern de structure

Le pattern matching supporte la destructuration de structures via NomStruct{champ1, champ2}. Le matching vérifie le type de l'instance puis lie chaque champ à une variable du même nom :

struct Point { x; y; }

p = Point(3, 4)

match p {
    Point{x, y} => { x + y }   # 7
    _ => { 0 }
}

Si un champ demandé dans le pattern n'existe pas dans la structure, le pattern est considéré comme non correspondant et le match continue vers la branche suivante.

Le nom de type écrit dans le pattern n'est pas un littéral : c'est une référence de nom, résolue dans le scope courant comme n'importe quelle autre. Le pattern matche ensuite les instances dont le type a la même forme que le type résolu (mêmes champs, mêmes méthodes, même hiérarchie). Deux conséquences directes.

Un alias, ou un paramètre qui transporte un type, se matche à travers :

struct Point { x; y; }

Alias = Point
match Point(1, 2) { Alias{x, y} => { x + y } }        # 3

check = (T, v) => { match v { T{x, y} => { x + y }  _ => { -1 } } }
check(Point, Point(3, 4))                              # 7

Et une redéfinition de forme différente ne rattrape plus les anciennes instances — le pattern désigne le type courant, pas tout ce qui porte son nom :

struct P { x }
a = P(x=1)
struct P { x; y }        # même nom, forme différente
list(match a { P{x} => { "hit" }  _ => { "miss" } })   # ["miss"] : a a l'ancienne forme

C'est le même critère que l'égalité (voir STRUCTURES), donc deux instances égales matchent toujours les mêmes patterns. Une redéfinition identique continue de matcher.

Un type n'est pas son nom. Le nom est l'adresse où l'on va demander de quel type il s'agit — et l'adresse peut avoir changé de locataire.

Combiné avec les guards, on peut filtrer sur les valeurs des champs :

struct Point { x; y; }

classify = (p) => {
    match p {
        Point{x, y} if x == 0 and y == 0 => { "origin" }
        Point{x, y} if x == 0 => { "y-axis" }
        Point{x, y} if y == 0 => { "x-axis" }
        Point{x, y} => { "general" }
    }
}

print(classify(Point(0, 0)))  # → "origin"
print(classify(Point(0, 5)))  # → "y-axis"
print(classify(Point(3, 4)))  # → "general"

Plusieurs types de structures peuvent être testés dans le même match :

struct Circle { radius }
struct Rect { width; height; }

area = (shape) => {
    match shape {
        Circle{radius} => { 3.14159 * radius ** 2 }
        Rect{width, height} => { width * height }
        _ => { 0 }
    }
}

Le pattern de structure vérifie type + champs. Si le type ne correspond pas, la branche reste dans une timeline parallèle.

Pattern de variante d'union

Le même pattern struct sert à destructurer les variantes d'union avec payload. La forme qualifiée Union.Variant{field} matche les instances construites par Union.Variant(...) :

union Option { Some(value); None; }

opt = Option.Some(42)

match opt {
    Option.Some{value} => { value }
    Option.None => { 0 }
}
# → 42

Les variantes nullaires se matchent comme des variantes d'enum (Union.Variant sans accolades). Voir UNIONS pour la sémantique complète.

Résolution du type nommé par un pattern

Un pattern qui nomme un type — struct simple (Point{x}) ou qualifié (Color.Red, Union.Variant) — exige que ce type soit résolvable dans le scope courant. Deux échecs distincts, tous deux bruyants :

Le nom… Résultat
n'est lié à rien CatnipNameError
est lié à autre chose qu'une struct CatnipTypeError
struct P { x }
p = P(1)
Q = 42
match p { Q{x} => { "hit" }  _ => { "no" } }   # CatnipTypeError : Q n'est pas une structure

Un module importé sous namespace n'expose pas le type en direct : il faut l'aliaser ou l'importer en wild.

m = import("colors")
Color = m.Color                                  # requis : le pattern nomme `Color`, pas `m.Color`
match c { Color.Red => { "r" }  _ => { "o" } }

Sans cette mise en scope (Color = m.Color ou import("colors", wild=True)), le pattern lève CatnipNameError — pas un non-match silencieux. Le critère est le même que pour toute référence : un nom non lié est une erreur. Pour les patterns qualifiés, les trois runtimes (AST, VM, et la VM pure du serveur MCP et des binaires standalone) rejettent le pattern de façon identique.

La résolution par forme des patterns de struct simples décrite plus haut vaut pour l'AST et la VM ; la VM pure (binaires standalone, serveur MCP) les matche encore par nom seul, et sera alignée avec son égalité de structures.

Un pattern qui nomme un type jamais importé ne « ne matche pas » : il n'existe pas. La distinction est celle entre une question sans réponse et une question mal posée.

Patterns struct dans les tuples

Les patterns de structure peuvent apparaître à l'intérieur de tuple patterns. Chaque position du tuple est matchée récursivement, ce qui permet de destructurer simultanément un tuple et les structures qu'il contient :

struct Point { x; y; }

data = tuple(Point(1, 2), "label")

match data {
    (Point{x, y}, name) => { print(x + y, name) }   # 3 "label"
    _ => { print("no match") }
}

Plusieurs structures dans le même tuple :

struct Point { x; y; }
struct Color { r; g; b; }

match tuple(Point(1, 2), Color(255, 0, 128)) {
    (Point{x, y}, Color{r, g, b}) => { x + y + r + g + b }   # 386
}

Les guards s'appliquent sur les bindings extraits de l'ensemble du pattern :

struct Point { x; y; }

match tuple(Point(0, 5), 10) {
    (Point{x, y}, z) if x == 0 => { y * z }   # 50
    (Point{x, y}, z) => { x + y + z }
}

Propriétés du Pattern Matching

Le système de pattern matching garantit plusieurs propriétés observables :

Mermaid diagram lang__PATTERN_MATCHING--m001 Mermaid diagram lang__PATTERN_MATCHING--m001

Déterminisme : Pour une valeur donnée, le matching produit toujours le même résultat

  • Le premier pattern qui matche est toujours choisi
  • L'ordre d'évaluation est prévisible (gauche à droite pour les OR patterns)
  • Un seul parcours de la liste de cases, pas de backtracking

Composition : Les OR patterns se composent de manière associative

  • a | (b | c) produit le même résultat que (a | b) | c
  • La recherche est court-circuitée au premier succès
  • Réduction du nombre de cas à traiter (un seul case au lieu de multiples)

Isolation : Les guards n'ont pas d'effet de bord sur le scope principal

  • Chaque guard évalue dans un scope temporaire
  • Les bindings du pattern sont visibles dans le guard
  • Le scope principal reste intact si le guard échoue
  • Cette localité facilite le raisonnement (toute la logique au même endroit)

L'isolation s'arrête au guard : le corps d'un bras s'exécute dans le scope courant, comme une branche de if. Un seul bras s'exécute, donc rien n'y entre en collision et ce qu'il assigne — captures de motif comprises — reste lisible après le match :

p = 5
match p {
    n => { d = n * 2 }
}
d           # → 10

Note théorique : Ces propriétés correspondent à celles d'un morphisme discriminant dans un topos (voir Johnstone, Sketches of an Elephant, vol. 1, D1.3). Le pattern matching construit une fonction partielle (valeur → bindings) avec des garanties de décision unique et prévisible. Cette structure explique pourquoi :

  • Il n'y a pas d'ambiguïté possible (un seul chemin d'exécution)
  • La composition des patterns préserve ces garanties
  • L'ajout de guards correspond à une restriction de domaine sans changer la structure
  • L'exhaustivité peut être vérifiée mécaniquement (car le domaine est bien défini)

Ces fondements catégoriques garantissent que les propriétés observables (déterminisme, prévisibilité) ne sont pas accidentelles mais découlent de la structure mathématique sous-jacente.