Génériques#

La programmation générique permet d’écrire du code qui fonctionne de manière uniforme sur une famille de types, sans sacrifier la sécurité ni les performances. En Rust, les génériques constituent le principal mécanisme d’abstraction sur les types : ils éliminent la duplication de code tout en garantissant, grâce à la monomorphisation, un coût nul à l’exécution. Ce chapitre approfondit les notions introduites au chapitre 8 (structures génériques) et s’appuie sur les traits présentés au chapitre 13.

Motivation : le problème de la duplication#

Considérons une fonction qui retourne le plus grand élément d’une tranche (slice) d’entiers.

fn plus_grand_i32(liste: &[i32]) -> &i32 {
    let mut max = &liste[0];
    for element in &liste[1..] {
        if element > max {
            max = element;
        }
    }
    max
}

let nombres = [34, 50, 25, 100, 65];
println!("Le plus grand est {}", plus_grand_i32(&nombres));
Le plus grand est 100

Si l’on souhaite la même logique pour des f64 ou des char, il faudrait dupliquer le corps de la fonction en ne changeant que les annotations de type. Cette duplication viole le principe DRY (Don’t Repeat Yourself) et rend la maintenance difficile. Les génériques résolvent ce problème en paramétrant le code par un ou plusieurs types.

Fonctions génériques#

Définition 77 (Fonction générique)

Une fonction générique déclare un ou plusieurs paramètres de type entre chevrons (<T>) après son nom. Ces paramètres peuvent apparaître dans la signature (paramètres, retour) et dans le corps de la fonction. Le compilateur génère une version spécialisée pour chaque type concret utilisé à l’appel.

Exemple 77 (Fonction générique avec borne de trait)

Voir le code ci-dessous.

fn plus_grand<T: PartialOrd>(liste: &[T]) -> &T {
    let mut max = &liste[0];
    for element in &liste[1..] {
        if element > max {
            max = element;
        }
    }
    max
}

let entiers = [34, 50, 25, 100, 65];
let flottants = [2.7, 1.4, 3.14, 0.5];
let caracteres = ['r', 'u', 's', 't'];

println!("Plus grand entier    : {}", plus_grand(&entiers));
println!("Plus grand flottant  : {}", plus_grand(&flottants));
println!("Plus grand caractère : {}", plus_grand(&caracteres));
Plus grand entier    : 100
Plus grand flottant  : 3.14
Plus grand caractère : u

La borne T: PartialOrd est nécessaire car l’opérateur > n’est pas disponible pour un type T arbitraire. Sans cette contrainte, le compilateur refuse le code. Les bornes de traits seront détaillées plus loin dans ce chapitre.

Remarque 61

Rust infère presque toujours les paramètres de type à partir des arguments. La syntaxe turbofish plus_grand::<i32>(&entiers) permet de les spécifier explicitement lorsque l’inférence est ambiguë.

Structures et énumérations génériques#

Définition 78 (Type générique)

Une structure ou une énumération peut déclarer des paramètres de type entre chevrons après son nom. Chaque paramètre peut être utilisé dans la définition des champs (ou des variantes) et dans les blocs impl associés.

Structures génériques#

On a vu au chapitre 8 la structure Point<T>. Voici un exemple avec deux paramètres de type distincts.

#[derive(Debug)]
struct Point<X, Y> {
    x: X,
    y: Y,
}

impl<X, Y> Point<X, Y> {
    fn new(x: X, y: Y) -> Self {
        Self { x, y }
    }

    /// Mélange les coordonnées de deux points de types différents.
    fn melanger<X2, Y2>(self, autre: Point<X2, Y2>) -> Point<X, Y2> {
        Point {
            x: self.x,
            y: autre.y,
        }
    }
}

let p1 = Point::new(5, 10.4);
let p2 = Point::new("Bonjour", 'c');
let p3 = p1.melanger(p2);
println!("{:?}", p3); // Point { x: 5, y: 'c' }
Point { x: 5, y: 'c' }

Remarque 62

La méthode melanger introduit ses propres paramètres de type <X2, Y2>, indépendants des paramètres <X, Y> de la structure. Les paramètres de type d’une méthode sont déclarés après le nom de la méthode.

Énumérations génériques#

Les deux énumérations génériques les plus utilisées en Rust sont Option<T> et Result<T, E>, étudiées au chapitre 9.

Définition 79 (Option et Result)

Option<T> est défini par enum Option<T> { Some(T), None } et modélise une valeur optionnelle.
Result<T, E> est défini par enum Result<T, E> { Ok(T), Err(E) } et modélise une opération faillible.
Ces deux types sont des exemples canoniques d’énumérations génériques à un et deux paramètres de type.

// Un type maison qui mime le comportement de Option
#[derive(Debug)]
enum Peut_etre<T> {
    Valeur(T),
    Rien,
}

let a: Peut_etre<i32> = Peut_etre::Valeur(42);
let b: Peut_etre<String> = Peut_etre::Rien;
println!("{:?}, {:?}", a, b);
Valeur(42), Rien

Bornes de traits (trait bounds)#

Définition 80 (Borne de trait)

Une borne de trait (trait bound) restreint un paramètre de type aux types qui implémentent un ou plusieurs traits donnés. La syntaxe de base est T: Trait. Les bornes peuvent apparaître directement dans la déclaration des paramètres de type ou dans une clause where.

Syntaxe directe#

use std::fmt::Display;

fn afficher_paire<T: Display>(a: T, b: T) {
    println!("({}, {})", a, b);
}

afficher_paire(3, 7);
afficher_paire("alpha", "beta");
(3, 7)
(alpha, beta)

Bornes multiples#

Lorsqu’un paramètre de type doit satisfaire plusieurs traits, on les sépare par +.

use std::fmt::{Display, Debug};

fn inspecter<T: Display + Debug>(valeur: T) {
    println!("Display : {}", valeur);
    println!("Debug   : {:?}", valeur);
}

inspecter(42);
inspecter("texte");
Display : 42
Debug   : 42
Display : texte
Debug   : "texte"

Clause where#

Proposition 17 (Equivalence syntaxique)

La clause where est syntaxiquement équivalente aux bornes en ligne. Le compilateur les traite de manière identique. La clause where est préférable lorsque la signature comporte plusieurs paramètres de type avec des bornes complexes, car elle améliore la lisibilité.

use std::fmt::Display;

fn formater_paire<A, B>(a: A, b: B) -> String
where
    A: Display + Clone,
    B: Display,
{
    format!("[{}, {}]", a, b)
}

println!("{}", formater_paire(3.14, "pi"));
[3.14, pi]

Remarque 63

On recommande la clause where dès que les bornes dépassent une ou deux contraintes simples. Le style idiomatique en Rust privilégie la lisibilité de la signature.

Code qui ne compile pas sans borne#

L’exemple suivant illustre l’erreur que produit le compilateur lorsqu’on omet une borne nécessaire.

fn afficher<T>(valeur: T) {
    println!("{}", valeur); // erreur : T n'implémente pas Display
}
warning: type `Peut_etre` should have an upper camel case name
type `Peut_etre` should have an upper camel case name
help: convert the identifier to upper camel case

PeutEtre
    println!("{}", valeur); // erreur : T n'implémente pas Display
                   ^^^^^^ `T` cannot be formatted with the default formatter
    println!("{}", valeur); // erreur : T n'implémente pas Display
              ^^ required by this formatting parameter
`T` doesn't implement `std::fmt::Display`
help: consider restricting type parameter `T` with trait `Display`

: std::fmt::Display

Remarque 64

Le message du compilateur indique précisément quel trait manque et suggère l’ajout de la borne T: std::fmt::Display. Cette conception par diagnostic explicite est l’un des principes directeurs de Rust.

Implémentation conditionnelle#

Définition 81 (Implémentation conditionnelle)

Un bloc impl peut restreindre ses paramètres de type par des bornes de traits. Les méthodes définies dans ce bloc ne sont alors disponibles que pour les instanciations qui satisfont les bornes.

Exemple 78 (Méthodes conditionnelles)

Voir le code ci-dessous.

use std::fmt::Display;

#[derive(Debug)]
struct Boite<T> {
    contenu: T,
}

// Disponible pour tout T
impl<T> Boite<T> {
    fn new(contenu: T) -> Self {
        Self { contenu }
    }
}

// Disponible uniquement si T implémente Display
impl<T: Display> Boite<T> {
    fn afficher(&self) {
        println!("Boîte contenant : {}", self.contenu);
    }
}

let b1 = Boite::new(42);
b1.afficher(); // OK : i32 implémente Display

let b2 = Boite::new(vec![1, 2, 3]);
// b2.afficher(); // ne compilerait pas : Vec<i32> n'implémente pas Display
println!("{:?}", b2); // Debug fonctionne grâce à #[derive(Debug)]
Boîte contenant : 42
Boite { contenu: [1, 2, 3] }

Implémentations couvertures (blanket implementations)#

Définition 82 (Implémentation couverture)

Une implémentation couverture (blanket implementation) implémente un trait pour tout type satisfaisant certaines bornes. C’est le mécanisme qui permet, par exemple, à la bibliothèque standard de fournir automatiquement ToString pour tout type implémentant Display.

