Génériques en Rust : pourquoi sont-ils indispensables ?
\n\nQuand on débute avec Rust, le système de types peut sembler un adversaire. Le compilateur râle à chaque virgule, et le mot Generic fait trembler. Pourtant, les génériques (types génériques) sont vos meilleurs alliés. Ils vous permettent d'écrire un code flexible, sûr et réutilisable sans sacrifier les performances.
Dans cet article, nous allons voir ce que sont les génériques en Rust, comment les utiliser dans les fonctions, les structures et les traits. Vous découvrirez que la programmation générique n'est pas de la magie, mais un outil puissant qui rend votre code plus propre et plus rapide.
\n\nQue sont les génériques et pourquoi les utiliser ?
\n\nImaginez que vous écrivez une fonction qui trouve l'élément maximum dans un tableau. Sans génériques, vous devriez écrire une implémentation séparée pour i32, f64, String, etc. C'est long, fastidieux et viole le principe DRY (Don't Repeat Yourself).
Les génériques résolvent ce problème. Ils permettent de paramétrer les types : vous écrivez une seule fonction qui fonctionne avec n'importe quel type de données. Le compilateur génère lui-même les versions de code nécessaires pour chaque cas spécifique. Ce processus s'appelle la monomorphisation.
\n\nSyntaxe des génériques : du simple au complexe
\n\n1. Fonctions génériques
\n\nCommençons par un exemple de base. Supposons que nous voulions créer une fonction qui retourne la valeur passée (fonction identité) :
\n\nfn identity<T>(value: T) -> T {\n value\n}\n\nfn main() {\n let n = identity(42);\n let s = identity("hello");\n println!("{}, {}", n, s); // 42, hello\n}\n\nIci, <T> est un paramètre de type. T peut être n'importe quel type : nombre, chaîne, structure. Lors de l'appel, le compilateur déduit automatiquement le type à utiliser. Cela ne fonctionne pas grâce à la répartition dynamique (comme en Java ou Python), mais grâce à la génération de code spécialisé à la compilation. C'est pourquoi Rust ne sacrifie pas les performances.
2. Contraintes de type (Trait Bounds)
\n\nMais que faire si nous voulons non seulement passer une valeur, mais aussi, par exemple, comparer deux nombres ? Un simple <T> ne suffit pas, car l'opération de comparaison n'est pas définie pour tous les types. Nous devons spécifier que T doit implémenter le trait PartialOrd :
fn max<T: PartialOrd>(a: T, b: T) -> T {\n if a > b { a } else { b }\n}\n\nfn main() {\n println!("{}", max(10, 20)); // 20\n println!("{}", max(3.14, 2.71)); // 3.14\n}\n\nLa notation T: PartialOrd est une trait bound. Elle indique au compilateur : « N'utilise que les types pour lesquels un ordre de comparaison est défini ». Essayez de passer deux chaînes à max — cela fonctionnera, car String implémente aussi PartialOrd. En revanche, si vous passez une structure sans implémentation, vous obtiendrez une erreur de compilation.
3. Contraintes multiples
\n\nParfois, un type doit satisfaire plusieurs traits à la fois. Par exemple, être à la fois comparable et clonable :
\n\nfn clone_and_max<T: PartialOrd + Clone>(a: T, b: T) -> T {\n let a_cloned = a.clone();\n let b_cloned = b.clone();\n if a_cloned > b_cloned { a } else { b }\n}\n\nLa syntaxe T: PartialOrd + Clone signifie que le type doit implémenter les deux traits. C'est puissant, mais à utiliser avec modération : trop de contraintes nuisent à la lisibilité.
Génériques dans les structures et les énumérations
\n\nStructures génériques
\n\nLes génériques fonctionnent aussi très bien avec les types personnalisés. L'exemple classique est la structure Point, qui peut stocker des coordonnées de n'importe quel type :
struct Point<T> {\n x: T,\n y: T,\n}\n\nimpl<T> Point<T> {\n fn new(x: