Un programme en C qui libère un buffer puis continue à le lire compile quand même. Il corrompt ensuite la mémoire, parfois immédiatement, parfois trois requêtes plus tard, dans une partie du code qui n’a jamais touché ce buffer. Rust rejette ce programme dès le cargo build, sans aucun garbage collector. Le compilateur garde la trace de qui possède chaque valeur et qui peut y accéder. Ce suivi, c’est l’ownership et le borrowing.

Quelles sont les règles d’ownership de Rust ?

Le modèle d’ownership de Rust se résume à trois règles :

  1. Chaque valeur a exactement un owner.
  2. Il ne peut y avoir qu’un seul owner à la fois.
  3. Quand l’owner sort de son scope, la valeur est droppée.

« Droppée » signifie que son destructeur s’exécute et que sa mémoire est libérée immédiatement, pas quand un garbage collector trouve le temps de s’en occuper. C’est le marché que propose Rust : aucun runtime ne décide quand récupérer la mémoire, en échange de règles que le compilateur applique à chaque ligne que vous écrivez.

La règle 2 est celle qui déroute le plus au début. Affectez une String à une seconde variable et la première cesse de fonctionner :

1
2
3
4
5
6
fn main() {
    let s1 = String::from("hello");
    let s2 = s1;

    println!("{s1}, world!");
}
1
2
3
4
5
6
7
8
9
error[E0382]: borrow of moved value: `s1`
 --> src/main.rs:5:16
  |
2 |     let s1 = String::from("hello");
  |         -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
3 |     let s2 = s1;
  |              -- value moved here
5 |     println!("{s1}, world!");
  |                ^^ value borrowed here after move

let s2 = s1 ne copie pas les octets de la chaîne. String contient un pointeur vers un buffer sur le heap, une longueur et une capacité. Copier cette struct bit à bit laisserait deux owners pointer vers le même buffer, et quand les deux sortiraient de leur scope, le buffer serait libéré deux fois. Rust évite cela en transformant l’affectation en move : s2 prend possession de la valeur, et s1 n’est plus valide. Passer une String à une fonction fait la même chose : la fonction en prend possession, et la variable que vous avez passée devient invalide chez l’appelant.

Pourquoi les entiers ne sont-ils pas déplacés en Rust ?

Les entiers n’ont pas ce problème :

1
2
3
4
5
6
fn main() {
    let x = 5;
    let y = x;

    println!("{x} and {y}");
}

Ce code compile. i32 implémente le trait Copy, donc l’affectation duplique la valeur au lieu de la déplacer. Il n’y a rien sur le heap à libérer deux fois, seulement quatre octets sur la stack. Chaque type entier, bool, chaque type à virgule flottante, char, et les tuples composés uniquement de types Copy ((i32, bool), pas (i32, String)) se comportent de la même façon. Un type ne peut implémenter Copy que si aucune de ses parties n’implémente Drop — la façon dont Rust dit : si le nettoyage compte, la duplication doit être explicite.

Quand vous voulez une seconde copie indépendante d’une String ou d’un Vec, vous le dites avec .clone(). C’est une allocation sur le heap en plus, et c’est précisément pour ça que Rust ne le fait pas silencieusement à chaque affectation.

Comment fonctionne le borrowing en Rust ?

Si chaque fonction qui a besoin de lire une valeur devait en prendre possession, vous finiriez par renvoyer des valeurs de chaque fonction dans un tuple juste pour continuer à les utiliser. Le borrowing élimine ce problème : prenez une référence avec &, utilisez la valeur, et l’ownership ne change jamais de main.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
fn main() {
    let s1 = String::from("hello");
    let len = calculate_length(&s1);

    println!("The length of '{s1}' is {len}.");
}

fn calculate_length(s: &String) -> usize {
    s.len()
}

s1 reste valide après l’appel, car calculate_length n’a fait qu’un borrow. Le type du paramètre &String est une référence : elle pointe vers les données de s1 sans les posséder, donc il n’y a rien à dropper quand le borrow se termine.

Deux règles rendent cela sûr :

  • À un instant donné, vous pouvez avoir soit une seule référence mutable à une valeur, soit un nombre quelconque de références immuables, jamais les deux.
  • Une référence ne peut jamais vivre plus longtemps que la donnée qu’elle désigne.

Enfreignez la première et le compilateur vous arrête :

1
2
3
4
5
6
7
8
9
fn main() {
    let mut s = String::from("hello");

    let r1 = &s;
    let r2 = &s;
    let r3 = &mut s;

    println!("{r1}, {r2}, and {r3}");
}
1
2
3
4
5
6
7
8
9
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
 --> src/main.rs:6:14
  |
