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 :
/// 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_ttca un corps par défaut : les types l'obtiennent sans rien écrire, et peuvent le remplacer s'ils le souhaitent.impl Valorisable for Posteimplémente le trait pour un type, dans un bloc séparé deimpl 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 :
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 :
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);
}
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 :Test un paramètre de type, et la borneT: Valorisableexige 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::Displayest le trait de la bibliothèque standard qui définit l'affichage avec{}. L'implémenter donne aussito_string()à ton type.
Quand les bornes s'allongent, on les déplace dans une clause where, plus lisible :
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 :
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) :
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");
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 <T: Trait> ou impl Trait | Objet trait dyn Trait | |
|---|---|---|
| Choix de la méthode | À la compilation (répartition statique) | À l'exécution (répartition dynamique) |
| Performance | Maximale : appel direct, souvent intégré | Un léger détour par une table de méthodes |
| Une collection peut mélanger les types | Non | Oui |
| Équivalent C# | Méthode générique avec where T : IValorisable | Variable 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 :
| Trait | Ce qu'il permet | Comment l'obtenir |
|---|---|---|
Debug | Afficher avec {:?} | #[derive(Debug)] |
Display | Afficher avec {}, et to_string() | À écrire : impl fmt::Display |
Clone, Copy | Copier avec .clone(), ou implicitement | #[derive(Clone, Copy)] (Copy seulement si tous les champs le sont) |
PartialEq, Eq | Comparer avec == | #[derive(PartialEq, Eq)] |
PartialOrd, Ord | Comparer avec <, trier avec sort() | #[derive(PartialOrd, Ord)] : ordre des champs, ou des variantes |
Hash | Servir de clé de HashMap | #[derive(Hash)], avec Eq |
Default | Créer une valeur par défaut | #[derive(Default)] : 0, chaîne vide, None… |
From, Into | Convertir d'un type vers un autre | À écrire : impl From<A> for B donne aussi a.into() |
FromStr | Convertir 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 |
#[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));
}
[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 :
/// 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>(&[]));
}
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 commun | Oui | Oui |
| Méthodes avec un corps par défaut | Oui, appelables directement sur le type | Oui depuis C# 8, mais appelables seulement à travers l'interface |
| Où se déclare l'implémentation | Dans 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 écrit | Oui (un trait à toi pour u32, ou Display pour ta struct) | Non, sauf méthodes d'extension |
| Héritage de classes | N'existe pas : composition et traits | Oui, une classe mère |
| Génériques contraints | T: Trait ou where, résolus à la compilation | where T : IInterface |
| Polymorphisme à l'exécution | dyn Trait, explicite | Le 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
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));
}
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
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.
- Calculer la valeur totale d'une liste qui ne contient que des postes.
- Lire un fichier où chaque ligne est soit un poste, soit une licence, et garder le tout dans une seule liste.
- Afficher un élément quelconque dans un journal.
Voir la réponseMasquer la réponse de l'exercice 3
- Générique : un seul type dans la liste, la répartition statique est plus rapide et suffit.
Vec<Box<dyn Valorisable>>: la liste mélange deux types connus seulement à l'exécution. (Une alternative idiomatique : une enumElement { Poste(Poste), Licence(Licence) }, quand la liste des types est fermée.)- 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# :
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
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é ?