Les types avancés

Le système de type de Rust offre quelques fonctionnalités que nous avons mentionnées dans ce livre mais que nous n'avons pas encore étudiées. Nous allons commencer par voir les newtypes en général lorsque nous examinerons pourquoi les newtypes sont des types utiles. Ensuite nous nous pencherons sur les alias de type, une fonctionnalité qui ressemble aux newtypes mais avec quelques différences sémantiques. Nous allons aussi voir le type ! et les types à taille dynamique.

Utiliser le motif newtype pour la sécurité et l'abstraction des types

Remarque : cette section suppose que vous avez lu la section précédente

Le motif newtype est utile pour faire des choses qui vont au-delà de ce que nous avons vu jusqu'à présent, notamment pour s'assurer statiquement que des valeurs ne soient jamais confondues ou pour spécifier les unités d'une valeur. Vous avez vu un exemple d'utilisation des newtypes pour indiquer des unités dans l'encart 19-15 : souvenez-vous des structures Millimetres et Metres qui englobaient des valeurs u32 dans ces newtypes. Si nous avions écrit une fonction avec un paramètre de type Millimetres, nous ne n'aurions pas pu compiler un programme qui aurait accidentellement fait appel à cette fonction avec une valeur du type Metres ou u32 pur.

Une autre utilisation du motif newtype est de permettre d'abstraire certains détails d'implémentation d'un type : le newtype peut exposer une API publique qui est différente de l'API du type interne privé.

Les newtypes peuvent aussi masquer des implémentations internes. Par exemple, nous pouvons fournir un type Personnes pour embarquer un HashMap<i32, String> qui stocke l'identifiant de personnes associés à leur nom. Le code qui utilisera Personnes ne pourra utiliser que l'API publique que nous fournissons, telle qu'une méthode pour ajouter une chaîne de caractères en tant que nom à la collection Personnes ; ce code n'aura pas besoin de savoir que nous assignons en interne un identifiant i32 aux noms. Le motif newtype est une façon allégée de procéder à de l'encapsulation pour masquer des détails d'implémentation, comme nous l'avons vu dans une partie du chapitre 17.

Créer des synonymes de types avec les alias de type

En plus du motif newtype, Rust fournit la possibilité de déclarer un alias de type pour donner un autre nom à un type déjà existant. Pour faire cela, nous utilisons le mot-clé type. Par exemple, nous pouvons créer l'alias Kilometres pour un i32, comme ceci :

fn main() {
    type Kilometres = i32;

    let x: i32 = 5;
    let y: Kilometres = 5;

    println!("x + y = {}", x + y);
}

Désormais, l'alias Kilometres est un synonyme de i32 ; contrairement aux types Millimetres et Metres que nous avons créés dans l'encart 19-15, Kilometres n'est pas un newtype séparé. Les valeurs qui ont le type Kilometre seront traitées comme si elles étaient du type i32 :

fn main() {
    type Kilometres = i32;

    let x: i32 = 5;
    let y: Kilometres = 5;

    println!("x + y = {}", x + y);
}

Comme Kilometres et i32 sont du même type, nous pouvons additionner les valeurs des deux types et nous pouvons envoyer des valeurs Kilometres aux fonctions qui prennent des paramètres i32. Cependant, en utilisant cette méthode, nous ne bénéficions pas des bienfaits de la vérification du type que nous avions avec le motif newtype que nous avons vu précédemment.

L'utilisation principale pour les synonymes de types est de réduire la répétition. Par exemple, nous pourrions avoir un type un peu long tel que celui-ci :

Box<dyn Fn() + Send + 'static>

Ecrire ce type un peu long dans des signatures de fonctions et comme annotations de types tout au long du code peut s'avérer pénible et faciliter les erreurs. Imaginez que vous ayez un projet avec plein de code ressemblant à celui de l'encart 19-24.

fn main() {
    let f: Box<dyn Fn() + Send + 'static> = Box::new(|| println!("salut"));

    fn prend_un_long_type(f: Box<dyn Fn() + Send + 'static>) {
        // -- partie masquée ici --
    }

    fn retourne_un_long_type() -> Box<dyn Fn() + Send + 'static> {
        // -- partie masquée ici --
        Box::new(|| ())
    }
}

Encart 19-24 : utilisation d'un type long à écrire dans de nombreux endroits

Un alias de type simplifie ce code en réduisant la répétition. Dans l'encart 19-25, nous avons ajouté un alias Thunk pour ce type verbeux, alias plus court qui peut le remplacer partout où il est utilisé.

fn main() {
    type Thunk = Box<dyn Fn() + Send + 'static>;

    let f: Thunk = Box::new(|| println!("salut"));

    fn prend_un_long_type(f: Thunk) {
        // -- partie masquée ici --
    }

    fn retourne_un_long_type() -> Thunk {
        // -- partie masquée ici --
        Box::new(|| ())
    }
}

Encart 19-25 : ajout et utilisation d'un alias Thunk pour réduire les répétitions

Ce code est plus facile à lire et écrire ! Choisir un nom plus explicite pour un alias peut aussi vous aider à communiquer ce que vous voulez faire (thunk est un terme désignant du code qui doit être évalué plus tard, donc c'est un nom approprié pour une fermeture qui est stockée).

Les alias de type sont couramment utilisés avec le type Result<T, E> pour réduire la répétition. Regardez le module std::io de la bibliothèque standard. Les opérations d'entrée/sortie retournent souvent un Result<T, E> pour gérer les situations où les opérations échouent. Cette bibliothèque a une structure std::io::Error qui représente toutes les erreurs possibles d'entrée/sortie. De nombreuses fonctions dans std::io vont retourner un Result<T, E> avec E qui est un alias pour std::io::Error, comme par exemple ces fonctions sont dans le trait Write :

use std::fmt;
use std::io::Error;

pub trait Write {
    fn write(&mut self, buf: &[u8]) -> Result<usize, Error>;
    fn flush(&mut self) -> Result<(), Error>;

    fn write_all(&mut self, buf: &[u8]) -> Result<(), Error>;
    fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<(), Error>;
}

Le Result<..., Error> est répété plein de fois. C'est pourquoi std::io possède cette déclaration d'alias de type :

use std::fmt;

type Result<T> = std::result::Result<T, std::io::Error>;

pub trait Write {
    fn write(&mut self, buf: &[u8]) -> Result<usize>;
    fn flush(&mut self) -> Result<()>;

    fn write_all(&mut self, buf: &[u8]) -> Result<()>;
    fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<()>;
}

Comme cette déclaration est dans le module std::io, nous pouvons utiliser l'alias std::io::Result<T> — qui est un Result<T, E> avec le E qui est déjà renseigné comme étant un std::io::Error. Les fonctions du trait Write ressemblent finalement à ceci :

use std::fmt;

type Result<T> = std::result::Result<T, std::io::Error>;

pub trait Write {
    fn write(&mut self, buf: &[u8]) -> Result<usize>;
    fn flush(&mut self) -> Result<()>;

    fn write_all(&mut self, buf: &[u8]) -> Result<()>;
    fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<()>;
}

L'alias de type nous aide sur deux domaines : il permet de faciliter l'écriture du code et il nous donne une interface cohérente pour tout std::io. Comme c'est un alias, c'est simplement un autre Result<T, E>, ce qui signifie que nous pouvons utiliser n'importe quelle méthode qui fonctionne avec Result<T, E>, ainsi que les syntaxes spéciales telle que l'opérateur ?.

Le type "jamais", qui ne retourne jamais de valeur

Rust a un type spécial qui s'appelle ! qui est connu dans le vocabulaire de la théorie des types comme étant le type vide car il n'a pas de valeur. Nous préférons appeler cela le type jamais car il remplace le type de retour lorsqu'une fonction ne va jamais retourner quelque chose. Voici un exemple :

fn bar() -> ! {
    // -- partie masquée ici --
    panic!();
}

Ce code peut être interprété comme “la fonction bar qui ne retourne pas de valeur”. Les fonctions qui ne retournent pas de valeur s'appellent des fonctions divergentes. Nous ne pouvons pas créer de valeurs de type ! donc bar ne pourra jamais retourner de valeur.

Mais à quoi sert un type dont on ne peut jamais créer de valeurs ? Souvenez-vous du code de l'encart 2-5 ; nous avons reproduit une partie de celui-ci dans l'encart 19-26.

use rand::Rng;
use std::cmp::Ordering;
use std::io;

fn main() {
    println!("Devinez le nombre !");

    let nombre_secret = rand::thread_rng().gen_range(1..101);

    println!("Le nombre secret est : {}", nombre_secret);

    loop {
        println!("Veuillez entrer un nombre.");

        let mut supposition = String::new();

        // -- partie masquée ici --

        io::stdin()
            .read_line(&mut supposition)
            .expect("Échec de la lecture de l'entrée utilisateur");

        let supposition: u32 = match supposition.trim().parse() {
            Ok(nombre) => nombre,
            Err(_) => continue,
        };

        println!("Votre nombre : {}", supposition);

        // -- partie masquée ici --

        match supposition.cmp(&nombre_secret) {
            Ordering::Less => println!("C'est plus !"),
            Ordering::Greater => println!("C'est moins !"),
            Ordering::Equal => {
                println!("Vous avez gagné !");
                break;
            }
        }
    }
}

Encart 19-26 : un match avec une branche qui finit par un continue

A l'époque, nous avions sauté quelques détails dans ce code. Dans la section “La structure de contrôle match du chapitre 6, nous avons vu que les branches d'un match doivent toutes retourner le même type. Donc, par exemple, le code suivant ne fonctionne pas :

fn main() {
    let supposition = "3";
    let supposition = match supposition.trim().parse() {
        Ok(_) => 5,
        Err(_) => "salut",
    };
}

Le type de supposition dans ce code devrait être un entier et une chaîne de caractères, et Rust nécessite que supposition n'ait qu'un seul type possible. Donc que retourne continue ? Pourquoi pouvons-nous retourner un u32 dans une branche et avoir une autre branche qui finit avec un continue dans l'encart 19-26 ?

Comme vous l'avez deviné, continue a une valeur !. Ainsi, lorsque Rust calcule le type de supposition, il regarde les deux branches, la première avec une valeur u32 et la seconde avec une valeur !. Comme ! ne peut jamais retourner de valeur, Rust décide alors que le type de supposition est u32.

Une façon classique de décrire ce comportement est de dire que les expressions du type ! peuvent être transformées dans n'importe quel type. Nous pouvons finir cette branche de match avec continue car continue ne retourne pas de valeur ; à la place, il retourne le contrôle en haut de la boucle, donc dans le cas d'un Err, nous n'assignons jamais de valeur à supposition.

Ce type "jamais" est tout aussi utile avec la macro panic!. Vous souvenez-vous que la fonction unwrap que nous appelons sur les valeurs Option<T> fournit une valeur ou panique ? Voici sa définition :

enum Option<T> {
    Some(T),
    None,
}

use crate::Option::*;

impl<T> Option<T> {
    pub fn unwrap(self) -> T {
        match self {
            Some(val) => val,
            None => panic!("called `Option::unwrap()` on a `None` value"),
        }
    }
}

Dans ce code, il se passe la même chose que l'encart 19-26 : Rust constate que val est du type T et que panic! est du type !, donc le résultat de l'ensemble de l'expression match est T. Ce code fonctionne car panic! ne produit pas de valeur ; il termine le programme. Dans le cas d'un None, nous ne retournons pas une valeur de unwrap, donc ce code est valide.

Une des expressions qui sont du type ! est le loop :

fn main() {
    print!("pour toujours ");

    loop {
        print!("et toujours ");
    }
}

Ici, la boucle ne se termine jamais, donc ! est la valeur de cette expression. En revanche, cela ne sera pas vrai si nous utilisons un break, car la boucle va s'arrêter lorsqu'elle rencontrera le break.

Les types à taille dynamique et le trait Sized

Vu qu'il est nécessaire pour Rust de connaître certains détails, comme la quantité d'espace à allouer à une valeur d'un type donné, il y a un aspect de ce système de type qui peut être déroutant : le concept des types à taille dynamique. Parfois appelés DST (Dynamically Sized Types) ou types sans taille, ces types nous permettent d'écrire du code qui utilise des valeurs dont la taille ne peut être connue qu'à l'exécution.

Voyons les détails d'un type à taille dynamique qui s'appelle str, que nous avons utilisé dans ce livre. Notez bien que ce n'est pas &str, mais bien str qui est un DST. Nous ne pouvons connaître la longueur de la chaîne de caractère qu'à l'exécution, ce qui signifie que nous ne pouvons ni créer une variable de type str, ni prendre en argument un type str. Imaginons le code suivant, qui ne fonctionnera pas :

fn main() {
    let s1: str = "Salut tout le monde !";
    let s2: str = "Comment ça va ?";
}

Rust a besoin de savoir combien de mémoire allouer pour chaque valeur d'un type donné, et toutes les valeurs de ce type doivent utiliser la même quantité de mémoire. Si Rust nous avait autorisé à écrire ce code, ces deux valeurs str devraient occuper la même quantité de mémoire. Mais elles ont deux longueurs différentes : s1 prend 21 octets en mémoire alors que s2 en a besoin de 15. C'est pourquoi il est impossible de créer une variable qui stocke un type à taille dynamique.

Donc qu'est-ce qu'on peut faire ? Dans ce cas, vous connaissez déjà la réponse : nous faisons en sorte que le type de s1 et s2 soit &str plutôt que str. Souvenez-vous que dans la section “Les slices de chaînes de caractères” du chapitre 4, nous avions dit que la structure de données slice stockait l'emplacement de départ et la longueur de la slice.

Aussi, bien qu'un &T soit une valeur unique qui stocke l'adresse mémoire à laquelle se trouve le T, un &str est constitué de deux valeurs : l'adresse du str et sa longueur. Ainsi, nous pouvons connaître la taille d'une valeur &str à la compilation : elle vaut deux fois la taille d'un usize. Ce faisant, nous connaissons toujours la taille d'un &str, peu importe la longueur de la chaîne de caractères sur laquelle il pointe. Généralement, c'est comme cela que les types à taille dynamique sont utilisés en Rust : ils ont des métadonnées supplémentaires qui stockent la taille des informations dynamiques. La règle d'or des types à taille dynamique est que nous devons toujours placer les valeurs à types à taille dynamique derrière un pointeur d'une certaine sorte.

Nous pouvons combiner str avec n'importe quel type de pointeur : par exemple, Box<str> ou Rc<str>. En fait, vous avez vu cela déjà auparavant mais avec un autre type à taille dynamique : les traits. Chaque trait est un type à taille dynamique auquel nous pouvons nous référer en utilisant le nom du trait. Dans une section du chapitre 17, nous avions mentionné que pour utiliser les traits comme des objets traits, nous devions les utiliser via un pointeur, tel que &dyn Trait ou Box<dyn Trait> (Rc<dyn Trait> fonctionnera également).

Pour pouvoir travailler avec les DST, Rust dispose d'un trait particulier Sized pour déterminer si oui ou non la taille d'un type est connue à la compilation. Ce trait est automatiquement implémenté sur tout ce qui a une taille connue à la compilation. De plus, Rust ajoute implicitement le trait lié Sized sur chaque fonction générique. Ainsi, la définition d'une fonction générique telle que celle-ci :

fn generique<T>(t: T) {
    // -- partie masquée ici --
}

... est en réalité traitée comme si nous avions écrit ceci :

fn generique<T: Sized>(t: T) {
    // -- partie masquée ici --
}

Par défaut, les fonctions génériques vont fonctionner uniquement sur des types qui ont une taille connue à la compilation. Cependant, vous pouvez utiliser la syntaxe spéciale suivante pour éviter cette restriction :

fn generique<T: ?Sized>(t: &T) {
    // -- partie masquée ici --
}

Le trait lié ?Sized signifie que “T peut être ou ne pas être Sized” et cette notation prévaut sur le comportement par défaut qui dit que les types génériques doivent avoir une taille connue au moment de la compilation. La syntaxe ?Trait avec ce comportement n'est disponible que pour Sized, et pour aucun autre trait.

Remarquez aussi que nous avons changé le type du paramètre t de T en &T. Comme ce type pourrait ne pas être un Sized, nous devons l'utiliser via un pointeur d'une sorte ou d'une autre. Dans ce cas, nous avons choisi une référence.

Dans la partie suivante, nous allons parler des fonctions et des fermetures !