Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Utiliser Box<T> pour pointer sur des données présentes sur le tas

Le pointeur intelligent le plus simple est la boîte, dont le type s’écrit Box<T>. Les boîtes vous permettent de stocker des données sur le tas plutôt que sur la pile. La seule chose qui reste sur la pile est le pointeur vers les données sur le tas. Revenez au chapitre 4 pour vous rappeler la différence entre la pile et le tas.

Les boîtes ne provoquent pas de surcharge au niveau des performances, si ce n’est le stockage de leurs données sur le tas plutôt que sur la pile. Mais elles n’ont pas non plus beaucoup plus de fonctionnalités. Vous allez les utiliser principalement dans les situations suivantes :

  • lorsque vous avez un type dont la taille ne peut pas être connue au moment de la compilation et que vous souhaitez une valeur d’un certain type dans un contexte qui nécessite de savoir exactement sa taille ;
  • lorsque vous avez une grosse quantité de données et que vous souhaitez transférer la possession tout en assurant que les données ne seront pas copiées lorsque vous le ferez ;
  • lorsque vous voulez prendre possession d’une valeur et que vous souhaitez seulement qu’elle soit d’un type qui implémente un trait particulier plutôt que d’être d’un type spécique.

Nous allons expérimenter la première situation dans la section “Pouvoir utiliser des types récursifs grâce aux boîtes”. Pour la seconde situation, le transfert de possession d’une grosse quantité de données peut prendre beaucoup de temps car les données sont recopiées sur la pile. Pour améliorer les performances dans cette situation, nous pouvons stocker ces données sur le tas grâce à une boîte. Ainsi, seul le petit pointeur vers les données est copié sur la pile, alors que les données qu’il pointe restent à leur place sur le tas. La troisième situation décrit ce qu’on appelle un objet de trait et “Using Trait Objects to Abstract over Shared Behavior” dans le chapitre 18 est dédiée à ce sujet. Donc ce que vous apprenez ici, vous le retrouverez à nouveau dans cette section !

Stockage des données sur le tas

Avant de parler du cas d’usage de stockage sur le tas pour Box<T>, nous devons voir sa syntaxe et comment interagir avec les valeurs stockées dans un Box<T>.

L’encart 15-1 nous montre comment utiliser une boîte pour stocker une valeur i32 sur le tas :

Filename: src/main.rs
fn main() {
    let b = Box::new(5);
    println!("b = {b}");
}
Listing 15-1: Storing an i32 value on the heap using a box

Nous avons défini la variable b pour avoir la valeur d’une Box qui pointe sur la valeur 5, qui est donc allouée sur le tas. Ce programme va afficher b = 5 ; dans ce cas, nous pouvons accéder à la donnée présente dans la boîte de la même manière que nous le ferions si elle était sur la pile. Comme toute valeur possédée, lorsqu’une boîte sort de la portée, comme lorsque b le fait à la fin du main, elle sera désallouée. La désallocation aura lieu pour la boîte (qui est stockée sur la pile) ainsi que les données sur lesquelles elle pointait (qui sont stockées sur le tas).

Déposer une seule valeur sur le tas n’est pas très utile, donc vous n’utiliserez que très rarement les boîtes de cette manière. Laisser les valeurs comme des i32 indépendantes sur la pile, où elles sont stockées par défaut, reste plus approprié dans la majeure partie des situations. Regardons un cas où les boîtes nous permettent de définir des types que nous ne pourrions pas définir si nous n’avions pas les boîtes.

Pouvoir utiliser des types récursifs grâce aux boîtes

Une valeur d’un type récursif peut avoir une autre valeur du même type qu’elle-même. Les types récursifs posent un problème car Rust a besoin de savoir, au moment de la compilation, combien d’espace prend un type. Cependant, cet emboîtement de valeurs pourrait théoriquement se poursuivre à l’infini, donc Rust ne peut pas savoir de combien d’espace une valeur d’un type récursif peut avoir besoin. Comme les boîtes ont une taille connue, nous pouvons créer des types récursifs en insérant une boîte dans la définition d’un type récursif.

En guise d’exemple de type récursif, découvrons maintenant la liste de construction (NdT : cons list). Il s’agit d’un type de donnée courant dans les langages de programmation fonctionnels. Le type liste de construction que nous allons définir est plutôt simple, sauf pour les cas de récursivité ; par conséquent, les concepts dans l’exemple avec lequel nous allons travailler vous seront utiles à chaque fois que vous vous retrouverez dans des situations plus complexes qui impliquent des types récursifs.

Comprendre les listes de construction

Une liste de construction est une structure de donnée qui provient du langage de programmation Lisp et de ses dérivés, est constituée de paires imbriquées, et constituent la version en Lisp d’une liste chaînée. Son nom vient de la fonction cons (qui est une forme contractée de “fonction de construction”) en Lisp qui construit une nouvelle paire à partir de ses deux arguments. En appellant cons sur une paire faite d’une valeur et d’une autre paire, nous pouvons construire des listes de construction faites de paires récursives.

Par exemple, voici une représentation en pseudocode d’une liste de construction contenant la liste 1, 2, 3 avec chaque paire entre parenthèses :

(1, (2, (3, Nil)))

Chaque élément dans une liste de construction contient deux éléments : la valeur de l’élément courant et celle de l’élément suivant. Le dernier élément dans la liste contient seulement une valeur Nil sans aucun élément suivant. Une liste de construction est produite de manière récursive en appelant la fonction cons. Le nom canonique pour indiquer le cas de base de la récursion est Nil. Notez que ce n’est pas la même chose que les concepts “null” ou “nil” dont il est question dans le chapitre 6, qui signale une valeur invalide ou absente.

La liste de construction n’est pas une structure de donnée utilisée couramment en Rust. La plupart du temps, lorsque vous avez une liste d’éléments en Rust, Vec<T> s’avère être un meilleur choix à faire. Autrement, il existe des types de données récursifs plus complexes qui sont utiles dans d’autres situations, mais en commençant avec les listes de construction dans ce chapitre, nous pouvons découvrir comment les boîtes nous permettent de définir un type de données récursif sans être trop perturbé par la complexité.

L’encart 15-2 propose une définition d’une énumération pour une liste de construction. Notez que ce code ne se compile pas encore, car le type List n’a pas encore de taille connue, ce que nous allons voir ensuite.

Filename: src/main.rs
enum List {
    Cons(i32, List),
    Nil,
}

fn main() {}
Listing 15-2: The first attempt at defining an enum to represent a cons list data structure of i32 values

Remarque : nous implémentons une liste de construction qui stocke uniquement des valeurs i32 pour les besoins de cet exemple. Nous aurions pu l’implémenter en utilisant des génériques, que nous avons vu chapitre 10, afin de définir une liste de construction qui pourrait stocker n’importe quel type.

L’utilisation du type List pour stocker la liste 1, 2, 3 ressemblerait au code dans l’encart 15-3 :

Filename: src/main.rs
enum List {
    Cons(i32, List),
    Nil,
}

// -- partie masquée ici --

use crate::List::{Cons, Nil};

fn main() {
    let list = Cons(1, Cons(2, Cons(3, Nil)));
}
Listing 15-3: Using the List enum to store the list 1, 2, 3

La première valeur Cons stocke 1 et une autre valeur de List. Cette valeur List est une autre valeur Cons qui stocke 2 et une autre valeur de List. Cette valeur List n’est rien d’autre qu’une valeur Cons qui stocke 3 et une valeur List, qui finalement est Nil, la variante non récursive qui signale la fin de la liste.

Si nous essayons de compiler le code de l’encart 15-3, nous avons l’erreur de l’encart 15-4 :

$ cargo run
   Compiling cons-list v0.1.0 (file:///projects/cons-list)
error[E0072]: recursive type `List` has infinite size
 --> src/main.rs:1:1
  |
1 | enum List {
  | ^^^^^^^^^
2 |     Cons(i32, List),
  |               ---- recursive without indirection
  |
help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to break the cycle
  |
2 |     Cons(i32, Box<List>),
  |               ++++    +

error[E0391]: cycle detected when computing when `List` needs drop
 --> src/main.rs:1:1
  |
1 | enum List {
  | ^^^^^^^^^
  |
  = note: ...which immediately requires computing when `List` needs drop again
  = note: cycle used when computing whether `List` needs drop
  = note: see https://rustc-dev-guide.rust-lang.org/overview.html#queries and https://rustc-dev-guide.rust-lang.org/query.html for more information

Some errors have detailed explanations: E0072, E0391.
For more information about an error, try `rustc --explain E0072`.
error: could not compile `cons-list` (bin "cons-list") due to 2 previous errors
Listing 15-4: The error we get when attempting to define a recursive enum

L’erreur explique que ce type “a une taille infinie”. La raison est que nous avons défini List avec une variante qui est récursive : elle stocke directement une autre valeur d’elle-même. Au final, Rust ne peut pas savoir de combien de place il a besoin pour stocker une valeur List. Analysons pourquoi nous obtenons cette erreur. D’abord, regardons comment Rust décide de l’espace dont il a besoin pour stocker une valeur d’un type non récursif.

Calculer la taille d’un type non récursif

Rappelez-vous de l’énumération Message que nous avons défini dans l’encart 6-2 lorsque nous avons abordé les définitions des énumérations au chapitre 6 :

enum Message {
    Quitter,
    Deplacer { x: i32, y: i32 },
    Ecrire(String),
    ChangerCouleur(i32, i32, i32),
}

fn main() {}

Pour déterminer combien d’espace allouer pour une valeur Message, Rust parcourt chaque variante pour voir quelle variante a besoin du plus d’espace. Rust voit que Message::Quitter n’a pas besoin d’espace, Message::Deplacer a besoin de suffisamment d’espace pour stocker deux valeurs i32, et ainsi de suite. Comme une seule variante sera utilisée, le plus grand espace dont une valeur de Message aura besoin sera l’espace nécessaire pour stocker la plus grosse de ses variantes.

Comparez cela avec ce qui se passe lorsque Rust essaye de déterminer combien d’espace un type récursif comme l’énumération List de l’encart 15-2 aurait besoin. Le compilateur commence par regarder la variante Cons, qui stocke une valeur de type i32 et une valeur de type List. Ainsi, Cons a besoin d’une quantité d’espace égale à la taille d’un i32 plus la taille d’une valeur List. Pour savoir combien de mémoire le type List a besoin, le compilateur va regarder ses variantes, en commençant avec la variante Cons. La variante Cons stocke une valeur de type i32 et une valeur de type List, et ce processus continue à l’infini, comme l’illustration 15-1.

An infinite Cons list: a rectangle labeled 'Cons' split into two smaller rectangles. The first smaller rectangle holds the label 'i32', and the second smaller rectangle holds the label 'Cons' and a smaller version of the outer 'Cons' rectangle. The 'Cons' rectangles continue to hold smaller and smaller versions of themselves until the smallest comfortably sized rectangle holds an infinity symbol, indicating that this repetition goes on forever.

Illustration 15-1 : Une List infinie qui contient des variantes Cons infinies

Récupération d’un type récursif avec une taille finie

Comme Rust ne peut pas calculer la quantité d’espace à allouer pour les types définis récursivement, le compilateur déclenche une erreur avec cette suggestion très utile :

help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to break the cycle
  |
2 |     Cons(i32, Box<List>),
  |               ++++    +

Dans cette suggestion, “indirection” (NdT : redirection) signifie qu’au lieu de stocker une valeur directement, nous allons changer la structure des données pour stocker à la place un pointeur vers la valeur.

Comme Box<T> est un pointeur, Rust connaît toujours de combien d’espace un Box<T> a besoin : la taille d’un pointeur ne change pas, peu importe la quantité de données sur lesquelles il pointe. Cela signifie que nous pouvons insérer un Box<T> à l’intérieur d’une variante Cons au lieu d’y mettre directement une autre valeur List. Le Box<T> va pointer sur la prochaine valeur List qui sera sur le tas plutôt que d’être dans la variante Cons. Théoriquement, nous avons toujours une liste, créée avec des listes qui “contiennent” d’autres listes, mais cette implémentation ressemble plus à présent à des éléments placés les uns à côté des autres plutôt que les uns dans les autres.

Nous pouvons changer la définition de l’énumération List de l’encart 15-2 et l’utilisation de List dans l’encart 15-3 pour le code de l’encart 15-5, qui va se compiler.

Filename: src/main.rs
enum List {
    Cons(i32, Box<List>),
    Nil,
}

use crate::List::{Cons, Nil};

fn main() {
    let list = Cons(1, Box::new(Cons(2, Box::new(Cons(3, Box::new(Nil))))));
}
Listing 15-5: The definition of List that uses Box<T> in order to have a known size

La variante Cons a besoin de l’espace d’un i32 plus l’espace pour stocker le pointeur vers la donnée de la boîte. La variante Nil ne stocke pas de valeurs, donc elle a besoin de moins d’espace sur la pile que la variante Cons. Nous savons maintenant que chaque valeur List va prendre la taille d’un i32 plus la taille d’un pointeur vers la donnée de la boîte. En utilisant une boîte, vous avez arrêté la chaîne infinie et récursive, donc le compilateur peut savoir l’espace dont il a besoin pour stocker une valeur List. L’illustration 15-2 montre à quoi ressemble maintenant la variante Cons.

A rectangle labeled 'Cons' split into two smaller rectangles. The first smaller rectangle holds the label 'i32', and the second smaller rectangle holds the label 'Box' with one inner rectangle that contains the label 'usize', representing the finite size of the box's pointer.

Illustration 15-2 : Une List qui n’a pas de taille infinie car Cons est une Box

Les boîtes fournissent uniquement la redirection et l’allocation sur le tas ; elles n’ont pas d’autres fonctionnalités, comme celles que nous verrons sur d’autres types de pointeurs intelligents. Elles n’ont pas non plus les surcoût de performances autres qu’entraînent ces capacités spéciales, elles sont donc utiles dans des cas comme les listes de construction où la redirection est la seule fonctionnalité dont nous avons besoin. Nous verrons aussi plus de cas d’usages pour les boîtes dans le chapitre 18.

Le type Box<T> est un pointeur intelligent car il implémente le trait Deref, qui permet aux valeurs Box<T> d’être traitées comme des références. Lorsqu’une valeur Box<T> sort de la portée, les données sur le tas pointées par la boîte seront également nettoyées grâce à l’implémentation du trait Drop. Ces deux traits deviendront encore plus importants pour les fonctionnalités offertes par les autres pointeurs intelligents que nous verrons dans le reste de ce chapitre. Explorons plus en détail ces deux traits.