Apprendre Rust de zéro jusqu'à un vrai outil en ligne de commande : ownership, emprunts, enums, gestion d'erreurs, traits, génériques, durées de vie, itérateurs, tests, threads et async avec Tokio.
Un trait décrit un comportement : « ce type sait s'afficher », « ce type sait se comparer », « ce type a une aire ». Un générique est du code écrit une fois pour plusieurs types. Ensemble, ils remplacent ce que d'autres langages font avec les interfaces et l'héritage, sans coût à l'exécution dans le cas courant.
Si vous venez de TypeScript, un trait ressemble à une interface, avec une différence de taille : on peut implémenter un trait pour un type qu'on n'a pas écrit, y compris i32 ou String. L'article sur TypeScript pour les développeurs JavaScript permet de comparer les deux approches.
trait Resume {
fn auteur(&self) -> String;
// méthode avec une implémentation par défaut
fn resumer(&self) -> String {
format!("(lire la suite de {}...)", self.auteur())
}
}
struct Article {
titre: String,
auteur: String,
}
struct Tweet {
pseudo: String,
contenu: String,
}
impl Resume for Article {
fn auteur(&self) -> String {
self.auteur.clone()
}
fn resumer(&self) -> String {
format!("{}, par {}", self.titre, self.auteur)
}
}
impl Resume for Tweet {
fn auteur(&self) -> String {
format!("@{}", self.pseudo)
}
// resumer : on garde la version par défaut
}
fn main() {
let a = Article { titre: "Rust en production".into(), auteur: "Ada".into() };
let t = Tweet { pseudo: "linus".into(), contenu: "Bonjour".into() };
println!("{}", a.resumer());
println!("{} — {}", t.resumer(), t.contenu);
}.into() convertit un &str en String : on reviendra sur ce mécanisme avec le trait From.
Une fonction peut accepter n'importe quel type qui implémente un trait :
fn notifier(element: &impl Resume) {
println!("Nouveau : {}", element.resumer());
}impl Resume est un raccourci. La forme complète, avec un paramètre de type, permet plus de choses :
fn notifier<T: Resume>(element: &T) {
println!("Nouveau : {}", element.resumer());
}
// deux paramètres du même type T
fn comparer<T: Resume>(a: &T, b: &T) { /* ... */ }
// plusieurs contraintes : la clause where reste lisible
fn journaliser<T>(element: &T)
where
T: Resume + std::fmt::Debug,
{
println!("{:?} -> {}", element, element.resumer());
}T: Resume se lit « n'importe quel type T qui implémente Resume ». C'est une contrainte (un « trait bound »). Sans elle, le compilateur refuserait l'appel à resumer, car il ne saurait pas que T possède cette méthode.
Un exemple classique, le plus grand élément d'une liste :
fn plus_grand<T: PartialOrd + Copy>(liste: &[T]) -> Option<T> {
let mut max = *liste.first()?;
for &x in liste {
if x > max {
max = x;
}
}
Some(max)
}
fn main() {
println!("{:?}", plus_grand(&[3, 17, 8])); // Some(17)
println!("{:?}", plus_grand(&[2.5, -1.0])); // Some(2.5)
println!("{:?}", plus_grand(&['r', 'u', 's'])); // Some('u')
println!("{:?}", plus_grand::<i32>(&[])); // None
}PartialOrd autorise >, Copy autorise la copie de x dans max. Le compilateur génère une version de la fonction pour chaque type utilisé (i32, f64, char). Ce procédé, la monomorphisation, rend les génériques aussi rapides que du code écrit à la main pour chaque type.
#[derive(Debug)]
struct Paire<T> {
a: T,
b: T,
}
impl<T: PartialOrd + std::fmt::Display> Paire<T> {
fn afficher_max(&self) {
let max = if self.a > self.b { &self.a } else { &self.b };
println!("le plus grand est {max}");
}
}
fn main() {
Paire { a: 3, b: 9 }.afficher_max();
Paire { a: "pomme", b: "kiwi" }.afficher_max();
}Option<T>, Result<T, E>, Vec<T> et HashMap<K, V> sont construits exactement ainsi.
Une bonne partie du travail consiste à implémenter, ou à dériver, des traits standards :
| Trait | Permet | Dérivable |
|---|---|---|
Debug | {:?} | Oui |
Clone | .clone() | Oui |
Copy | Copie implicite (types simples) | Oui |
PartialEq, Eq | == et != | Oui |
PartialOrd, Ord | <, >, tri | Oui |
Hash | Clé de HashMap | Oui |
Default | Type::default() | Oui |
Display | {} | Non, à écrire |
From / Into | Conversions | Non, à écrire |
Iterator | Boucles for, adaptateurs | Non, à écrire |
#[derive(...)] génère l'implémentation évidente, champ par champ :
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
struct Version {
majeure: u32,
mineure: u32,
correctif: u32,
}
fn main() {
let mut versions = vec![
Version { majeure: 1, mineure: 10, correctif: 0 },
Version { majeure: 1, mineure: 2, correctif: 3 },
Version::default(),
];
versions.sort(); // ordre des champs : majeure, puis mineure, puis correctif
println!("{versions:?}");
}Mon réflexe sur toute nouvelle struct de données : #[derive(Debug, Clone, PartialEq)], et j'ajoute le reste quand le compilateur le réclame.
Displayuse std::fmt;
struct Version {
majeure: u32,
mineure: u32,
correctif: u32,
}
impl fmt::Display for Version {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "v{}.{}.{}", self.majeure, self.mineure, self.correctif)
}
}
fn main() {
let v = Version { majeure: 2, mineure: 0, correctif: 1 };
println!("{v}"); // v2.0.1
let texte = v.to_string(); // offert par Display
println!("{}", texte.len());
}Implémenter Display donne .to_string() gratuitement : la bibliothèque standard l'implémente pour tout type qui a Display.
From et Intostruct Celsius(f64);
struct Fahrenheit(f64);
impl From<Celsius> for Fahrenheit {
fn from(c: Celsius) -> Self {
Fahrenheit(c.0 * 9.0 / 5.0 + 32.0)
}
}
fn main() {
let f = Fahrenheit::from(Celsius(100.0));
let g: Fahrenheit = Celsius(0.0).into(); // Into est offert par From
println!("{} {}", f.0, g.0); // 212 32
}From sert aussi à l'opérateur ? : si votre type d'erreur implémente From<io::Error>, un ? sur une opération de fichier convertit l'erreur automatiquement.
dyn TraitLes génériques fixent le type à la compilation. Parfois, on veut une liste qui mélange des types différents, tous dotés du même comportement. On utilise alors un objet trait :
trait Forme {
fn aire(&self) -> f64;
fn nom(&self) -> &str;
}
struct Cercle(f64);
struct Carre(f64);
impl Forme for Cercle {
fn aire(&self) -> f64 { std::f64::consts::PI * self.0 * self.0 }
fn nom(&self) -> &str { "cercle" }
}
impl Forme for Carre {
fn aire(&self) -> f64 { self.0 * self.0 }
fn nom(&self) -> &str { "carré" }
}
fn main() {
let formes: Vec<Box<dyn Forme>> = vec![Box::new(Cercle(1.0)), Box::new(Carre(2.0))];
let total: f64 = formes.iter().map(|f| f.aire()).sum();
for f in &formes {
println!("{} : {:.2}", f.nom(), f.aire());
}
println!("total : {total:.2}");
}Box<dyn Forme> est un pointeur vers « une valeur d'un type inconnu qui implémente Forme ». L'appel de méthode passe par une table de fonctions à l'exécution, un léger coût.
Générique <T: Trait> | Objet trait dyn Trait | |
|---|---|---|
| Type connu | À la compilation | À l'exécution |
| Liste de types mélangés | Non | Oui |
| Coût | Aucun, mais binaire plus gros | Un appel indirect |
| Usage courant | Fonctions utilitaires, bibliothèques | Plugins, listes hétérogènes |
Par défaut, utilisez les génériques. Passez à dyn quand vous avez besoin de mélanger des types. Et si la liste des types est fermée et connue, un enum, comme au chapitre 4, est souvent encore plus simple.
Écrivez un trait Taxable avec une méthode prix_ht(&self) -> f64 et une méthode par défaut prix_ttc(&self) -> f64 qui applique 20 % de TVA. Implémentez-le pour Livre (TVA réduite à 5,5 % : redéfinissez prix_ttc) et pour Ordinateur. Écrivez une fonction générique qui calcule le total TTC d'une tranche de Taxable.
trait Taxable {
fn prix_ht(&self) -> f64;
fn prix_ttc(&self) -> f64 {
self.prix_ht() * 1.20
}
}
struct Livre { prix: f64 }
struct Ordinateur { prix: f64 }
impl Taxable for Livre {
fn prix_ht(&self) -> f64 { self.prix }
fn prix_ttc(&self) -> f64 { self.prix * 1.055 }
}
impl Taxable for Ordinateur {
fn prix_ht(&self) -> f64 { self.prix }
}
fn total_ttc<T: Taxable>(articles: &[T]) -> f64 {
articles.iter().map(|a| a.prix_ttc()).sum()
}
fn main() {
let livres = [Livre { prix: 20.0 }, Livre { prix: 12.0 }];
let pc = [Ordinateur { prix: 1000.0 }];
println!("{:.2} {:.2}", total_ttc(&livres), total_ttc(&pc)); // 33.76 1200.00
}Pour un panier qui mélange livres et ordinateurs, il faudrait &[Box<dyn Taxable>]. Essayez de l'écrire.
Les traits décrivent ce qu'un type sait faire, les génériques écrivent du code pour tous les types qui savent le faire, sans coût à l'exécution. Il reste une pièce du système de types à comprendre avant d'être à l'aise : les durées de vie, au chapitre suivant, avec les pointeurs intelligents.