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(|| ()) } }
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(|| ()) } }
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;
}
}
}
}
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 !