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');
}

Encart 10-4 : deux fonctions qui se distinguent seulement par leur nom et le type dans leur signature

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

Encart 10-5 : une définition de la fonction le_plus_grand qui utilise des paramètres de type génériques, mais qui ne compile pas encore

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 };
}

Encart 10-6 : une structure Point<T> qui stocke les valeurs x et y de type T

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 };
}

Encart 10-7 : les champs x et y doivent être du même type car ils ont tous les deux le même type de données générique T.

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 Pointx 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 Ux 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 };
}

Encart 10-8: un Point<T, U> générique en fonction de deux types x et y qui peuvent être des valeurs de types différents

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

Encart 10-9 : implémentation d'une méthode x sur la structure Point<T> qui va retourner une référence au champ x, de type T

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

Encart 10-10 : un bloc impl qui ne s'applique que sur une structure d'un type concret particulier pour le paramètre de type générique T

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

Encart 10-11 : une méthode qui utilise différents types génériques provenant de la définition de la structure

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.