Traits et généricité

Après un long chemin, nous en arrivons enfin au point où nous pouvons aborder confortablement la généricité en Rust et son ingrédient essentiel, le trait.

Introduction

Une bonne première approche des traits en Rust, c’est de se rappeler l’utilisation de base d’une interface en Java. Comme une interface Java, un trait Rust peut notamment…

  • Définir un certains nombres de méthodes qu’un type doit implémenter quand il implémente le trait, ainsi que la sémantique que l’utilisateur de ces méthodes peut en attendre.
  • Demander par “héritage” que le type implémente d’autres traits, dont la définition du trait et ses utilisateurs pourront supposer la présence.

…mais cette analogie connaît ses limites, car l’interface définie par un trait Rust ne s’arrête pas à des méthodes, et peut contenir d’autres choses comme des constantes associées et des types associées. Et les traits permettent du polymorphisme aussi bien à la compilation qu’à l’exécution.

Définissons, pour faire une première démonstration, un trait qui permet de découper des données d’un type quelconque en éléments plus simples.

#![allow(unused)]
fn main() {
/// Outil servant à découper les objets en petits morceaux
trait Hachoir {
    /// Petit morceau de Self
    type Morceau;

    /// Découpe `self` en petits morceaux
    fn hacher(&self) -> Vec<Self::Morceau>;
}
}

Avec un nouveau type de bloc impl dont la syntaxe est impl Trait for Type, on peut créer des implémentations du trait pour différents types, qui respectent la sémantique spécifiée dans la définition du trait :

#![allow(unused)]
fn main() {
trait Hachoir {
    type Morceau;
    fn hacher(&self) -> Vec<Self::Morceau>;
}

impl Hachoir for &'_ str {
    type Morceau = char;

    fn hacher(&self) -> Vec<char> {
        self.chars().collect()
    }
}

impl Hachoir for usize {
    type Morceau = bool;

    fn hacher(&self) -> Vec<bool> {
        let mut acc = *self;
        (0..Self::BITS)
            .map(|x| {
                let bit = (acc & 1) != 0;
                acc >>= 1;
                bit
            })
            .collect()
    }
}
}

Le fait que ces implémentations soient explicites permet d’éviter qu’un type implémente accidentellement un trait alors qu’il ne devrait pas. Si les traits étaient implémentés automatiquement pour tous les types ayant des méthodes qui portent le bon nom, on pourrait facilement imaginer que Hachoir soit implémenté automatiquement pour un type préexistant avec une méthode hacher() visant à implémenter une table de hachage par le biais d’une fonction de hachage, ce qui n’a absolument rien à voir avec ce que nous essayons de faire ici. Les traits évitent ce problème, contrairement aux concepts de C++20.

Une fois un trait implémenté, il devient possible pour tout code qui a le trait dans son scope d’utiliser les méthodes définies au sein du trait sur tous les types qui implémentent le trait :

#![allow(unused)]
fn main() {
trait Hachoir {
    type Morceau;
    fn hacher(&self) -> Vec<Self::Morceau>;
}

impl Hachoir for &'_ str {
    type Morceau = char;

    fn hacher(&self) -> Vec<char> {
        self.chars().collect()
    }
}

let morceaux = "bonjour".hacher();
println!("{morceaux:?}");
}

Comme on le voit, il est possible de créer des implémentations de ses traits pour tous les types, y compris des types d’autres bibliothèques comme la bibliothèque standard. On peut donc utiliser des traits pour ajouter des fonctionnalités à ces types tiers, du moment que ces fonctionnalités ne nécessitent pas l’ajout de champs de données supplémentaires.

On peut également créer des implémentations de traits d’autres bibliothèques pour ses propres types, et c’est notamment comme ça qu’on crée des types avec un comportement similaire à celui des types de la bibliothèque standard.

Mais en revanche, il n’est possible d’implémenter le trait d’une autre bibliothèque pour un type d’une autre bibliothèque que dans de rares cas. Les règles associées sont un peu complexes, mieux vaut commencer par se dire que ce n’est pas possible en première approximation, et raffiner sa compréhension plus tard après avoir un peu pratiqué les bases.

L’objectif de ces règles est d’éviter au maximum les situations d’incohérence où deux bibliothèques se retrouvent à implémenter un même trait pour un même type. En effet, dans ce cas, le compilateur ne sait pas quelle implémentation utiliser et doit rejeter le code. Cela nuit à l’interopérabilité entre bibliothèques, donc ça doit se produire aussi rarement que possible, et les règles du langage sont donc conçues pour que ça se produise rarement.

Généricité contrainte

En Rust, comme en C++, on peut définir des types et fonctions génériques, qui stockent et manipulent respectivement des valeurs de différents types.

#![allow(unused)]
fn main() {
/// Peut contenir des données de n'importe quel type
struct Wrapper<T>(T);

/// Accepte des données de n'importe quel type en entrée
fn identite<T>(x: T) -> T {
    x
}
}

Mais contrairement aux templates de C++ et au duck typing de Python, la généricité est contrainte en Rust : nos fonctions génériques ne peuvent pas faire tout et n’importe quoi avec les données qu’on leur a passé en entrée sans prévenir l’utilisateur à l’avance.

Cela évite qu’un utilisateur qui les utilise avec des données d’un type non prévu par l’auteur du code générique soit récompensé de sa créativité par un message d’erreur incompréhensible quelque part dans les profondeurs de l’implémentation, au moment où une opération non supportée est utilisée.

Prenons un exemple de code qui ne peut pas compiler :

#![allow(unused)]
fn main() {
pub mod bidouilles {
    // API publique
    pub fn manipuler<T, U>(x: &mut T, y: U) {
        triturer(x, y);
    }

    // Détails d'implémentation sans intérêt pour l'utilisateur
    fn triturer<T, U>(x: &mut T, y: U) {
        transmogrifier(x, y);
    }

    fn transmogrifier<T, U>(x: &mut T, y: U) {
        *x -= y;
    }
}

// Utilisation incorrecte (cf implémentation de transmogrifier)
let mut x = 123usize;
bidouilles::manipuler(&mut x, "abc");
}

Si nous étions en C++, la compilation de ce code échouerait au moment de la compilation du code utilisateur, avec un message d’erreur dans le détail d’implémentation transmogrifier() comme quoi usize ne possède pas d’opérateur -= prenant un &str en paramètre.

Le message d’erreur contiendrait aussi une backtrace indiquant que transmogrifier() est appelé par triturer(), lui-même appelé par la fonction manipuler() que l’utilisateur invoque. L’utilisateur serait ensuite sensé aller étudier ces différentes fonctions de l’implémentation de manipuler() pour comprendre comment on en est arrivé à soustraire une chaîne de caractères d’un entier, et essayer de déduire de l’implémentation et des choix de nommages et commentaires plus ou moins clairs de l’auteur du code quels types manipuler() accepte vraiment en paramètre.

A la place, en Rust, c’est la définition de la fonction générique qui est considérée comme incorrecte. Une fonction générique doit spécifier tout ce qu’elle est susceptible de faire avec les données qu’on lui passe en entrée, via des contraintes sur les types appelées trait bounds

#![allow(unused)]
fn main() {
use std::ops::SubAssign;

// Une clause "where" est la syntaxe la plus flexible et verbeuse
fn transmogrifier<T, U>(x: &mut T, y: U)
    // Interprétation compilateur: T doit implémenter le trait SubAssign<U>
    // Sens pour l'utilisateur: On peut soustraire U de T avec l'opérateur -=
    where T: SubAssign<U>
{
    *x -= y;
}

// Ce raccourci syntaxique peut parfois être utilisé à la place de "where"
fn transmogrifier2<U, T: SubAssign<U>>(x: &mut T, y: U) {
    *x -= y;
}
}

…en échange de quoi, l’appelant aura un message d’erreur beaucoup plus clair indiquant que manipuler() n’accepte pas des paires de types T et U sans opérateur de soustraction. Ce message d’erreur ne fera pas référence aux fonctions de l’implémentation de manipuler() :

#![allow(unused)]
fn main() {
pub mod bidouilles {
    use std::ops::SubAssign;

    // API publique
    pub fn manipuler<U, T: SubAssign<U>>(x: &mut T, y: U) {
        triturer(x, y);
    }

    // Détails d'implémentation sans intérêt pour l'utilisateur
    fn triturer<U, T: SubAssign<U>>(x: &mut T, y: U) {
        transmogrifier(x, y);
    }

    fn transmogrifier<U, T: SubAssign<U>>(x: &mut T, y: U) {
        *x -= y;
    }
}

// Appel invalide
let mut x = 123usize;
bidouilles::manipuler(&mut x, "abc");
}

C’est ensuite à l’auteur du code générique de décider si il souhaite plutôt…

  1. Avoir un contrat d’interface très précis comme ci-dessus, qui permet d’accepter le maximum de types en entrée au prix de contraintes fortes sur l’implémentation (actuellement, changer l’opération utilisée en += changerait la borne en T: AddAssign<U> et donc briserait l’API publique en rejetant des appels auparavant valides avec un type T qui implémente SubAssign mais pas AddAssign).
  2. Fixer un contrat sur-spécifié du style “le type d’entrée doit implémenter toutes les opérations arithmétiques de base”, ce qui réduira un peu le nombre de types acceptés en entrée mais donnera en contrepartie plus de liberté au niveau de l’évolution future de l’implémentation.

