Déstructuration et patterns

En Rust, la déstructuration n’est pas réservée au match : elle est disponible partout où le langage attend un motif (pattern), y compris dans les paramètres de fonction. C’est ce mécanisme qui explique des syntaxes déroutantes au premier abord, comme les extracteurs du framework web Axum.

Un motif, pas seulement un nom de variable

N’importe quel paramètre de fonction peut utiliser un motif, et pas uniquement un identifiant. La partie à gauche du : est un motif, celle de droite un type.

//      ┌─── motif ────┐  ┌──── type ────┐
fn f1(          x       :       i32       ) {}
fn f2(   Etat(etat)     :   Etat<Config>  ) {}

Dans f1, le motif se réduit au nom de la variable. Dans f2, le motif Etat(etat) signifie deux choses :

  1. « j’attends une valeur de type Etat<...> » ;
  2. « déconstruis-la, prends le champ interne et nomme-le etat ».

C’est exactement le mécanisme du match :

match valeur {
    Etat(interne) => { /* interne est le contenu de l'enveloppe */ }
}

ou celui du let :

let Etat(etat) = Etat(Config { debug: true });
// etat est de type Config, l'enveloppe a disparu

Les structures tuples

Le type utilisé ci-dessus est une structure tuple : une structure dont les champs n’ont pas de nom mais sont accessibles par index.

pub struct Etat<S>(pub S);

let e = Etat(42);
println!("{}", e.0);        // Accès par index
let Etat(valeur) = e;       // ...ou par déstructuration

Le nom Etat sert alors à la fois de constructeur (Etat(42)) et de motif (Etat(valeur)). C’est la même dualité que pour les variantes d’énumération, comme Some(x).

Déstructurer les structures nommées

struct Point { x: i32, y: i32 }

let p = Point { x: 10, y: 20 };

let Point { x, y } = p;                 // Raccourci : les variables prennent le nom des champs
let Point { x: abscisse, y: ordonnee } = p;     // Avec renommage

Le motif .. permet d’ignorer les champs restants, ce qui évite d’avoir à modifier tous les appels quand la structure s’enrichit :

struct Config { hote: String, port: u16, debug: bool, verbeux: bool }

let Config { hote, port, .. } = config;

L’imbrication fonctionne également :

struct Rectangle { coin: Point, largeur: u32 }

let Rectangle { coin: Point { x, y }, largeur } = rect;

Déstructurer les énumérations

enum Message {
    Quitter,
    Deplacer { x: i32, y: i32 },
    Ecrire(String),
    Couleur(u8, u8, u8),
}

match message {
    Message::Quitter => println!("Fin"),
    Message::Deplacer { x, y } => println!("Déplacement en {x}, {y}"),
    Message::Ecrire(texte) => println!("{texte}"),
    Message::Couleur(r, v, b) => println!("#{r:02x}{v:02x}{b:02x}"),
}

Les autres emplacements

La déstructuration est possible dans tous ces contextes :

// let
let (a, b, c) = (1, 2, 3);

// Paramètre de fonction
fn distance((x1, y1): (f64, f64), (x2, y2): (f64, f64)) -> f64 {
    ((x2 - x1).powi(2) + (y2 - y1).powi(2)).sqrt()
}

// Boucle for
for (index, valeur) in v.iter().enumerate() {}
for (cle, valeur) in &table {}

// if let et while let
if let Some(Point { x, .. }) = position {}
while let Some((cle, valeur)) = pile.pop() {}

// Closure
table.iter().map(|(cle, valeur)| format!("{cle}={valeur}"));

// Affectation (depuis Rust 1.59)
let (mut a, mut b);
(a, b) = (1, 2);

Motifs avancés

// Alternatives
match n {
    1 | 2 | 3 => println!("petit"),
    4..=10 => println!("moyen"),          // Intervalle inclusif
    _ => println!("grand"),               // Motif attrape-tout
}

// Garde conditionnelle
match point {
    Point { x, y } if x == y => println!("sur la diagonale"),
    Point { x, .. } => println!("abscisse {x}"),
}

Les alternatives s’imbriquent

Le | n’est pas réservé au premier niveau d’un bras : il s’utilise à l’intérieur d’un motif, ce qui évite de dupliquer l’enveloppe.

match option {
    Some(1 | 2) => println!("un ou deux"),      // Plutôt que Some(1) | Some(2)
    Some(_) => println!("autre chose"),
    None => println!("rien"),
}

La liaison avec @

L’opérateur @ permet de tester et capturer simultanément :

match n {
    x @ 1..=5 => println!("{x} est entre 1 et 5"),
    x => println!("{x} est hors intervalle"),
}

Il s’applique aussi à un motif composé, pour conserver la valeur entière tout en la déstructurant :

match point {
    tout @ Point { x: 1..=5, .. } => {
        println!("{tout:?} a une abscisse comprise entre 1 et 5");
    }
    _ => {}
}

Le reste : ..

