Pratiques sur les types avancés de Rust

RustRustBeginner
Pratiquer maintenant

This tutorial is from open-source community. Access the source code

💡 Ce tutoriel est traduit par l'IA à partir de la version anglaise. Pour voir la version originale, vous pouvez cliquer ici

Introduction

Bienvenue dans Advanced Types. Ce laboratoire est une partie du Rust Book. Vous pouvez pratiquer vos compétences Rust dans LabEx.

Dans ce laboratoire, nous allons discuter des newtypes, des alias de type, du type ! et des types de taille dynamique dans le système de types Rust.

Advanced Types

Le système de types Rust possède certaines fonctionnalités que nous avons mentionnées jusqu'à présent mais que nous n'avons pas encore discutées. Nous commencerons par discuter des newtypes en général en examinant pourquoi les newtypes sont utiles en tant que types. Ensuite, nous passerons aux alias de type, une fonctionnalité similaire aux newtypes mais avec des sémantiques légèrement différentes. Nous discuterons également du type ! et des types de taille dynamique.

Utilisation du motif newtype pour la sécurité et l'abstraction de type

Note : Cette section suppose que vous avez lu la section précédente "Utilisation du motif newtype pour implémenter des traits externes".

Le motif newtype est également utile pour des tâches autres que celles que nous avons discutées jusqu'à présent, notamment pour contraindre statiquement les valeurs à ne jamais être confondues et pour indiquer les unités d'une valeur. Vous avez vu un exemple d'utilisation de newtypes pour indiquer les unités dans la liste 19-15 : rappelez-vous que les structs Millimeters et Meters ont encapsulé des valeurs u32 dans un newtype. Si nous écrivons une fonction avec un paramètre de type Millimeters, nous ne pourrons pas compiler un programme qui essayerait accidentellement d'appeler cette fonction avec une valeur de type Meters ou une simple u32.

Nous pouvons également utiliser le motif newtype pour dissimuler certains détails d'implémentation d'un type : le nouveau type peut exposer une API publique différente de l'API du type interne privé.

Les newtypes peuvent également cacher l'implémentation interne. Par exemple, nous pourrions fournir un type People pour encapsuler un HashMap<i32, String> qui stocke l'identifiant d'une personne associé à son nom. Le code utilisant People n'interagirait que avec l'API publique que nous fournissons, telle qu'une méthode pour ajouter une chaîne de nom à la collection People ; ce code n'aurait pas besoin de savoir que nous attribuons un identifiant i32 aux noms en interne. Le motif newtype est un moyen léger d'atteindre l'encapsulation pour cacher les détails d'implémentation, que nous avons discuté dans "L'encapsulation qui cache les détails d'implémentation".

Création de synonymes de type avec des alias de type

Rust permet de déclarer un alias de type pour donner un autre nom à un type existant. Pour ce faire, on utilise le mot clé type. Par exemple, on peut créer l'alias Kilomètres pour i32 comme ceci :

type Kilomètres = i32;

Maintenant, l'alias Kilomètres est un synonyme de i32 ; contrairement aux types Millimètres et Mètres que nous avons créés dans la liste 19-15, Kilomètres n'est pas un type séparé et nouveau. Les valeurs qui ont le type Kilomètres seront traitées de la même manière que les valeurs de type i32 :

type Kilomètres = i32;

let x: i32 = 5;
let y: Kilomètres = 5;

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

Comme Kilomètres et i32 sont le même type, on peut additionner les valeurs des deux types et on peut passer des valeurs de type Kilomètres à des fonctions qui prennent des paramètres de type i32. Cependant, avec cette méthode, on ne bénéficie pas des avantages de vérification de type que l'on obtient avec le motif newtype discuté précédemment. En d'autres termes, si on mélange des valeurs de type Kilomètres et i32 quelque part, le compilateur ne nous donnera pas d'erreur.

Le principal cas d'utilisation des synonymes de type est de réduire la répétition. Par exemple, on pourrait avoir un type long comme celui-ci :

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

Écrire ce type long dans les signatures de fonctions et comme annotations de type partout dans le code peut être fastidieux et propice à des erreurs. Imaginez avoir un projet plein de code comme celui de la liste 19-24.

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

fn takes_long_type(f: Box<dyn Fn() + Send + 'static>) {
    --snip--
}

fn returns_long_type() -> Box<dyn Fn() + Send + 'static> {
    --snip--
}

Liste 19-24 : Utilisation d'un type long à de nombreux endroits

Un alias de type rend ce code plus facile à gérer en réduisant la répétition. Dans la liste 19-25, on a introduit un alias nommé Thunk pour le type verbeux et on peut remplacer toutes les utilisations du type par l'alias plus court Thunk.

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

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

