Références et emprunt#

Le chapitre précédent a introduit le système de possession : chaque valeur a un unique propriétaire, et la valeur est libérée lorsque ce propriétaire sort de la portée. Ce modèle est rigoureux mais contraignant : il est fréquent de vouloir accéder à une valeur sans en prendre la possession. C’est précisément le role des références, qui permettent d”emprunter (borrow) une valeur.

Références partagées &T#

Définition 33 (Référence partagée)

Une référence partagée (shared reference) de type &T permet de lire une valeur de type T sans en prendre la possession. Emprunter une valeur via & ne déplace ni ne copie la donnée : la référence est un pointeur vers la valeur originale. Plusieurs références partagées vers la meme valeur peuvent coexister simultanément.

L’opérateur & crée une référence, et l’opérateur * la déréférence pour accéder à la valeur sous-jacente. Dans de nombreux contextes, Rust effectue le déréférencement automatiquement.

Exemple 29 (Emprunter sans prendre la possession)

Voir le code ci-dessous.

fn calculer_longueur(s: &String) -> usize {
    s.len()  // déréférencement automatique
}

let mot = String::from("emprunt");
let longueur = calculer_longueur(&mot);

// `mot` est toujours utilisable : la possession n'a pas été transférée
println!("Le mot «{}» contient {} octets.", mot, longueur);
Le mot «emprunt» contient 7 octets.

Sans la référence, passer mot à une fonction en transférerait la possession, rendant la variable inutilisable par la suite. La référence partagée résout ce problème : la fonction emprunte la valeur le temps de son exécution, puis l’emprunt prend fin.

fn afficher_deux(a: &String, b: &String) {
    println!("{} et {}", a, b);
}

let x = String::from("alpha");
let y = String::from("beta");

// Plusieurs références partagées simultanées : aucun problème
afficher_deux(&x, &y);
println!("x = {}, y = {}", x, y);
alpha et beta
x = alpha, y = beta

Remarque 22

Une référence partagée &T ne permet pas de modifier la valeur empruntée. Toute tentative de modification à travers une &T est rejetée à la compilation. C’est cette garantie d’immutabilité qui autorise l’existence simultanée de plusieurs &T.

Références mutables &mut T#

Définition 34 (Référence mutable)

Une référence mutable (mutable reference) de type &mut T permet d’emprunter une valeur de type T avec le droit de la modifier. Pour créer une référence mutable, la variable empruntée doit elle-meme etre déclarée mut.

Exemple 30 (Modification à travers une référence mutable)

Voir le code ci-dessous.

fn ajouter_suffixe(s: &mut String) {
    s.push_str("_modifié");
}

let mut texte = String::from("original");
ajouter_suffixe(&mut texte);

println!("{}", texte);
original_modifié

La référence mutable donne un acces exclusif à la valeur : tant qu’elle existe, aucune autre référence (partagée ou mutable) ne peut coexister vers la meme donnée. Cette exclusivité est au coeur du modèle de securité mémoire de Rust.

Règles de l’emprunt#

Proposition 5 (Règles fondamentales de l’emprunt)

A tout instant, pour une valeur donnée, le compilateur impose l’une des deux situations suivantes, et jamais les deux :

  1. Plusieurs références partagées &T peuvent coexister.

  2. Exactement une référence mutable &mut T peut exister, et aucune référence partagée ne doit etre active en meme temps.

De plus, toute référence doit toujours pointer vers une valeur valide : le compilateur garantit qu’une référence ne survit jamais à la donnée qu’elle emprunte.

Ces deux règles assurent l’absence de courses aux données (data races) à la compilation, sans recourir à un ramasse-miettes ni à des vérifications à l’exécution.

Plusieurs &T simultanées#

{
let valeur = 42;

let r1 = &valeur;
let r2 = &valeur;
let r3 = &valeur;

println!("r1 = {}, r2 = {}, r3 = {}", r1, r2, r3);
}
r1 = 42, r2 = 42, r3 = 42
()

Trois références partagées coexistent sans conflit : aucune ne modifie la valeur.

Exclusivité de &mut T#

Tenter de créer une seconde référence mutable vers la meme valeur est interdit.

let mut s = String::from("bonjour");

let r1 = &mut s;
let r2 = &mut s; // erreur : second emprunt mutable

println!("{}, {}", r1, r2);
// error[E0499]: cannot borrow `s` as mutable more than once at a time

