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.
Rust n'a pas de classes. On regroupe des données dans des structs, on décrit des alternatives avec des enums, et on leur attache des méthodes avec impl. Le match fait le lien : il examine une valeur et oblige à traiter chaque cas. Cette combinaison remplace null, les exceptions et une bonne partie de l'héritage.
struct Utilisateur {
nom: String,
email: String,
age: u32,
actif: bool,
}
fn main() {
let mut ada = Utilisateur {
nom: String::from("Ada"),
email: String::from("ada@exemple.fr"),
age: 36,
actif: true,
};
ada.age += 1;
println!("{} a {} ans", ada.nom, ada.age);
}La mutabilité s'applique à toute la struct : on ne peut pas rendre un seul champ modifiable.
Deux raccourcis d'écriture reviennent souvent :
fn creer(nom: String, email: String) -> Utilisateur {
Utilisateur { nom, email, age: 0, actif: true } // nom: nom abrégé
}
let bob = Utilisateur {
nom: String::from("Bob"),
..ada // reprendre les autres champs de ada
};Attention, ..ada déplace les champs non Copy de ada (ici email). ada.email devient inutilisable, la règle du chapitre 3 s'applique champ par champ.
Il existe aussi des structs sans noms de champs, utiles pour donner un type distinct à une valeur simple :
struct Metres(f64);
struct Secondes(f64);Impossible désormais de passer des secondes à une fonction qui attend des mètres, même si les deux sont des f64.
impl#[derive(Debug)]
struct Rectangle {
largeur: f64,
hauteur: f64,
}
impl Rectangle {
// fonction associée (pas de self) : un constructeur
fn carre(cote: f64) -> Self {
Self { largeur: cote, hauteur: cote }
}
// méthode qui lit
fn aire(&self) -> f64 {
self.largeur * self.hauteur
}
// méthode qui modifie
fn agrandir(&mut self, facteur: f64) {
self.largeur *= facteur;
self.hauteur *= facteur;
}
fn peut_contenir(&self, autre: &Rectangle) -> bool {
self.largeur >= autre.largeur && self.hauteur >= autre.hauteur
}
}
fn main() {
let mut r = Rectangle { largeur: 3.0, hauteur: 2.0 };
let c = Rectangle::carre(1.5);
r.agrandir(2.0);
println!("{r:?}, aire {}, contient c : {}", r.aire(), r.peut_contenir(&c));
}Le premier paramètre dit comment la méthode utilise l'objet, avec exactement les règles du chapitre précédent : &self emprunte en lecture, &mut self en écriture, self prend possession (l'objet n'est plus utilisable après l'appel). Self désigne le type en cours.
#[derive(Debug)] demande au compilateur de générer le code d'affichage {:?}. On y reviendra avec les traits au chapitre 6.
Il n'y a pas de constructeur spécial en Rust. La convention est une fonction associée new.
Un enum Rust est bien plus qu'une liste de constantes : chaque variante peut porter ses propres données.
enum Forme {
Cercle { rayon: f64 },
Rectangle { largeur: f64, hauteur: f64 },
Triangle(f64, f64, f64),
}
impl Forme {
fn aire(&self) -> f64 {
match self {
Forme::Cercle { rayon } => std::f64::consts::PI * rayon * rayon,
Forme::Rectangle { largeur, hauteur } => largeur * hauteur,
Forme::Triangle(a, b, c) => {
let s = (a + b + c) / 2.0;
(s * (s - a) * (s - b) * (s - c)).sqrt()
}
}
}
}
fn main() {
let formes = [
Forme::Cercle { rayon: 1.0 },
Forme::Rectangle { largeur: 2.0, hauteur: 3.0 },
Forme::Triangle(3.0, 4.0, 5.0),
];
for f in &formes {
println!("{:.2}", f.aire());
}
}Une valeur de type Forme est forcément l'une des trois variantes, avec les données correspondantes. Dans un langage objet, on aurait une classe abstraite et trois sous-classes. Ici, tout tient en un type, et le match voit tous les cas d'un coup.
match est exhaustifSi on ajoute une variante Hexagone à l'enum, chaque match qui ne la traite pas devient une erreur de compilation :
error[E0004]: non-exhaustive patterns: `&Forme::Hexagone { .. }` not coveredC'est la fonction la plus précieuse de Rust au quotidien. Ajouter un cas à un type ne laisse jamais de code oublié quelque part : le compilateur vous donne la liste des endroits à mettre à jour.
match sait faire bien plus que comparer des variantes :
fn decrire(n: i32) -> &'static str {
match n {
0 => "zéro",
1 | 2 | 3 => "petit",
4..=9 => "moyen",
x if x < 0 => "négatif",
_ => "grand",
}
}
fn main() {
for n in [0, 2, 7, -5, 100] {
println!("{n} : {}", decrire(n));
}
}| combine des motifs, 4..=9 teste une plage, if ajoute une condition (une « garde ») et _ attrape tout le reste. Les branches sont testées dans l'ordre.
Option : la fin de nullRust n'a pas de null. Une valeur qui peut être absente a le type Option<T>, un enum de la bibliothèque standard :
enum Option<T> {
Some(T),
None,
}fn trouver_age(nom: &str) -> Option<u32> {
match nom {
"Ada" => Some(36),
"Linus" => Some(55),
_ => None,
}
}
fn main() {
match trouver_age("Ada") {
Some(age) => println!("Ada a {age} ans"),
None => println!("inconnue"),
}
}Un Option<u32> n'est pas un u32. Impossible de faire trouver_age("Ada") + 1 : le compilateur oblige à traiter le cas None avant d'utiliser la valeur. Le fameux « erreur d'un milliard de dollars », la référence nulle déréférencée, ne peut pas arriver.
Quelques méthodes évitent d'écrire un match à chaque fois :
fn main() {
let age: Option<u32> = Some(36);
let rien: Option<u32> = None;
println!("{}", age.unwrap_or(0)); // 36
println!("{}", rien.unwrap_or(0)); // 0
println!("{:?}", age.map(|a| a + 1)); // Some(37)
println!("{}", age.is_some()); // true
// rien.unwrap(); // panique : à éviter hors des tests et prototypes
}if let et let elseQuand un seul cas vous intéresse, match est verbeux. if let teste un motif :
fn main() {
let config_port: Option<u16> = Some(8080);
if let Some(port) = config_port {
println!("écoute sur {port}");
}
}let else fait l'inverse : il extrait la valeur ou sort de la fonction. C'est idéal pour les vérifications en début de fonction :
fn saluer(nom: Option<&str>) {
let Some(nom) = nom else {
println!("personne à saluer");
return;
};
println!("bonjour {nom}");
}
fn main() {
saluer(Some("Ada"));
saluer(None);
}Le code principal reste à gauche, sans s'enfoncer dans des niveaux d'indentation.
Modélisez les feux d'un carrefour avec un enum Feu (Rouge, Orange, Vert), et écrivez deux méthodes : suivant(&self) -> Feu et duree(&self) -> u32 (en secondes : 30, 5, 25). Faites tourner le feu pendant un cycle complet.
#[derive(Debug, Clone, Copy)]
enum Feu {
Rouge,
Orange,
Vert,
}
impl Feu {
fn suivant(&self) -> Feu {
match self {
Feu::Rouge => Feu::Vert,
Feu::Vert => Feu::Orange,
Feu::Orange => Feu::Rouge,
}
}
fn duree(&self) -> u32 {
match self {
Feu::Rouge => 30,
Feu::Orange => 5,
Feu::Vert => 25,
}
}
}
fn main() {
let mut feu = Feu::Rouge;
for _ in 0..3 {
println!("{feu:?} pendant {} s", feu.duree());
feu = feu.suivant();
}
}Structs pour regrouper, enums pour les alternatives, match pour ne jamais oublier un cas, Option à la place de null. Ce modèle de données est l'un des points forts de Rust, et il prépare la gestion d'erreurs du chapitre suivant, qui repose sur un autre enum : Result.