Les types de données génériques
Nous utilisons les génériques pour créer des définitions d’éléments comme des signatures de fonctions ou des structs, que nous pouvons ensuite utiliser avec de nombreux types de données concrets. Voyons d’abord comment définir des fonctions, des structs, des énumérations et des méthodes en utilisant les génériques. Ensuite, nous verrons comment les génériques affectent les performances du code.
Dans les définitions de fonctions
Lorsque nous définissons une fonction qui utilise des génériques, nous plaçons les génériques dans la signature de la fonction à l’endroit où nous spécifierions normalement les types de données des paramètres et de la valeur de retour. Ce faisant, notre code devient plus flexible et offre plus de fonctionnalités aux appelants de notre fonction tout en évitant la duplication de code.
En poursuivant avec notre fonction largest, l’encart 10-4 montre deux fonctions qui trouvent toutes deux la plus grande valeur dans une slice. Nous les combinerons ensuite en une seule fonction qui utilise des génériques.
fn largest_i32(list: &[i32]) -> &i32 {
let mut largest = &list[0];
for item in list {
if item > largest {
largest = item;
}
}
largest
}
fn largest_char(list: &[char]) -> &char {
let mut largest = &list[0];
for item in list {
if item > largest {
largest = item;
}
}
largest
}
fn main() {
let number_list = vec![34, 50, 25, 100, 65];
let result = largest_i32(&number_list);
println!("The largest number is {result}");
assert_eq!(*result, 100);
let char_list = vec!['y', 'm', 'a', 'q'];
let result = largest_char(&char_list);
println!("The largest char is {result}");
assert_eq!(*result, 'y');
}
La fonction largest_i32 est celle que nous avons extraite dans l’encart 10-3 et qui trouve le plus grand i32 dans une slice. La fonction largest_char trouve le plus grand char dans une slice. Les corps des fonctions contiennent le même code, alors éliminons la duplication en introduisant un paramètre de type générique dans une seule fonction.
Pour paramétrer les types dans une nouvelle fonction unique, nous devons nommer le paramètre de type, tout comme nous le faisons pour les paramètres de valeur d’une fonction. Vous pouvez utiliser n’importe quel identifiant comme nom de paramètre de type. Mais nous utiliserons T car, par convention, les noms de paramètres de type en Rust sont courts, souvent une seule lettre, et la convention de nommage des types en Rust est l’UpperCamelCase. Abréviation de type, T est le choix par défaut de la plupart des programmeurs Rust.
Lorsque nous utilisons un paramètre dans le corps de la fonction, nous devons déclarer le nom du paramètre dans la signature pour que le compilateur sache ce que ce nom signifie. De même, lorsque nous utilisons un nom de paramètre de type dans une signature de fonction, nous devons déclarer le nom du paramètre de type avant de l’utiliser. Pour définir la fonction générique largest, nous plaçons les déclarations de noms de types entre chevrons, <>, entre le nom de la fonction et la liste des paramètres, comme ceci :
fn largest<T>(list: &[T]) -> &T {
Nous lisons cette définition comme : « La fonction largest est générique sur un certain type T. » Cette fonction à un paramètre nommé list, qui est une slice de valeurs de type T. La fonction largest retournera une référence vers une valeur du même type T.
L’encart 10-5 montre la définition combinée de la fonction largest utilisant le type de données générique dans sa signature. L’encart montre aussi comment nous pouvons appeler la fonction avec une slice de valeurs i32 ou de valeurs char. Notez que ce code ne compilera pas encore.
fn largest<T>(list: &[T]) -> &T {
let mut largest = &list[0];
for item in list {
if item > largest {
largest = item;
}
}
largest
}
fn main() {
let number_list = vec![34, 50, 25, 100, 65];
let result = largest(&number_list);
println!("The largest number is {result}");
let char_list = vec!['y', 'm', 'a', 'q'];
let result = largest(&char_list);
println!("The largest char is {result}");
}
largest function using generic type parameters; this doesn’t compile yetSi nous compilons ce code maintenant, nous obtiendrons cette erreur : console {{#include ../listings/ch10-generic-types-traits-and-lifetimes/listing-10-05/output.txt}}
$ 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 item > largest {
| ---- ^ ------- &T
| |
| &T
|
help: consider restricting type parameter `T` with trait `PartialOrd`
|
1 | fn largest<T: std::cmp::PartialOrd>(list: &[T]) -> &T {
| ++++++++++++++++++++++
For more information about this error, try `rustc --explain E0369`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error
Le texte d’aide mentionné std::cmp::PartialOrd, qui est un trait, et nous allons parler des traits dans la prochaine section. Pour l’instant, sachez que cette erreur indique que le corps de largest ne fonctionnera pas pour tous les types possibles que T pourrait être. Comme nous voulons comparer des valeurs de type T dans le corps, nous ne pouvons utiliser que des types dont les valeurs peuvent être ordonnées. Pour permettre les comparaisons, la bibliothèque standard dispose du trait std::cmp::PartialOrd que vous pouvez implémenter sur les types (voir l’annexe C pour plus d’informations sur ce trait). Pour corriger l’encart 10-5, nous pouvons suivre la suggestion du texte d’aide et restreindre les types valides pour T à ceux qui implémentent PartialOrd. L’encart compilera alors, car la bibliothèque standard implémente PartialOrd pour i32 et char.
Dans les définitions de structs
Nous pouvons aussi définir des structs qui utilisent un paramètre de type générique dans un où plusieurs champs en utilisant la syntaxe <>. L’encart 10-6 définit une struct Point<T> pour contenir des valeurs de coordonnées x et y de n’importe quel type.
struct Point<T> {
x: T,
y: T,
}
fn main() {
let integer = Point { x: 5, y: 10 };
let float = Point { x: 1.0, y: 4.0 };
}
Point<T> struct that holds x and y values of type TLa syntaxe pour utiliser les génériques dans les définitions de structs est similaire à celle utilisée dans les définitions de fonctions. D’abord, nous déclarons le nom du paramètre de type entre chevrons juste après le nom de la struct. Ensuite, nous utilisons le type générique dans la définition de la struct là où nous spécifierions autrement 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 indique que la struct Point<T> est générique sur un certain type T, et que 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> avec des valeurs de types différents, comme dans l’encart 10-7, notre code ne compilera pas.
struct Point<T> {
x: T,
y: T,
}
fn main() {
let wont_work = Point { x: 5, y: 4.0 };
}
x and y must be the same type because both have the same generic data type T.Dans cet exemple, lorsque nous assignons la valeur entière 5 à x, nous informons le compilateur que le type générique T sera un entier pour cette instance de Point<T>. Ensuite, lorsque nous spécifions 4.0 pour y, que nous avons défini comme ayant le même type que x, nous obtiendrons une erreur de non-correspondance de type comme celle-ci : console {{#include ../listings/ch10-generic-types-traits-and-lifetimes/listing-10-07/output.txt}}
$ cargo run
Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0308]: mismatched types
--> src/main.rs:7:38
|
7 | let wont_work = 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` (bin "chapter10") due to 1 previous error
Pour définir une struct Point où x et y sont tous les deux génériques mais pourraient avoir des types différents, nous pouvons utiliser plusieurs paramètres de type générique. Par exemple, dans l’encart 10-8, nous changeons la définition de Point pour qu’elle soit générique sur les types T et U, où x est de type T et y est de type U.
struct Point<T, U> {
x: T,
y: U,
}
fn main() {
let both_integer = Point { x: 5, y: 10 };
let both_float = Point { x: 1.0, y: 4.0 };
let integer_and_float = Point { x: 5, y: 4.0 };
}
Point<T, U> generic over two types so that x and y can be values of different typesMaintenant, toutes les instances de Point présentées sont autorisées ! Vous pouvez utiliser autant de paramètres de type générique que vous le souhaitez dans une définition, mais en utiliser trop rend votre code difficile à lire. Si vous constatez que vous avez besoin de beaucoup de types génériques dans votre code, cela pourrait indiquer que votre code a besoin d’être restructuré en morceaux plus petits.
Dans les définitions d’énumérations
Comme nous l’avons fait avec les structs, nous pouvons définir des énumérations qui contiennent des types de données génériques dans leurs variantes. Regardons à nouveau l’énumération Option<T> que la bibliothèque standard fournit et que nous avons utilisée au chapitre 6 :
#![allow(unused)]
fn main() {
enum Option<T> {
Some(T),
None,
}
}
Cette définition devrait maintenant avoir plus de sens pour vous. Comme vous pouvez le voir, l’énumération Option<T> est générique sur le type T et possède 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’une valeur optionnelle, et comme Option<T> est générique, nous pouvons utiliser cette abstraction quel que soit 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 sur deux types, T et E, et possède deux variantes : Ok, qui contient une valeur de type T, et Err, qui contient une valeur de type E. Cette définition rend pratique l’utilisation de l’énumération Result partout où nous avons une opération qui peut réussir (retourner une valeur d’un certain type T) ou échouer (retourner une erreur d’un certain type E). En fait, c’est ce que nous avons utilisé pour ouvrir un fichier dans l’encart 9-3, où T a été rempli avec le type std::fs::File lorsque le fichier a été ouvert avec succès et E a été rempli avec le type std::io::Error lorsqu’il y a eu des problèmes à l’ouverture du fichier.
Lorsque vous reconnaissez dans votre code des situations avec plusieurs définitions de structs ou d’énumérations qui ne diffèrent que par les types des valeurs qu’elles contiennent, vous pouvez éviter la duplication en utilisant des types génériques à la place.
Dans les définitions de méthodes
Nous pouvons implémenter des méthodes sur les structs et les énumérations (comme nous l’avons fait au chapitre 5) et utiliser des types génériques dans leurs définitions aussi. L’encart 10-9 montre la struct Point<T> que nous avons définie dans l’encart 10-6 avec une méthode nommée x implémentée dessus.
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());
}
x on the Point<T> struct that will return a reference to the x field of type TIci, nous avons défini une méthode nommée x sur Point<T> qui retourné une référence vers les données du champ x.
Notez que nous devons déclarer T juste après impl pour pouvoir utiliser T afin de spécifier que nous implémentons des méthodes sur le type Point<T>. En déclarant T comme type générique après impl, Rust peut identifier que le type entre chevrons dans Point est un type générique plutôt qu’un type concret. Nous aurions pu choisir un nom différent pour ce paramètre générique par rapport au paramètre générique déclaré dans la définition de la struct, mais utiliser le même nom est conventionnel. Si vous écrivez une méthode dans un impl qui déclare un type générique, cette méthode sera définie sur n’importe quelle instance du type, quel que soit le type concret qui finit par se substituer au type générique.
Nous pouvons aussi spécifier des contraintes sur les types génériques lors de la définition de méthodes sur le type. Nous pourrions, par exemple, implémenter des méthodes uniquement sur les instances de Point<f32> plutôt que sur les instances de Point<T> avec n’importe quel type générique. Dans l’encart 10-10, nous utilisons le type concret f32, ce qui signifie que nous ne déclarons aucun type après impl.
struct Point<T> {
x: T,
y: T,
}
impl<T> Point<T> {
fn x(&self) -> &T {
&self.x
}
}
impl Point<f32> {
fn distance_from_origin(&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());
}
impl block that only applies to a struct with a particular concrete type for the generic type parameter TCe code signifie que le type Point<f32> aura une méthode distance_from_origin ; les autres instances de Point<T> où T n’est pas de type f32 n’auront pas cette méthode définie. La méthode mesure la distance entre notre point et le point aux coordonnées (0.0, 0.0) et utilise des opérations mathématiques qui ne sont disponibles que pour les types à virgule flottante.
Les paramètres de type générique dans une définition de struct ne sont pas toujours les mêmes que ceux que vous utilisez dans les signatures de méthodes de cette même struct. L’encart 10-11 utilise les types génériques X1 et Y1 pour la struct Point et X2 et Y2 pour la signature de la méthode mixup afin de rendre l’exemple plus clair. La méthode crée une nouvelle instance de Point avec la valeur x du Point self (de type X1) et la valeur y du Point passé en argument (de type Y2).
struct Point<X1, Y1> {
x: X1,
y: Y1,
}
impl<X1, Y1> Point<X1, Y1> {
fn mixup<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.mixup(p2);
println!("p3.x = {}, p3.y = {}", p3.x, p3.y);
}
Dans main, nous avons défini un Point qui à un i32 pour x (avec la valeur 5) et un f64 pour y (avec la valeur 10.4). La variable p2 est une struct Point qui à une slice de chaîne de caractères pour x (avec la valeur "Hello") et un char pour y (avec la valeur c). Appeler mixup sur p1 avec l’argument p2 nous donne p3, qui aura un i32 pour x car x vient de p1. La variable p3 aura un char pour y car y vient de p2. L’appel de la macro println! affichera p3.x = 5, p3.y = c.
Le but de cet exemple est de démontrer une situation dans laquelle certains paramètres génériques sont déclarés avec impl et d’autres sont déclarés avec 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 accompagnent la définition de la struct. Les paramètres génériques X2 et Y2 sont déclarés après fn mixup car ils ne sont pertinents que pour la méthode.
Performances du code utilisant les génériques
Vous vous demandez peut-être s’il y à un coût à l’exécution lors de l’utilisation de paramètres de type générique. La bonne nouvelle est que l’utilisation de types génériques ne ralentira pas votre programme par rapport à l’utilisation de types concrets.
Rust accomplit cela en effectuant la monomorphisation du code utilisant les génériques au moment de la compilation. La monomorphisation est le processus de transformation du code générique en code spécifique en remplissant les types concrets utilisés lors de la compilation. Dans ce processus, le compilateur fait l’inverse des étapes que nous avons utilisées pour créer la fonction générique dans l’encart 10-5 : le compilateur examine tous les endroits où le code générique est appelé et génère du code pour les types concrets avec lesquels le code générique est appelé.
Voyons comment cela fonctionne en utilisant l’énumération générique Option<T> de la bibliothèque standard :
#![allow(unused)]
fn main() {
let integer = Some(5);
let float = Some(5.0);
}
Lorsque Rust compilé ce code, il effectue la monomorphisation. Au cours de ce processus, le compilateur lit les valeurs qui ont été utilisées dans les instances d’Option<T> et identifie deux sortes d’Option<T> : l’une est i32 et l’autre est f64. Ainsi, il développe la définition générique d’Option<T> en deux définitions spécialisées pour i32 et f64, remplaçant ainsi la définition générique par les définitions spécifiques.
La version monomorphisée du code ressemble à ce qui suit (le compilateur utilise des noms différents de ceux que nous utilisons ici à titre d’illustration) :
enum Option_i32 {
Some(i32),
None,
}
enum Option_f64 {
Some(f64),
None,
}
fn main() {
let integer = Option_i32::Some(5);
let float = Option_f64::Some(5.0);
}
Le Option<T> générique est remplacé par les définitions spécifiques créées par le compilateur. Comme Rust compilé le code générique en code qui spécifie le type dans chaque instance, nous ne payons aucun coût à l’exécution pour l’utilisation des génériques. Lorsque le code s’exécute, il se comporte exactement comme si nous avions dupliqué chaque définition à la main. Le processus de monomorphisation rend les génériques de Rust extrêmement efficaces à l’exécution.