De meme, combiner une référence partagée et une référence mutable est interdit tant que la référence partagée est encore utilisée.

let mut s = String::from("bonjour");

let r1 = &s;       // emprunt partagé
let r2 = &mut s;   // erreur : emprunt mutable alors qu'un emprunt partagé est actif

println!("{}, {}", r1, r2);
// error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable

Portées non lexicales des emprunts (NLL)#

Remarque 23

Depuis l’édition 2018, le compilateur Rust utilise les Non-Lexical Lifetimes (NLL). La portée d’un emprunt ne s’étend pas jusqu’à la fin du bloc, mais seulement jusqu’à sa dernière utilisation. Cela permet des schémas qui seraient autrement rejetés.

{
let mut s = String::from("bonjour");

let r1 = &s;
let r2 = &s;
println!("{} et {}", r1, r2);
// r1 et r2 ne sont plus utilisés après cette ligne

let r3 = &mut s;  // autorisé : les emprunts partagés sont terminés
r3.push_str(" monde");
println!("{}", r3);
}
bonjour et bonjour
bonjour monde
()

Le vérificateur d’emprunt (borrow checker)#

Le vérificateur d’emprunt est le composant du compilateur qui applique les règles de l’emprunt. Il analyse statiquement le code pour s’assurer qu’aucune référence n’est utilisée de manière invalide. Voici plusieurs scénarios illustrant ce qui compile et ce qui ne compile pas.

Ce qui compile#

Exemple 31 (Emprunts séquentiels)

On peut créer des emprunts mutables successifs, pourvu qu’ils ne se chevauchent pas.

let mut v = vec![1, 2, 3];

{
    let r = &mut v;
    r.push(4);
}  // l'emprunt mutable se termine ici

println!("{:?}", v);  // emprunt partagé, autorisé car le mutable est terminé
[1, 2, 3, 4]

Ce qui ne compile pas#

Exemple 32 (Utilisation après déplacement dans une boucle)

Voir le code ci-dessous.

let mut v = vec![1, 2, 3];
let r = &v;

v.push(4);  // emprunt mutable implicite via push, mais r est encore actif

println!("r = {:?}", r);
// error[E0596]: cannot borrow `v` as mutable because it is also borrowed as immutable

L’appel v.push(4) nécessite un emprunt mutable de v, mais la référence partagée r est encore utilisée à la ligne suivante. Le vérificateur d’emprunt rejette le code.

Le principe aliasing XOR mutation#

Définition 35 (Aliasing XOR mutation)

Le principe aliasing XOR mutation stipule qu’à tout instant, une donnée peut etre soit aliasée (accessible par plusieurs références), soit mutable (modifiable par une unique référence), mais jamais les deux simultanément. Ce principe, noté parfois \(\text{alias} \oplus \text{mutation}\), est le fondement théorique des règles d’emprunt de Rust.

Ce principe résout une classe entière de bogues courants dans les langages à pointeurs :

  • Courses aux données (data races) : deux accès concurrents dont au moins un est une écriture.

  • Invalidation d’itérateurs : modifier une collection pendant qu’on l’itère.

  • Use-after-free : utiliser un pointeur vers une zone mémoire libérée.

Exemple 33 (Invalidation d’itérateur empechée)

Voir le code ci-dessous.

let mut nombres = vec![1, 2, 3, 4, 5];

for n in &nombres {
    if *n == 3 {
        nombres.push(6);  // modification pendant l'itération : interdit
    }
}
// error[E0502]: cannot borrow `nombres` as mutable because it is also borrowed as immutable

En C++, ce type de code compilerait mais produirait un comportement indéfini. En Rust, le vérificateur d’emprunt le rejette statiquement.

Tranches (slices)#

Définition 36 (Tranche)

Une tranche (slice) est une référence vers une sous-séquence contigue d’éléments. Elle ne possède pas les données : c’est un emprunt. Structurellement, une tranche est un pointeur gras (fat pointer) composé d’un pointeur vers le premier élément et d’une longueur. Les deux types de tranches les plus courants sont &[T] (tranche de tableau) et &str (tranche de chaine).

Tranches de tableaux &[T]#

