Aller au contenu
Réussir mon BTS SIO

Tape une notion, une commande ou le nom d'une épreuve.

Par exemple

Chapitre 724 minIntermédiaire

Traits et génériques : les interfaces de Rust

Par Josselyn C., prof de BTS SIO. Mis à jour le .

À la fin de ce chapitre, tu sauras

  • définir un trait et l'implémenter pour plusieurs types ;
  • écrire une fonction générique avec une borne de trait ;
  • choisir entre un générique et un dyn Trait ;
  • comparer un trait de Rust à une interface C#.

4 exercices corrigés1 schéma

Un trait décrit un comportement que plusieurs types peuvent partager : une liste de méthodes qu'un type s'engage à fournir. C'est l'équivalent Rust d'une interface en C# ou en Java. Les génériques permettent ensuite d'écrire une seule fonction ou une seule struct pour tous les types qui ont ce comportement. Ensemble, ils remplacent l'héritage, qui n'existe pas en Rust. Ce chapitre les présente en les comparant pas à pas au C# du cours de programmation orientée objet (classe abstraite et interface).

Définir et implémenter un trait

Le service informatique veut connaître la valeur de tout ce qu'il achète : des postes, mais aussi des licences logicielles. On définit un trait Valorisable :

rust
/// Tout ce qui a une valeur d'achat dans le parc.
trait Valorisable {
    /// Valeur HT en centimes : chaque type doit la fournir.
    fn valeur_ht(&self) -> u64;

    /// Valeur TTC : méthode par défaut, héritée telle quelle.
    fn valeur_ttc(&self) -> u64 {
        self.valeur_ht() * 120 / 100
    }
}

struct Poste {
    numero: String,
    prix_ht: u32,
}

struct Licence {
    logiciel: String,
    prix_annuel_ht: u32,
    nb_postes: u32,
}

impl Valorisable for Poste {
    fn valeur_ht(&self) -> u64 {
        u64::from(self.prix_ht)
    }
}

impl Valorisable for Licence {
    fn valeur_ht(&self) -> u64 {
        u64::from(self.prix_annuel_ht) * u64::from(self.nb_postes)
    }
}
  • Le trait déclare la signature de valeur_ht, sans corps : chaque type doit l'écrire.
  • valeur_ttc a un corps par défaut : les types l'obtiennent sans rien écrire, et peuvent le remplacer s'ils le souhaitent.
  • impl Valorisable for Poste implémente le trait pour un type, dans un bloc séparé de impl Poste. Un type peut implémenter autant de traits que nécessaire, chacun dans son bloc.

Voici l'équivalent en C# 14 (.NET 10), qui compile et donne les mêmes résultats :

csharp
interface IValorisable
{
    long ValeurHt();
    long ValeurTtc() => ValeurHt() * 120 / 100; // méthode par défaut (C# 8 et plus)
}

class Poste(string numero, int prixHt) : IValorisable
{
    public string Numero { get; } = numero;
    public long ValeurHt() => prixHt;
}

class Licence(string logiciel, int prixAnnuelHt, int nbPostes) : IValorisable
{
    public string Logiciel { get; } = logiciel;
    public long ValeurHt() => (long)prixAnnuelHt * nbPostes;
}

La différence de structure saute aux yeux : en C#, la classe annonce ses interfaces dans son en-tête (: IValorisable) ; en Rust, chaque implémentation est un bloc indépendant, qu'on peut même écrire dans un autre fichier que la struct.

Utiliser un trait : impl Trait et génériques

Une fonction peut accepter n'importe quel type qui implémente un trait. Deux écritures :

rust
use std::fmt;

impl fmt::Display for Poste {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "poste {}", self.numero)
    }
}

impl fmt::Display for Licence {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "licence {} ({} postes)", self.logiciel, self.nb_postes)
    }
}

/// Générique : marche pour une tranche de n'importe quel type Valorisable.
fn total_ht<T: Valorisable>(elements: &[T]) -> u64 {
    elements.iter().map(|e| e.valeur_ht()).sum()
}

/// impl Trait : la même idée, écrite plus court, pour un seul élément.
fn afficher(element: &(impl Valorisable + fmt::Display)) {
    println!("{element} : {} HT, {} TTC", element.valeur_ht(), element.valeur_ttc());
}

fn main() {
    let postes = vec![
        Poste { numero: String::from("PC-0001"), prix_ht: 89900 },
        Poste { numero: String::from("PC-0002"), prix_ht: 64900 },
    ];
    let suite = Licence { logiciel: String::from("Suite bureautique"), prix_annuel_ht: 12600, nb_postes: 12 };

    println!("Postes : {} centimes HT", total_ht(&postes));
    afficher(&postes[0]);
    afficher(&suite);
}
text
Postes : 154800 centimes HT
poste PC-0001 : 89900 HT, 107880 TTC
licence Suite bureautique (12 postes) : 151200 HT, 181440 TTC
  • fn total_ht<T: Valorisable>(elements: &[T]) est une fonction générique : T est un paramètre de type, et la borne T: Valorisable exige que ce type implémente le trait. Le paramètre &[T] est une tranche : il accepte un &Vec<T> comme un tableau.
  • &(impl Valorisable + fmt::Display) est une écriture courte : « une référence vers un type qui implémente les deux traits ». Le + combine les bornes.
  • fmt::Display est le trait de la bibliothèque standard qui définit l'affichage avec {}. L'implémenter donne aussi to_string() à ton type.

Quand les bornes s'allongent, on les déplace dans une clause where, plus lisible :

rust
fn rapport<T>(elements: &[T]) -> String
where
    T: Valorisable + fmt::Display,
{
    elements
        .iter()
        .map(|e| format!("{e} : {} HT", e.valeur_ht()))
        .collect::<Vec<String>>()
        .join("\n")
}

La version C# de total_ht utilise une contrainte where, avec le même sens :

csharp
static long TotalHt<T>(IEnumerable<T> elements) where T : IValorisable =>
    elements.Sum(e => e.ValeurHt());

Mélanger des types : dyn Trait

Un Vec<T> ne contient qu'un seul type. Pour ranger dans la même liste des postes et des licences, il faut un objet trait, dyn Valorisable, placé dans une Box (une valeur rangée sur le tas) :

rust
let budget: Vec<Box<dyn Valorisable>> = vec![
    Box::new(Poste { numero: String::from("PC-0003"), prix_ht: 104900 }),
    Box::new(suite),
];
let total: u64 = budget.iter().map(|e| e.valeur_ht()).sum();
println!("Budget : {total} centimes HT");
text
Budget : 256100 centimes HT

C'est exactement ce que fait C# avec List<IValorisable> : chaque élément connaît sa propre version de ValeurHt, choisie à l'exécution.

Générique : répartition statiquetotal_ht<T: Valorisable>compilationtotal_ht pour Postetotal_ht pour Licenceune copie par type, appel direct ; une liste ne contient qu'un typedyn Trait : répartition dynamiqueVec<Box<dyn Valorisable>>Poste PC-0003Licence Suite bureautiqueà l'exécutiontable de Poste : valeur_httable de Licence : valeur_htcomme List<IValorisable> en C#
Générique : une copie de la fonction par type, choisie à la compilation. dyn Trait : une table de méthodes consultée à l'exécution
Générique <T: Trait> ou impl TraitObjet trait dyn Trait
Choix de la méthodeÀ la compilation (répartition statique)À l'exécution (répartition dynamique)
PerformanceMaximale : appel direct, souvent intégréUn léger détour par une table de méthodes
Une collection peut mélanger les typesNonOui
Équivalent C#Méthode générique avec where T : IValorisableVariable ou liste de type IValorisable

Les traits de la bibliothèque standard

Tu utilises des traits depuis le début du cours sans le savoir. #[derive(…)] en génère l'implémentation, et d'autres s'écrivent à la main :

TraitCe qu'il permetComment l'obtenir
DebugAfficher avec {:?}#[derive(Debug)]
DisplayAfficher avec {}, et to_string()À écrire : impl fmt::Display
Clone, CopyCopier avec .clone(), ou implicitement#[derive(Clone, Copy)] (Copy seulement si tous les champs le sont)
PartialEq, EqComparer avec ==#[derive(PartialEq, Eq)]
PartialOrd, OrdComparer avec <, trier avec sort()#[derive(PartialOrd, Ord)] : ordre des champs, ou des variantes
HashServir de clé de HashMap#[derive(Hash)], avec Eq
DefaultCréer une valeur par défaut#[derive(Default)] : 0, chaîne vide, None…
From, IntoConvertir d'un type vers un autreÀ écrire : impl From<A> for B donne aussi a.into()
FromStrConvertir un texte avec parse()À écrire : impl FromStr for TypePoste (chapitre 8)
IteratorÊtre parcouru par for et les itérateursÀ écrire : une méthode next
rust
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
enum TypePoste {
    Fixe,
    Portable,
    Serveur,
}

#[derive(Debug, Clone, PartialEq, Default)]
struct Filtre {
    marque: Option<String>,
    prix_max: Option<u32>,
    seulement_en_stock: bool,
}

/// Un montant en centimes, pour ne plus confondre prix et quantités.
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
struct Centimes(u64);

impl From<u32> for Centimes {
    fn from(valeur: u32) -> Self {
        Centimes(u64::from(valeur))
    }
}

fn main() {
    let mut types = vec![TypePoste::Serveur, TypePoste::Fixe, TypePoste::Portable];
    types.sort(); // possible grâce à Ord : l'ordre de déclaration
    println!("{types:?}");

    let filtre = Filtre { prix_max: Some(100000), ..Default::default() };
    println!("{filtre:?}");

    let prix: Centimes = 89900_u32.into(); // into() s'appuie sur From
    println!("{prix:?} {}", prix > Centimes(50000));
}
text
[Fixe, Portable, Serveur]
Filtre { marque: None, prix_max: Some(100000), seulement_en_stock: false }
Centimes(89900) true

Self, dans un bloc impl, désigne le type que l'on implémente (ici Centimes). ..Default::default() remplit les champs non cités avec leur valeur par défaut.

Structs et fonctions génériques

Les génériques ne servent pas qu'aux traits : Vec<T>, Option<T> et Result<T, E> sont eux-mêmes des types génériques. Tu peux écrire les tiens :

rust
/// Un inventaire de n'importe quel type d'élément.
struct Inventaire<T> {
    elements: Vec<T>,
}

impl<T> Inventaire<T> {
    fn new() -> Self {
        Inventaire { elements: Vec::new() }
    }

    fn ajouter(&mut self, element: T) {
        self.elements.push(element);
    }

    fn taille(&self) -> usize {
        self.elements.len()
    }
}

/// Le plus grand élément, pour tout type comparable.
fn maximum<T: PartialOrd + Copy>(valeurs: &[T]) -> Option<T> {
    let mut resultat = *valeurs.first()?;
    for &v in valeurs {
        if v > resultat {
            resultat = v;
        }
    }
    Some(resultat)
}

fn main() {
    let mut numeros: Inventaire<&str> = Inventaire::new();
    numeros.ajouter("PC-0001");
    numeros.ajouter("PC-0002");
    let mut prix: Inventaire<u32> = Inventaire::new();
    prix.ajouter(89900);
    println!("{} numéros, {} prix", numeros.taille(), prix.taille());

    println!("{:?}", maximum(&[64900, 104900, 89900]));
    println!("{:?}", maximum(&[2.5, 1.0]));
    println!("{:?}", maximum::<i32>(&[]));
}
text
2 numéros, 1 prix
Some(104900)
Some(2.5)
None

maximum fonctionne pour des entiers comme pour des flottants, parce que les deux implémentent PartialOrd (comparables) et Copy (copiables). Sur une tranche vide, first() renvoie None, et le ? le propage : la fonction renvoie None sans paniquer. Le compilateur produit une version de maximum par type utilisé : les génériques ne coûtent rien à l'exécution.

Trait de Rust ou interface C# : le bilan

Trait (Rust)Interface (C#)
Déclarer un comportement communOuiOui
Méthodes avec un corps par défautOui, appelables directement sur le typeOui depuis C# 8, mais appelables seulement à travers l'interface
Où se déclare l'implémentationDans un bloc impl Trait for Type séparéDans l'en-tête de la classe
Implémenter un trait pour un type qu'on n'a pas écritOui (un trait à toi pour u32, ou Display pour ta struct)Non, sauf méthodes d'extension
Héritage de classesN'existe pas : composition et traitsOui, une classe mère
Génériques contraintsT: Trait ou where, résolus à la compilationwhere T : IInterface
Polymorphisme à l'exécutiondyn Trait, expliciteLe comportement par défaut de toute interface

Une seule restriction, la règle de l'orphelin : pour implémenter un trait pour un type, il faut que l'un des deux (le trait ou le type) soit défini dans ton projet. Tu ne peux pas implémenter Display pour Vec<u32>, car ni l'un ni l'autre ne t'appartient.

Exercices

Exercice 1 sur 4 Le trait Garantie Réussi À revoir

Définis un trait Garantie avec une méthode duree_garantie(&self) -> u32 (en années) et une méthode par défaut fin_garantie(&self, annee_achat: i32) -> i32 qui renvoie l'année d'achat plus la durée. Implémente-le pour une struct Portable (3 ans) et une struct Serveur (5 ans). Calcule la fin de garantie d'un serveur acheté en 2021.

Voir la réponseMasquer la réponse de l'exercice 1
rust
trait Garantie {
    fn duree_garantie(&self) -> u32;

    fn fin_garantie(&self, annee_achat: i32) -> i32 {
        annee_achat + self.duree_garantie() as i32
    }
}

struct Portable;
struct Serveur;

impl Garantie for Portable {
    fn duree_garantie(&self) -> u32 {
        3
    }
}

impl Garantie for Serveur {
    fn duree_garantie(&self) -> u32 {
        5
    }
}

fn main() {
    println!("{}", Serveur.fin_garantie(2021));
    println!("{}", Portable.fin_garantie(2025));
}
text
2026
2028

struct Portable; est une struct unitaire, sans champ : elle ne sert qu'à porter des implémentations. La conversion as i32 est sans risque ici (une durée de garantie tient largement dans un i32) ; i32::try_from serait la version prudente.

Tu avais trouvé ?

Exercice 2 sur 4 Implémenter Display Réussi À revoir

Implémente fmt::Display pour l'enum TypePoste (Fixe, Portable, Serveur) afin que println!("{}", TypePoste::Portable) affiche « portable ». Puis explique pourquoi TypePoste::Portable.to_string() fonctionne aussi, alors que tu n'as pas écrit de méthode to_string.

Voir la réponseMasquer la réponse de l'exercice 2
rust
use std::fmt;

impl fmt::Display for TypePoste {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        let nom = match self {
            TypePoste::Fixe => "fixe",
            TypePoste::Portable => "portable",
            TypePoste::Serveur => "serveur",
        };
        write!(f, "{nom}")
    }
}

La bibliothèque standard implémente le trait ToString pour tout type qui implémente Display. En écrivant Display, tu obtiens donc to_string() gratuitement. C'est un exemple d'implémentation générique : impl<T: Display> ToString for T.

Tu avais trouvé ?

Exercice 3 sur 4 Générique ou dyn ? Réussi À revoir

Pour chaque besoin, choisis entre une fonction générique (<T: Valorisable>) et un objet trait (dyn Valorisable), et justifie.

  1. Calculer la valeur totale d'une liste qui ne contient que des postes.
  2. Lire un fichier où chaque ligne est soit un poste, soit une licence, et garder le tout dans une seule liste.
  3. Afficher un élément quelconque dans un journal.
Voir la réponseMasquer la réponse de l'exercice 3
  1. Générique : un seul type dans la liste, la répartition statique est plus rapide et suffit.
  2. Vec<Box<dyn Valorisable>> : la liste mélange deux types connus seulement à l'exécution. (Une alternative idiomatique : une enum Element { Poste(Poste), Licence(Licence) }, quand la liste des types est fermée.)
  3. Générique (&impl Valorisable, ou &dyn Valorisable) : les deux marchent pour un seul élément ; la version générique est la plus courante.

Tu avais trouvé ?

Exercice 4 sur 4 Traduire une interface C# Réussi À revoir

Traduis en Rust cette interface et cette classe C# :

csharp
interface IIdentifiable
{
    string Identifiant();
    string Etiquette() => $"[{Identifiant()}]";
}

class Imprimante(string numero) : IIdentifiable
{
    public string Identifiant() => numero;
}
Voir la réponseMasquer la réponse de l'exercice 4
rust
trait Identifiable {
    fn identifiant(&self) -> String;

    fn etiquette(&self) -> String {
        format!("[{}]", self.identifiant())
    }
}

struct Imprimante {
    numero: String,
}

impl Identifiable for Imprimante {
    fn identifiant(&self) -> String {
        self.numero.clone()
    }
}

fn main() {
    let imprimante = Imprimante { numero: String::from("IMP-0003") };
    println!("{}", imprimante.etiquette());
}

Le programme affiche [IMP-0003]. Les méthodes passent en snake_case, l'interface perd son préfixe I, et le constructeur principal de C# devient une struct avec un champ. En Rust, etiquette() s'appelle directement sur imprimante ; en C#, la méthode par défaut n'est accessible qu'à travers une variable de type IIdentifiable.

Tu avais trouvé ?

Chapitre terminé !

Fais les 4 exercices avant de passer à la suite : c'est en t'entraînant que tout se fixe.

chapitres
–
de série
–
du cours
–
Chapitre suivant : Projet : un outil en ligne de commande, modules et cargo testRevoir le sommaire du cours