Le motif .. s’emploie dans les trois familles de conteneurs, avec à chaque fois le même sens — « ignorer ce qui n’est pas nommé » :

let Config { hote, port, .. } = config;             // Structure
let (premier, .., dernier) = (1, 2, 3, 4);          // Tuple
let [premier, .., dernier] = tableau;               // Slice ou tableau

Les motifs de slice

match tranche {
    [] => println!("vide"),
    [unique] => println!("un seul : {unique}"),
    [premier, .., dernier] => println!("de {premier} à {dernier}"),
}

Combiné à @, le reste peut être capturé au lieu d’être ignoré. C’est le moyen idiomatique de traiter une séquence tête/queue :

match tranche {
    [tete, reste @ ..] => println!("{tete} suivi de {} éléments", reste.len()),
    [] => println!("vide"),
}

match tranche {
    [debut @ .., queue] => println!("{} éléments avant {queue}", debut.len()),
    [] => println!("vide"),
}
Warning

Un identifiant seul dans un motif lie toujours une nouvelle variable ; il ne compare pas à une variable existante. C’est une source d’erreur classique :

let attendu = 5;
match n {
    attendu => println!("égal"),    // Lie n à une nouvelle variable `attendu` : toujours vrai
}

match n {
    x if x == attendu => println!("égal"),   // Correct
}

L’exception, c’est la constante : un chemin vers une const ou une variante d’énumération est bien compris comme une comparaison.

const SEUIL: i32 = 5;

match n {
    SEUIL => println!("exactement le seuil"),   // Compare, ne lie pas
    _ => println!("autre"),
}

C’est la raison pour laquelle la convention de nommage en majuscules des constantes n’est pas seulement cosmétique : elle rend visible, à la lecture d’un motif, la différence entre comparer et lier.

Ignorer une valeur : _ et _nom

Les deux formes font taire l’avertissement de variable inutilisée, mais elles ne se comportent pas du tout pareil vis-à-vis de la propriété.

_ est un motif à part entière : il ne lie rien, donc ne déplace pas la valeur.

let s = String::from("Hello");
let _ = s;
println!("{s}");        // Compile : s n'a pas été déplacée

_nom est un identifiant ordinaire, qui commence simplement par un tiret bas : il lie, donc il déplace.

let s = String::from("Hello");
let _nom = s;
println!("{s}");        // Erreur : borrow of moved value: `s`
Tip

Le cas le plus fréquent est celui des gardes (guards) rendues par une bibliothèque : let _ = verrou.lock(); libère le verrou immédiatement, puisque la valeur temporaire n’est liée à rien. let _garde = verrou.lock(); le conserve jusqu’à la fin de la portée. Le comportement souhaité est presque toujours le second.

Modes de liaison

La question sous-jacente à tout motif est : la variable liée reçoit-elle la valeur, ou une référence vers elle ?

ref et ref mut

Historiquement, la réponse se donnait explicitement dans le motif : ref produit une référence partagée, ref mut une référence mutable, là où un identifiant nu aurait déplacé la valeur.

let mut valeur = Some(String::from("Hello"));

match valeur {
    Some(ref s) => println!("{s}"),         // s est un &String, valeur n'est pas déplacée
    None => {}
}

match valeur {
    Some(ref mut s) => s.push('!'),         // s est un &mut String
    None => {}
}

ref est un mot-clé du motif, à ne pas confondre avec &, qui appartient à l’expression : let ref a = x; équivaut à let a = &x;.

Les modes de liaison par défaut

Depuis Rust 2018, les match ergonomics rendent ref largement inutile. Lorsque l’on filtre une référence vers un conteneur, le compilateur bascule automatiquement le mode de liaison en « par référence », et propage ce mode aux motifs imbriqués.

let valeur: Option<String> = Some("Hello".into());

match &valeur {
    Some(s) => println!("{}", s.len()),     // s est un &String, sans avoir écrit ref
    None => {}
}

C’est ce qui explique qu’un match sur &self.champ ou une boucle for (cle, valeur) in &table fournissent des références sans annotation particulière.

Le motif &

À l’inverse, un & dans le motif déréférence explicitement : il consomme la référence et lie la valeur pointée.

let r: &Option<i32> = &Some(5);

match r {
    Some(v) => { /* v est un &i32   — mode par défaut */ }
    None => {}
}

match r {
    &Some(v) => { /* v est un i32   — le & du motif déréférence */ }
    &None => {}
}

Le second n’est possible que parce que i32 implémente Copy. Sur un type possédé, il tenterait un déplacement hors d’une référence et échouerait avec E0507. C’est également la forme que l’on rencontre dans les closures, où |&x| évite un déréférencement manuel :

let nombres = [1, 2, 3];
let pairs: Vec<i32> = nombres.iter().filter(|&&n| n % 2 == 0).copied().collect();

mut dans un motif

Enfin, mut rend la variable liée modifiable, ce qui est indépendant de la mutabilité de la source :