{
let tableau = [10, 20, 30, 40, 50];

let debut = &tableau[..2];   // [10, 20]
let milieu = &tableau[1..4]; // [20, 30, 40]
let fin = &tableau[3..];     // [40, 50]
let tout = &tableau[..];     // [10, 20, 30, 40, 50]

println!("début  = {:?}", debut);
println!("milieu = {:?}", milieu);
println!("fin    = {:?}", fin);
println!("tout   = {:?}", tout);
}
début  = [10, 20]
milieu = [20, 30, 40]
fin    = [40, 50]
tout   = [10, 20, 30, 40, 50]
()

Les tranches sont le type idiomatique pour les paramètres de fonctions qui lisent une séquence sans en revendiquer la possession.

Exemple 34 (Fonction acceptant une tranche)

Voir le code ci-dessous.

{
fn somme(valeurs: &[i32]) -> i32 {
    let mut total = 0;
    for v in valeurs {
        total += v;
    }
    total
}

let tableau = [1, 2, 3, 4, 5];
let vecteur = vec![10, 20, 30];

println!("somme tableau = {}", somme(&tableau));
println!("somme vecteur = {}", somme(&vecteur));
println!("somme partielle = {}", somme(&tableau[1..4]));
}
somme tableau = 15
somme vecteur = 60
somme partielle = 9
()

Tranches de chaines &str#

Le type &str est une tranche vers des données encodées en UTF-8. Les littéraux de chaine sont de type &str avec une durée de vie statique. On peut aussi obtenir une &str à partir d’une String.

{
let s = String::from("Bonjour le monde");

let mot1: &str = &s[0..7];
let mot2: &str = &s[8..10];
let mot3: &str = &s[11..16];

println!("{} {} {}", mot1, mot2, mot3);
}
Bonjour le monde
()

Remarque 24

L’indexation d’une String par plage opère sur les octets, pas sur les caractères. Si l’indice tombe au milieu d’un caractère multi-octets, le programme panique à l’exécution. Pour un découpage sur de l’UTF-8 arbitraire, il convient d’utiliser char_indices() ou une bibliothèque dédiée.

Tranches mutables &mut [T]#

Les tranches peuvent aussi etre mutables, permettant de modifier les éléments en place sans posséder la collection.

fn doubler(valeurs: &mut [i32]) {
    for v in valeurs.iter_mut() {
        *v *= 2;
    }
}

let mut donnees = [1, 2, 3, 4, 5];
doubler(&mut donnees[1..4]);

println!("{:?}", donnees);  // [1, 4, 6, 8, 5]
[1, 4, 6, 8, 5]

Références pendantes (dangling references)#

Définition 37 (Référence pendante)

Une référence pendante (dangling reference) est une référence qui pointe vers une zone mémoire qui a été libérée ou qui n’est plus valide. Dans les langages comme C ou C++, les références pendantes sont une source majeure de bogues et de failles de securité. Rust les interdit à la compilation.

Exemple 35 (Tentative de création d’une référence pendante)

Voir le code ci-dessous.

fn creer_reference() -> &String {
    let s = String::from("temporaire");
    &s  // erreur : s sera libérée à la fin de la fonction
}
// error[E0106]: missing lifetime specifier / error[E0515]: cannot return reference to local variable `s`

La variable s est possédée par la fonction creer_reference. A la fin de la fonction, s est libérée. Retourner &s produirait une référence vers une zone mémoire invalide. Le compilateur rejette ce code avec une erreur de durée de vie (lifetime).

La solution est de transférer la possession plutot que de retourner une référence.

fn creer_string() -> String {
    let s = String::from("valide");
    s  // la possession est transférée à l'appelant
}

let resultat = creer_string();
println!("{}", resultat);
valide

Remarque 25

L’interdiction des références pendantes est rendue possible par le système de durées de vie (lifetimes), qui sera approfondi au chapitre suivant. Le vérificateur d’emprunt s’assure que toute référence &T ou &mut T reste valide pendant toute la durée de son utilisation, c’est-à-dire que la donnée empruntée n’est pas libérée avant la fin de l’emprunt.

Résumé#

Les références constituent le mécanisme central par lequel Rust concilie performance et securité mémoire. En imposant les règles d’emprunt à la compilation, le vérificateur d’emprunt élimine des catégories entières de bogues — courses aux données, use-after-free, invalidation d’itérateurs — sans cout à l’exécution. Le principe aliasing XOR mutation en est la clé de voute : à tout instant, une donnée est soit lisible par plusieurs observateurs, soit modifiable par un unique acteur, mais jamais les deux.