Les types de données génériques
Nous pouvons utiliser la généricité pour créer des définitions pour des éléments comme les signatures de fonctions ou les structures, que nous pouvons ensuite utiliser sur de nombreux types de données concrets. Commençons par regarder comment définir des fonctions, des structures, des énumérations, et des méthodes en utilisant la généricité. Ensuite nous verrons comment la généricité impacte la performance du code.
Dans la définition d'une fonction
Lorsque nous définissons une fonction en utilisant la généricité, nous utilisons des types génériques dans la signature de la fonction là où nous précisons habituellement les types de données des paramètres et de la valeur de retour. Faire ainsi rend notre code plus flexible et apporte plus de fonctionnalités au code appelant notre fonction, tout en évitant la duplication de code.
Pour continuer avec notre fonction le_plus_grand
, l'encart 10-4 nous montre
deux fonctions qui trouvent toutes les deux la valeur la plus grande dans une
slice.
Fichier : src/main.rs
fn le_plus_grand_i32(liste: &[i32]) -> i32 { let mut le_plus_grand = liste[0]; for &element in liste.iter() { if element > le_plus_grand { le_plus_grand = element; } } le_plus_grand } fn le_plus_grand_caractere(liste: &[char]) -> char { let mut le_plus_grand = liste[0]; for &element in liste.iter() { if element > le_plus_grand { le_plus_grand = element; } } le_plus_grand } fn main() { let liste_de_nombres = vec![34, 50, 25, 100, 65]; let resultat = le_plus_grand_i32(&liste_de_nombres); println!("Le plus grand nombre est {}", resultat); assert_eq!(resultat, 100); let liste_de_caracteres = vec!['y', 'm', 'a', 'q']; let resultat = le_plus_grand_caractere(&liste_de_caracteres); println!("Le plus grand caractère est {}", resultat); assert_eq!(resultat, 'y'); }
La fonction le_plus_grand_i32
est celle que nous avons construite à l'encart 10-3
lorsqu'elle trouvait le plus grand i32
dans une slice. La fonction
le_plus_grand_caractere
recherche le plus grand char
dans une slice. Les
corps des fonctions ont le même code, donc essayons d'éviter cette duplication
en utilisant un paramètre de type générique dans une seule et unique fonction.
Pour paramétrer les types dans la nouvelle fonction que nous allons définir,
nous avons besoin de donner un nom au paramètre de type, comme nous l'avons
fait pour les paramètres de valeur des fonctions. Vous pouvez utiliser
n'importe quel identificateur pour nommer le paramètre de type. Mais ici nous allons
utiliser T
car, par convention, les noms de paramètres en Rust sont courts,
souvent même une seule lettre, et la convention de nommage des types en Rust est
d'utiliser le CamelCase. Et puisque la version courte de “type” est T
, c'est
le choix par défaut de nombreux développeurs Rust.
Lorsqu'on utilise un paramètre dans le corps de la fonction, nous devons
déclarer le nom du paramètre dans la signature afin que le compilateur puisse
savoir à quoi réfère ce nom. De la même manière, lorsqu'on utilise un nom de
paramètre de type dans la signature d'une fonction, nous devons déclarer le nom
du paramètre de type avant de pouvoir l'utiliser. Pour déclarer la fonction
générique le_plus_grand
, il faut placer la déclaration du nom du type entre
des chevrons <>
, le tout entre le nom de la fonction et la liste des
paramètres, comme ceci :
fn le_plus_grand<T>(liste: &[T]) -> T {
Cette définition se lit comme ceci : la fonction le_plus_grand
est générique
en fonction du type T
. Cette fonction a un paramètre qui s'appelle liste
,
qui est une slice de valeurs de type T
. Cette fonction le_plus_grand
va
retourner une valeur du même type T
.
L'encart 10-5 nous montre la combinaison de la définition de la fonction
le_plus_grand
avec le type de données générique présent dans sa signature.
L'encart montre aussi que nous pouvons appeler la fonction avec une slice soit
de valeurs i32
, soit de valeurs char
. Notez que ce code ne se compile pas
encore, mais nous allons y remédier plus tard dans ce chapitre.
Fichier : src/main.rs
fn le_plus_grand<T>(liste: &[T]) -> T {
let mut le_plus_grand = liste[0];
for &element in liste {
if element > le_plus_grand {
le_plus_grand = element;
}
}
le_plus_grand
}
fn main() {
let liste_de_nombres = vec![34, 50, 25, 100, 65];
let resultat = le_plus_grand(&liste_de_nombres);
println!("Le nombre le plus grand est {}", resultat);
let liste_de_caracteres = vec!['y', 'm', 'a', 'q'];
let resultat = le_plus_grand(&liste_de_caracteres);
println!("Le plus grand caractère est {}", resultat);
}
Si nous essayons de compiler ce code dès maintenant, nous aurons l'erreur suivante :
$ cargo run
Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0369]: binary operation `>` cannot be applied to type `T`
--> src/main.rs:5:17
|
5 | if element > le_plus_grand {
| ------- ^ ------------- T
| |
| T
|
help: consider restricting type parameter `T`
|
1 | fn le_plus_grand<T: std::cmp::PartialOrd>(liste: &[T]) -> T {
| ++++++++++++++++++++++
For more information about this error, try `rustc --explain E0369`.
error: could not compile `chapter10` due to previous error
La note cite std::cmp::PartialOrd
, qui est un trait. Nous allons voir les
traits dans la prochaine section. Pour le moment, cette erreur nous informe que
le corps de le_plus_grand
ne va pas fonctionner pour tous les types possibles
que T
peut représenter. Comme nous voulons comparer des valeurs de type T
dans le corps, nous pouvons utiliser uniquement des types dont les valeurs
peuvent être triées dans l'ordre. Pour effectuer des comparaisons, la bibliothèque
standard propose le trait std::cmp::PartialOrd
que vous pouvez implémenter sur
des types (voir l'annexe C pour en savoir plus sur ce trait). Vous allez
apprendre à indiquer qu'un type générique a un trait spécifique dans la section
“Des traits en paramètres”, mais d'abord
nous allons explorer d'autres manières d'utiliser les paramètres de types
génériques.
Dans la définition des structures
Nous pouvons aussi définir des structures en utilisant des paramètres de type
génériques dans un ou plusieurs champs en utilisant la syntaxe <>
. L'encart
10-6 nous montre comment définir une structure Point<T>
pour stocker des
valeurs de coordonnées x
et y
de n'importe quel type.
Fichier : src/main.rs
struct Point<T> { x: T, y: T, } fn main() { let entiers = Point { x: 5, y: 10 }; let flottants = Point { x: 1.0, y: 4.0 }; }
La syntaxe pour l'utilisation des génériques dans les définitions de structures est similaire à celle utilisée dans les définitions de fonctions. D'abord, on déclare le nom du paramètre de type entre des chevrons juste après le nom de la structure. Ensuite, on peut utiliser le type générique dans la définition de la structure là où on indiquerait en temps normal des types de données concrets.
Notez que comme nous n'avons utilisé qu'un seul type générique pour définir
Point<T>
, cette définition dit que la structure Point<T>
est générique en
fonction d'un type T
, et les champs x
et y
sont tous les deux de ce même
type, quel qu'il soit. Si nous créons une instance de Point<T>
qui a des
valeurs de types différents, comme dans l'encart 10-7, notre code ne va pas se
compiler.
Fichier : src/main.rs
struct Point<T> {
x: T,
y: T,
}
fn main() {
let ne_fonctionnera_pas = Point { x: 5, y: 4.0 };
}
Dans cet exemple, lorsque nous assignons l'entier 5 à x
, nous laissons
entendre au compilateur que le type générique T
sera un entier pour cette
instance de Point<T>
. Ensuite, lorsque nous assignons 4.0 à y
, que nous
avons défini comme ayant le même type que x
, nous obtenons une erreur
d'incompatibilité de type comme celle-ci :
$ cargo run
Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0308]: mismatched types
--> src/main.rs:7:38
|
7 | let ne_fonctionnera_pas = Point { x: 5, y: 4.0 };
| ^^^ expected integer, found floating-point number
For more information about this error, try `rustc --explain E0308`.
error: could not compile `chapter10` due to previous error
Pour définir une structure Point
où x
et y
sont tous les deux génériques
mais peuvent avoir des types différents, nous pouvons utiliser plusieurs
paramètres de types génériques différents. Par exemple, dans l'encart 10-8,
nous pouvons changer la définition de Point
pour être générique en fonction
des types T
et U
où x
est de type T
et y
est de type U
.
Fichier : src/main.rs
struct Point<T, U> { x: T, y: U, } fn main() { let deux_entiers = Point { x: 5, y: 10 }; let deux_flottants = Point { x: 1.0, y: 4.0 }; let un_entier_et_un_flottant = Point { x: 5, y: 4.0 }; }
Maintenant, toutes les instances de Point
montrées ici sont valides ! Vous
pouvez utiliser autant de paramètres de type génériques que vous souhaitez dans
la déclaration de la définition, mais en utiliser plus de quelques-uns rend
votre code difficile à lire. Lorsque vous avez besoin de nombreux types
génériques dans votre code, cela peut être un signe que votre code a besoin
d'être remanié en éléments plus petits.
Dans les définitions d'énumérations
Comme nous l'avons fait avec les structures, nous pouvons définir des
énumérations qui utilisent des types de données génériques dans leurs variantes.
Commençons par regarder à nouveau l'énumération Option<T>
que fournit la
bibliothèque standard, et que nous avons utilisée au chapitre 6 :
#![allow(unused)] fn main() { enum Option<T> { Some(T), None, } }
Cette définition devrait désormais avoir plus de sens pour vous. Comme vous
pouvez le constater, Option<T>
est une énumération qui est générique en
fonction du type T
et a deux variantes : Some
, qui contient une valeur de
type T
, et une variante None
qui ne contient aucune valeur. En utilisant
l'énumération Option<T>
, nous pouvons exprimer le concept abstrait d'avoir
une valeur optionnelle, et comme Option<T>
est générique, nous pouvons
utiliser cette abstraction peu importe le type de la valeur optionnelle.
Les énumérations peuvent aussi utiliser plusieurs types génériques. La
définition de l'énumération Result
que nous avons utilisée au chapitre 9 en est
un exemple :
#![allow(unused)] fn main() { enum Result<T, E> { Ok(T), Err(E), } }
L'énumération Result
est générique en fonction de deux types, T
et E
, et a
deux variantes : Ok
, qui contient une valeur de type T
, et Err
, qui
contient une valeur de type E
. Cette définition rend possible l'utilisation de
l'énumération Result
partout où nous avons une opération qui peut réussir (et
retourner une valeur du type T
) ou échouer (et retourner une erreur du type
E
). En fait, c'est ce qui est utilisé pour ouvrir un fichier dans l'encart
9-3, où T
contenait un type std::fs::File
lorsque le fichier était ouvert
avec succès et E
contenait un type std::io::Error
lorsqu'il y avait des
problèmes pour ouvrir le fichier.
Lorsque vous reconnaîtrez des cas dans votre code où vous aurez plusieurs définitions de structures ou d'énumérations qui se distinguent uniquement par le type de valeurs qu'elles stockent, vous pourrez éviter les doublons en utilisant des types génériques à la place.
Dans la définition des méthodes
Nous pouvons implémenter des méthodes sur des structures et des énumérations
(comme nous l'avons fait dans le chapitre 5) et aussi utiliser des types
génériques dans leurs définitions. L'encart 10-9 montre la structure Point<T>
que nous avons définie dans l'encart 10-6 avec une méthode qui s'appelle x
implémentée sur cette dernière.
Fichier : src/main.rs
struct Point<T> { x: T, y: T, } impl<T> Point<T> { fn x(&self) -> &T { &self.x } } fn main() { let p = Point { x: 5, y: 10 }; println!("p.x = {}", p.x()); }
Ici, nous avons défini une méthode qui s'appelle x
sur Point<T>
qui retourne
une référence à la donnée présente dans le champ x
.
Notez que nous devons déclarer T
juste après impl
afin de pouvoir l'utiliser
pour préciser que nous implémentons des méthodes sur le type Point<T>
. En
déclarant T
comme un type générique après impl
, Rust peut comprendre que le
type entre les chevrons dans Point
est un type générique plutôt qu'un type
concret. Comme cela revient à déclarer à nouveau le générique, nous aurions pu
choisir un nom différent pour le paramètre générique plutôt que de réutiliser
le même nom que dans la définition de la structure, mais c'est devenu une
convention d'utiliser le même nom. Les méthodes écrites dans un impl
qui
déclarent un type générique peuvent être définies sur n'importe quelle instance
du type, peu importe quel type concret sera substitué dans le type générique.
L'autre possibilité que nous avons est de définir les méthodes sur le type avec
des contraintes sur le type générique. Nous pouvons par exemple implémenter des
méthodes uniquement sur des instances de Point<f32>
plutôt que sur des
instances de n'importe quel type Point<T>
. Dans l'encart 10-10, nous
utilisons le type concret f32
, ce qui veut dire que nous n'avons pas besoin
de déclarer un type après impl
.
Fichier : src/main.rs
struct Point<T> { x: T, y: T, } impl<T> Point<T> { fn x(&self) -> &T { &self.x } } impl Point<f32> { fn distance_depuis_lorigine(&self) -> f32 { (self.x.powi(2) + self.y.powi(2)).sqrt() } } fn main() { let p = Point { x: 5, y: 10 }; println!("p.x = {}", p.x()); }
Ce code signifie que le type Point<f32>
va avoir une méthode qui s'appelle
distance_depuis_lorigine
et les autres instances de Point<T>
où T
n'est
pas du type f32
ne pourront pas appeler cette méthode. Cette méthode calcule
la distance entre notre point et la coordonnée (0.0, 0.0) et utilise des
opérations mathématiques qui ne sont disponibles que pour les types de
flottants.
Les paramètres de type génériques dans la définition d'une structure ne sont
pas toujours les mêmes que ceux qui sont utilisés dans la signature des
méthodes de cette structure. Par exemple, l'encart 10-11 utilise les types
génériques X1
et Y1
pour la structure Point
, ainsi que X2
et Y2
pour
la signature de la méthode melange
pour rendre l'exemple plus clair. La
méthode crée une nouvelle instance de Point
avec la valeur de x
provenant
du Point
self
(de type X1
) et la valeur de y
provenant du Point
en
paramètre (de type Y2
).
Fichier : src/main.rs
struct Point<X1, Y1> { x: X1, y: Y1, } impl<X1, Y1> Point<X1, Y1> { fn melange<X2, Y2>(self, other: Point<X2, Y2>) -> Point<X1, Y2> { Point { x: self.x, y: other.y, } } } fn main() { let p1 = Point { x: 5, y: 10.4 }; let p2 = Point { x: "Hello", y: 'c' }; let p3 = p1.melange(p2); println!("p3.x = {}, p3.y = {}", p3.x, p3.y); }
Dans le main
, nous avons défini un Point
qui a un i32
pour x
(avec la
valeur 5
) et un f64
pour y
(avec la valeur 10.4). La variable p2
est une
structure Point
qui a une slice de chaine de caractères pour x
(avec la
valeur "Hello"
) et un caractère char
pour y
(avec la valeur c
). L'appel
à melange
sur p1
avec l'argument p2
nous donne p3
, qui aura un i32
pour
x
, car x
provient de p1
. La variable p3
aura un caractère char
pour
y
, car y
provient de p2
. L'appel à la macro println!
va afficher
p3.x = 5, p3.y = c
.
Le but de cet exemple est de montrer une situation dans laquelle des paramètres
génériques sont déclarés avec impl
et d'autres sont déclarés dans la
définition de la méthode. Ici, les paramètres génériques X1
et Y1
sont
déclarés après impl
, car ils sont liés à la définition de la structure. Les
paramètres génériques X2
et Y2
sont déclarés après fn melange
, car ils ne
sont liés qu'à cette méthode.
Performance du code utilisant les génériques
Vous vous demandez peut-être s'il y a un coût à l'exécution lorsque vous utilisez des paramètres de type génériques. La bonne nouvelle est que Rust implémente les génériques de manière à ce que votre code ne s'exécute pas plus lentement que vous utilisiez les types génériques ou des types concrets.
Rust accomplit cela en pratiquant la monomorphisation à la compilation du code qui utilise les génériques. La monomorphisation est un processus qui transforme du code générique en code spécifique en définissant au moment de la compilation les types concrets utilisés dans le code.
Dans ce processus, le compilateur fait l'inverse des étapes que nous avons suivies pour créer la fonction générique de l'encart 10-5 : le compilateur cherche tous les endroits où le code générique est utilisé et génère du code pour les types concrets avec lesquels le code générique est appelé.
Regardons comment cela fonctionne avec un exemple qui utilise l'énumération
Option<T>
de la bibliothèque standard :
#![allow(unused)] fn main() { let entier = Some(5); let flottant = Some(5.0); }
Lorsque Rust compile ce code, il applique la monomorphisation. Pendant ce
processus, le compilateur lit les valeurs qui ont été utilisées dans les
instances de Option<T>
et en déduit les deux sortes de Option<T>
: une est
i32
et l'autre est f64
. Ainsi, il décompose la définition générique de
Option<T>
en Option_i32
et en Option_f64
, remplaçant ainsi la définition
générique par deux définitions concrètes.
La version monomorphe du code ressemble à ce qui suit. Le Option<T>
générique
est remplacé par deux définitions concrètes créées par le compilateur :
Fichier : src/main.rs
enum Option_i32 { Some(i32), None, } enum Option_f64 { Some(f64), None, } fn main() { let entier = Option_i32::Some(5); let flottant = Option_f64::Some(5.0); }
Comme Rust compile le code générique dans du code qui précise le type dans chaque instance, l'utilisation des génériques n'a pas de conséquence sur les performances de l'exécution. Quand le code s'exécute, il fonctionne comme il devrait le faire si nous avions dupliqué chaque définition à la main. Le processus de monomorphisation rend les génériques de Rust très performants au moment de l'exécution.