// La bibliothèque standard contient (simplification) :
// impl<T: Display> ToString for T { ... }

// Grâce à cette blanket impl, tout type Display obtient to_string()
let n: i32 = 42;
let s: String = n.to_string();
println!("{}", s);
42

Remarque 65

Les implémentations couvertures sont un outil puissant pour étendre la fonctionnalité de manière cohérente. Elles permettent d’exprimer des relations logiques entre traits : « tout type affichable est convertible en chaîne ». C’est l’un des piliers de la composabilité dans l’écosystème Rust.

Monomorphisation : des génériques à coût zéro#

Proposition 18 (Monomorphisation)

Le compilateur Rust résout les génériques à la compilation par monomorphisation : pour chaque combinaison concrète de paramètres de type utilisée dans le programme, il génère une version spécialisée du code. Le binaire résultant ne contient aucune indirection liée aux génériques. Les performances sont identiques à celles d’un code écrit manuellement pour chaque type.

Exemple 79 (Monomorphisation en action)

Lorsque le compilateur rencontre :

fn identite<T>(x: T) -> T { x }

let a = identite(5);       // génère identite_i32
let b = identite(3.14);    // génère identite_f64
let c = identite("hello"); // génère identite_&str

println!("{}, {}, {}", a, b, c);
5, 3.14, hello

Le compilateur produit en interne trois fonctions distinctes — identite_i32, identite_f64, identite_&str — chacune optimisée pour son type concret.

Remarque 66

Comparaison avec d’autres langages.
Les templates C++ fonctionnent sur un principe similaire d’instanciation à la compilation, mais sans vérification préalable des contraintes : les erreurs ne sont détectées qu’au point d’instanciation, produisant souvent des messages obscurs. Les concepts C++20 corrigent partiellement ce défaut, à la manière des bornes de traits de Rust.
Les génériques Java, en revanche, utilisent l”erasure : le paramètre de type est effacé à la compilation et remplacé par Object. Cela introduit des casts implicites et interdit l’usage de types primitifs (int, double) comme paramètres de type. Les performances s’en ressentent, car les accès passent par des indirections et de la boxe (boxing).
Rust offre le meilleur des deux mondes : vérification statique des contraintes et spécialisation complète sans surcoût.

Types associés#

Définition 83 (Type associé)

Un type associé est un type déclaré à l’intérieur d’un trait avec le mot-clé type. Contrairement à un paramètre de type sur le trait lui-même, un type associé est fixé de manière unique par chaque implémentation. Il n’est pas nécessaire de le spécifier lors de l’appel.

Le trait Iterator de la bibliothèque standard (que l’on étudiera en détail au chapitre 16) est l’exemple canonique.

struct Compteur {
    valeur: u32,
    max: u32,
}

impl Compteur {
    fn new(max: u32) -> Self {
        Self { valeur: 0, max }
    }
}

impl Iterator for Compteur {
    type Item = u32;  // type associé fixé à u32

    fn next(&mut self) -> Option<Self::Item> {
        if self.valeur < self.max {
            self.valeur += 1;
            Some(self.valeur)
        } else {
            None
        }
    }
}

let compteur = Compteur::new(5);
let resultat: Vec<u32> = compteur.collect();
println!("{:?}", resultat);
[1, 2, 3, 4, 5]

Types associés vs paramètres de type#

Proposition 19 (Unicité du type associé)

Si un trait utilise un type associé, chaque type ne peut l’implémenter qu’une seule fois : le type associé est déterminé sans ambiguïté. Si le même trait utilisait un paramètre de type (trait Iterator<T>), un même type pourrait l’implémenter plusieurs fois pour des T différents, et le compilateur aurait besoin d’annotations supplémentaires pour désambiguïser.

Exemple 80 (Comparaison : type associé vs paramètre de type)

Voir le code ci-dessous.

// Avec un type associé : une seule implémentation par type
trait Convertir {
    type Sortie;
    fn convertir(&self) -> Self::Sortie;
}

impl Convertir for i32 {
    type Sortie = f64;
    fn convertir(&self) -> f64 {
        *self as f64
    }
}

let n: i32 = 42;
let f: f64 = n.convertir(); // pas d'ambiguïté
println!("{} -> {}", n, f);
42 -> 42
// Avec un paramètre de type : plusieurs implémentations possibles
trait ConvertirEn<T> {
    fn convertir_en(&self) -> T;
}

impl ConvertirEn<f64> for i32 {
    fn convertir_en(&self) -> f64 { *self as f64 }
}

