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.
Une closure est une fonction anonyme qui peut capturer les variables autour d'elle. Un itérateur est un objet qui produit des valeurs une par une. Combinés, ils donnent le style le plus idiomatique de Rust : des chaînes filter, map, collect qui se lisent comme une phrase, et qui compilent en une boucle aussi rapide qu'un for écrit à la main.
fn main() {
let double = |x: i32| x * 2;
let ajouter = |a, b| a + b; // types déduits au premier appel
println!("{} {}", double(21), ajouter(2, 3));
let seuil = 10;
let au_dessus = |n: &i32| *n > seuil; // capture seuil
let valeurs = vec![4, 12, 9, 30];
let grands: Vec<_> = valeurs.into_iter().filter(au_dessus).collect();
println!("{grands:?}"); // [12, 30]
}La syntaxe : les paramètres entre barres verticales, puis une expression ou un bloc. Contrairement à une fn, une closure peut lire seuil, défini à l'extérieur.
Une closure capture chaque variable de la façon la plus légère possible, selon ce qu'elle en fait :
| Ce que fait la closure | Capture | Trait implémenté |
|---|---|---|
| Lit la variable | Par référence & | Fn |
| Modifie la variable | Par référence &mut | FnMut |
| Consomme la variable | Par valeur (déplacement) | FnOnce |
fn main() {
let mut compteur = 0;
let mut incrementer = || compteur += 1; // FnMut : modifie compteur
incrementer();
incrementer();
println!("{compteur}"); // 2
let nom = String::from("Ada");
let consommer = move || nom; // move : prend possession de nom
let recupere = consommer();
println!("{recupere}");
// println!("{nom}"); // erreur : nom a été déplacé dans la closure
}move force la capture par valeur. On l'utilise surtout quand la closure doit survivre à la fonction qui l'a créée : un thread, une tâche asynchrone, une closure renvoyée par une fonction.
Ces trois traits servent à typer une fonction qui reçoit une closure :
fn appliquer_deux_fois<F: Fn(i32) -> i32>(f: F, x: i32) -> i32 {
f(f(x))
}
fn fabriquer_multiplicateur(facteur: i32) -> impl Fn(i32) -> i32 {
move |x| x * facteur
}
fn main() {
println!("{}", appliquer_deux_fois(|x| x + 3, 10)); // 16
let triple = fabriquer_multiplicateur(3);
println!("{}", triple(7)); // 21
}IteratorTout itérateur implémente un trait qui tient en une méthode :
trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
}next renvoie Some(valeur) tant qu'il en reste, puis None. Une boucle for ne fait rien d'autre qu'appeler next jusqu'à None.
Trois méthodes créent un itérateur à partir d'une collection, avec les trois modes d'accès habituels :
| Méthode | Produit | La collection après |
|---|---|---|
.iter() | &T | Intacte |
.iter_mut() | &mut T | Modifiée sur place |
.into_iter() | T | Consommée |
Un adaptateur transforme un itérateur en un autre : map, filter, take, skip, enumerate, zip, rev, chain, flat_map. Un consommateur le parcourt et produit un résultat : collect, sum, count, min, max, fold, any, all, find, position.
fn main() {
let notes = vec![("Ada", 17), ("Linus", 9), ("Grace", 14), ("Alan", 11)];
let admis: Vec<&str> = notes
.iter()
.filter(|(_, note)| *note >= 10)
.map(|(nom, _)| *nom)
.collect();
println!("{admis:?}"); // ["Ada", "Grace", "Alan"]
let moyenne = notes.iter().map(|(_, n)| *n as f64).sum::<f64>() / notes.len() as f64;
println!("moyenne : {moyenne:.2}");
let meilleur = notes.iter().max_by_key(|(_, n)| *n).unwrap();
println!("meilleur : {}", meilleur.0);
let tous_presents = notes.iter().all(|(_, n)| *n > 0);
let premier_echec = notes.iter().find(|(_, n)| *n < 10);
println!("{tous_presents} {premier_echec:?}");
}Les adaptateurs sont paresseux : rien ne se passe tant qu'un consommateur ne réclame pas les valeurs. notes.iter().map(...) seul ne calcule rien, et le compilateur vous prévient d'ailleurs si vous l'oubliez.
collect, le couteau suissecollect construit n'importe quelle collection, selon le type demandé :
use std::collections::{HashMap, HashSet};
fn main() {
let mots = ["rust", "go", "rust", "zig"];
let uniques: HashSet<_> = mots.iter().collect();
let longueurs: HashMap<&str, usize> = mots.iter().map(|m| (*m, m.len())).collect();
let phrase: String = mots.iter().map(|m| m.to_uppercase()).collect::<Vec<_>>().join("-");
println!("{} uniques, {:?}, {}", uniques.len(), longueurs.get("zig"), phrase);
// collecter des Result : s'arrête à la première erreur
let nombres: Result<Vec<i32>, _> = ["1", "2", "x"].iter().map(|s| s.parse::<i32>()).collect();
println!("{nombres:?}"); // Err(ParseIntError { kind: InvalidDigit })
}La dernière forme est précieuse : un itérateur de Result se collecte en Result<Vec<_>, _>, qui vaut Ok avec tout le vecteur si tout a réussi, ou la première erreur sinon.
fn main() {
let a = [1, 2, 3];
let b = ["un", "deux", "trois"];
for (n, mot) in a.iter().zip(b.iter()) {
print!("{n}={mot} ");
}
println!();
let pairs: Vec<i32> = (1..=20).filter(|n| n % 2 == 0).skip(2).take(3).collect();
println!("{pairs:?}"); // [6, 8, 10]
let lettres: Vec<char> = ["ab", "cd"].iter().flat_map(|s| s.chars()).collect();
println!("{lettres:?}"); // ['a', 'b', 'c', 'd']
let produit = (1..=5).fold(1, |acc, n| acc * n);
println!("{produit}"); // 120
let groupes: Vec<Vec<i32>> = (1..=7).collect::<Vec<_>>().chunks(3).map(|c| c.to_vec()).collect();
println!("{groupes:?}"); // [[1, 2, 3], [4, 5, 6], [7]]
}struct Fibonacci {
courant: u64,
suivant: u64,
}
impl Iterator for Fibonacci {
type Item = u64;
fn next(&mut self) -> Option<u64> {
let valeur = self.courant;
self.courant = self.suivant;
self.suivant += valeur;
Some(valeur)
}
}
fn fibonacci() -> Fibonacci {
Fibonacci { courant: 0, suivant: 1 }
}
fn main() {
let premiers: Vec<u64> = fibonacci().take(10).collect();
println!("{premiers:?}");
let pairs_sous_1000: u64 = fibonacci().take_while(|&n| n < 1000).filter(|n| n % 2 == 0).sum();
println!("{pairs_sous_1000}"); // 798
}En implémentant une seule méthode, next, on récupère gratuitement les dizaines d'adaptateurs du trait. L'itérateur est infini, et ce n'est pas un problème : la paresse fait qu'on ne calcule que ce que take ou take_while demandent.
On pourrait croire qu'une chaîne filter().map().sum() coûte plus cher qu'une boucle. C'est faux : le compilateur fusionne tout en une seule boucle, souvent même vectorisée. Les itérateurs sont une abstraction à coût nul, l'une des promesses centrales de Rust. Choisissez entre boucle et itérateur pour la lisibilité, pas pour la vitesse.
Ma règle : une transformation (filtrer, convertir, agréger) s'écrit en itérateur. Une boucle avec des effets de bord compliqués, des break multiples ou des états qui s'entremêlent reste un for.
À partir d'un texte, affichez les trois mots de plus de trois lettres les plus fréquents, en ignorant la casse et la ponctuation.
use std::collections::HashMap;
fn main() {
let texte = "Le Rust est rapide. Le Rust est sûr. Rust compile, Rust protège, rapide et sûr !";
let mut frequences: HashMap<String, usize> = HashMap::new();
texte
.split(|c: char| !c.is_alphanumeric())
.filter(|m| m.chars().count() > 3)
.map(|m| m.to_lowercase())
.for_each(|m| *frequences.entry(m).or_insert(0) += 1);
let mut classement: Vec<_> = frequences.into_iter().collect();
classement.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
for (mot, n) in classement.iter().take(3) {
println!("{mot} : {n}");
}
}chars().count() plutôt que len() : len compte les octets, et sûr en fait quatre pour trois lettres.
Closures et itérateurs donnent à Rust un style fonctionnel lisible, sans rien céder en vitesse. Le prochain chapitre sort du fichier unique : modules, crates, dépendances et tests, tout ce qu'il faut pour organiser un vrai projet.