4 |     let r1 = &s;
  |              -- immutable borrow occurs here
6 |     let r3 = &mut s;
  |              ^^^^^^ mutable borrow occurs here
8 |     println!("{r1}, {r2}, and {r3}");
  |                -- immutable borrow later used here

Deux références mutables à la même valeur en même temps sont rejetées aussi (error[E0499]: cannot borrow `s` as mutable more than once at a time). Aucune des deux erreurs n’est arbitraire. Une référence immuable est une promesse faite au reste du programme que la valeur ne sera pas modifiée tant qu’elle existe, et une référence mutable a besoin d’exclusivité pour tenir cette promesse. Le bug classique de thread-safety en C ou en Go, où deux morceaux de code écrivent dans la même mémoire pendant qu’un troisième la lit, est exactement ce que ces règles rendent impossible à compiler.

Cela ne veut pas dire que Rust attrape chaque race condition que vous pourriez écrire. Le Rust sûr peut toujours se bloquer sur un mutex ou se tromper dans l’ordre des opérations, et les blocs unsafe peuvent contourner les vérifications entièrement. Ce que le borrow checker élimine, c’est une forme spécifique et très courante du bug : la mémoire mutable aliasée, détectée avant que le programme ne s’exécute.

Que signifient les annotations de lifetime comme ‘a ?

Chaque référence a un lifetime : le scope dans lequel elle est valide. En général, le compilateur le déduit seul. Il a besoin de votre aide quand une fonction renvoie une référence et que plusieurs références en entrée sont en jeu, car il n’a aucun moyen de savoir à quelle entrée la sortie est liée.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {result}");
}

'a est un paramètre de lifetime, qui se prononce « tick-a ». C’est un nom, pas une durée. Écrire &'a str sur les deux paramètres et sur le type de retour indique au compilateur : la référence que je renvoie vit au maximum aussi longtemps que la plus courte de x et y. L’annotation ne change pas le lifetime de quoi que ce soit. Elle décrit une relation que le compilateur doit déjà vérifier, ce qui lui permet de rejeter un appel où la référence renvoyée vivrait plus longtemps que l’une de ses entrées.

Vous n’écrivez pas 'a partout, parce que Rust l’élide dans les cas courants. Une fonction avec exactement un paramètre de référence affecte automatiquement ce lifetime à son type de retour, et une méthode prenant &self affecte le lifetime de self au type de retour. C’est pourquoi fn first_word(s: &str) -> &str compile sans aucune annotation : une entrée, une sortie, rien d’ambigu. longest prend deux paramètres de référence indépendants et aucun self, donc l’élision n’a rien à quoi s’accrocher, et vous écrivez le lifetime à la main.

Que faire quand le borrow checker s’oppose à votre design

Les plaintes du borrow checker semblent hostiles les premières semaines. Et parfois, la version directe d’une structure de données ne colle pas du tout au modèle un-owner-à-la-fois : un arbre avec des pointeurs vers le parent, ou un cache que deux sous-systèmes doivent tous les deux modifier. Les solutions pragmatiques :

  • .clone() la valeur plutôt que de lutter contre un borrow. Cela coûte une allocation, et c’est une correction d’une ligne que vous pourrez optimiser plus tard, une fois que le reste du code fonctionne.
  • Rc<T> (ou Arc<T> entre threads) pour un ownership partagé, quand plusieurs parties du programme ont légitimement besoin de garder une valeur en vie.
  • RefCell<T> (ou Mutex<T> entre threads) quand vous devez modifier quelque chose via une référence partagée. Cela déplace la vérification du borrow de la compilation vers l’exécution, échangeant une erreur du compilateur contre un panic si vous vous trompez.

Il n’y a aucune honte à recourir à l’une de ces solutions. Elles indiquent aussi pourquoi ce modèle ne convient pas à tous les projets. Un script jetable qui lit un fichier et affiche un rapport ne gagne rien à suivre l’ownership au byte près, et un langage avec garbage collector et des goroutines vous permet d’arriver à un programme concurrent fonctionnel plus vite si la sécurité mémoire à la compilation n’est pas une exigence. Rust devient rentable quand un use-after-free ou une data race en production est l’échec que vous ne pouvez pas vous permettre, et que vous préférez en payer le prix en amont, avec des erreurs de compilation.

La prochaine fois que rustc rejette votre code avec E0382 ou E0502, lisez l’erreur avant de recourir à .clone() par réflexe. Elle nomme les deux borrows en conflit et où chacun commence, et corriger le problème de scope qu’elle signale demande en général moins de code que de le camoufler. Ces messages sont la façon la plus rapide d’apprendre le modèle ; ils sont plus précis que la plupart des explications à son sujet, celle-ci incluse.

Articles associés