Les traits et le polymorphisme en Rust

Par mlemay, 22 mars, 2025

Le type générique

Le type générique permet aux fonctions et aux structures de prendre en charge des variables de plusieurs types. Il est noté entre deux chevrons et généralement représenter par la lettre T.

Dans cet exemple, la structure Point peut être faites de deux nombres entiers, de deux nombres décimaux ou d’un mélange des deux.


struct Point <T, U> {

    x : T,

    y : U,

}

fn main(){

    let p1 =Point {x: 5, y : 10},

    let p2 =Point {x: 5.2, y : 10.1},

    let p3 =Point {x: 5, y : 10.75},

}

Les traits

Un trait est un ensemble de méthodes qui peuvent être appliquées à différents types. C’est l’équivalent de ce qu’on appelle souvent une interface dans d’autres langages comme Java ou C#. Par contre, les traits en Rust sont plus flexibles et plus puissants. Ils définissent une collection de comportements qu’un type peut implémenter.

Lorsqu’on définit un trait, on lui donne un nom ainsi que la signature de la méthode qui sera implémenté. Il est aussi possible d’ajouter une implémentation par défaut qui sera redéfinie par toute structure qui définit sa propre implémentation. On utilise le mot-clé « trait » pour définir un trait et la syntaxe « impl nom_trait for nom_structure» pour l’implémentation. Si le corps de l’implémentation est vide, alors la structure utilise la méthode par défaut. Si le corps contient du code, alors la méthode est redéfinie.

Voici le trait Parler dont la fonction parler retourne un String et le trait Chanter dont la fonction chanter retourne le String « Je vois la vie en rose» par défaut.


//Déclaration des traits

pub trait Parler {

    fn parler(&self) -> String;         // Signature de la méthode, aucun corps

}

pub trait Chanter {

     fn chanter(&self) -> String{      // Signature de la méthode et implémentation par défaut

        String ::from(« Je vois la vie en rose»)

    }

}

// Implémentation des traits

struct Humain;

impl Parler for Humain {

    fn parler(&self) {

        println!("Bonjour !");

    }

}

impl Chanter for Humain {}

struct Chien;

impl Parler for Chien {

    fn parler(&self) {

        println!("Wouf !");

    }

}

Le trait Parler est implémenté pour deux types différents : Humain et Chien. Pour chaque type, on implémente une méthode parler différente. On peut aussi voir qu’Humain utilise la méthode par défaut pour la méthode chanter du trait Chanter.

Les limites de traits

Les traits peuvent être utilisés de concert avec le type générique pour déclarer des paramètres de fonction. Cela est souvent nécessaire pour pouvoir utiliser des opérateurs et des méthodes comme >, < ou .split() sur un type générique. On doit préciser que le type générique est un type compatible ou le compilateur nous arrête. On peut utiliser des traits que nous avons nous-même créé ou des traits qui existent déjà dans librairie standard de Rust. Parmi les traits les plus utilisés, ou retrouve Clone, Copy, Debug, Eq et PartialEq ainsi que Ord et PartialOrd.

Dans cet exemple, la fonction afficher_et_comparer accepte n'importe quel type T qui implémente les traits Affichable et PartialOrd. Affichable est un trait que nous avons défini qui contient la méthode afficher. PartialOrd est un des traits de la librairie standard de Rust.


fn afficher_et_comparer<T: Affichable + PartialOrd >(a: &T, b: &T) {

    println!("a : {}", a.afficher());

    println!("b : {}", b.afficher());

    if a < b {

        println!("a est inférieur à b");

    }

    else if a > b {

        println!("a est supérieur à b");

    }

}

Le Polymorphisme

Les traits permettent de réaliser le polymorphisme en Rust. Le polymorphisme est le concept selon lequel un objet ou une méthode peut se comporter différemment en fonction du type de l'entité avec laquelle elle interagit. Grâce aux traits, Rust offre du polymorphisme à la fois statique et dynamique. Chacun à ces avantages et ces inconvenants.

Le polymorphisme statique

En Rust, Le polymorphisme statique existe quand des traits sont appliqués à différents types à la compilation. Pour ce faire, lors de la compilation, Rust va créer de multiple copie de la fonction. Il y aura une copie pour chaque type qui l’appelle. Cette façon de faire a pour avantage d’être très performante et d’éviter au programmeur d’écrire du code redondant, mais elle entraine l’utilisation plus de mémoire.

Dans cet exemple, la fonction faire_parler prend un type générique T, avec la limite de trait Parler.

Noter que cet exemple reprend le trait Parler ainsi que les structures Humain et Chien définis plus haut.


fn faire_parler<T: Parler>(t: T) {

    t.parler();

}

pub fn main() {

    faire_parler (Humain{});

    faire_parler (Chien{});

}

Le polymorphisme dynamique

Le polymorphisme dynamique en Rust est réalisé non pas avec les limites de traits mais avec les objets de trait. Un objet de trait est créé avec un pointeur sur le trait désiré. On ajoute aussi le mot-clé « dyn » devant le trait. Cette approche permet de traiter plusieurs types concrets de manière uniforme, sans connaître leur type exact à l'avance. Contrairement au polymorphisme statique, avec le polymorphisme dynamique le type est résolu lors de l'exécution. À l’inverse du polymorphisme statique, cette façon de faire prend moins de mémoire mais diminue la performance.

Le polymorphisme dynamique est utile quand le programmeur a besoin de flexibilité lors de l’exécution. Par exemple, un programme qui dépend des entrées de l’utilisateur ou un programme qui doit interagir avec des librairies ou des systèmes externes dont les types sont fournis lors de l'exécution.

Dans cet exemple, dyn Parler est un objet de trait. Il peut représenter différents types (Humain, Chien, etc.) tant qu’ils implémentent le trait Parler. Lors de l'appel de la méthode parler, Rust résout dynamiquement quel comportement exécuter, en fonction du type exact de l'objet.

Noter que cet exemple reprend le trait Parler ainsi que les structures Humain et Chien définis plus haut.


fn faire_parler<T: Parler>(personnage :  &dyn Parler) {

    personnage .parler();

}

pub fn main() {

    let chien = Chien;
    
    let humain = Humain;
    
    faire_parler(&chien);
    
    faire_parler(&humain);

}

Conclusion

Les traits permettent d’étendre les comportements d’un type sans modifier directement sa définition. Cela permet d’ajouter de nouvelles fonctionnalités à des types existants sans avoir à modifier beaucoup de code existant. Ils permettent également de définir des comportements génériques, ce qui facilite l’écriture de code flexibles, capable de fonctionner avec une variété de types.

Bref, les traits et le polymorphisme en Rust offrent une alternative à l'héritage traditionnel de la programmation orientée objet, tout en respectant les principes de sécurité et de performance qui sont au cœur de Rust.


Références

Let's Get Rusty, Traits in Rust [vidéo], 2021, 11 min 43s., Youtube, https://www.youtube.com/watch?v=T0Xfltu4h3A

Let's Get Rusty, Using Trait Objects in Rust [vidéo], 2021, 13 min 31s., Youtube, https://www.youtube.com/watch?v=ReBmm0eJg6g

OSWALT, Matt « Polymorphism in Rust» [billet], dans Matt Oswalt, 22 juin 2021, https://oswalt.dev/2021/06/polymorphism-in-rust/

« Traits (Programmation générique et Traits)», dans StudyRaid, 08 août 2024, https://app.studyraid.com/fr/read/2193/41444/traits

Étiquettes

Commentaires