On peut comparer cette utilisation des traits à celle des concepts en C++20, qui vise à clarifier les contrats d’interfaces des types et fonctions génériques de la même façon. Mais il y a une différence majeure : en Rust, le compilateur ne vérifie pas seulement que l’utilisation des traits est correcte du côté du code utilisateur, il vérifie aussi qu’elle est correcte du côté de l’implémentation.

Cela garantit que l’implémentation respecte bien le contrat qu’elle affiche dans son interface, et ne produira donc pas de légendaires messages d’erreurs à la C++98, contrairement à ce qui se passe en C++20 où les concepts ne garantissent l’absence de messages d’erreurs illisibles qu’en l’absence d’erreurs d’implémentation qui ne sont pas détectées à la compilation.

Cliquez ici pour un argumentaire plus détaillé si le sujet vous intéresse

Les concepts C++20 sont plus proches des annotations de type de Python que des traits de Rust car leur bonne utilisation n’est pas vérifiée par le compilateur. Après avoir spécifié un contrat d’interface avec des concepts, l’auteur d’un code générique est ensuite tout à fait libre de violer ce contrat en utilisant dans son implémentation des opérations qui n’en font pas partie, sans que le compilateur ne traite cela comme une erreur et le signale.

Ce que ça implique en pratique, c’est qu’un code C++ générique dont le contrat d’interface est mal spécifié compilera sans problème, passera les tests (qui ne sont effectués qu’avec les types d’entrée auxquels l’auteur du code générique a pensé) et ne posera problème qu’au niveau du code utilisateur qui fait appel au code générique avec un type que l’auteur du code n’a pas prévu. Dans ce cas, on aura comme avant un message d’erreur au fond de l’implémentation : l’utilisation des concepts ne résout donc pas complètement le problème du duck typing des templates C++.

Qui plus est, dans un code vivant en évolution constante, toute déclaration faite au niveau d’une interface tend tôt où tard à se désynchroniser avec le code écrit au niveau de l’implémentation. Les concepts C++20 étant purement déclaratifs et non vérifiés par le compilateur lors de la compilation de l’implémentation, ils ne protègent pas les auteurs de l’implémentation de cette tendance naturelle en leur signalant qu’une telle dérive est en train de se produire.

Donc même quand la spécification d’origine est correcte, on peut s’attendre à ce que les implémentations de code génériques C++20 “dérivent” au fil du temps dans une direction qui ne correspond plus au contrat d’interface initialement spécifié par les concepts, sans qu’aucun auteur de la bibliothèque ne s’en rende compte.

Cette situation est, de mon point de vue, encore pire que la situation initiale d’où nous partions : avant, l’utilisateur ne savait pas ce que le code générique acceptait en entrée. Maintenant, il croit savoir, mais l’implémentation est en fait susceptible de trahir sa promesse.

De ce point de vue, on peut considérer les concepts C++20 comme un échec, puisque tout en étant très difficiles à utiliser ils ne constituent pas vraiment une amélioration significative par rapport à un bon code C++98 où le contrat d’interface est spécifié dans la documentation, régressant même sur certains points faute de réelle vérification à la compilation.

Const generics

Comme en C++, on peut définir du code générique paramétré par une constante de compilation :

#![allow(unused)]
fn main() {
// Type générique par rapport à une constante
struct Entiers<const N: usize>([u32; N]);

// Fonction générique par rapport à une constante
fn entiers<const N: usize>(valeur: u32) -> Entiers<N> {
    Entiers([valeur; N])
}

// Fonction générique qui délègue à une autre fonction générique
fn par_defaut<const N: usize>() -> Entiers<N> {
    entiers(42)
}
}

Cependant cette fonctionnalité a été introduite récemment en Rust et est encore soumise à des restrictions importantes, qui ont vocation à être assouplies à l’avenir. Voici les deux plus gênantes :

  • Seuls les paramètres de types primitifs entiers, char ou bool sont actuellement acceptés.
  • Si une fonction générique veut construire un tableau ou appeler une autre fonction générique, elle ne peut le faire qu’en leur transmettant ses paramètres génériques tels quels, sans les utiliser au sein d’une expression. Par exemple, ce code est actuellement illégal :
    #![allow(unused)]
    fn main() {
    fn valeur<const N: usize>() -> [u32; { N - 1 }] {
        [42; { N - 1 }]
    }
    }

La première restriction permet de contourner temporairement le problème de la comparabilité en attendant qu’il ait été suffisamment étudié par l’équipe de conception du langage.

Pour vous donner une idée de ce problème, demandez-vous comment le compilateur peut savoir si Entiers<N> et Entiers<M> sont du même type lorsque N et M appartiennent à un type bizarre comme f32 où il existe des valeurs x telles que x != x. Ou ce qu’il doit se passer quand deux valeurs distinctes d’un type sont considérées comme égales, ce qui peut arriver en présence de références et autres pointeurs intelligents. C’est le genre de question auquel l’équipe de conception de Rust va devoir répondre avant que des données de types plus complexes comme les références puissent être acceptés en paramètre des const generics.

La deuxième restriction tient aussi au problème de la comparabilité (le compilateur doit souvent déterminer si deux types sont les mêmes bien avant d’avoir suffisamment digéré le code pour pouvoir évaluer des expressions arbitraires). Mais s’y ajoute aussi le choix fait par Rust d’avoir une généricité contrainte où les erreurs d’instantiation de code génériques doivent être signalées à l’interface et pas en plein milieu de l’implémentation.

On ne veut donc pas que ce genre de code soit légal…

#![allow(unused)]
fn main() {
pub mod bidouilles {
    // API publique sans contrainte apparente sur le paramètre N
    pub fn manipuler<const N: usize>() {
        triturer::<N>();
    }

    // Détails d'implémentation qui dépendent de la condition N > 0
    fn triturer<const N: usize>() {
        assert!(transmogrifier::<N>().into_iter().all(|i| *i > 24));
    }

    fn transmogrifier<const N: usize>() -> [u32; { N - 1 }] {
        [42; { N - 1 }]
    }
}

// Appel invalide : N == 0 n'est pas accepté par l'implémentation
bidouilles::manipuler::<0>();
}

…et par conséquent il faut introduire une syntaxe analogue aux trait bounds pour que manipuler puisse poser des conditions sur son paramètre constant N et que le compilateur puisse vérifier que ces conditions sont suffisantes pour qu’il n’y ait pas d’erreur possible au moment de l’instantiation de l’implémentation. Pour l’instant, cette syntaxe n’existe pas encore, et c’est un prérequis pour que des expressions arbitraires soient utilisables dans les const generics.

Retenez donc de tout ça qu’à l’heure actuelle, le support de Rust pour ce type de généricité est suffisant pour des tâches simples comme écrire du code qui accepte des tableaux de toutes les tailles en entrée, mais qu’il est encore trop limité pour des utilisations plus complexes comme l’écriture d’algorithmes récursifs, qui nécessitent un support plus poussé du langage encore à venir.

Implémentations par défaut et blanket impl

Revenons maintenant aux traits, car nous sommes très loin d’avoir fait le tour de leurs possibilités.

Il est possible de définir des implémentations par défaut des méthodes d’un trait. Le trait Iterator de la bibliothèque standard utilise cette fonctionnalité pour implémenter automatiquement l’ensemble des opérations d’itérateurs de la bibliothèque standard sur les itérateurs définis par l’utilisateur, qui n’ont besoin de supporter que l’opération Iterator::next() de base :

#![allow(unused)]
fn main() {
// Itérateur simple qui descend d'une valeur initiale à 1, puis s'arrête
struct Compteur(usize);
//
impl Iterator for Compteur {
    type Item = usize;

    // Recette pour produire l'élément suivant de l'itérateur, si il y en a un
    fn next(&mut self) -> Option<usize> {
        let resultat = self.0;
        self.0 = self.0.checked_sub(1)?;
        Some(resultat)
    }

    // Pas besoin d'implémenter les autres méthodes d'Iterator, les
    // implémentations par défaut conviennent.
}

// Toutes les opérations usuelles d'Iterator sont disponibles :
let v = Compteur(30)
        .filter(|x| x % 2 == 0)
        .collect::<Vec<_>>();
println!("{v:?}");
}

Une implémentation par défaut est définie en utilisant d’autres méthodes définies par le trait, ou par d’autres traits dont le trait hérite. Par exemple, si on en avait marre de la syntaxe utilisée pour spécifier les types dans Iterator::collect(), on pourrait définir une méthode collect_vec() qui retourne toujours un Vec (et donc permet l’inférence de type), via le sous-trait suivant :

#![allow(unused)]
fn main() {
// La syntaxe "trait Trait : A + B { ... }" signifie que pour implémenter Trait,
// un type doit aussi implémenter les autres traits A et B.
//
// En contrepartie, on a accès aux fonctionnalités de A et B dans les
// implémentations par défaut des méthodes de Trait et dans le code générique
// qui prend un T: Trait en paramètre.
//
// La borne Sized est requise pour pouvoir manipuler self par valeur. Elle
// impose que le type ait une taille connue à la compilation.
trait CollectToVec : Iterator + Sized {
    fn collect_vec(self) -> Vec<Self::Item> {
        self.collect()
    }
}
}

On peut ensuite implémenter ce trait de façon générique pour tous les itérateurs compatibles avec un bloc d’implémentation générique (on parle de blanket impl), et il devient alors utilisable sur tout itérateur par tout code qui a le trait CollectToVec dans son scope :

