Considérer les pointeurs intelligents comme des références grâce au trait Deref

L'implémentation du trait Deref vous permet de personnaliser le comportement de l'opérateur de déréférencement * (qui n'est pas l'opérateur de multiplication ou le joker global). En implémentant Deref de manière à ce qu'un pointeur intelligent puisse être considéré comme une référence classique, vous pouvez écrire du code qui fonctionne avec des références mais aussi avec des pointeurs intelligents.

Regardons d'abord comment l'opérateur de déréférencement fonctionne avec des références classiques. Ensuite nous essayerons de définir un type personnalisé qui se comporte comme Box<T> et voir pourquoi l'opérateur de déréférencement ne fonctionne pas comme une référence sur notre type fraîchement défini. Nous allons découvrir comment implémenter le trait Deref de manière à ce qu'il soit possible que les pointeurs intelligents fonctionnent comme les références. Ensuite nous verrons la fonctionnalité d'extrapolation de déréférencement de Rust et comment elle nous permet de travailler à la fois avec des références et des pointeurs intelligents.

Remarque : il y a une grosse différence entre le type MaBoite<T> que nous allons construire et la vraie Box<T> : notre version ne va pas stocker ses données sur le tas. Nous allons concentrer cet exemple sur Deref, donc l'endroit où est concrètement stocké la donnée est moins important que le comportement similaire aux pointeurs.

Suivre le pointeur vers la valeur grâce à l'opérateur de déréférencement

Une référence classique est un type de pointeur, et une manière de modéliser un pointeur est d'imaginer une flèche pointant vers une valeur stockée autre part. Dans l'encart 15-6, nous créons une référence vers une valeur i32 et utilisons ensuite l'opérateur de déréférencement pour suivre la référence vers la donnée :

Fichier : src/main.rs

fn main() {
    let x = 5;
    let y = &x;

    assert_eq!(5, x);
    assert_eq!(5, *y);
}

Encart 15-6 : utiliser l'opérateur de déréférencement pour suivre une référence vers une valeur i32

La variable x stocke une valeur i32 : 5. Nous avons assigné à y une référence vers x. Nous pouvons faire une assert pour vérifier que x est égal à 5. Cependant, si nous souhaitons faire une assert sur la valeur dans y, nous devons utiliser *y pour suivre la référence vers la valeur sur laquelle elle pointe (d'où le déréférencement). Une fois que nous avons déréférencé y, nous avons accès à la valeur de l'entier sur laquelle y pointe afin que nous puissions la comparer avec 5.

Si nous avions essayé d'écrire assert_eq!(5, y); à la place, nous aurions obtenu cette erreur de compilation :

$ cargo run
   Compiling deref-example v0.1.0 (file:///projects/deref-example)
error[E0277]: can't compare `{integer}` with `&{integer}`
 --> src/main.rs:6:5
  |
6 |     assert_eq!(5, y);
  |     ^^^^^^^^^^^^^^^^ no implementation for `{integer} == &{integer}`
  |
  = help: the trait `PartialEq<&{integer}>` is not implemented for `{integer}`
  = note: this error originates in the macro `assert_eq` (in Nightly builds, run with -Z macro-backtrace for more info)

For more information about this error, try `rustc --explain E0277`.
error: could not compile `deref-example` due to previous error

Comparer un nombre et une référence vers un nombre n'est pas autorisé car ils sont de types différents. Nous devons utiliser l'opérateur de déréférencement pour suivre la référence vers la valeur sur laquelle elle pointe.

Utiliser Box<T> comme étant une référence

Nous pouvons réécrire le code l'encart 15-6 pour utiliser une Box<T> au lieu d'une référence ; l'opérateur de déréférencement devrait fonctionner comme montré dans l'encart 15-7 :

Fichier : src/main.rs

fn main() {
    let x = 5;
    let y = Box::new(x);

    assert_eq!(5, x);
    assert_eq!(5, *y);
}

Encart 15-7 : utilisation de l'opérateur de déréférencement sur un Box<i32>

La principale différence entre l'encart 15-7 et l'encart 15-6 est qu'ici nous avons fait en sorte que y soit une instance de boite qui pointe sur une copie de la valeur de x plutôt qu'avoir une référence vers la valeur de x. Dans la dernière assertion, nous pouvons utiliser l'opérateur de déréférencement pour suivre le pointeur de la boite de la même manière que nous l'avons fait lorsque y était une référence. Maintenant, nous allons regarder ce qu'il y a de si spécial dans Box<T> qui nous permet d'utiliser l'opérateur de déréférencement en définissant notre propre type de boite.

Définir notre propre pointeur intelligent

Construisons un pointeur intelligent similaire au type Box<T> fourni par la bibliothèque standard pour apprendre comment les pointeurs intelligents se comportent différemment des références classiques. Ensuite nous regarderons comment lui ajouter la possibilité d'utiliser l'opérateur de déréférencement.

Le type Box<T> est essentiellement défini comme étant une structure de tuple d'un seul élément, donc l'encart 15-8 définit un type MaBoite<T> de la même manière. Nous allons aussi définir une fonction new pour correspondre à la fonction new définie sur Box<T>.

Fichier : src/main.rs

struct MaBoite<T>(T);

impl<T> MaBoite<T> {
    fn new(x: T) -> MaBoite<T> {
        MaBoite(x)
    }
}

fn main() {}

Encart 15-8 : définition du type MaBoite<T>

Nous définissons une structure MaBoite et on déclare un paramètre générique T, car nous souhaitons que notre type stocke des valeurs de n'importe quel type. Le type MaBoite est une structure de tuple avec un seul élément de type T. La fonction MaBoite::new prend un paramètre de type T et retourne une instance MaBoite qui stocke la valeur qui lui est passée.

Essayons d'ajouter la fonction main de l'encart 15-7 dans l'encart 15-8 et la modifier pour utiliser le type MaBoite<T> que nous avons défini à la place de Box<T>. Le code de l'encart 15-9 ne se compile pas car Rust ne sait pas comment déréférencer MaBoite.

Fichier : src/main.rs

struct MaBoite<T>(T);

impl<T> MaBoite<T> {
    fn new(x: T) -> MaBoite<T> {
        MaBoite(x)
    }
}

fn main() {
    let x = 5;
    let y = MaBoite::new(x);

    assert_eq!(5, x);
    assert_eq!(5, *y);
}

Encart 15-9 : tentative d'utiliser MaBoite<T> de la même manière que nous avions utilisé les références et Box<T>

Voici l'erreur de compilation qui en résulte :

$ cargo run
   Compiling deref-example v0.1.0 (file:///projects/deref-example)
error[E0614]: type `MaBoite<{integer}>` cannot be dereferenced
  --> src/main.rs:14:19
   |
14 |     assert_eq!(5, *y);
   |                   ^^

For more information about this error, try `rustc --explain E0614`.
error: could not compile `deref-example` due to previous error

Notre type MaBoite<T> ne peut pas être déréférencée car nous n'avons pas implémenté cette fonctionnalité sur notre type. Pour permettre le déréférencement avec l'opérateur *, nous devons implémenter le trait Deref.

Considérer un type comme une référence en implémentant le trait Deref

Comme nous l'avons vu dans une section du chapitre 10, pour implémenter un trait, nous devons fournir les implémentations des méthodes nécessaires pour ce trait. Le trait Deref, fourni par la bibliothèque standard, nécessite que nous implémentions une méthode deref qui emprunte self et retourne une référence vers la donnée interne. L'encart 15-10 contient une implémentation de Deref à ajouter à la définition de MaBoite :

Fichier : src/main.rs

use std::ops::Deref;

impl<T> Deref for MaBoite<T> {
    type Target = T;

    fn deref(&self) -> &Self::Target {
        &self.0
    }
}

struct MaBoite<T>(T);

impl<T> MaBoite<T> {
    fn new(x: T) -> MaBoite<T> {
        MaBoite(x)
    }
}

fn main() {
    let x = 5;
    let y = MaBoite::new(x);

    assert_eq!(5, x);
    assert_eq!(5, *y);
}

Encart 15-10 : implémentation de Deref sur MaBoite<T>

La syntaxe type Target = T; définit un type associé pour le trait Deref à utiliser. Les types associés sont une manière légèrement différente de déclarer un paramètre générique, mais vous n'avez pas à vous préoccuper d'eux pour le moment ; nous les verrons plus en détail au chapitre 19.

Nous renseignons le corps de la méthode deref avec &self.0 afin que deref retourne une référence vers la valeur que nous souhaitons accéder avec l'opérateur *. Rappellez-vous de la section du chapitre 5 où nous avons appris que le .0 accède à la première valeur d'une structure tuple. La fonction main de l'encart 15-9 qui appelle * sur la valeur MaBoite<T> se compile désormais, et le assert réussit aussi !

Sans le trait Deref, le compilateur peut seulement déréférencer des références &. La méthode deref donne la possibilité au compilateur d'obtenir la valeur de n'importe quel type qui implémente Deref en appelant la méthode deref pour obtenir une référence & qu'il sait comment déréférencer.

Lorsque nous avons précisé *y dans l'encart 15-9, Rust fait tourner ce code en coulisses :

*(y.deref())

Rust remplace l'opérateur * par un appel à la méthode deref suivi par un simple déréférencement afin que nous n'ayons pas à nous demander si nous devons ou non appeler la méthode deref. Cette fonctionnalité de Rust nous permet d'écrire du code qui fonctionne de manière identique que nous ayons une référence classique ou un type qui implémente Deref.

La raison pour laquelle la méthode deref retourne une référence à une valeur, et que le déréférencement du tout dans les parenthèses externes de *(y.deref()) reste nécessaire, est le système de possession. Si la méthode deref retournait la valeur directement au lieu d'une référence à cette valeur, la valeur serait déplacée à l'extérieur de self. Nous ne souhaitons pas prendre possession de la valeur à l'intérieur de MaBoite<T> dans ce cas ainsi que la plupart des cas où nous utilisons l'opérateur de déréférencement.

Notez que l'opérateur * est remplacé par un appel à la méthode deref suivi par un appel à l'opérateur * une seule fois, à chaque fois que nous utilisons un * dans notre code. Comme la substitution de l'opérateur * ne s'effectue pas de manière récursive et infinie, nous récupèrerons une donnée de type i32, qui correspond au 5 du assert_eq! de l'encart 15-9.

Extrapolation de déréférencement implicite avec les fonctions et les méthodes

L'extrapolation de déréférencement est une commodité que Rust applique sur les arguments des fonctions et des méthodes. L'extrapolation de déréférencement fonctionne uniquement avec un type qui implémente le trait Deref. L'extrapolation de déréférencement convertit une référence vers ce type en une référence vers un autre type. Par exemple, l'extrapolation de déréférencement peut convertir &String en &str car String implémente le trait Deref de sorte qu'il puisse retourner &str. L'extrapolation de déréférencement s'applique automatiquement lorsque nous passons une référence vers une valeur d'un type particulier en argument d'une fonction ou d'une méthode qui ne correspond pas à ce type de paramètre dans la définition de la fonction ou de la méthode. Une série d'appels à la méthode deref convertit le type que nous donnons dans le type que le paramètre nécessite.

L'extrapolation de déréférencement a été ajoutée à Rust afin de permettre aux développeurs d'écrire des appels de fonctions et de méthodes qui n'ont pas besoin d'indiquer explicitement les références et les déréférencements avec & et *. La fonctionnalité d'extrapolation de déréférencement nous permet aussi d'écrire plus de code qui peut fonctionner à la fois pour les références et pour les pointeurs intelligents.

Pour voir l'extrapolation de déréférencement en action, utilisons le type MaBoite<T> que nous avons défini dans l'encart 15-8 ainsi que l'implémentation de Deref que nous avons ajoutée dans l'encart 15-10. L'encart 15-11 montre la définition d'une fonction qui a un paramètre qui est une slice de chaîne de caractères :

Fichier : src/main.rs

fn saluer(nom: &str) {
    println!("Salutations, {} !", nom);
}

fn main() {}

Encart 15-11 : une fonction saluer qui prend en paramètre nom du type &str

Nous pouvons appeler la fonction saluer avec une slice de chaîne de caractères en argument, comme par exemple saluer("Rust");. L'extrapolation de déréférencement rend possible l'appel de saluer avec une référence à une valeur du type MaBoite<String>, comme dans l'encart 15-12 :

Fichier : src/main.rs

use std::ops::Deref;

impl<T> Deref for MaBoite<T> {
    type Target = T;

    fn deref(&self) -> &T {
        &self.0
    }
}

struct MaBoite<T>(T);

impl<T> MaBoite<T> {
    fn new(x: T) -> MaBoite<T> {
        MaBoite(x)
    }
}

fn saluer(nom: &str) {
    println!("Salutations, {} !", nom);
}

fn main() {
    let m = MaBoite::new(String::from("Rust"));
    saluer(&m);
}

Encart 15-12 : appel à saluer avec une référence à une valeur du type MaBoite<String>, qui fonctionne grâce à l'extrapolation de déréférencement

Ici nous appelons la fonction saluer avec l'argument &m, qui est une référence vers une valeur de type MaBoite<String>. Comme nous avons implémenté le trait Deref sur MaBoite<T> dans l'encart 15-10, Rust peut transformer le &MaBoite<String> en &String en appelant deref. La bibliothèque standard fournit une implémentation de Deref sur String qui retourne une slice de chaîne de caractères, comme expliqué dans la documentation de l'API de Deref. Rust appelle à nouveau deref pour transformer le &String en &str, qui correspond à la définition de la fonction saluer.

Si Rust n'avait pas implémenté l'extrapolation de déréférencement, nous aurions dû écrire le code de l'encart 15-13 au lieu du code de l'encart 15-12 pour appeler saluer avec une valeur du type &MaBoite<String>.

Fichier : src/main.rs

use std::ops::Deref;

impl<T> Deref for MaBoite<T> {
    type Target = T;

    fn deref(&self) -> &T {
        &self.0
    }
}

struct MaBoite<T>(T);

impl<T> MaBoite<T> {
    fn new(x: T) -> MaBoite<T> {
        MaBoite(x)
    }
}

fn saluer(nom: &str) {
    println!("Salutations, {} !", nom);
}

fn main() {
    let m = MaBoite::new(String::from("Rust"));
    saluer(&(*m)[..]);
}

Encart 15-13 : le code que nous aurions dû écrire si Rust n'avait pas d'extrapolation de déréférencement

Le (*m) déréférence la MaBoite<String> en une String. Ensuite le & et le [..] créent une slice de chaîne de caractères à partir de la String qui est égale à l'intégralité du contenu de la String, ceci afin de correspondre à la signature de saluer. Le code sans l'extrapolation de déréférencement est bien plus difficile à lire, écrire et comprendre avec la présence de tous ces symboles. L'extrapolation de déréférencement permet à Rust d'automatiser ces convertions pour nous.

Lorsque le trait Deref est défini pour les types concernés, Rust va analyser les types et utiliser Deref::deref autant de fois que nécessaire pour obtenir une référence qui correspond au type du paramètre. Le nombre de fois qu'il est nécessaire d'insérer Deref::deref est résolu au moment de la compilation, ainsi il n'y a pas de surcoût au moment de l'exécution pour bénéficier de l'extrapolation de déréférencement !

L'interaction de l'extrapolation de déréférencement avec la mutabilité

De la même manière que vous pouvez utiliser le trait Deref pour remplacer le comportement de l'opérateur * sur les références immuables, vous pouvez utiliser le trait DerefMut pour remplacer le comportement de l'opérateur * sur les références mutables.

Rust procède à l'extrapolation de déréférencement lorsqu'il trouve des types et des implémentations de traits dans trois cas :

  • Passer de &T à &U lorsque T: Deref<Target=U>
  • Passer de &mut T à &mut U lorsque T: DerefMut<Target=U>
  • Passer de &mut T à &U lorsque T: Deref<Target=U>

Les deux premiers cas sont exactement les mêmes, sauf pour la mutabilité. Le premier cas signifie que si vous avez un &T et que T implémente Deref pour le type U, vous pouvez obtenir un &U de manière transparente. Le deuxième cas signifie que la même extrapolation de déréférencement se déroule pour les références mutables.

Le troisième cas est plus ardu : Rust va aussi procéder à une extrapolation de déréférencement d'une référence mutable vers une référence immuable. Mais l'inverse n'est pas possible: une extrapolation de déréférencement d'une valeur immuable ne donnera jamais une référence mutable. A cause des règles d'emprunt, si vous avez une référence mutable, cette référence mutable doit être la seule référence vers cette donnée (autrement, le programme ne peut pas être compilé). Convertir une référence mutable vers une référence immuable ne va jamais casser les règles d'emprunt. Convertir une référence immuable vers une référence mutable nécessite que la référence immuable initiale soit la seule référence immuable vers cette donnée, mais les règles d'emprunt ne garantissent pas cela. Rust ne peut donc pas déduire que la conversion d'une référence immuable vers une référence mutable est possible.