fn takes_long_type(f: Thunk) {
    --snip--
}

fn returns_long_type() -> Thunk {
    --snip--
}

Liste 19-25 : Introduction d'un alias de type Thunk pour réduire la répétition

Ce code est beaucoup plus facile à lire et à écrire! Choisir un nom significatif pour un alias de type peut également aider à communiquer votre intention (thunk est un mot pour le code à évaluer plus tard, donc c'est un nom approprié pour une closure qui est stockée).

Les alias de type sont également couramment utilisés avec le type Result<T, E> pour réduire la répétition. Considérez le module std::io de la bibliothèque standard. Les opérations d'entrée/sortie renvoient souvent un Result<T, E> pour gérer les situations où les opérations échouent. Cette bibliothèque a une struct std::io::Error qui représente toutes les erreurs d'entrée/sortie possibles. Beaucoup des fonctions dans std::io renverront Result<T, E> où l'E est std::io::Error, comme ces fonctions 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é beaucoup. En conséquence, std::io a cette déclaration d'alias de type :

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

Comme cette déclaration est dans le module std::io, on peut utiliser l'alias qualifié std::io::Result<T> ; c'est-à-dire un Result<T, E> avec l'E remplacé par std::io::Error. Les signatures de fonctions du trait Write finissent par ressembler à ceci :

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 est utile de deux manières : il facilite l'écriture du code et il nous donne une interface cohérente dans tout std::io. Comme c'est un alias, c'est juste un autre Result<T, E>, ce qui signifie que l'on peut utiliser toutes les méthodes qui fonctionnent sur Result<T, E> avec lui, ainsi que la syntaxe spéciale comme l'opérateur ?.

Le type ! qui ne renvoie jamais

Rust a un type spécial nommé ! qui est connu en termes de théorie des types comme le type vide car il n'a pas de valeurs. Nous préférons l'appeler type jamais car il prend la place du type de retour lorsqu'une fonction ne retournera jamais. Voici un exemple :

fn bar() ->! {
    --snip--
}

Ce code est lu comme "la fonction bar renvoie jamais". Les fonctions qui renvoient jamais sont appelées fonctions divergentes. Nous ne pouvons pas créer de valeurs du type !, donc bar ne peut jamais renvoyer.

Mais à quoi sert un type pour lequel vous ne pouvez jamais créer de valeurs? Rappelez-vous le code de la liste 2-5, partie du jeu de devinette de nombre ; nous en reproduisons un peu ici dans la liste 19-26.

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

Liste 19-26 : Un match avec un bras qui se termine par continue

À l'époque, nous avons sauté certains détails de ce code. Dans "La construction de flux de contrôle match", nous avons discuté que les bras d'un match doivent tous renvoyer le même type. Ainsi, par exemple, le code suivant ne fonctionne pas :

let guess = match guess.trim().parse() {
    Ok(_) => 5,
    Err(_) => "hello",
};

Le type de guess dans ce code devrait être à la fois un entier et une chaîne de caractères, et Rust exige que guess ait seulement un type. Alors, que renvoie continue? Comment avons-nous été autorisés à renvoyer un u32 à partir d'un bras et à avoir un autre bras qui se termine par continue dans la liste 19-26?

Comme vous avez peut-être deviné, continue a une valeur de type !. C'est-à-dire que lorsque Rust calcule le type de guess, il examine les deux bras du match, le premier avec une valeur de type u32 et le second avec une valeur de type !. Comme ! ne peut jamais avoir de valeur, Rust décide que le type de guess est u32.

La manière formelle de décrire ce comportement est que les expressions de type ! peuvent être contraintes à n'importe quel autre type. Nous sommes autorisés à terminer ce bras de match par continue car continue ne renvoie pas de valeur ; au lieu de cela, elle renvoie le contrôle au début de la boucle, donc dans le cas Err, nous n'assignons jamais de valeur à guess.

Le type jamais est également utile avec la macro panic!. Rappelez-vous la fonction unwrap que nous appelons sur des valeurs de type Option<T> pour produire une valeur ou déclencher une panique avec cette définition :

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, la même chose se passe que dans le match de la liste 19-26 : Rust voit que val a le type T et que panic! a le type !, donc le résultat de l'expression match globale est T. Ce code fonctionne car panic! ne produit pas de valeur ; elle termine le programme. Dans le cas None, nous ne renverrons pas de valeur à partir de unwrap, donc ce code est valide.

Une dernière expression qui a le type ! est une boucle loop :

print!("pour toujours ");

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

Ici, la boucle ne se termine jamais, donc ! est la valeur de l'expression. Cependant, ce ne serait pas vrai si nous avions inclus un break, car la boucle se terminerait lorsqu'elle arriverait au break.

Types de taille dynamique et le trait Sized

Rust doit connaître certains détails sur ses types, par exemple combien d'espace allouer pour une valeur d'un type particulier. Cela laisse un coin de son système de types un peu confus au départ : le concept de types de taille dynamique. Parfois appelés DST ou types non dimensionnés, ces types nous permettent d'écrire du code utilisant des valeurs dont la taille ne peut être connue que pendant l'exécution.

Plongeons dans les détails d'un type de taille dynamique appelé str, que nous avons utilisé tout au long du livre. C'est vrai, pas &str, mais str tout seul, est un DST. Nous ne pouvons pas savoir combien de caractères la chaîne contient jusqu'à l'exécution, ce qui signifie que nous ne pouvons pas créer une variable de type str, ni prendre un argument de type str. Considérez le code suivant, qui ne fonctionne pas :

let s1: str = "Hello there!";
let s2: str = "How's it going?";

Rust doit savoir combien de mémoire allouer pour toute valeur d'un type particulier, et toutes les valeurs d'un type doivent utiliser la même quantité de mémoire. Si Rust nous autorisait à écrire ce code, ces deux valeurs de type str devraient occuper la même quantité d'espace. Mais elles ont des longueurs différentes : s1 nécessite 12 octets de stockage et s2 nécessite 15. C'est pourquoi il n'est pas possible de créer une variable contenant un type de taille dynamique.

Alors, que faisons-nous? Dans ce cas, vous savez déjà la réponse : nous donnons à s1 et s2 le type &str plutôt que str. Rappelez-vous de "Tranche de chaîne" que la structure de données de tranche stocke juste la position de départ et la longueur de la tranche. Ainsi, bien qu'un &T soit une seule valeur qui stocke l'adresse mémoire où se trouve le T, un &str est deux valeurs : l'adresse du str et sa longueur. En conséquence, nous pouvons connaître la taille d'une valeur de type &str à la compilation : elle est le double de la longueur d'un usize. C'est-à-dire que nous connaissons toujours la taille d'un &str, quelle que soit la longueur de la chaîne qu'il référence. En général, c'est ainsi que les types de taille dynamique sont utilisés en Rust : ils ont un supplément d'informations métadonnées qui stockent la taille des informations dynamiques. La règle d'or des types de taille dynamique est que nous devons toujours placer les valeurs de types de taille dynamique derrière un pointeur de quelque type que ce soit.

Nous pouvons combiner str avec tous types de pointeurs : par exemple, Box<str> ou Rc<str>. En fait, vous l'avez déjà vu auparavant mais avec un type de taille dynamique différent : les traits. Chaque trait est un type de taille dynamique que nous pouvons référencer en utilisant le nom du trait. Dans "Utilisation d'objets de trait qui autorisent des valeurs de différents types", nous avons mentionné que pour utiliser les traits en tant qu'objets de trait, nous devons les placer derrière un pointeur, tel que &dyn Trait ou Box<dyn Trait> (Rc<dyn Trait> fonctionnerait également).

Pour travailler avec les DST, Rust fournit le trait Sized pour déterminer si la taille d'un type est connue à la compilation ou non. Ce trait est automatiquement implémenté pour tout ce dont la taille est connue à la compilation. De plus, Rust ajoute implicitement une contrainte sur Sized à chaque fonction générique. C'est-à-dire qu'une définition de fonction générique comme celle-ci :

fn generic<T>(t: T) {
    --snip--
}

est en fait traitée comme si nous avions écrit ceci :

fn generic<T: Sized>(t: T) {
    --snip--
}

Par défaut, les fonctions génériques ne fonctionneront que sur des types dont la taille est connue à la compilation. Cependant, vous pouvez utiliser la syntaxe spéciale suivante pour relâcher cette restriction :

fn generic<T:?Sized>(t: &T) {
    --snip--
}

Une contrainte de trait sur ?Sized signifie "T peut ou non être Sized" et cette notation remplace la valeur par défaut selon laquelle les types génériques doivent avoir une taille connue à la compilation. La syntaxe ?Trait avec ce sens n'est disponible que pour Sized, pas pour aucun autre trait.

Notez également que nous avons changé le type du paramètre t de T à &T. Parce que le type peut ne pas être Sized, nous devons l'utiliser derrière un pointeur de quelque type que ce soit. Dans ce cas, nous avons choisi une référence.

Ensuite, nous parlerons des fonctions et des closures!

Sommaire

Félicitations! Vous avez terminé le laboratoire Types avancés. Vous pouvez pratiquer d'autres laboratoires sur LabEx pour améliorer vos compétences.