#![allow(unused)]
fn main() {
trait CollectToVec : Iterator + Sized {
    fn collect_vec(self) -> Vec<Self::Item> {
        self.collect()
    }
}

// Pas besoin de donner une implémentation de collect_vec(),
// celle par défaut convient pour tous les itérateurs
impl<T: Iterator + Sized> CollectToVec for T {}

// Et voilà, plus beosin de guider l'inférence de type quand on veut un Vec !
let v = (0u8..10).collect_vec();
println!("{v:?}");
}

Spécialisation

Lorsqu’on implémente un trait pour un type, on peut aussi remplacer l’implémentation par défaut d’une méthode par une autre implémentation de notre choix. Cette possibilité est souvent utilisée pour spécialiser le code afin d’optimiser ses performances.

Par exemple, la documentation du trait Iterator standard recommande, chaque fois que c’est possible, de remplacer l’implémentation par défaut de la méthode size_hint(), qui donne une borne inférieure et supérieure du nombre d’éléments produits par l’itérateur.

En effet, par défaut ces bornes sont très pessimistes (entre 0 et l’infini). Et elles sont utilisées par l’implémentation de collect() pour préallouer le stockage du conteneur cible, donc plus elles sont précises moins collect() effectuera d’allocations.

On remplace l’implémentation par défaut d’une méthode de trait exactement comme on implémenterait n’importe quelle autre méthode d’un trait :

#![allow(unused)]
fn main() {
// Itérateur simple qui descend d'une valeur initiale à 1, puis s'arrête
struct Compteur(usize);
//
impl Iterator for Compteur {
   type Item = usize;

   // Recette pour produire l'élément suivant de l'itérateur, si il y en a un
   fn next(&mut self) -> Option<usize> {
       let resultat = self.0;
       self.0 = self.0.checked_sub(1)?;
       Some(resultat)
   }

    /* ... */

    // Remplace l'implémentation size_hint par défaut de Iterator
    // par des bornes inférieures et supérieures précises.
    fn size_hint(&self) -> (usize, Option<usize>) {
        (self.0, Some(self.0))
    }
}
}

On le voit, le remplacement des méthodes par défaut permet une forme limitée de spécialisation des implémentations de trait pour un type donné. Par contre, il reste une forme plus générale de spécialisation qui est encore instable en Rust : à l’heure actuelle, on ne peut pas fournir une implémentation par défaut d’un trait complet pour un grand nombre de types (avec une blanket impl comme ci-dessus), puis décider plus tard de réimplémenter le trait pour un type précis de façon plus spécialisée, à la manière des spécialisations de templates en C++.

Une version expérimentale de cette fonctionnalité est implémentée au niveau du compilateur, et est utilisée pour optimiser certaines opérations de la bibliothèque standard. Mais la fonctionnalité n’est pas encore exposée aux développeurs Rust tiers car avec l’implémentation actuelle, il est possible de causer du comportement indéfini sans code unsafe avec des implémentations génériques par rapport aux lifetimes, ce qui viole les principes de conception de base de Rust. Il y a donc encore du travail à faire avant que cette fonctionnalité puisse être rendue accessible à tous.

Autres éléments d’un trait

Les traits que nous avons créés jusqu’ici définissent des méthodes, ainsi que des types associés permettant de spécifier les types de retour de ces méthodes. Mais un trait peut aussi contenir…

  • Des fonctions associées qui ne sont pas des méthodes : constructeurs, etc.
  • Des constantes, pour pouvoir associer des valeurs à des types ou retourner des tableaux de taille fixe des méthodes.

Les méthodes et types associés peuvent aussi être génériques, et les méthodes peuvent être unsafe, mais pas encore const et async car le travail de conception associé n’est pas terminé.

Un trait peut aussi être générique, ce qui permet de définir des opérations entre types :

#![allow(unused)]
fn main() {
// Produit au sens de l'algèbre linéaire
trait AlgebraProduct<Other> {
    type Result;

    fn algebra_mul(self, other: Other) -> Self::Result;
}

struct Matrix;
struct Vector;
struct Scalar;

impl AlgebraProduct<Matrix> for Matrix {
    type Result = Matrix;

    fn algebra_mul(self, other: Matrix) -> Matrix { todo!() }
}

impl AlgebraProduct<Vector> for Matrix {
    type Result = Vector;

    fn algebra_mul(self, other: Vector) -> Vector { todo!() }
}

impl AlgebraProduct<Scalar> for Matrix {
    type Result = Matrix;

    fn algebra_mul(self, other: Scalar) -> Matrix { todo!() }
}

impl AlgebraProduct<Vector> for Vector {
    type Result = Scalar;

    fn algebra_mul(self, other: Vector) -> Scalar { todo!() }
}
}

…et comme, dans ce genre d’opérations, il y a généralement un type privilégié correspondant à un opérateur interne, il est aussi possible de préciser un type par défaut pour que l’utilisateur n’ait pas besoin de spécifier le type à chaque fois :

