Pointeurs

Introduction

Lorsque les programmeurs découvrent l’emprunt en Rust, ils tendent à être tellement fascinés par ce concept relativement original qu’ils essaient de l’utiliser à toutes les sauces, y compris dans des contextes où ce n’est pas l’outil le plus approprié.

Une erreur classique est notamment de vouloir allouer le plus de variables possibles sur la pile avec une gestion de temps de vie à la compilation en mettant des références partout, alors que ce genre d’optimisation n’a d’importance que dans la partie chaude du code. Dans la grande majorité du code, une gestion du temps de vie à l’exécution sera nettement plus ergonomique tout en fournissant une performance qui reste raisonnable. Sans ça, les langages qui allouent presque tout sur le tas comme Java auraient fait faillite depuis longtemps.

Pour ces situations, Rust propose comme C++ une bibliothèque de pointeurs intelligents, qui fournissent un accès à une allocation tas qui sera automatiquement libérée par leur destructeur.

Une différence importante entre Rust et C++ est que grâce à la magie du trait Deref, il n’y a pas besoin de syntaxe spéciale -> pour accéder à l’élément pointé. L’opérateur standard d’accès au membre objet.membre fera le travail dans 99% des cas. On peut donc plus facilement convertir du code qui utilise des pointeurs intelligents en code qui n’en utilise pas et vice versa.

Box<T>

Le premier pointeur intelligent de la bibliothèque Rust est Box, un type qui alloue une allocation quand il est créé et la libère quand il est détruit, à la manière de std::unique_ptr en C++ :

#![allow(unused)]
fn main() {
// Création d'une Box
let b: Box<u32> = Box::new(42);
println!("{b}");

// Déréférencement explicite
let x: u32 = *b;
println!("{x}");

// Déréférencement implicite
println!("{}", x.count_ones());

// Destruction automatique en sortie de scope
}

Box réplique aussi fidèlement que possible la sémantique d’une valeur allouée sur la pile, et son ergonomie est donc globalement comparable, ni meilleure ni mauvaise. Il y a cependant quelques différences notables entre les deux :

  • Même si le type d’origine est Copy, Box ne sera pas Copy, car un memcpy() du pointeur n’est pas une façon correcte de dupliquer une allocation tas. Pour créer des copies, il faudra donc utiliser l’opérateur explicite b.clone().
  • Si la valeur pointée est de grande taille (ex : un gros tableau ou une grosse struct), déplacer une Box est plus efficace que de déplacer une valeur sur la pile, puisque le memcpy() associé ne copie qu’un pointeur. On est donc moins à la merci des optimisations d’élimination de copie du compilateur, qui ne sont pas toujours aussi fiable qu’on aimerait.
    • De plus, sur la plupart des OS, la pile est de taille limitée (quelques Ko à quelques Mo), et à trop en mettre sur la pile on risque un crash. Alors que le tas n’est limité que par la quantité de RAM disponible ou presque.
  • Au niveau des performances, Box, comme tout pointeur, implique une indirection dans l’accès aux données qui va rendre les accès mémoire moins efficaces. Il faut donc éviter d’accèder aux données pointées par un grand nombre de Box différentes dans une boucle chaude.
  • Il est possible d’allouer sur le tas des blocs de taille inconnue à la compilation, donc on peut avoir des Box<[T]>, Box<str> et Box<dyn Trait>.

Ce dernier point mérite clarification, prenons donc l’exemple de Box<[T]>. Dans le chapitre sur les collections, nous avons introduit Vec<T>, une collection de taille variable représentant un tableau auquel on peut ajouter et enlever des éléments. Pour faire ce travail correctement, Vec a besoin de stocker trois informations :

  • Un pointeur base vers le début de l’allocation mémoire.
  • Un entier size indiquant la taille actuelle du vecteur.
  • Un entier capacity indiquant la taille de l’allocation mémoire, qui peut être supérieure à size (cela évite de refaire une allocation et déplacer les données à chaque opération).

Mais il arrive très souvent que l’on utilise Vec dans un certain cycle de vie où après une phase d’initialisation, la taille du vecteur ne varie plus. Dans ce cas, la nuance size/capacity devient inutile, on pourrait juste redimensionner une dernière fois l’allocation pour qu’elle fasse exactement la bonne taille et ne plus conserver que le pointeur de début d’allocation et la taille du vecteur.

Et c’est là qu’intervient le type Box<[T]> de Rust : c’est un pointeur intelligent vers un tableau de taille fixe, mais de taille non connue à la compilation.

Voici deux façons courantes de construire un objet de type Box<[T]> :

#![allow(unused)]
fn main() {
// Si un pipeline d'itérateur suffit, on utilise collect() :
let boxed_slice = (0..20).collect::<Box<[u8]>>();
println!("{boxed_slice:?}");

// Sinon, on commence par créer un vecteur, puis on le convertit en allocation
// de taille fixe quand on a fini d'ajouter/supprimer des éléments.
let mut data = Vec::new();
data.push(123);
data.push(456);
data.push(789);
data.pop();  // Suppression d'un élément
let data = data.into_boxed_slice();
println!("{data:?}");
}

Comptage de références

Parfois, la sémantique de propriété peut être limitante, et on veut que plusieurs parties indépendantes du code partagent la valeur d’une même variable sans que l’une d’entre elle ait une référence sur le stockage de l’autre.

Pour gérer ce cas en Rust, on utilise principalement la technique du comptage de référence, via les types Rc (“Reference Counted”) et Arc (“Atomically Reference Counted”) :

  1. On crée une allocation tas comprenant la valeur à partager et un entier indiquant le nombre de pointeurs intelligents existant vers cette valeur, qu’on appelle compteur de références.
  2. On retourne à l’utilisateur un pointeur intelligent Rc<T> ou Arc<T> qui peut être copié via l’opérateur clone(). A chaque fois qu’une copie est faite, la copie pointe vers la même allocation qu’avant, mais le compteur de références de l’allocation est incrémenté.
  3. A chaque fois qu’un pointeur intelligent est détruit, le compteur de références de l’allocation associée est décrémenté.
  4. Quand le compteur de références tombe à zéro, il n’y a plus d’accès possible à l’allocation (grâce à l’analyse de validité des références extraites de cette allocation), elle peut donc être libérée en toute sécurité.

Utilisé à grande échelle, le comptage de références est moins efficace que les autres implémentations de ramasse-miettes basée sur l’analyse récursive des pointeurs de la pile de l’application. Et il faut faire attention avec les références cycliques (A pointe vers B, qui pointe vers A), qui doivent être gérées via un mécanisme spécifique des références faibles.

Mais en contrepartie, cette approche…

  • A des caractéristiques de performances plus déterministes : si on ne détruit pas de Rc<T> ou de Arc<T>, on ne risque pas de libération de mémoire (opération coûteuse à éviter dans certains types de code opérant sous contraintes de temps réel).
  • Ne nécessite pas que l’implémentation ait une connaissance exhaustive de tous les pointeurs manipulés par le code et donc permet une interopérabilité plus facile avec les langages ayant d’autres méthodes de gestion mémoire.

En Rust, l’utilisation du comptage de références a aussi une interaction subtile avec l’analyse de la validité des références effectuées par le compilateur :

  • Les données situées derrière un pointeur Rc<T> ou Arc<T> sont accessibles depuis plusieurs endroits du code. Ce sont donc des références partagées, et il n’est possible de modifier les données sous-jacentes que via des mécanismes de mutabilité interne (que nous allons aborder dans un chapitre ultérieur).
  • Le compteur de référence de l’implémentation nécessite lui-même une forme de mutabilité interne, puisque c’est un état partagé qui est modifiable depuis tous les pointeurs Rc<T> et Arc<T> en créant un clone du pointeur.
  • Il faut faire, à ce niveau, un choix entre une implémentation efficace (simple incrément d’entier machine) et une implémentation thread-safe (incrément atomique via une instruction matérielle spéciale coûtant typiquement 100x plus cher).

En C++, ce dernier choix est fait pour vous : si vous utilisez std::shared_ptr, vous avez une implémentation thread-safe, et en paierez le prix en termes de performances même si vous ne partagez jamais de shared_ptr entre threads.

Mais en Rust, ce choix est sous votre contrôle. Si vous avez besoin de partage entre threads, vous utilisez Arc<T>, sinon vous utilisez Rc<T>. En cas de doute, essayez Rc<T> : si vous avez besoin des garanties de Arc<T>, le compilateur vous le fera savoir en refusant de compiler votre code au motif que vous essayer de partager des structures de données non thread-safe entre plusieurs threads.

Pour le reste, Rc et Arc fonctionnent globalement exactement comme Box du point de vu de l’utilisateur, l’accès mutable en moins.

Pointeurs bruts

Rust fait de son mieux pour fournir des alternatives sûres à la gestion mémoire traditionnelle du C, et y parvient très bien la plupart du temps. Mais il peut arriver qu’on ait besoin d’un accès à cette fonctionnalité bas niveau :

  • Quand on veut implémenter une structure de données avec un graphe de relations compliquées entre les objets intérieurs, relations qui sont mal représentées par le modèle de possession et d’emprunt en arbre de Rust.
  • Quand on veut interopérer avec d’autres langages de programmation via une interface C, où c’est la seule sémantique disponible pour passer des données par référence.

Dans ces cas-là, il faut utiliser directement ou indirectement (via une bibliothèque) le pendant maléfique des pointeurs intelligents, les pointeurs bruts (raw pointers).

Rust possède trois types de pointeurs bruts, NonNull<T>, *const T et *mut T.

  • NonNull<T> est un pointeur qui, comme tous les types de références et pointeurs intelligents en Rust, ne peut pas être nul. Cela autorise certaines optimisations au niveau de l’implémentation et clarifie les choses pour les utilisateurs, donc je vous encourage à l’utiliser chaque fois que c’est possible.
  • *const T et *mut T visent respectivement à imiter la sémantique de const T* et T* en C : ils font exactement la même chose au niveau du compilateur, mais le premier suggère que vous ne souhaitez pas que les données soient modifiées par la fonction à laquelle vous le passez, et agit donc comme une forme limitée d’auto-documentation.

En Rust, les pointeurs bruts sont extrêmement difficiles à utiliser correctement, encore plus qu’en C et en C++, parce que le compilateur Rust tire abondamment parti des garanties offertes par le langage (pas de pointeurs nuls, pas de coexistence de & et &mut, etc) au cours de son processus d’optimisation du code. Si vous violez ces règles par une utilisation incorrecte des pointeurs bruts, vous causerez instantanément du comportement indéfini encore plus vicieux que celui qui existe dans le code C/++ ordinaire (hors restrict, std::execution_policy::unseq, et autres formes de masochisme encouragées par la norme C++).

Je vous encourage donc très fortement à ne pas aborder cette partie du langage Rust avant d’avoir acquis une très bonne maîtrise du reste, et à privilégier l’utilisation de bibliothèques écrites par des experts reconnus quand il en existe. Lorsque vous vous sentirez prêts, une bonne introduction au sujet est le Rustnomicon, un guide qui présente un panorama à peu près complet des invariants du langage Rust et des techniques que le code unsafe utilise couramment pour les préserver.