Generics en Rust : pourquoi en a-t-on besoin et comment les utiliser en pratique
La programmation générique (Generics) est l'une des fonctionnalités clés de Rust, qui permet d'écrire un code flexible, réutilisable et sûr. Dans cet article, nous verrons pourquoi les Generics sont nécessaires, comment ils fonctionnent sous le capot et comment les appliquer dans des projets réels. Ce contenu sera utile aussi bien aux débutants qui découvrent Rust qu'aux développeurs expérimentés souhaitant systématiser leurs connaissances.
Pourquoi les Generics sont-ils nécessaires en Rust ?
Imaginez que vous devez écrire une fonction qui trouve le plus grand élément dans un tableau. Sans Generics, vous devriez créer des versions séparées pour chaque type de données : i32, f64, String, etc. Cela entraîne une duplication de code et complique la maintenance.
Les Generics résolvent ce problème en permettant de paramétrer les types. Au lieu d'écrire :
fn find_max_i32(list: &[i32]) -> i32 { ... }fn find_max_f64(list: &[f64]) -> f64 { ... }Vous écrivez une seule fonction universelle :
fn find_max<T: PartialOrd>(list: &[T]) -> &T { ... }Les principaux avantages des Generics :
- Réutilisation du code — une fonction ou une structure fonctionne avec différents types
- Sécurité des types à la compilation — toutes les erreurs de type sont détectées avant l'exécution du programme
- Performance — Rust utilise la monomorphisation, créant un code optimisé pour chaque type spécifique
- Lisibilité — moins de duplication, le code devient plus concis
Comment fonctionnent les Generics : la monomorphisation
Rust compile le code générique d'une manière particulière — via la monomorphisation. Cela signifie que le compilateur crée une copie distincte de la fonction ou de la structure pour chaque type spécifique utilisé dans le programme.
Exemple :
fn print_type<T>(val: T) { println!("Valeur : {}", val);}
fn main() { print_type(42); // T = i32 print_type(3.14); // T = f64 print_type("Bonjour"); // T = &str}Après compilation, Rust créera trois versions de print_type :
print_type_i32print_type_f64print_type_str
Cela garantit que le code générique n'est pas inférieur en performance au code spécialisé. Le seul "inconvénient" est l'augmentation de la taille du fichier binaire, mais en pratique, cela devient rarement un problème.
Utilisation des Generics avec les fonctions et les structures
Generics dans les fonctions
La façon la plus simple d'utiliser les Generics est de paramétrer une fonction :
fn swap<T>(a: &mut T, b: &mut T) { let temp = std::mem::replace(a, unsafe { std::mem::zeroed() }); *a = std::mem::replace(b, temp); *b = temp;}
fn main() { let mut x = 10; let mut y = 20; swap(&mut x, &mut y); println!("x = {}, y = {}", x, y); // x = 20, y = 10}Remarque : nous ne spécifions pas le type — le compilateur le déduit automatiquement en fonction des arguments.
Generics dans les structures
Les structures peuvent également être génériques. C'est particulièrement utile pour les conteneurs de données :
struct Point<T> { x: T, y: T,}
impl<T> Point<T> { fn new(x: T, y: T) -> Self { Point { x, y } }
fn distance_from_origin(&self) -> f64 where T: Into<f64> + Copy, { let x: f64 = self.x.into(); let y: f64 = self.y.into(); (x * x + y * y).sqrt() }}
fn main() { let int_point = Point::new(3, 4); let float_point = Point::new(3.0, 4.0); // distance_from_origin fonctionne uniquement pour les types implémentant Into<f6