#![allow(unused)]
fn main() {
trait AlgebraProduct<Other = Self> {
    /* ... */
   type Result;
   fn algebra_mul(self, other: Other) -> Self::Result;
}

// Que les mathématiciens me pardonnent...
fn internal_product<T: AlgebraProduct>(x: T, y: T) -> T::Result {
    x.algebra_mul(y)
}
}

Deux remarques s’imposent à la lecture de l’exemple ci-dessus :

  • On aimerait spécifier au niveau de l’interface de internal_product que T::Result doit être T. C’est possible avec la trait bound suivante :
    #![allow(unused)]
    fn main() {
    trait AlgebraProduct<Other = Self> {
       type Result;
       fn algebra_mul(self, other: Other) -> Self::Result;
    }
    
    fn internal_product<T>(x: T, y: T) -> T
        where T: AlgebraProduct<Result = T>
    {
        x.algebra_mul(y)
    }
    }
  • On sent bien qu’avec des noms de associés simples comme T::Result, il va un jour y avoir des collisions de noms entre traits. On résout ces ambiguités avec la syntaxe <T as Trait>::Type. De même, on peut faire référence à une méthode d’un trait avec <T as Trait>::methode().

Enfin, mentionnons qu’un trait peut être unsafe, ce qui veut dire qu’un bloc impl qui l’implémente doit commencer par unsafe impl. On utilise cela quand l’implémentation d’un trait pour un type doit vérifier certaines propriétés pour que le programme reste sûr, et le compilateur ne peut pas les prouver automatiquement donc c’est au programmeur qui implémente le trait de le faire.

Les traits unsafe sont utilisés pour le code unsafe générique, qui ne peut pas dépendre du fait que les traits soient bien implémentés par des types arbitraires pour assurer l’absence de comportement indéfini. Sinon, on pourrait causer du comportement indéfini sans écrire de code unsafe en passant un objet avec des traits mal implémentés à l’interface réputée sûre de ce code unsafe générique…

impl Trait

La syntaxe impl Trait1 + Trait2 + ... peut être employée à la fois dans les paramètres et arguments des fonctions libres et associées (pas encore les fonctions de traits malheureusement) pour représenter un type quelconque qui implémente les traits désignés.

  • En argument d’une fonction, c’est juste une syntaxe plus légère pour déclarer une fonction générique, un peu comme auto en argument en C++ :
    #![allow(unused)]
    fn main() {
    use std::fmt::Debug;
    
    // Les deux déclarations qui suivent sont presque équivalentes...
    fn afficher_impl(x: impl Debug) {
        println!("{x:?}");
    }
    //
    fn afficher_gen<T: Debug>(x: T) {
        println!("{x:?}");
    }
    
    // ...mais la seconde forme est plus puissante, car elle permet à
    // l'utilisateur de spécifier le type T. Par conséquent, passer de la seconde
    // variante à la première dans une API publique peut casser du code client.
    afficher_gen::<u32>(123);
    }
  • En valeur de retour d’une fonction, impl Trait permet de dire qu’on retourne un type implémentant certains traits sans donner le type exact, ce qui laisse l’implémentation libre de changer le type plus tard sans casser le code client (qui ne peut utiliser que les traits indiqués) :
    #![allow(unused)]
    fn main() {
    fn iteration() -> impl Iterator<Item = u32> {
        (0..5)
    }
    
    for i in iteration() {
        println!("{i}");
    }
    }

Traits de la bibliothèque standard

La plupart des opérationss utiles des types de la bibliothèque standard sont implémentées via des traits, dont voici quelques exemples :

  • Les opérateurs arithmétiques de base délèguent le travail aux traits du module std::ops. En implémentant ces traits pour vos propres types, vous pouvez donc surcharger ces opérateurs.
    • Comme d’habitude en présence de surchage d’opérateurs, un peu de self-control s’impose. Assurez-vous que vos implémentations ont des propriétés mathématiques très similaires à celles de la bibliothèque standard. Ne soyez pas cette personne qui surcharge l’opérateur de décalage de bits pour effectuer des entrées/sorties.
    • Un cas particulier important est l’opérateur d’appel de fonction f(). Celui-ci est basé sur les traits Fn(), FnMut() et FnOnce() qui sont spéciaux de plusieurs façons : ils ont une syntaxe dédiée (ex : Fn(A, B) -> C) et à l’heure actuelle vous ne pouvez pas les implémenter vous-même, il faut utiliser des closures si vous avez besoin d’un objet avec un opérateur d’appel de fonction sous votre contrôle.
  • Les comparaisons délèguent aux traits du module std::cmp, qui permettent de définir un opérateur d’égalité et une relation d’ordre. Vous pouvez préciser si votre relation d’ordre est partielle (comme les flottants, où f32::NAN != f32::NAN) ou totale (comme les entiers), et cela affectera les opérations disponibles : pas de tri sur un tableau de flottants.
  • L’affichage textuel de types par des méthodes comme println!() est assuré par le biais des traits Debug et Display du module std::fmt, que nous avons déjà rencontrés plusieurs fois.
  • Si votre type a une bonne valeur par défaut (ex : 0 pour les entiers, Vec::new() pour Vec…), vous pouvez l’indiquer avec le trait Default.
  • Pour convertir entre des valeurs de différents types, il y a les traits de std::convert.

