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

Types avancés

Le système de type de Rust offre quelques fonctionnalités que nous avons déjà mentionnées mais que nous n’avons pas encore étudiées. Nous allons commencer par voir les newtypes en général lorsque nous examinerons pourquoi ce 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.

Sécurité de type et abstraction avec le motif newtype

On présuppose que vous avez déjà lu la section “Implémentation de traits externes avec le motif Newtype”. Le motif newtype est également 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 20-16 : 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 n’aurions pas pu compiler un programme qui aurait accidentellement fait appel à cette fonction avec une valeur du type Metres ou u32 pur.

Nous pouvons aussi utiliser le motif newtype pour 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 la section “L’encapsulation qui masque les détails d’implémentation” du chapitre 18.

Synonymes de types et alias de types

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 20-16, Kilometres n’est pas un newtype séparé. Les valeurs qui ont le type Kilometres 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 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.Autrement dit, si nous mélangeons des valeurs Kilometres et i32 quelque part, le compilateur ne nous renverra pas une erreur.

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>

Écrire 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 20-25.

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(|| ())
    }
}
Listing 20-25: Using a long type in many places

Un alias de type simplifie ce code en réduisant la répétition. Dans l’encart 20-26, 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(|| ())
    }
}
Listing 20-26: Introducing a type alias, Thunk, to reduce repetition

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 pleinement qualifié std::io::Result<T>, à savoir 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, qui fait partie du jeu du plus ou du moins ;nous avons reproduit ici une partie de celui-ci dans l’encart 20-27.

use std::cmp::Ordering;
use std::io;

use rand::prelude::*;

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

    let nombre_secret = rand::rng().random_range(1..=100);

    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!("You win!");
                break;
            }
        }
    }
}
Listing 20-27: A match with an arm that ends in continue

À l’époque, nous avions sauté quelques détails dans ce code. Dans la section “La structure de contrôle de flux 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 20-27 ?

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!. Rappelez-vous de la fonction unwrap que nous appelons sur les valeurs Option<T> pour fournir une valeur ou paniquer, avec cette 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!("appelé `Option::unwrap()` sur une valeur `None`"),
        }
    }
}

Dans ce code, il se passe la même chose que l’encart 20-27 : Rust constate que valeur 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 expression finale qui a le type ! est la boucle loop :

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

    loop {
        print!("et à jamais ");
    }
}

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

Rust a besoin de connaître certains détails concernant ses types, comme la quantité d’espace à allouer à une valeur d’un type donné. Ceci laisse un aspect de ce système de type qui peut paraître a priori 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. Dans de nombreux cas, comme par exemple quand on veut stocker un texte entré par un utilisateur, nous ne pouvons connaître la longueur de la chaîne de caractères 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.

Que pouvons-nous donc faire ? Dans ce cas, vous connaissez déjà la réponse : nous faisons en sorte que le type de s1 et s2 soit un slice d’une chaîne de caractères (&str) plutôt que str. Souvenez-vous que dans la section “Les slices de chaînes de caractères” du chapitre 4, 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, une slice de chaîne de caractères est constituée 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’une slice de chaîne de caractères, peu importe la longueur de la chaîne de caractères sur laquelle elle 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 la section “Utilisation des objets traits pour abstraire des comportements communs” du chapitre 18, 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 fournit le trait 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 !