Aller au contenu

Les Generics en Rust : Des déclinaisons de types

Les generics vont nous permettre d’écrire du code qui va pouvoir fonctionner avec plusieurs types.

Dans les struct

Un exemple imagine qu’on veuille représenter un point sur un graphique en 2D, avec des données qu’on récupère, pour ce faire on peut simplement créer un struct point comme ça.

struct Point{
x: i32,
y: i32
}

Bon okay mais comment on fait si on veut pouvoir gérer le cas ou le point est composé de 2 nombres flottants ? Il faut refaire un struct comme ça :

struct PointFloat{
x: f64,
y: f64,
}

Bon et maintenant imagine qu’on ait un entier et un flottant

struct PointMixed{
x: i32,
y: f64,
}

Voilà ça devient l’enfer, puisque qu’il faut recréer un struct dès qu’un champ change de type, donc pour corriger ça on a les generics à la rescousse.

On va devoir écrire ça de cette façon :

struct Point<T,U> {
x: T,
y: U
}

Je t’explique la syntaxe, on définit ici après le nom du struct, 2 types génériques T et U, ces types n’existent pas réellement, mais ils sont là pour représenter le type que prendra x et le type que prendra y.

Ainsi si on crée un struct de la forme :

struct Point<T,U> {
x: T,
y: U
}
fn main(){
let x : i32 = 44;
let y : f64 = 120.23;
let point = Point{x:x, y:y};// le type ici est Point<i32,f64>
let a : f32 = 30.0;
let b : f32 = 21.43;
let point2 = Point{x:a, y:b}; // le type ici est Point<f32,f32>
}

Ce qu’il faut comprendre ici, c’est que Rust va alors créer les différents types qu’il rencontre à partir du type générique que l’on a défini.

Donc là concrètement Rust en interne va créer les types :

struct Point<i32,f64> {
x: i32,
y: f64
}
struct Point<f32,f32> {
x: f32,
y: f32,
}

En termes de performance, ça ne change absolument rien puisque pour Rust, c’est un type comme un autre qu’il définit de son côté en fonction de ce qu’on utilise pour créer le struct.

Dans les enums

On va aussi pouvoir utiliser les génériques dans les enums, d’ailleurs le type option est un enum qui prend cette forme :

enum Option<T> {
Some(T),
None,
}

Ce qui veut dire que quand on définit un type Option<u32> par exemple, un nouvel enum est créé en interne par Rust sous cette forme :

enum Option<u32>{
Some(u32),
None
}

Ce qu’on a alors dans la variante Some est de type u32, et donc si tu crées quelque chose de type Option<Banane> tu auras une variante Some de la forme Some(Banane).

Voilà la puissance des génériques définir une fois un type générique (comme le type option par exemple) et pouvoir en créer une infinité de déclinaisons sans devoir redéfinir manuellement un type.

Dans les fonctions

Imaginons qu’on veuille créer une fonction qui va transformer une valeur, en un tuple qui contient deux fois la valeur, bon okay c’est un peu inutile, mais pourquoi pas ?

Si on veut faire ça en une fonction tout en gérant plusieurs types on va pouvoir le faire grâce aux generics.

Voilà un bon point de départ :

// ❌ ne compile pas
fn dupliquer<T>(valeur: T) -> (T, T) {
(valeur.clone(), valeur.clone())
}

Malheureusement on a une erreur : no method named 'clone' found for type parameter 'T', en fait ici Rust nous indique que le type T ne possède pas de méthode clone.

En d’autres termes, il va falloir préciser que T possède la méthode clone, pour faire ça on va contraindre (c’est comme ça qu’on dit) le type T, qui représentait jusqu’à lors tous les types possibles, à seulement les types qui possède la méthode clone.

Pour faire ça on va devoir utiliser les traits, qu’on va voir prochainement, je te montre déjà la syntaxe.

// ✅ compile
fn dupliquer<T: Clone>(valeur: T) -> (T, T) {
(valeur.clone(), valeur.clone())
}

Ici on précise que T possède le trait Clone, qui va nous garantir que T possède la méthode clone.

Pour réellement comprendre ce que sont les traits je te conseille d’aller voir l’article dédié aux traits.