…et ainsi de suite. Une part importante du développement d’une bibliothèque Rust est consacrée à s’assurer que tous les traits de la bibliothèque standard sont bien implémentés pour vos types chaque fois que ça à du sens, afin que vos types aient une interface familière et soient utilisables dans tous les contextes où un type standard similaire le serait.

Souvent, pour les types structurés, il y a une implémentation évidente, qui consiste à appeler récursivement l’implémentation du trait pour toutes les données membres avec un enrobage dépendant du trait. Pour éviter d’avoir à écrire ce genre de mécanique inintéressante soi-même comme on le devrait le faire en C++, on utilise les macros derive() :

#![allow(unused)]
fn main() {
#[derive(Debug)]
struct S {
    x: u32,
    y: f32,
}

let s = S { x: 12, y: 3.4 };
println!("{s:?}");
}

Les implémentations de derive() fournies par la bibliothèque standard sont assez conservatrices et ne couvrent pas des traits dont l’implémentation évidente “je délègue à tous les membres” aurait une sémantique un peu plus discutable du point de vue utilisateur. Par exemple, il n’y a pas de derive() standard pour les opérateurs arithmétiques. Mais en cas de besoin, on peut en trouver des implémentations tierces dans des bibliothèques comme derive_more.

Traits implémentés automatiquement

Dans l’ensemble, Rust évite d’implémenter des traits standard automatiquement, parce que les traits font partie de l’API publique d’un type, et c’est important de laisser les programmeurs minimiser l’API exposée par leur type quand ils anticipent des changements d’implémentation.

Mais il y a quelques exceptions, qui concernent des propriétés très fondamentales des types, principalement représentées par les traits du module std::marker. En voici quelques exemples :

  • Sized indique qu’un type a une taille connue à la compilation. Ce trait est implémenté pour presque tous les types, à l’exception des types [T], str, dyn Trait et des types structurés qui contiennent une valeur de ces types.
  • Sync indique que plusieurs threads peuvent accéder à une même variable par le biais d’une référence partagée. Ce trait est implémenté automatiquement par tous les types sans mutabilité interne non synchronisée.
  • Send indique qu’une valeur d’un type peut être transmise à un autre thread. Ce trait est implémenté automatiquement pour les types ne contenant pas de références partagées vers un état à mutabilité interne non synchronisée.

Ces traits sont assez importants pour mériter un traitement de faveur, car un type doit être Sized pour être passé en argument à une fonction ou retourné en résultat d’une fonction. Et les traits Send et Sync sont à la base du multi-threading transparent et sûr de Rust.

Polymorphisme statique et dynamique

Toutes les formes de généricité que nous avons abordé jusqu’à présent sont résolues à la compilation par un mécanisme moralement équivalent à un copier-coller de code, comme lorsqu’on utilise des templates en C++. On parle de polymorphisme statique de façon générale, et la communauté Rust utilise aussi souvent le terme plus exotique de “monomorphisation”.

En polymorphisme statique, chaque fois qu’on appelle une fonction générique avec un nouveau type d’arguments, une copie du code de la fonction est créée, spécialisée pour ce nouveau type d’argument. De même, à chaque fois qu’on instantie un type générique Generique<T> avec un nouveau paramètre U, le compilateur crée une copie de la définition du type et des méthodes utilisées, qui sont tous spécialisés pour le type U.

Il existe aussi une autre façon d’implémenter le polymorphisme, qui est le polymorphisme dynamique. Dans ce modèle, on exploite le fait qu’on ne va utiliser que certaines méthodes de l’objet dans le code générique en créant une table de ces méthodes pour chaque type d’objet supporté. Le code générique n’est compilé qu’une seule fois, et il reçoit en paramètre une référence vers l’objet cible et une table d’implémentations des méthodes (vtable) pour le type d’objet cible. Le comportement s’adapte ensuite à chaque type en appelant les implémentations spécifiques des méthodes via les pointeurs de la vtable. C’est le modèle utilisé par les méthodes virtual en C++.