impl ConvertirEn<String> for i32 {
    fn convertir_en(&self) -> String { self.to_string() }
}

let n: i32 = 42;
let f: f64 = n.convertir_en();      // le type cible lève l'ambiguïté
let s: String = n.convertir_en();
println!("{} -> {}, {}", n, f, s);
42 -> 42, 42

Remarque 67

Quand choisir un type associé ? Lorsqu’il n’y a logiquement qu’une seule implémentation par type (un itérateur produit un seul type d’élément). Quand choisir un paramètre de type ? Lorsqu’on veut permettre plusieurs implémentations (un type peut être converti vers plusieurs cibles).

Const generics#

Définition 84 (Paramètre constant générique)

Un paramètre constant générique (const generic) permet de paramétrer un type ou une fonction par une valeur constante connue à la compilation, déclarée par la syntaxe <const N: Type>. Les types autorisés sont les entiers, les booléens et les caractères.

Les const generics résolvent un problème historique en Rust : l’impossibilité d’écrire du code générique sur la taille des tableaux.

Exemple 81 (Tableaux de taille générique)

Voir le code ci-dessous.

fn afficher_tableau<const N: usize>(tab: &[i32; N]) {
    print!("[");
    for (i, elem) in tab.iter().enumerate() {
        if i > 0 { print!(", "); }
        print!("{}", elem);
    }
    println!("]");
}

afficher_tableau(&[1, 2, 3]);
afficher_tableau(&[10, 20, 30, 40, 50]);
[1, 2, 3]
[10, 20, 30, 40, 50]

Structures avec const generics#

#[derive(Debug)]
struct Vecteur<const N: usize> {
    donnees: [f64; N],
}

impl<const N: usize> Vecteur<N> {
    fn zero() -> Self {
        Self { donnees: [0.0; N] }
    }

    fn norme(&self) -> f64 {
        self.donnees.iter()
            .map(|x| x * x)
            .sum::<f64>()
            .sqrt()
    }
}

let v2 = Vecteur::<2> { donnees: [3.0, 4.0] };
let v3 = Vecteur::<3> { donnees: [1.0, 2.0, 2.0] };

println!("v2 = {:?}, norme = {}", v2, v2.norme());
println!("v3 = {:?}, norme = {}", v3, v3.norme());

let origine = Vecteur::<4>::zero();
println!("Origine 4D : {:?}", origine);
v2 = Vecteur { donnees: [3.0, 4.0] }, norme = 5
v3 = Vecteur { donnees: [1.0, 2.0, 2.0] }, norme = 3
Origine 4D : Vecteur { donnees: [0.0, 0.0, 0.0, 0.0] }

Combiner const generics et paramètres de type#

Les const generics se composent naturellement avec les paramètres de type classiques.

#[derive(Debug)]
struct Matrice<T, const L: usize, const C: usize> {
    donnees: [[T; C]; L],
}

impl<T: Default + Copy, const L: usize, const C: usize> Matrice<T, L, C> {
    fn nouvelle() -> Self {
        Self {
            donnees: [[T::default(); C]; L],
        }
    }
}

let m: Matrice<f64, 2, 3> = Matrice::nouvelle();
println!("{:?}", m);
Matrice { donnees: [[0.0, 0.0, 0.0], [0.0, 0.0, 0.0]] }

Remarque 68

Les const generics en Rust sont encore en cours de maturation. Les expressions constantes complexes dans les paramètres (par exemple N + 1 ou {N * 2}) nécessitent la fonctionnalité generic_const_exprs, qui reste expérimentale. En pratique, on se limite aux valeurs littérales et aux paramètres constants simples, ce qui couvre déjà la grande majorité des cas d’usage (tableaux de taille fixe, tampons, matrices).

Proposition 20 (Monomorphisation des const generics)

Les const generics sont eux aussi résolus par monomorphisation. Vecteur<3> et Vecteur<4> sont des types distincts, chacun disposant de sa propre version du code. Aucune indirection dynamique n’est introduite.

Résumé#

Les génériques de Rust forment un système cohérent qui repose sur trois piliers :

  1. Les paramètres de type (<T>) permettent d’abstraire sur les types.

  2. Les bornes de traits (T: Trait) garantissent que seules les opérations valides sont utilisées.

  3. La monomorphisation assure que cette abstraction n’a aucun coût à l’exécution.

Les types associés et les const generics complètent ce système en permettant respectivement de fixer un type par implémentation et de paramétrer le code par des valeurs constantes. Au chapitre 16, nous verrons comment ces mécanismes s’articulent avec les itérateurs, l’un des usages les plus élégants de la programmation générique en Rust.