if let Some(mut s) = option {        // s est possédée et modifiable
    s.push('!');
}
Note

Quatre écritures voisines, quatre rôles distincts :

ÉcritureEffet
Some(s)Lie par valeur, ou par référence si le mode par défaut l’impose
Some(ref s)Force la liaison par référence — rarement nécessaire aujourd’hui
Some(mut s)Lie par valeur, en rendant la variable modifiable
&Some(s)Déréférence la référence filtrée, puis lie la valeur

Exhaustivité

Un match doit couvrir tous les cas possibles ; le compilateur refuse le code sinon.

enum Etat { Actif, Suspendu, Archive }

match etat {
    Etat::Actif => {}
    Etat::Suspendu => {}
}
// Erreur E0004 : non-exhaustive patterns: `Etat::Archive` not covered

C’est une propriété précieuse à l’usage : ajouter une variante à une énumération fait échouer la compilation de tous les match qui l’ignorent, ce qui transforme une omission silencieuse en liste de travail fournie par le compilateur.

Warning

Ajouter un bras _ => {} par confort annule ce bénéfice : les variantes futures y tomberont silencieusement. Il est préférable d’énumérer les variantes, et de réserver _ aux cas où le domaine est réellement ouvert, comme les entiers.

L’attribut #[non_exhaustive] permet à une bibliothèque de se réserver le droit d’ajouter des variantes sans casser ses utilisateurs. En dehors de la crate qui le définit, un type ainsi marqué impose un bras attrape-tout :

#[non_exhaustive]
pub enum Erreur { Reseau, Delai }

Motifs réfutables et irréfutables

Rust distingue deux catégories de motifs :

CatégoriePeut échouerContextes autorisés
IrréfutableNon — il correspond toujourslet, paramètres de fonction, for
RéfutableOuimatch, if let, while let, let ... else
let Point { x, y } = p;             // Irréfutable : une structure a toujours cette forme
let Some(v) = option;               // Erreur : motif réfutable dans un let

if let Some(v) = option {}          // Correct
let Some(v) = option else { return; };      // Correct également

C’est la raison pour laquelle un paramètre de fonction ne peut jamais déstructurer une énumération à plusieurs variantes : le compilateur doit avoir la garantie que le motif correspond systématiquement. Une structure tuple à variante unique, elle, satisfait cette contrainte — d’où la possibilité d’écrire Etat(etat): Etat<Config> en paramètre.

Cas concret : les extracteurs Axum

Un extracteur est un type qui sait prélever une information dans une requête HTTP entrante : un segment d’URL, le corps JSON, un en-tête, l’état partagé. Le framework Axum est bâti sur ce principe — les paramètres du gestionnaire sont déclarés avec des types extracteurs, qu’Axum remplit à partir de la requête avant d’appeler la fonction.

async fn gestionnaire(
    Path(id): Path<i32>,                // Extrait de l'URL : /users/{id}
    Query(params): Query<MesParams>,    // Extrait de la chaîne de requête ?cle=valeur
    Json(corps): Json<MonDto>,          // Extrait du corps JSON
    State(etat): State<AppState>,       // Extrait de l'état partagé
) -> impl IntoResponse { /* ... */ }

Tout type implémentant FromRequest ou FromRequestParts peut servir d’extracteur.

State<T> n’est rien d’autre qu’une structure tuple, dont voici la forme simplifiée :

pub struct State<S>(pub S);

La syntaxe State(etat): State<AppState> se décompose donc ainsi :

State(etat): State<AppState>
│     │      │     │
│     │      │     └─ le type interne : l'état partagé
│     │      └─────── l'extracteur Axum
│     └────────────── le nom donné à la valeur extraite (libre)
└──────────────────── le constructeur de la structure tuple, employé comme motif

Pourquoi le nom apparaît deux fois

La répétition de State à gauche et à droite est inévitable, car les deux moitiés portent des informations indépendantes :

  • le type indique à Axum quel extracteur employer ;
  • le motif indique comment nommer la valeur extraite.

Rust ne peut pas déduire le type à partir du motif dans une signature de fonction.

Sans déstructuration

Écrire le paramètre sans motif reste parfaitement légal :

async fn lister(etat: State<AppState>) {
    let db = &etat.0.db;        // Accès au champ tuple, puis au champ db
    let db = &etat.db;          // Fonctionne aussi : State implémente Deref vers AppState
}

La seconde forme repose sur Le mécanisme Deref. Les deux sont correctes, mais la déstructuration élimine l’enveloppe dès la signature et donne directement un nom parlant à la valeur utile.

À retenir

Partout où Rust attend un nom de variable, il accepte en réalité un motif.

Cette généralisation traverse tout le langage : let, for, les closures, les paramètres de fonction, match, if let, while let, let ... else. La syntaxe des extracteurs Axum n’est pas une astuce du framework, mais une simple application de cette règle à une structure tuple.

Ressources