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.
Deux sujets du quotidien dans ce chapitre. Les collections de la bibliothèque standard, Vec, String et HashMap, qui couvrent la grande majorité des besoins. Et la gestion d'erreurs, où Rust fait un choix radical : pas d'exceptions. Une fonction qui peut échouer le dit dans son type de retour, et l'appelant est obligé d'en tenir compte.
Vec : la liste dynamiquefn main() {
let mut notes: Vec<u32> = Vec::new();
notes.push(14);
notes.push(9);
notes.push(17);
let autres = vec![12, 15]; // macro de création
notes.extend(&autres);
println!("{} notes : {:?}", notes.len(), notes);
println!("première : {}", notes[0]);
println!("dixième : {:?}", notes.get(9)); // None, pas de plantage
notes.sort();
notes.retain(|&n| n >= 10); // garder les notes >= 10
println!("{notes:?}");
}Deux façons d'accéder à un élément : notes[i] panique si l'indice n'existe pas, notes.get(i) renvoie un Option. Utilisez l'indice quand une absence serait un bug, get quand elle est un cas normal.
Pour parcourir un vecteur, trois formes, qui suivent les règles d'ownership du chapitre 3 :
fn main() {
let mut v = vec![1, 2, 3];
for x in &v { println!("{x}"); } // emprunt en lecture
for x in &mut v { *x *= 10; } // emprunt en écriture
for x in v { println!("{x}"); } // consomme v, inutilisable ensuite
}Le * dans *x *= 10 déréférence : x est une référence &mut i32, et on modifie l'entier vers lequel elle pointe.
String en pratiquefn main() {
let mut s = String::from("Bonjour");
s.push_str(", monde");
s.push('!');
let prenom = "Ada";
let message = format!("{s} Je suis {prenom}."); // comme println!, renvoie une String
println!("{message}");
println!("{}", message.to_uppercase());
println!("{}", message.contains("monde"));
println!("{}", message.replace("monde", "Rust"));
for mot in message.split_whitespace() {
print!("[{mot}] ");
}
println!();
}format! est la façon la plus lisible de construire une chaîne. Et rappel du chapitre 3 : une fonction qui lit du texte prend &str.
HashMap : le dictionnaireuse std::collections::HashMap;
fn main() {
let texte = "le chat et le chien et le poisson";
let mut compteur: HashMap<&str, u32> = HashMap::new();
for mot in texte.split_whitespace() {
*compteur.entry(mot).or_insert(0) += 1;
}
println!("{:?}", compteur.get("le")); // Some(3)
let mut tries: Vec<_> = compteur.iter().collect();
tries.sort_by(|a, b| b.1.cmp(a.1).then(a.0.cmp(b.0)));
for (mot, n) in tries {
println!("{mot} : {n}");
}
}L'API entry est l'idiome à retenir : elle cherche la clé, l'insère avec une valeur par défaut si elle manque, et renvoie une référence modifiable vers la valeur. Une seule recherche dans la table, au lieu de deux avec un if contains_key.
Une HashMap ne garde aucun ordre. Si vous en avez besoin, BTreeMap trie ses clés, au prix d'un accès un peu plus lent.
Rust sépare nettement :
| Sorte | Exemple | Mécanisme |
|---|---|---|
| Bug, état impossible | Indice hors limites, invariant violé | panic! : le programme s'arrête |
| Échec attendu | Fichier absent, saisie invalide, réseau coupé | Result<T, E> : une valeur à traiter |
Une panique n'est pas faite pour être rattrapée. Elle signale un bug. Tout ce qui peut raisonnablement échouer renvoie un Result.
ResultResult est un enum, cousin d'Option :
enum Result<T, E> {
Ok(T),
Err(E),
}fn main() {
let saisies = ["42", "abc", "-7"];
for s in saisies {
match s.parse::<i32>() {
Ok(n) => println!("{s} -> {n}"),
Err(e) => println!("{s} -> erreur : {e}"),
}
}
}42 -> 42
abc -> erreur : invalid digit found in string
-7 -> -7Comme avec Option, impossible d'utiliser le nombre sans avoir décidé quoi faire de l'erreur. Le type de retour de parse annonce l'échec possible ; un lecteur le voit sans lire la documentation.
?Écrire un match pour chaque opération qui peut échouer deviendrait vite illisible. L'opérateur ? fait la chose la plus courante : si c'est Ok, il extrait la valeur ; si c'est Err, il renvoie l'erreur à l'appelant immédiatement.
use std::fs;
use std::io;
fn lire_premiere_ligne(chemin: &str) -> Result<String, io::Error> {
let contenu = fs::read_to_string(chemin)?;
let ligne = contenu.lines().next().unwrap_or("").to_string();
Ok(ligne)
}
fn main() {
match lire_premiere_ligne("Cargo.toml") {
Ok(l) => println!("première ligne : {l}"),
Err(e) => println!("impossible de lire : {e}"),
}
}? ne fonctionne que dans une fonction qui renvoie elle-même un Result (ou un Option). main peut en renvoyer un :
use std::fs;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let contenu = fs::read_to_string("Cargo.toml")?;
let lignes = contenu.lines().count();
println!("{lignes} lignes");
Ok(())
}Box<dyn std::error::Error> signifie « n'importe quel type d'erreur ». Pratique pour un petit programme, on verra mieux plus bas.
Une bibliothèque doit dire précisément ce qui peut échouer. On décrit les cas avec un enum :
use std::fmt;
#[derive(Debug)]
enum ErreurCommande {
QuantiteNulle,
StockInsuffisant { demande: u32, disponible: u32 },
}
impl fmt::Display for ErreurCommande {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
ErreurCommande::QuantiteNulle => write!(f, "la quantité doit être positive"),
ErreurCommande::StockInsuffisant { demande, disponible } => {
write!(f, "stock insuffisant : {demande} demandés, {disponible} disponibles")
}
}
}
}
impl std::error::Error for ErreurCommande {}
fn commander(quantite: u32, stock: u32) -> Result<u32, ErreurCommande> {
if quantite == 0 {
return Err(ErreurCommande::QuantiteNulle);
}
if quantite > stock {
return Err(ErreurCommande::StockInsuffisant { demande: quantite, disponible: stock });
}
Ok(stock - quantite)
}
fn main() {
for q in [3, 0, 50] {
match commander(q, 10) {
Ok(reste) => println!("commande OK, reste {reste}"),
Err(e) => println!("refusée : {e}"),
}
}
}L'appelant peut faire un match sur les variantes et réagir différemment à chaque cas. C'est ce qu'une exception attrapée par type essaie de faire, avec la garantie en plus que tous les cas sont connus.
thiserror et anyhowÉcrire Display à la main devient vite fastidieux. Deux crates sont devenues des standards de fait :
| Crate | Pour | Idée |
|---|---|---|
thiserror | Les bibliothèques | Génère Display et Error à partir d'attributs |
anyhow | Les applications | Un type d'erreur unique, avec du contexte ajouté au fil des appels |
cargo add thiserror anyhowuse thiserror::Error;
#[derive(Debug, Error)]
enum ErreurCommande {
#[error("la quantité doit être positive")]
QuantiteNulle,
#[error("stock insuffisant : {demande} demandés, {disponible} disponibles")]
StockInsuffisant { demande: u32, disponible: u32 },
}Et côté application :
use anyhow::{Context, Result};
use std::fs;
fn charger_config(chemin: &str) -> Result<String> {
let contenu = fs::read_to_string(chemin)
.with_context(|| format!("lecture de la configuration {chemin}"))?;
Ok(contenu)
}
fn main() -> Result<()> {
let config = charger_config("config.toml")?;
println!("{config}");
Ok(())
}Si le fichier manque, le message affiché enchaîne les contextes :
Error: lecture de la configuration config.toml
Caused by:
No such file or directory (os error 2)Ma règle : thiserror dans le code qu'un autre programme appellera, anyhow dans main et autour.
unwrap et expectunwrap() extrait la valeur ou panique. expect("message") fait pareil avec un message lisible. Ils ont leur place dans les tests, les prototypes, et quand une erreur est réellement impossible :
let port: u16 = "8080".parse().expect("littéral valide");Partout ailleurs, un unwrap est une panique qui attend son heure. Cherchez-les avant chaque mise en production.
Écrivez moyenne(notes: &str) -> Result<f64, String> qui reçoit des notes séparées par des virgules ("12, 15, 9"), renvoie une erreur si une note n'est pas un nombre ou si la liste est vide, et la moyenne sinon.
fn moyenne(notes: &str) -> Result<f64, String> {
let mut valeurs = Vec::new();
for morceau in notes.split(',') {
let morceau = morceau.trim();
let n: f64 = morceau
.parse()
.map_err(|_| format!("« {morceau} » n'est pas une note"))?;
valeurs.push(n);
}
if valeurs.is_empty() {
return Err("aucune note".to_string());
}
Ok(valeurs.iter().sum::<f64>() / valeurs.len() as f64)
}
fn main() {
println!("{:?}", moyenne("12, 15, 9")); // Ok(12.0)
println!("{:?}", moyenne("12, quinze")); // Err("« quinze » n'est pas une note")
}map_err transforme l'erreur de parse en notre message, puis ? la renvoie. Remarquez que "" donne l'erreur « n'est pas une note » plutôt que « aucune note » : split renvoie un morceau vide. Le traiter proprement est un bon prolongement.
Vec, String et HashMap couvrent l'essentiel des besoins. Côté erreurs, le contrat est clair : panique pour les bugs, Result pour tout le reste, ? pour propager sans bruit. Le chapitre suivant présente les traits et les génériques, qui permettent d'écrire du code valable pour plusieurs types.