Les traits

Un trait décrit un comportement qu’un type peut fournir. C’est le mécanisme d’abstraction central de Rust : il remplace à la fois les interfaces, les classes abstraites et la surcharge d’opérateurs des langages à objets, et il sous-tend la quasi-totalité de la bibliothèque standard — Iterator, Display, Clone, Deref, From sont tous des traits.

Déclarer et implémenter

trait Surface {
    fn aire(&self) -> f64;
}

struct Cercle { rayon: f64 }
struct Rectangle { largeur: f64, hauteur: f64 }

impl Surface for Cercle {
    fn aire(&self) -> f64 {
        std::f64::consts::PI * self.rayon * self.rayon
    }
}

impl Surface for Rectangle {
    fn aire(&self) -> f64 {
        self.largeur * self.hauteur
    }
}

Le trait déclare la signature, chaque impl ... for ... fournit le corps. La différence essentielle avec une interface classique : le trait et le type sont indépendants. Il est possible d’implémenter son propre trait sur un type de la bibliothèque standard.

trait Resume {
    fn resume(&self) -> String;
}

impl Resume for String {
    fn resume(&self) -> String {
        self.chars().take(20).collect()
    }
}

Les méthodes par défaut

Un trait peut fournir une implémentation, que les types peuvent accepter telle quelle ou remplacer.

trait Salutation {
    fn nom(&self) -> String;

    fn saluer(&self) -> String {              // Méthode par défaut
        format!("Bonjour, {} !", self.nom())
    }
}

struct Personne { prenom: String }

impl Salutation for Personne {
    fn nom(&self) -> String { self.prenom.clone() }
    // saluer() est fourni gratuitement
}

C’est le ressort de la richesse d’Iterator : une seule méthode requise, next(), et une soixantaine de méthodes par défaut définies à partir d’elle.

La règle d’orphelin

Une implémentation n’est permise que si le trait ou le type appartient à votre crate. Implémenter un trait étranger sur un type étranger — par exemple Display sur Vec<T> — est interdit.

impl std::fmt::Display for Vec<u8> {}       // Erreur E0117

Cette règle, dite de cohérence, garantit qu’il ne peut exister qu’une seule implémentation d’un trait pour un type donné dans tout le programme, quelles que soient les dépendances assemblées.

Tip

Le contournement est le patron newtype : envelopper le type étranger dans une structure tuple locale, qui devient un type de votre crate.

struct Octets(Vec<u8>);

impl std::fmt::Display for Octets {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        write!(f, "{} octets", self.0.len())
    }
}

derive

L’attribut derive demande au compilateur de générer l’implémentation d’un trait, quand elle est mécaniquement déductible de la structure du type.

#[derive(Debug, Clone, PartialEq, Default)]
struct Point { x: f64, y: f64 }

C’est strictement équivalent à écrire les blocs impl à la main, en plus court et sans risque d’oubli lors de l’ajout d’un champ. Les traits dérivables courants sont listés dans Structures, énumérations et méthodes.

Les traits comme contraintes génériques

C’est le premier usage : écrire une fonction valable pour tout type fournissant un comportement donné.

fn afficher_aire<T: Surface>(forme: &T) {
    println!("{:.2}", forme.aire());
}

T: Surface se lit « pour tout type T implémentant Surface ». Plusieurs contraintes se cumulent avec +, et la clause where allège les signatures chargées :

fn traiter<T: Surface + std::fmt::Debug>(forme: &T) {}

fn comparer<T, U>(a: &T, b: &U) -> bool
where
    T: Surface,
    U: Surface + Clone,
{
    a.aire() > b.aire()
}
Info

Ces génériques sont résolus par monomorphisation : le compilateur produit une version spécialisée du code pour chaque type réellement employé. D’où l’absence de surcoût à l’exécution — l’appel est direct, et peut être aligné (inlined).

impl Trait en paramètre

Pour une contrainte simple, impl Trait en position d’argument évite de nommer le paramètre générique :

fn afficher_aire(forme: &impl Surface) {
    println!("{:.2}", forme.aire());
}

C’est un sucre syntaxique strictement équivalent à la version <T: Surface>, à une nuance près : le type ne pouvant plus être nommé, il devient impossible de le fixer explicitement à l’appel avec un turbofish.

impl Trait en retour

En position de retour, impl Trait signifie « un type concret unique, que je ne nomme pas ».

fn compteur(depuis: u32) -> impl Iterator<Item = u32> {
    (depuis..).step_by(2)
}

C’est indispensable pour les closures et les itérateurs, dont les types réels sont inexprimables. La contrepartie : toutes les valeurs retournées doivent avoir le même type concret, ce qui interdit de renvoyer deux itérateurs différents selon une branche.

Les objets-traits : dyn Trait

