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 durée de vie (lifetime) est la portion du programme pendant laquelle une référence est valide. Le compilateur les calcule tout seul dans 95 % des cas. Le reste du temps, il vous demande d'expliciter le lien entre plusieurs références : c'est la syntaxe 'a qui effraie tant de débutants. La seconde moitié du chapitre présente les pointeurs intelligents, Box, Rc, RefCell et Arc, qui servent quand une seule propriétaire ne suffit pas.
Cette fonction renvoie la plus longue de deux chaînes :
fn plus_longue(a: &str, b: &str) -> &str {
if a.len() > b.len() { a } else { b }
}error[E0106]: missing lifetime specifier
|
| fn plus_longue(a: &str, b: &str) -> &str {
| ---- ---- ^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value, but the
signature does not say whether it is borrowed from `a` or `b`Le compilateur ne regarde pas le corps des fonctions pour vérifier les emprunts de l'appelant : il se fie à la signature. Or celle-ci ne dit pas si le résultat vient de a ou de b. Et c'est important : si b disparaît avant qu'on utilise le résultat, la référence serait invalide.
fn plus_longue<'a>(a: &'a str, b: &'a str) -> &'a str {
if a.len() > b.len() { a } else { b }
}
fn main() {
let s1 = String::from("une longue phrase");
let resultat;
{
let s2 = String::from("courte");
resultat = plus_longue(&s1, &s2);
println!("{resultat}"); // OK : s2 est encore vivante
}
// println!("{resultat}"); // erreur : s2 n'existe plus
}'a se lit « une certaine durée de vie a ». La signature dit : le résultat est valide tant que a et b le sont. Une annotation ne change rien à la durée de vie réelle des valeurs. Elle décrit une relation, que le compilateur vérifie à chaque appel.
Si le résultat ne dépend que du premier paramètre, on le dit, et l'appelant gagne en liberté :
fn premier_mot<'a>(texte: &'a str, _separateur: &str) -> &'a str {
texte.split(' ').next().unwrap_or("")
}Le compilateur applique trois règles d'élision qui couvrent la plupart des fonctions :
&self ou &mut self, la durée de vie de self est donnée au résultat.C'est pour ça que fn premier_mot(s: &str) -> &str du chapitre 3 n'avait besoin de rien : un seul paramètre, règle 2. Dans la pratique, on n'écrit 'a que pour les fonctions qui reçoivent plusieurs références et en renvoient une.
Une struct peut emprunter des données au lieu de les posséder. Il faut alors annoter :
struct Extrait<'a> {
texte: &'a str,
}
impl<'a> Extrait<'a> {
fn premiere_phrase(&self) -> &'a str {
self.texte.split('.').next().unwrap_or("")
}
}
fn main() {
let roman = String::from("Il était une fois. La suite plus tard.");
let e = Extrait { texte: &roman };
println!("{}", e.premiere_phrase());
}L'annotation garantit qu'un Extrait ne survit jamais au texte qu'il emprunte.
Un conseil de débutant : si une struct avec des durées de vie vous fait souffrir, faites-lui posséder ses données (String au lieu de &str). C'est un peu moins efficace et beaucoup plus simple. On optimisera quand un profil le justifiera.
'static'static désigne une référence valide pendant toute l'exécution du programme. Les littéraux de chaîne sont des &'static str : ils sont gravés dans l'exécutable.
fn salutation() -> &'static str {
"bonjour"
}Quand le compilateur suggère d'ajouter 'static pour corriger une erreur, méfiance : c'est rarement la bonne réponse. Le problème est presque toujours une référence qu'on essaie de garder trop longtemps.
Box<T> : une valeur sur le tasBox place une valeur sur le tas et en garde la propriété unique. Il sert surtout dans deux cas : les objets trait du chapitre 6 (Box<dyn Forme>), et les types récursifs, dont la taille serait sinon infinie.
#[derive(Debug)]
enum Arbre {
Feuille(i32),
Noeud(Box<Arbre>, Box<Arbre>),
}
fn somme(a: &Arbre) -> i32 {
match a {
Arbre::Feuille(v) => *v,
Arbre::Noeud(g, d) => somme(g) + somme(d),
}
}
fn main() {
let arbre = Arbre::Noeud(
Box::new(Arbre::Feuille(1)),
Box::new(Arbre::Noeud(Box::new(Arbre::Feuille(2)), Box::new(Arbre::Feuille(3)))),
);
println!("{}", somme(&arbre)); // 6
}Sans Box, un Arbre contiendrait un Arbre qui contient un Arbre… Le compilateur ne pourrait pas calculer sa taille. Un Box a toujours la taille d'un pointeur.
Rc<T> : plusieurs propriétairesParfois une donnée a vraiment plusieurs propriétaires. Une configuration partagée par plusieurs composants, un nœud de graphe pointé par plusieurs autres. Rc (reference counted) compte les propriétaires et libère la valeur quand le dernier disparaît.
use std::rc::Rc;
struct Config {
nom_app: String,
}
struct Service {
config: Rc<Config>,
}
fn main() {
let config = Rc::new(Config { nom_app: "iducation".into() });
let api = Service { config: Rc::clone(&config) };
let worker = Service { config: Rc::clone(&config) };
println!("{} propriétaires", Rc::strong_count(&config)); // 3
println!("{} / {}", api.config.nom_app, worker.config.nom_app);
}Rc::clone ne copie pas la configuration : il incrémente un compteur. Par convention, on écrit Rc::clone(&x) plutôt que x.clone() pour que le lecteur voie que c'est bon marché.
Rc ne donne qu'un accès en lecture. Et il ne fonctionne que dans un seul thread : pour plusieurs threads, on utilise Arc, au chapitre 10.
RefCell<T> : modifier à travers une référence partagéeLa règle « plusieurs lecteurs ou un seul écrivain » est vérifiée à la compilation. RefCell la déplace à l'exécution : on peut emprunter en écriture à travers une référence partagée, et si la règle est violée, le programme panique.
use std::cell::RefCell;
use std::rc::Rc;
fn main() {
let historique = Rc::new(RefCell::new(Vec::<String>::new()));
let journal = Rc::clone(&historique);
journal.borrow_mut().push("connexion".into());
journal.borrow_mut().push("lecture".into());
println!("{:?}", historique.borrow()); // ["connexion", "lecture"]
}Rc<RefCell<T>> est la combinaison classique pour une donnée partagée et modifiable dans un seul thread. Si vous en mettez partout, c'est souvent le signe que la conception lutte contre l'ownership : demandez-vous si un seul propriétaire, qui prête aux autres, ne suffirait pas.
| Type | Propriétaires | Modification | Threads | Usage |
|---|---|---|---|---|
Box<T> | Un | Oui, s'il est mut | Oui | Types récursifs, dyn Trait |
Rc<T> | Plusieurs | Non | Non | Partage en lecture |
RefCell<T> | Un | Oui, vérifié à l'exécution | Non | Mutation intérieure |
Arc<T> | Plusieurs | Non | Oui | Partage entre threads |
Mutex<T> | Un | Oui, avec verrou | Oui | Mutation entre threads |
Une liste de tâches partagée entre deux « vues » : chacune peut ajouter une tâche, et les deux voient le même contenu. Utilisez Rc<RefCell<Vec<String>>>.
use std::cell::RefCell;
use std::rc::Rc;
struct Vue {
nom: String,
taches: Rc<RefCell<Vec<String>>>,
}
impl Vue {
fn ajouter(&self, tache: &str) {
self.taches.borrow_mut().push(format!("{} ({})", tache, self.nom));
}
}
fn main() {
let taches = Rc::new(RefCell::new(Vec::new()));
let mobile = Vue { nom: "mobile".into(), taches: Rc::clone(&taches) };
let web = Vue { nom: "web".into(), taches: Rc::clone(&taches) };
mobile.ajouter("acheter du pain");
web.ajouter("répondre aux mails");
for t in taches.borrow().iter() {
println!("- {t}");
}
}Remarquez que ajouter prend &self et pas &mut self : c'est la mutation intérieure permise par RefCell.
Les durées de vie ne sont qu'une façon d'écrire dans la signature ce que le compilateur vérifie déjà : aucune référence ne survit à sa donnée. Les pointeurs intelligents couvrent les cas où un propriétaire unique ne suffit pas. Le chapitre suivant revient à du code plus léger, et plus élégant : les closures et les itérateurs.