Les types avancés
Le système de types de Rust possède des 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 ils sont utiles en tant que types. Ensuite, nous passerons aux alias de types, une fonctionnalité similaire aux newtypes mais avec une sémantique légèrement différente. Nous discuterons aussi du type ! et des types à taille dynamique.
Sécurité de type et abstraction avec le patron newtype
Cette section suppose que vous avez lu la section précédente [“Implémenter des traits externes avec le patron newtype”][newtype]. Le patron newtype est aussi utile pour des tâches au-delà de celles que nous avons discutées jusqu’à présent, notamment pour imposer statiquement que les valeurs ne soient jamais confondues et indiquer les unités d’une valeur. Vous avez vu un exemple d’utilisation des newtypes pour indiquer les unités dans l’encart 20-16 : rappelez-vous que les structures Millimeters et Meters enveloppaient des valeurs u32 dans un newtype. Si nous écrivions une fonction avec un paramètre de type Millimeters, nous ne pourrions pas compiler un programme qui essaierait accidentellement d’appeler cette fonction avec une valeur de type Meters ou un simple u32.
Nous pouvons aussi utiliser le patron newtype pour abstraire 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é.
section in Chapter 18. –> Les newtypes peuvent aussi masquer l’implémentation interne. Par exemple, nous pourrions fournir un type People pour envelopper un HashMap<i32, String> qui stocké l’identifiant d’une personne associé à son nom. Le code utilisant People n’interagirait qu’avec l’API publique que nous fournissons, comme une méthode pour ajouter un nom en chaîne de caractères à la collection People ; ce code n’aurait pas besoin de savoir que nous assignons un identifiant i32 aux noms en interne. Le patron newtype est un moyen léger d’atteindre l’encapsulation pour masquer les détails d’implémentation, ce dont nous avons discuté dans la section [“L’encapsulation qui masque les détails d’implémentation”][encapsulation-that-hides-implementation-details] du chapitre 18.
Les synonymes de types et les alias de types
Rust offre la possibilité de déclarer un alias de type pour donner un autre nom à un type existant. Pour cela, nous utilisons le mot-clé type. Par exemple, nous pouvons créer l’alias Kilometers pour i32 comme suit : rust {{#rustdoc_include ../listings/ch20-advanced-features/no-listing-04-kilometers-alias/src/main.rs:here}}
fn main() {
type Kilometers = i32;
let x: i32 = 5;
let y: Kilometers = 5;
println!("x + y = {}", x + y);
}
Maintenant l’alias Kilometers est un synonyme de i32 ; contrairement aux types Millimeters et Meters que nous avons créés dans l’encart 20-16, Kilometers n’est pas un type nouveau et distinct. Les valeurs de type Kilometers seront traitées de la même manière que les valeurs de type i32 : rust {{#rustdoc_include ../listings/ch20-advanced-features/no-listing-04-kilometers-alias/src/main.rs:there}}
fn main() {
type Kilometers = i32;
let x: i32 = 5;
let y: Kilometers = 5;
println!("x + y = {}", x + y);
}
Comme Kilometers et i32 sont le même type, nous pouvons additionner des valeurs des deux types et passer des valeurs Kilometers à des fonctions qui prennent des paramètres i32. Cependant, avec cette méthode, nous n’obtenons pas les avantages de vérification de types que nous obtenons avec le patron newtype discuté précédemment. En d’autres termes, si nous mélangeons des valeurs Kilometers et i32 quelque part, le compilateur ne nous donnera pas d’erreur.
Le principal cas d’utilisation des synonymes de types est de réduire la répétition. Par exemple, nous pourrions 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 source d’erreurs. Imaginez avoir un projet plein de code comme dans l’encart 20-25.
fn main() {
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--
Box::new(|| ())
}
}
Un alias de type rend ce code plus gérable en réduisant la répétition. Dans l’encart 20-26, nous avons introduit un alias nommé Thunk pour le type verbeux et pouvons remplacer toutes les utilisations du type par l’alias plus court Thunk.
fn main() {
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--
Box::new(|| ())
}
}
Thunk, to reduce repetitionCe code est beaucoup plus facile à lire et à écrire ! Choisir un nom significatif pour un alias de type peut aussi aider à communiquer votre intention (thunk est un mot désignant du code à évaluer ultérieurement, c’est donc un nom approprié pour une fermeture qui est stockée).
Les alias de types sont aussi 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’E/S retournent souvent un Result<T, E> pour gérer les situations où les opérations échouent. Cette bibliothèque possède une structure std::io::Error qui représente toutes les erreurs d’E/S possibles. Beaucoup de fonctions dans std::io retourneront un Result<T, E> où E est std::io::Error, comme ces fonctions dans le trait Write : rust,noplayground {{#rustdoc_include ../listings/ch20-advanced-features/no-listing-05-write-trait/src/lib.rs}}
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 beaucoup répété. C’est pourquoi std::io possède cette déclaration d’alias de type : rust,noplayground {{#rustdoc_include ../listings/ch20-advanced-features/no-listing-06-result-alias/src/lib.rs:here}}
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 se trouve dans le module std::io, nous pouvons utiliser l’alias pleinement qualifié std::io::Result<T> ; c’est-à-dire un Result<T, E> avec E rempli par std::io::Error. Les signatures des fonctions du trait Write finissent par ressembler à ceci : rust,noplayground {{#rustdoc_include ../listings/ch20-advanced-features/no-listing-06-result-alias/src/lib.rs:there}}
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 aide de deux manières : il rend le code plus facile à écrire et nous donne une interface cohérente dans tout std::io. Parce que c’est un alias, c’est juste un autre Result<T, E>, ce qui signifie que nous pouvons 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 never qui ne retourné jamais
Rust possède un type spécial nommé ! qui est connu dans le jargon de la théorie des types comme le type vide car il n’a pas de valeurs. Nous préférons l’appeler le type never car il tient la place du type de retour lorsqu’une fonction ne retournera jamais. Voici un exemple : rust,noplayground {{#rustdoc_include ../listings/ch20-advanced-features/no-listing-07-never-type/src/lib.rs:here}}
fn bar() -> ! {
// --snip--
panic!();
}
Ce code se lit comme “la fonction bar ne retourné jamais.” Les fonctions qui ne retournent jamais sont appelées fonctions divergentes. Nous ne pouvons pas créer de valeurs du type !, donc bar ne peut jamais retourner.
Mais à quoi sert un type dont vous ne pouvez jamais créer de valeurs ? Rappelez-vous le code de l’encart 2-5, partie du jeu de devinette de nombres ; nous en avons reproduit un extrait ici dans l’encart 20-27.
use std::cmp::Ordering;
use std::io;
use rand::Rng;
fn main() {
println!("Guess the number!");
let secret_number = rand::thread_rng().gen_range(1..=100);
println!("The secret number is: {secret_number}");
loop {
println!("Please input your guess.");
let mut guess = String::new();
// --snip--
io::stdin()
.read_line(&mut guess)
.expect("Failed to read line");
let guess: u32 = match guess.trim().parse() {
Ok(num) => num,
Err(_) => continue,
};
println!("You guessed: {guess}");
// --snip--
match guess.cmp(&secret_number) {
Ordering::Less => println!("Too small!"),
Ordering::Greater => println!("Too big!"),
Ordering::Equal => {
println!("You win!");
break;
}
}
}
}
match with an arm that ends in continueÀ l’époque, nous avions passé sous silence certains détails de ce code. Dans la section « La construction de flux de contrôle match » du chapitre 6, nous avons discuté du fait que toutes les branches de match doivent retourner le même type. Ainsi, par exemple, le code suivant ne fonctionne pas{N}:
fn main() {
let guess = "3";
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 n’ait qu’un seul type. Alors, que retourné continue ? Comment avons-nous été autorisés à retourner un u32 d’une branche et à avoir une autre branche qui se terminé par continue dans l’encart 20-27 ?
Comme vous l’avez peut-être deviné, continue à une valeur de type !. C’est-à-dire, lorsque Rust calcule le type de guess, il regarde les deux branches du match, la première avec une valeur u32 et la seconde avec une valeur !. Parce que ! 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 converties implicitement en n’importe quel autre type. Nous sommes autorisés à terminer cette branche du match avec continue car continue ne retourné pas de valeur ; à la place, il transfère le contrôle au début de la boucle, donc dans le cas Err, nous n’assignons jamais de valeur à guess.
Le type never est aussi utile avec la macro panic!. Rappelez-vous la fonction unwrap que nous appelons sur les valeurs Option<T> pour produire une valeur ou paniquer avec cette définition : rust,ignore {{#rustdoc_include ../listings/ch20-advanced-features/no-listing-09-unwrap-definition/src/lib.rs:here}}
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, la même chose se passe que dans le match de l’encart 20-27 : Rust voit que val à le type T et panic! à le type !, donc le résultat de l’expression match globale est T. Ce code fonctionne car panic! ne produit pas de valeur ; il terminé le programme. Dans le cas None, nous ne retournerons pas de valeur depuis unwrap, donc ce code est valide.
Une dernière expression qui à le type ! est une boucle : rust,ignore {{#rustdoc_include ../listings/ch20-advanced-features/no-listing-10-loop-returns-never/src/main.rs:here}}
fn main() {
print!("forever ");
loop {
print!("and ever ");
}
}
Ici, la boucle ne se terminé jamais, donc ! est la valeur de l’expression. Cependant, ce ne serait pas vrai si nous incluions un break, car la boucle se terminerait lorsqu’elle atteindrait le break.
Les types à taille dynamique et le trait Sized
Rust a besoin de connaître certains détails sur ses types, comme combien d’espace allouer pour une valeur d’un type particulier. Cela laisse un coin de son système de types un peu déroutant au début : le concept de types à taille dynamique. Parfois appelés DST ou types non dimensionnés, ces types nous permettent d’écrire du code utilisant des valeurs dont nous ne pouvons connaître la taille qu’à l’exécution.
Creusons les détails d’un type à taille dynamique appelé str, que nous avons utilisé tout au long du livre. C’est exact, pas &str, mais str seul, est un DST. Dans de nombreux cas, comme lors du stockage de texte saisi par un utilisateur, nous ne pouvons pas savoir quelle est la longueur de la chaîne avant l’exécution. Cela 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 : rust,ignore,does_not_compile {{#rustdoc_include ../listings/ch20-advanced-features/no-listing-11-cant-create-str/src/main.rs:here}}
fn main() {
let s1: str = "Hello there!";
let s2: str = "How's it going?";
}
Rust a besoin de 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 permettait d’écrire ce code, ces deux valeurs str devraient occuper la même quantité d’espace. Mais elles ont des longueurs différentes : s1 a besoin de 12 octets de stockage et s2 a besoin de 15. C’est pourquoi il n’est pas possible de créer une variable contenant un type à taille dynamique.
Alors, que faisons-nous ? Dans ce cas, vous connaissez déjà la réponse : nous faisons du type de s1 et s2 une slice de chaîne (&str) plutôt que str. Rappelez-vous de la section [“Les slices de chaînes”][string-slices] du chapitre 4 que la structure de données slice ne stocké que la position de début et la longueur de la slice. Ainsi, bien que &T soit une seule valeur qui stocké l’adresse mémoire où se trouve le T, une slice de chaîne est deux valeurs : l’adresse du str et sa longueur. En tant que tel, nous pouvons connaître la taille d’une valeur de slice de chaîne au moment de la compilation : c’est deux fois la longueur d’un usize. C’est-à-dire que nous connaissons toujours la taille d’une slice de chaîne, quelle que soit la longueur de la chaîne à laquelle elle se réfère. En général, c’est de cette manière que les types à taille dynamique sont utilisés en Rust : ils ont un bit supplémentaire de métadonnées qui stocké la taille de l’information dynamique. La règle d’or des types à taille dynamique est que nous devons toujours placer les valeurs de types à taille dynamique derrière un pointeur d’un type ou d’un autre.
Nous pouvons combiner str avec toutes sortes de pointeurs : par exemple, Box<str> ou Rc<str>. En fait, vous avez déjà vu cela mais avec un type à taille dynamique différent : 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 [“Utiliser les objets trait pour abstraire un comportement partagé”][using-trait-objects-to-abstract-over-shared-behavior] du chapitre 18, nous avons mentionné que pour utiliser des traits comme objets trait, nous devons les placer derrière un pointeur, comme &dyn Trait ou Box<dyn Trait> (Rc<dyn Trait> fonctionnerait aussi).
Pour travailler avec les DST, Rust fournit le trait Sized pour déterminer si la taille d’un type est connue ou non au moment de la compilation. Ce trait est automatiquement implémenté pour tout ce dont la taille est connue au moment de la compilation. De plus, Rust ajouté 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 : rust,ignore {{#rustdoc_include ../listings/ch20-advanced-features/no-listing-12-generic-fn-definition/src/lib.rs}}
fn generic<T>(t: T) {
// --snip--
}
est en fait traitée comme si nous avions écrit ceci : rust,ignore {{#rustdoc_include ../listings/ch20-advanced-features/no-listing-13-generic-implicit-sized-bound/src/lib.rs}}
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 au moment de la compilation. Cependant, vous pouvez utiliser la syntaxe spéciale suivante pour assouplir cette restriction : rust,ignore {{#rustdoc_include ../listings/ch20-advanced-features/no-listing-14-generic-maybe-sized/src/lib.rs}}
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 au moment de la compilation. La syntaxe ?Trait avec cette signification n’est disponible que pour Sized, pas pour d’autres traits.
Notez aussi que nous avons changé le type du paramètre t de T à &T. Parce que le type pourrait ne pas être Sized, nous devons l’utiliser derrière un pointeur d’un type ou d’un autre. Dans ce cas, nous avons choisi une référence.
Ensuite, nous allons parler des fonctions et des fermetures !