Quand le type concret ne peut être connu qu’à l’exécution — une collection hétérogène, une stratégie choisie par configuration — il faut passer par un objet-trait, derrière un pointeur.

let formes: Vec<Box<dyn Surface>> = vec![
    Box::new(Cercle { rayon: 1.0 }),
    Box::new(Rectangle { largeur: 2.0, hauteur: 3.0 }),
];

let total: f64 = formes.iter().map(|f| f.aire()).sum();

L’appel passe alors par une table de méthodes virtuelles, résolue à l’exécution.

Générique <T: Trait>Objet-trait dyn Trait
RésolutionCompilation (monomorphisation)Exécution (table virtuelle)
Coût à l’appelNul, alignement possibleUne indirection
Taille du binaireUne copie par type utiliséUne seule copie
Types mélangeablesNonOui
UsageLe cas par défautCollections hétérogènes, greffons
Note

Tous les traits ne peuvent pas devenir des objets-traits. Un trait est compatible avec dyn si aucune de ses méthodes ne renvoie Self, n’est générique, ni n’est une fonction associée sans receveur. C’est pourquoi Clone, dont clone() renvoie Self, ne s’emploie pas en dyn Clone :

error[E0038]: the trait `Clone` is not dyn compatible

La documentation plus ancienne parle d’object safety ; le terme a été renommé en dyn compatibility, y compris dans les messages du compilateur.

Types associés

Un trait peut déclarer un type que l’implémenteur devra fixer, plutôt que de le recevoir en paramètre générique.

trait Conteneur {
    type Element;
    fn obtenir(&self, index: usize) -> Option<&Self::Element>;
}

impl Conteneur for Vec<String> {
    type Element = String;
    fn obtenir(&self, index: usize) -> Option<&String> {
        self.get(index)
    }
}

Le choix entre type associé et paramètre générique tient à une question : un type peut-il implémenter le trait plusieurs fois ?

  • Iterator a un type associé Item : un type est un itérateur d’une seule sorte d’éléments.
  • From<T> est générique : un même type peut être convertible depuis plusieurs sources, d’où impl From<u8> for Nombre et impl From<u16> for Nombre côte à côte.

Supertraits

Un trait peut en exiger un autre :

trait Identifiable: std::fmt::Display {
    fn identifiant(&self) -> u64;

    fn etiquette(&self) -> String {
        format!("{} (#{})", self, self.identifiant())   // Display est garanti
    }
}

Implémenter Identifiable impose alors d’implémenter aussi Display.

Implémentations générales

Une implémentation peut viser d’un coup tous les types satisfaisant une contrainte. C’est ce que l’on appelle une blanket impl.

impl<T: Surface> Descriptible for T {
    fn decrire(&self) -> String {
        format!("forme de {:.2} unités carrées", self.aire())
    }
}

La bibliothèque standard en fait un usage massif : impl<T: Display> ToString for T est la raison pour laquelle to_string() existe sur tout type sachant s’afficher.

Les traits à connaître

TraitRôle
Debug / DisplayAffichage de diagnostic {:?} / affichage destiné à l’utilisateur {}
Clone / CopyDuplication explicite / copie implicite
PartialEq / EqÉgalité
PartialOrd / OrdOrdre et tri
HashEmploi comme clé de table associative
DefaultValeur par défaut
From / IntoConversions infaillibles
TryFrom / TryIntoConversions faillibles, renvoyant un Result
Iterator / IntoIteratorParcours — voir Les itérateurs
Deref / DerefMutCoercition — voir Le mécanisme Deref
DropTraitement à la libération
Fn / FnMut / FnOnceAppelabilité — voir Les closures
Send / SyncTransmissibilité et partage entre fils d’exécution
Tip

Implémenter From plutôt qu’Into : une implémentation générale de la bibliothèque standard fournit automatiquement l’Into réciproque.

struct Celsius(f64);
struct Fahrenheit(f64);

impl From<Celsius> for Fahrenheit {
    fn from(c: Celsius) -> Self {
        Fahrenheit(c.0 * 9.0 / 5.0 + 32.0)
    }
}

let f: Fahrenheit = Celsius(100.0).into();      // Into obtenu gratuitement

C’est aussi ce qui fait fonctionner la conversion automatique d’erreurs par l’opérateur ?, décrite dans Result et la gestion des erreurs.

À retenir

  • Un trait décrit un comportement ; il est déclaré indépendamment des types qui le fournissent.
  • Les méthodes par défaut permettent d’exiger peu et d’offrir beaucoup.
  • La règle d’orphelin garantit l’unicité des implémentations ; le patron newtype la contourne légitimement.
  • <T: Trait> résout à la compilation, dyn Trait à l’exécution — le premier est le choix par défaut.
  • Type associé quand l’implémentation est unique, paramètre générique quand elle peut être multiple.

Ressources