Ces deux façons de faire ont leurs avantages et leurs inconvénients :

  • Un programme qui utilise du polymorphisme statique est généralement plus performant à l’exécution, car il est spécialisé pour le type de valeur manipulée (ce qui permet davantage d’optimisations) et évite l’indirection liée au passage de valeurs et de pointeurs de fonctions par référence. En polymorphisme statique, il est aussi beaucoup plus facile de stocker les données sur la pile, là où en polymorphisme dynamique on doit souvent stocker les valeurs sur le tas pour s’abstraire de leur taille.
  • Un programme qui utilise du polymorphisme dynamique compile généralement plus rapidement et en consommant moins de mémoire, a un code plus petit qui peut mieux tenir dans le cache instructions du CPU, peut manipuler des collections de données de type hétérogène, et plus généralement peut s’adapter plus facilement à des données dont le type ne sera pas connu avant l’exécution.

En voyant ces deux listes, on devine que le choix entre les deux formes de polymorphisme n’est pas trivial, et qu’on sera souvent amené à utiliser les deux formes de polymorphisme au sein d’un programme donné, voire à basculer de l’une à l’autre en fonction de l’évolution des besoins.

Malheureusement, en C++, le polymorphisme statique et dynamique utilisent deux syntaxes complètement incompatibles avec des règles de fonctionnement très différentes, et basculer de l’un à l’autre est un travail de longue haleine.

En Rust, en revanche, on a déjà brièvement mentionné qu’il existe &dyn Trait :

#![allow(unused)]
fn main() {
use std::fmt::Display;

let mut r: &dyn Display = &42u8;
println!("{r}");
r = &"bonjour";
println!("{r}");
r = &123.456f32;
println!("{r}");
}

&dyn Trait est implémenté sous forme de “gros pointeur”, composé d’un pointeur vers la valeur cible et d’un pointeur vers l’implémentation des différentes méthodes du traits pour le type cible. C’est donc un peu analogue à l’implémentation de l’orienté objet en C++, sauf que la vtable est dans le pointeur et pas dans l’objet pointé, ce qui est plus adapté à Rust car on peut implémenter des traits pour des types externes en Rust.

Si on a besoin de passer des objets implémentant un trait par valeur, on peut aussi utiliser Box<dyn Trait>, Arc<dyn Trait>, etc. avec des règles similaires.

L’utilisation de dyn Trait n’est cependant pas parfaitement transparente, car on doit composer avec le fait que le code qui utilise dyn Trait ne connaît pas la taille de l’objet ni de ses types associés, et que la vtable doit être de taille finie. Ce qui signifie entre autres que…

  • On ne peut pas utiliser des méthodes qui prennent self par valeur, ou qui retournent des valeurs du type Self ou d’un type associé Self::Xyz.
  • On ne peut pas utiliser des méthodes génériques via dyn Trait (il faudrait une vtable infinie).

Une liste complète des règles qu’un trait doit respecter pour être utilisable via dyn Trait est disponible dans la section “Object Safety” de la documentation de référence du langage.

Afin d’éviter une explosion de la taille des pointeurs, on ne peut non plus utiliser plusieurs traits à la fois via dyn Trait1 + Trait2 + Trait3..., à l’exception de quelques traits privilégiés comme Sized dont la vtable est de taille nulle. Mais il est facile de contourner cette limitation :

trait Composite : Trait1 + Trait2 + Trait3 {}
impl<T: Trait1 + Trait2 + Trait3> Composite for T {}

dyn Trait ou types sommes ?

Si vous êtes attentifs, vous avez peut-être remarqué que dyn Trait n’est pas le seul mécanisme permettant de traiter des objets de plusieurs types comme si ils étaient d’un seul type en Rust. On peut aussi utiliser des types sommes :

#![allow(unused)]
fn main() {
enum Variable {
    Int(u32),
    Float(f32),
}

fn carre(x: Variable) -> Variable {
    match x {
        Variable::Int(i) => Variable::Int(i * i),
        Variable::Float(f) => Variable::Float(f * f),
    }
}
}

Quand choisir l’une ou l’autre de ces approches ?

  • enum représente un ensemble fermé, complètement connu quand le code est compilé. dyn Trait représente un ensemble ouvert, extensible par des bibliothèques tierces.
    • Par conséquent, enum facilite un peu plus le travail d’optimisation du compilateur, même si il reste plus difficile qu’en polymorphisme statique, là où dyn Trait est plus flexible.
  • enum peut être manipulé par valeur, dyn Trait ne peut être manipulé que par référence.
    • Si les types à l’intérieur de l’enum sont de taille très différente, enum est de la taille de la plus grosse alternative, ce qui conduit à un gaspillage de mémoire et des copies longues. On peut parfois limiter la casse par une utilisation judicieuse de Box.
  • enum facilite le support de nouvelles opérations, alors que dyn Trait facilite le support de nouveaux types (dans l’autre cas, il faut à chaque fois modifier beaucoup de code).
  • dyn Trait est un peu moins facile à utiliser que enum, toutes choses étant égales.