Sûreté mémoire avec l’ownership et l’emprunt de Rust

Sûreté mémoire avec l'ownership et l'emprunt de Rust

Après des années à courir après des pointeurs pendouillants et des double-frees en C, Rust m’a donné l’impression que quelqu’un avait enfin écrit les règles que je gardais en tête et les avait fait appliquer par le compilateur. Rust offre un contrôle manuel de la mémoire sans ramasse-miettes, et pourtant il empêche statiquement toute la classe de bugs mémoire qui ronge le C. Le mécanisme est l’ownership et l’emprunt, et c’est plus simple que sa réputation ne le laisse croire.

Les trois règles de l’ownership

Tout en Rust part de trois règles que le compilateur applique :

  • Chaque valeur a exactement un propriétaire, une seule variable responsable d’elle.
  • Il ne peut y avoir qu’un propriétaire à la fois.
  • Quand le propriétaire sort de portée, la valeur est détruite et sa mémoire libérée.

Cette dernière règle est le génie discret. Il n’y a aucun free à appeler ni ramasse-miettes à exécuter. Le compilateur sait exactement où finit la portée de chaque valeur et insère le nettoyage pour vous. C’est la même durée de vie liée à la portée que celle de la pile, décrite dans pile contre tas : comment la mémoire fonctionne vraiment, sauf que Rust l’étend aussi aux données du tas.

Des déplacements, pas des copies

Parce qu’il n’y a qu’un propriétaire, affecter une valeur de tas à une autre variable déplace l’ownership au lieu de copier les données. L’ancienne variable devient invalide, et le compilateur rejettera toute utilisation de celle-ci.

let s1 = String::from("bonjour");
let s2 = s1;            // l'ownership passe de s1 à s2
// println!("{}", s1);  // erreur de compilation : s1 a été déplacée
println!("{}", s2);     // correct, s2 possède les données maintenant

Cette seule règle élimine le double-free à la compilation. En C, deux pointeurs vers le même bloc de tas pensent chacun devoir le libérer. En Rust, une seule variable possède les données, donc elles sont libérées exactement une fois. Toute la catégorie de bug disparaît avant même que le programme tourne.

Emprunter au lieu de déplacer

Déplacer partout serait pénible, donc Rust vous laisse emprunter une valeur en prenant une référence. Une référence est un pointeur qui ne possède pas ce vers quoi il pointe. Le vérificateur d’emprunt applique un jeu de règles supplémentaire pour garder les références sûres :

  • Vous pouvez avoir autant de références immuables que vous voulez en même temps.
  • Ou exactement une référence mutable.
  • Mais jamais les deux à la fois.
fn main() {
    let mut data = vec![1, 2, 3];
    let r1 = &data;        // emprunt immuable
    let r2 = &data;        // un autre, correct
    println!("{} {}", r1[0], r2[0]);

    let m = &mut data;     // emprunt mutable, autorisé maintenant que r1/r2 sont finis
    m.push(4);
}

Cette règle « un écrivain ou plusieurs lecteurs » est ce qui empêche les courses de données et le use-after-free. Vous ne pouvez pas tenir une référence dans un vecteur pendant qu’un autre bout de code le réorganise, car cela exigerait un emprunt mutable et un emprunt immuable simultanés. Le compilateur refuse de le construire.

Les durées de vie rendent le pendouillant impossible

Le vérificateur d’emprunt suit aussi combien de temps vit chaque référence et garantit qu’une référence ne survit jamais aux données qu’elle pointe. Le classique pointeur pendouillant du C, retourner une référence vers une locale, ne compile tout simplement pas.

fn dangle() -> &String {
    let s = String::from("oups");
    &s    // erreur : s est détruite ici, la référence pendouillerait
}

Si vous avez lu comment fonctionnent vraiment les pointeurs, vous savez que c’est exactement le bug qui produit la corruption silencieuse en C. Rust le transforme en erreur de compilation avec un message clair.

Ce qu’on abandonne et ce qu’on gagne

Le coût est réel : le vérificateur d’emprunt rejette des programmes qui seraient en fait corrects, et vous passez du temps à restructurer le code pour le satisfaire. Cette courbe d’apprentissage est la fameuse phase de « lutte contre le borrow checker ». Ce que vous gagnez en retour, c’est une performance et un contrôle de niveau C sans aucune insécurité mémoire, vérifiés avant que le programme tourne. Après avoir vécu dans les deux mondes, je pense que le compromis vaut la peine pour tout ce où la correction compte. Rust n’a pas inventé ces règles. Il a juste fait du compilateur celui qui s’en souvient, pour que je n’aie pas à le faire.

Commentaires

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *