Emprunt et références

Motivation

La notion de propriété est puissante, mais elle a ses limites. En-dehors du cas particulier des types Copy, quand on passe une valeur en paramètre à une fonction, on ne peut plus utiliser cette valeur par la suite. Ce code ne compile donc pas :

#![allow(unused)]
fn main() {
struct Wrapper(u32);

fn fonction(x: Wrapper) {
    println!("J'ai reçu {} en paramètre", x.0);
}

let x = Wrapper(42);
fonction(x);

// Erreur : x n'est plus accessible
println!("Je voudrais encore utiliser {}", x.0);
}

Bien sûr, il y a des contournements, comme copier la valeur avant…

#![allow(unused)]
fn main() {
#[derive(Clone)]
struct Wrapper(u32);

fn fonction(x: Wrapper) {
    println!("J'ai reçu {} en paramètre", x.0);
}

let x = Wrapper(42);
let x2 = x.clone();
fonction(x);

println!("J'ai gardé une copie de {}", x2.0);
}

…ou modifier la fonction pour qu’elle retourne la valeur après utilisation…

#![allow(unused)]
fn main() {
struct Wrapper(u32);

fn fonction(x: Wrapper) -> Wrapper {
    println!("J'ai reçu {} en paramètre", x.0);
    x
}

let mut x = Wrapper(42);
x = fonction(x);

println!("J'ai récupéré l'accès à {}", x.0);
}

…mais on sent bien que tout ceci n’est pas brillant au niveau ergonomique, et aussi qu’on entre dans une zone dangereuse au niveau des performances, où on est très dépendant du compilateur pour éliminer toutes les copies liées aux transferts de propriété.

Il y a donc besoin d’une alternative plus légère au transfert de propriété, et cette alternative c’est l’emprunt (borrow) de références.

Conception

Une référence en Rust, c’est un peu comme un pointeur ou une référence en C++, mais avec des garanties de validité que n’ont ni les pointeurs, ni les références en C++.

Quand on utilise des pointeurs ou des références en C++, on doit faire preuve d’une vigilance constante pour ne pas créer un pointeur invalide et déclencher du comportement indéfini. Il existe de trop nombreuses manières de le faire accidentellement, qui vont de l’indémodable fonction qui retourne une référence vers une variable locale…

int& locale() {
    int x = 42;
    return x;
}

…à la boucle qui insère des éléments dans le conteneur sur lequel elle est en train d’itérer, ce qui peut invalider l’itérateur de la boucle en cas de réallocation du stockage sous-jacent :

std::vector v { 1, 2, 3, 4 };
for (auto& x : v) {
    v.push_back(2 * x);
}

Et l’utilisation de mémoire en parallèle depuis plusieurs threads est bien sûr une véritable usine à comportement indéfini en C++, puisqu’il est très facile d’écrire du code qui a l’air parfaitement raisonnable quand on étudie le code de chaque thread isolément, mais qui explose en vol dès qu’on considère les interactions entre threads et les problèmes de cohérence mémoire liés aux accès non synchronisés (il y a une bonne raison pour laquelle c’est un comportement indéfini).

Les langages fonctionnels ont depuis longtemps tiré la sonette d’alarme, et défendent que l’on pourrait éliminer beaucoup de ces problèmes (à l’exception de celui de la portée des variables, qui est généralement résolu avec un ramasse-miettes) en s’interdisant de modifier le contenu des variables après création.

Rust, quand à lui, est bâti sur l’idée qu’il n’est pas nécessaire d’interdire totalement la mutation, et qu’on peut préserver toutes les bonnes propriétés des langages fonctionnels (intelligibilité, absence de comportement indéfini…) avec une plus grande puissance expressive en adoptant un modèle mémoire un peu plus laxe où à chaque instant, soit une variable est accessible en écriture, soit elle est accessible depuis plusieurs points du code, mais jamais les deux en même temps.

Il s’avère que quand on couple cette simple contrainte à une analyse statique de la portée des variables, on peut éliminer tous les comportements indéfinis liés aux pointeurs et références en C++.

Et l’adoption de ce modèle mémoire simplifié permet aussi au compilateur de prouver trivialement l’absence d’aliasing mutable, ce qui veut dire concrètement qu’il n’y a pas besoin d’un mot-clé restrict et d’une preuve manuelle de son bon emploi par l’auteur du code pour avoir de bonnes performances de calcul quand on manipule des données par référence en Rust.

Utilisation

Bases

On crée une référence partagée avec le symbole &, comme on construit un pointeur en C. En Rust, on parle d’emprunt (borrow) pour désigner cette opération.

#![allow(unused)]
fn main() {
let x = 42;
let y = &x;
}

Le type d’une référence partagée est &TT est le type de la donnée à laquelle on fait référence. Une référence ne peut pas être nulle, si on a besoin de nullabilité on utilise Option<&T>.

On accède à la valeur située derrière la référence avec le symbole *, comme on déréférence un pointeur en C. Si cette valeur est Copy, on peut en créer une copie comme ça :

#![allow(unused)]
fn main() {
let y = &42;
let z = *y;
}

En revanche, si le type n’est pas Copy on ne peut pas déplacer la valeur située derrière la référence en la subtilisant à son propriétaire. Emprunter c’est emprunter, emprunter sans rendre c’est voler.

#![allow(unused)]
fn main() {
struct Wrapper(u32);

let x = Wrapper(42);
let y = &x;

// Erreur : On ne peut pas utiliser y pour "voler" la valeur de x
let z = *y;
}

Une référence partagée donne un accès presque équivalent à la déclaration d’une variable avec let. Donc hors cas particulier des types à mutabilité interne, on ne peut pas non plus modifier une variable via une référence partagée en Rust, indépendamment de la mutabilité de ladite variable. C’est similaire aux références const en C++.

#![allow(unused)]
fn main() {
let mut x = 42;
let y = &x;
*y = 24;  // Erreur : y ne donne pas un accès en écriture à x
}

Et les références ne peuvent pas être utilisées en-dehors de la portée de la variable sur laquelle elles pointent, donc ce genre de code maladroit qu’on a évoqué au début ne compile pas.

#![allow(unused)]
fn main() {
// Erreur : Tentative de construction d'une référence invalide
let invalide = {
    let y = 42;
    &y
};
}

De même, il n’est bien sûr pas autorisé de déplacer la donnée sur laquelle une référence pointe, puisque ça créerait une référence invalide.

#![allow(unused)]
fn main() {
struct Wrapper(u32);

let x = Wrapper(42);
let y = &x;
let z = x;  // Déplacement de x;

// Erreur : Accès à une référence y invalidée (donnée source déplacée)
println!("{}", y.0);
}

Fonctions

On peut aussi utiliser les méthodes d’un type à travers une référence. Comme le type référence n’a pas de méthodes, il y a un raccourci ergonomique qui traverse toutes les couches de référence pour atteindre la valeur, ré-acquiert une référence dessus si besoin, et appelle la méthode :

#![allow(unused)]
fn main() {
let x = 42u32;
let y = &x;
let z = &y;
println!("{}", z.count_ones());
}

Plus généralement, toutes les fonctions peuvent prendre des références en paramètre :

#![allow(unused)]
fn main() {
fn par_reference(x: &u32) -> bool {
    return *x == 42
}

println!("{}", par_reference(&24));
}

Pour les fonctions qui retournent des références, en revanche, il faut réfléchir un tout petit peu plus. Bien sûr, les cas simples se codent simplement…

#![allow(unused)]
fn main() {
fn identite(x: &u32) -> &u32 {
    x
}
}

…mais en présence de plusieurs paramètres d’entrée passés par référence, la question se pose : à quel paramètre est-ce que la valeur de retour correspond ?

fn double(x: &u32, y: &u32) -> &u32 {
    /* ... implémentation ... */
}

Ne pas pouvoir le dire sans regarder l’implémentation serait un problème, parce qu’en changeant l’implémentation de la fonction, on pourrait changer quelles utilisations sont valides ou pas :

let x = 42;
let z = {
    let y = 24;
    double(&x, &y)
};

// Cet accès à z est-il valide ? Ca dépend de l'implémentation de double() !
println!("{}", *z);

Par conséquent, en dehors de cas triviaux, Rust impose de clarifier la provenance des références émises par la fonction au niveau de l’interface, via la nouvelle syntaxe des lifetimes, qui reprend celle utilisée pour la généricité comme nous le verrons ultérieurement.

#![allow(unused)]
fn main() {
fn double<'a>(x: &'a u32, y: &u32) -> &'a u32 {
    x
}
}

Il y a cependant deux cas où le compilateur choisit une lifetime par défaut. Dans ces cas, il n’y a pas besoin de préciser l’origine des données avec la syntaxe ci-dessus, même si on peut le faire quand même pour remplacer le comportement par défaut :

  1. Quand la fonction ne prend qu’un seul paramètre par référence, le compilateur suppose par défaut que la référence de sortie vient de ce paramètre. Ce n’est pas toujours vrai, la fonction pourrait aussi retourner une référence vers une valeur statique. Mais si un programmeur veut exprimer ce cas d’utilisation exotique, il peut l’écrire explicitement :
    #![allow(unused)]
    fn main() {
    // Les données statiques ont la lifetime spéciale 'static
    fn statique(x: &u32) -> &'static u32 {
        &42
    }
    }
  2. Quand la fonction est une méthode de type avec un paramètre &self, on suppose par défaut que la valeur vient de ce type. Cela couvre tous les cas courants d’accesseurs qui retournent des données par références en code orienté objet.

Types

Il y a cependant encore un trou dans notre raquette de stabilité d’API à ce stade. Supposons que quelque part dans une bibliothèque nous ayons une fonction qui retourne une structure…

#![allow(unused)]
fn main() {
struct Wrapper(u32);

fn wrapper(x: &u32) -> Wrapper {
    Wrapper(*x)
}
}

…et que maintenant, le mainteneur de la bibliothèque décide de remplacer la valeur à l’intérieur de la structure par une référence :

struct Wrapper(&u32);

fn wrapper(x: &u32) -> Wrapper {
    Wrapper(x)
}

Si c’était légal, alors bien qu’on n’aie changé qu’un membre privé de la struct Wrapper, caché au monde extérieur (cf le chapitre sur les modules), on se retrouverait soudain dans une situation où des appels auparavant valides à wrapper() ne seraient plus valides !

let x = {
    let y = 42;
    wrapper(&y)  // Valide avant, plus maintenant !
};
println!("{x}");

Pour éviter ça, le fait qu’un type contienne une référence fait partie de l’interface publique du type. Le besoin d’ajouter ce paramètre de lifetime, visible du monde extérieur, agit comme un signal très clair qui avertit le mainteneur de la bibliothèque qu’il est en train de briser son API.

#![allow(unused)]
fn main() {
struct Wrapper<'a>(&'a u32);

struct Wrapper2<'a, 'b> {
    inner: Wrapper<'a>,
    inner2 : Wrapper<'b>,
}
}

Vous imaginez que cette syntaxe peut devenir lourde à grande échelle. C’est un petit rappel que les références en Rust sont avant tout destinées à optimiser l’ergonomie et les performances d’emprunts temporaires, et pas à être stockées indéfiniment et en grandes quantité.

Mutation

Maintenant que nous avons introduit les références partagées, il est temps de tenir la promesse de Rust en introduisant la possibilité de modifier les données situées derrière la référence.

Pour expliquer le problème que ça représente, je vais prendre un peu d’avance sur le chapitre sur les collections et introduire Vec, qui est l’équivalent Rust du std::vector de C++. On peut créer un vecteur avec une syntaxe très similaire à celle utilisée pour créer un tableau…

#![allow(unused)]
fn main() {
let v = vec![1, 2, 3];
}

…mais contrairement au tableau qui est alloué sur la pile, le vecteur est alloué sur le tas. Quand on le crée, une allocation mémoire est effectuée pour stocker ses données, et quand il est détruit, cette allocation mémoire est libérée. Comme le std::vector de C++ donc.

Maintenant, j’ai une question pour vous : que se passe-t’il si on fait ceci ?

#![allow(unused)]
fn main() {
let mut v = vec![1, 2, 3];
let elem = &v[1];
v = vec![4, 5, 6];
println!("{elem}");
}

Si on était en C++, ce code invoquerait du comportement indéfini, puisqu’on essaierait de lire le contenu de l’allocation mémoire associée à l’ancienne valeur de v qui n’existe plus.

Conformément aux objectifs de Rust, c’est donc une erreur de compilation dans ce langage. Dans ce cas, comme dans de très nombreux autres (invalidation des itérateurs, partage de données entre threads…), ce qui nous sauve c’est l’interdiction de la mutabilité partagée.

Plus précisément, la règle est que lorsqu’un modifie une donnée, on invalide toutes les références partagées qui pointent dessus. Il est donc interdit de se resservir de ces références par la suite.

Référence mutable

Sachant cela, on peut maintenant introduire les références mutables. Leur syntaxe est cohérente avec le reste du langage : de la même façon que pour rendre une donnée modifiable on utilise let mut au lieu de let, pour permettre la modification au travers d’une référence on utilise &mut au lieu de & :

#![allow(unused)]
fn main() {
let mut x = 42;
let y = &mut x;
*y = 24;
}

Les règles de base ne surprendront pas non plus les habitués des pointeurs C/++ :

  • Pour créer une référence &mut, il faut que la cible soit mut.
  • Une référence n’a pas besoin d’être mut pour permettre l’écriture des données vers lesquelles elle pointe. C’est seulement nécessaire quand on veut changer la cible de la référence.

Mais le concepteur de compilateur, lui, va commencer à suer à grosses gouttes au moment où on introduit ce type, car il sait que tôt ou tard, on va vouloir le passer à une fonction opaque que le compilateur ne peut pas analyser : bibliothèque liée dynamiquement, appel système, etc.

// Pas exactement la vraie syntaxe, mais faisons comme si ça l'était.
extern fn fonction_externe(x: &mut Vec<u32>);

let mut vecteur = vec![1, 2, 3];
let elem = &v[1];
fonction_externe(&mut vecteur);
println!("{elem}");

Dans ces conditions, le compilateur n’a pas accès à l’implémentation de externe. Donc il ne sait pas si vecteur est modifié ou pas. Donc il ne sait pas si la référence elem est encore valide après l’appel à externe(). Et donc il ne sait pas si ce code et valide.

Face à ces angoisses existentielles liées aux limites de l’analyse statique, Rust utilise comme d’habitude une approche pessimiste, qui a l’avantage de rendre les règles simples pour l’utilisateur, mais le défaut d’interdire du code raisonnable. En Rust, le simple fait de créer une référence mutable, sans même l’utiliser, invalide instantanément les références partagées qui pointent vers la même donnée cible. Donc ce code ne compile pas :

#![allow(unused)]
fn main() {
let mut x = 42;
let y = &x;
let z = &mut x;
println!("{y}");
}

C’est indubitablement très pessimiste, mais ça a l’avantage de régler le problème des fonctions externes dont nous avons discuté précédemment, sans avoir besoin d’intégrer la notion d’analyse d’échappement (aka “savoir si un pointeur a fuité vers une fonction externe dont le compilateur ne connaît pas l’implémentation”) dans la spécification du langage.

Conversions implicites

Enfin, les références ont un certain nombre de conversions implicites, qui peuvent se résumer par “qui peut le plus peut le moins” :

  • On peut utiliser une référence mutable là où une référence partagée est demandée :
    #![allow(unused)]
    fn main() {
    fn conversion(x: &mut u32) -> &u32 {
        x
    }
    }
  • On peut utiliser une référence vers une donnée de longue durée de vie là où une référence vers une donnée de courte durée de vie est demandée :
    #![allow(unused)]
    fn main() {
    fn extension<'a>(x: &'a u32) -> &'a u32 {
        &42  // &'static u32 vit plus longtemps que &'a u32 -> OK
    }
    }

Je ne couvre ici que les cas les plus simples de conversions implicites liées aux références. Le sujet est d’une profondeur surprenante mais les règles sont assez intuitives pour qu’on n’ait généralement pas besoin d’en connaître le détail pour utiliser le langage.

Types de taille variable

Rust a un support limité des types de taille variable (Dynamic Sized Types ou DSTs). Il en existe actuellement trois, définis par le langage, dont deux sont conceptuellement très similaires :

  • Le type slice, qui s’écrit [T] avec T un type de taille fixe. Il représente un tableau de taille inconnue à la compilation, mais connue à l’exécution.
  • Le type chaîne de caractères str est une séquence UTF-8, elle aussi de taille inconnue à la compilation. On peut le voir comme une forme spécialisée de la slice d’octets [u8].
  • Le type objet trait (trait object) dyn Trait, représente une implémentation inconnue d’un trait.

Ces types ne peuvent actuellement pas être manipulés directement, mais uniquement par le biais de références spéciales, les fat references, ainsi appelées parce qu’en plus d’un pointeur vers les données elles contiennent des métadonnées permettant d’interpréter la cible du pointeur :

  • La référence &[T] représente un sous-ensemble d’un jeu de données tabulaire. En plus du pointeur vers le début du jeu de données, ce type de référence stocke la taille du sous-ensemble qui nous intéresse. Cette taille peut donc varier au gré des affectations de variables :
    #![allow(unused)]
    fn main() {
    let tab: [usize; 6] = std::array::from_fn(|i| 2 * i);
    println!("Tableau original : {tab:?}");
    
    // Création d'une référence de slice
    let mut s: &[usize] = &tab[1..4];
    println!("Sélection des indices 1 <= i < 4 : {s:?}");
    
    // Modification de la référence pour couvrir tout le tableau
    s = &tab[..];
    println!("Vue d'ensemble du tableau {s:?}");
    }
  • Le type &str représente un sous-ensemble d’une chaîne de caractères, et fonctionne exactement comme un &[T] sauf qu’on découpe selon les octets composant la représentation UTF-8 de la chaîne :
    #![allow(unused)]
    fn main() {
    // On l'a évoqué, les litérales chaînes sont déjà des &str...
    let mut s: &str = "Bonjour";
    println!("{s}");
    
    // ...mais on peut aussi les redécouper :
    s = &s[3..];
    println!("Portion de la chaîne démarrant à l'indice 3 de l'UTF-8 : {s}");
    }
  • Le type &dyn Trait est la base du polymorphisme dynamique en Rust. Il est composé d’un pointeur vers une implémentation d’un trait et d’une vtable permettant d’appeler l’implémentation du trait. Cela permet, par exemple, d’avoir des collections hétérogènes d’objets qui implémentent un même trait :
    #![allow(unused)]
    fn main() {
    use std::fmt::Debug;
    let debug: [&dyn Debug; 4] = [&42, &"abc", &56.78, &true];
    println!("{debug:?}");
    }

En dehors de leur capacité à pointer vers des données de différentes tailles/types au gré des affectations et de leur taille inhabituelle (2 fois la taille d’un pointeur machine), ces références obéissent aux mêmes règles que les références simples que nous avons déjà rencontrées.