Génériques avancés en Rust : const generics, const fn et PhantomData
Sur la page Les génériques en Rust, un paramètre générique était toujours un type : Pair<T> pouvait contenir des i32 ou des &str. Cette page présente trois outils qui poussent la même idée plus loin :
- Les const generics (génériques constants) permettent qu’un nombre soit un paramètre générique, comme le
3dans[i32; 3]. const fnpermet au compilateur d’exécuter votre fonction pendant qu’il construit votre programme : la réponse est prête avant même que le programme démarre.PhantomDatapermet à un type de porter une étiquette (comme « ceci est un ID d’utilisateur » ou « cette longueur est en pieds ») sans stocker la moindre donnée en plus.
Pensez à une boîte d’œufs. Une boîte de 6 et une boîte de 12 sont deux produits différents, même si ce sont toutes les deux des « boîtes d’œufs ». La taille fait partie de ce qu’elles sont. Les const generics permettent d’exprimer cela en Rust. Prenez votre temps avec cette page : chaque section se suffit à elle-même, et vous pourrez revenir aux dernières le jour où vous en aurez besoin.
Les const generics : un nombre comme paramètre générique
Section intitulée « Les const generics : un nombre comme paramètre générique »En Rust, la longueur d’un tableau fait partie de son type. [u32; 3] et [u32; 4] sont aussi différents que i32 et String. Une fonction qui prend un [u32; 3] refuse quatre éléments :
error[E0308]: mismatched types --> src/main.rs:7:26 |7 | println!("{}", total(basket)); | ----- ^^^^^^ expected an array with a size of 3, found one with a size of 4 | | | arguments to this function are incorrectLe message du compilateur (en anglais) indique qu’il attendait un tableau de taille 3 et en a reçu un de taille 4. Un const generic règle ce problème. Vous écrivez const N: usize entre les chevrons, et la fonction accepte un tableau de n’importe quelle longueur :
fn summary<const N: usize>(prices: [u32; N]) -> String { let mut sum = 0; for price in prices { sum += price; } // Dans la fonction, N est un nombre tout à fait normal format!("{N} articles coûtent {sum}")}
fn main() { let small_basket = [4, 10, 2]; let big_basket = [3, 8, 1, 12, 6];
println!("{}", summary(small_basket)); println!("{}", summary(big_basket));}Sortie :
3 articles coûtent 165 articles coûtent 30Lisez <const N: usize> comme « pour n’importe quel nombre N de type usize ». Rust déduit N à partir du tableau que vous passez, exactement comme il déduit T pour les génériques classiques. Et comme pour les génériques classiques, le compilateur crée une copie séparée de summary pour chaque longueur utilisée : il n’y a donc aucun coût à l’exécution.
Soyons honnêtes : pour cette petite fonction, une slice (prices: &[u32]) ferait aussi bien l’affaire, et en plus simple. Voir Les slices en Rust. Les const generics deviennent vraiment utiles quand la longueur doit faire partie d’un type : stockée dans une struct sans allocation sur le tas, ou utilisée dans un type de retour. C’est la suite.
Des structs avec const generics : une grille de sièges
Section intitulée « Des structs avec const generics : une grille de sièges »Imaginez le plan d’une salle de cinéma : quelques rangées, chacune avec le même nombre de sièges. Voici une matrice dont le nombre de lignes R et de colonnes C fait partie du type :
struct Matrix<const R: usize, const C: usize> { cells: [[i32; C]; R],}
impl<const R: usize, const C: usize> Matrix<R, C> { fn new(cells: [[i32; C]; R]) -> Self { Matrix { cells } }
fn size(&self) -> String { format!("{R}x{C}") }
// Les lignes deviennent des colonnes : une matrice R x C devient C x R fn transpose(&self) -> Matrix<C, R> { let mut cells = [[0; R]; C]; for row in 0..R { for col in 0..C { cells[col][row] = self.cells[row][col]; } } Matrix { cells } }}
fn main() { let seats = Matrix::new([[1, 2, 3], [4, 5, 6]]); let flipped = seats.transpose();
println!("{} {:?}", seats.size(), seats.cells); println!("{} {:?}", flipped.size(), flipped.cells);}Sortie :
2x3 [[1, 2, 3], [4, 5, 6]]3x2 [[1, 4], [2, 5], [3, 6]]Regardez le type de retour de transpose : Matrix<C, R>. Le compilateur sait que retourner une matrice 2x3 donne une matrice 3x2. Si vous vous trompiez à l’intérieur, par exemple en construisant une matrice de la mauvaise forme, le code ne compilerait pas. Toute la grille vit dans la struct, sans Vec et sans tas. La ligne impl<const R: usize, const C: usize> fonctionne comme impl<T> : elle dit « ces méthodes existent pour toutes les tailles ».
Un exemple concret : un tampon circulaire de taille fixe
Section intitulée « Un exemple concret : un tampon circulaire de taille fixe »Une dashcam de voiture ne garde que les dernières minutes de vidéo. Quand elle est pleine, le nouvel enregistrement écrase le plus ancien. C’est un tampon circulaire (ring buffer), et il convient parfaitement aux const generics : la capacité est fixe, donc on peut ranger les éléments dans un tableau.
Celui-ci est générique à la fois sur un type T et sur une taille N, c’est pourquoi la ligne impl s’écrit impl<T, const N: usize> :
struct RingBuffer<T, const N: usize> { items: [T; N], next: usize, // où ira le prochain push len: usize, // combien d'éléments sont stockés (jamais plus que N)}
impl<T: Copy + Default, const N: usize> RingBuffer<T, N> { fn new() -> Self { RingBuffer { items: [T::default(); N], next: 0, len: 0 } }
fn push(&mut self, item: T) { self.items[self.next] = item; // écrase le plus ancien quand c'est plein self.next = (self.next + 1) % N; if self.len < N { self.len += 1; } }
// Les éléments stockés, du plus ancien au plus récent fn to_vec(&self) -> Vec<T> { let start = (self.next + N - self.len) % N; (0..self.len).map(|i| self.items[(start + i) % N]).collect() }}
fn main() { // On ne garde que les 3 dernières mesures de température let mut recent: RingBuffer<i32, 3> = RingBuffer::new();
for temp in [18, 19, 21, 24, 22] { recent.push(temp); println!("après {temp} : {:?}", recent.to_vec()); }}Sortie :
après 18 : [18]après 19 : [18, 19]après 21 : [18, 19, 21]après 24 : [19, 21, 24]après 22 : [21, 24, 22][T::default(); N] remplit le tableau avec N copies d’une valeur par défaut (0 pour i32). Répéter une valeur de cette façon exige T: Copy, c’est pourquoi l’impl demande Copy + Default. Un RingBuffer<i32, 3> et un RingBuffer<i32, 60> sont des types différents : impossible de passer un tampon de 3 places là où on en attend un de 60. Si vous préférez la version extensible de la bibliothèque standard, voyez VecDeque et BinaryHeap.
Valeurs par défaut, accolades et limites actuelles
Section intitulée « Valeurs par défaut, accolades et limites actuelles »Un paramètre const peut avoir une valeur par défaut, écrite const N: usize = 4. Vous pouvez aussi passer une constante nommée, ou une expression entre accolades :
struct Buffer<const N: usize = 4> { data: [u8; N],}
impl<const N: usize> Buffer<N> { fn new() -> Self { Buffer { data: [0; N] } }}
const PAGE: usize = 8;
fn main() { let small: Buffer = Buffer::new(); // sans nombre : la valeur par défaut, 4 let page = Buffer::<PAGE>::new(); // une constante nommée let double = Buffer::<{ PAGE * 2 }>::new(); // une expression demande des { }
println!("{} {} {}", small.data.len(), page.data.len(), double.data.len());}Sortie :
4 8 16La valeur par défaut ne s’applique que lorsque vous écrivez le type sans nombre, comme dans let small: Buffer. Un simple let small = Buffer::new(); échoue avec error[E0284]: type annotations needed for Buffer<_> (il faut annoter le type), car Rust ne devine pas une valeur par défaut à partir d’une expression.
Les const generics de Rust stable sont utiles, mais volontairement limités. Mieux vaut connaître leurs limites avant de tomber dessus :
- Types autorisés : uniquement les entiers (
usize,u8,i32…),booletchar. Essayerconst NAME: &'static strdonne «&'static stris forbidden as the type of a const generic parameter » (ce type est interdit pour un paramètre const). - Pas de calcul avec un paramètre générique dans un type.
[f32; N * 2]dans une struct ou une signature générique est une erreur (« generic parameters may not be used in const operations »).Nseul ne pose aucun problème, etN * 2non plus dans le corps d’une fonction, oùNn’est qu’une valeur. La fonctionnalité qui autoriseraitN * 2dans les types (generic_const_exprs) n’existe encore qu’en nightly. - Vérifier une valeur. Vous ne pouvez pas écrire une contrainte du genre «
Ndoit être supérieur à 0 ». Le plus proche, c’est un blocconsten ligne dans une fonction :const { assert!(N > 0, "il faut au moins une place") };. Il échoue au moment où vous compilez le programme avec un mauvaisN(cargo checkpeut ne pas le détecter).
const fn : laissez le compilateur faire les calculs
Section intitulée « const fn : laissez le compilateur faire les calculs »Certains magasins impriment leur liste de prix une fois pour toutes, au lieu de recalculer chaque prix à la caisse. Une const fn est une fonction que le compilateur a le droit d’exécuter pendant qu’il construit votre programme : le résultat est « imprimé » dans le programme à l’avance.
Vous avez déjà rencontré les éléments const sur la page Les variables en Rust. Leur valeur doit être connue à la compilation, donc normalement vous ne pouvez utiliser que des littéraux et des calculs simples. Avec const fn, vous pouvez aussi y appeler vos propres fonctions :
const fn square(n: usize) -> usize { n * n}
const BOARD_SIDE: usize = 4;const CELLS: usize = square(BOARD_SIDE); // calculé par le compilateur
fn main() { // La longueur d'un tableau doit être connue à la compilation, et CELLS l'est let board = [0u8; CELLS];
println!("un plateau de {BOARD_SIDE}x{BOARD_SIDE} a {} cases", board.len()); println!("square(12) à l'exécution donne {}", square(12));}Sortie :
un plateau de 4x4 a 16 casessquare(12) à l'exécution donne 144Une const fn reste une fonction normale. square(12) dans main est un appel ordinaire, à l’exécution. Elle n’est obligée de s’exécuter à la compilation que lorsqu’elle est utilisée dans un contexte const : un élément const ou static, une longueur de tableau, un argument const generic ou un bloc const { ... }.
Un usage classique est la table de correspondance (lookup table) : on calcule une liste de valeurs une seule fois, à la compilation, et on se contente de la lire à l’exécution. Ici, le compilateur construit la table « jour de l’année où commence chaque mois » pour une année non bissextile :
const DAYS_IN_MONTH: [u32; 12] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
const fn days_before_each_month() -> [u32; 12] { let mut table = [0; 12]; let mut month = 1; while month < 12 { table[month] = table[month - 1] + DAYS_IN_MONTH[month - 1]; month += 1; } table}
// Construite une fois par le compilateur. À l'exécution, ce ne sont que 12 nombres.const DAYS_BEFORE: [u32; 12] = days_before_each_month();
fn day_of_year(month: usize, day: u32) -> u32 { DAYS_BEFORE[month - 1] + day}
fn main() { println!("{:?}", DAYS_BEFORE); println!("le 1er mars est le jour {}", day_of_year(3, 1)); println!("le 25 décembre est le jour {}", day_of_year(12, 25));}Sortie :
[0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334]le 1er mars est le jour 60le 25 décembre est le jour 359Remarquez la boucle while à la place d’une boucle for. Ce n’est pas une question de style. Voici ce que vous pouvez utiliser dans une const fn aujourd’hui :
letetlet mut, l’arithmétique, les comparaisons,if,match,loopetwhile- les tableaux, tuples, structs et enums, et l’indexation comme
table[month] - les appels à d’autres
const fn(beaucoup de fonctions standard le sont, commeu32::powouslice.len()) - les références
&mutet les calculs en virgule flottante (stables depuis Rust 1.83 et 1.82) panic!etassert!, qui deviennent des erreurs de compilation lorsqu’ils se déclenchent à la compilation
Et ce que vous ne pouvez pas encore utiliser : les boucles for (elles ont besoin du trait Iterator, et les méthodes de traits ne sont pas const en Rust stable), les appels à des fonctions normales comme String::from ou println!, et tout ce qui alloue sur le tas, comme ajouter un élément à un Vec.
const ou static ? Ce sont tous deux des valeurs globales qui ne changent jamais, mais elles sont stockées différemment :
const |
static |
|
|---|---|---|
| Où elle vit | copiée à chaque endroit où vous l’utilisez | un emplacement fixe en mémoire |
| Adresse | peut varier d’une fois à l’autre | toujours la même, vit pendant tout le programme ('static) |
| Peut être mutable | jamais | static mut existe, mais exige unsafe |
| À utiliser pour | des nombres, de petites tables, des réglages | une grande table à stocker une seule fois, ou une globale dont vous avez besoin d’une référence |
Dans le doute, utilisez const. Si vous avez besoin d’une globale que vous pouvez modifier, utilisez un static contenant un atomique ou un Mutex plutôt qu’un static mut. Voir L’état partagé (RwLock, atomiques).
PhantomData : une étiquette impossible à confondre
Section intitulée « PhantomData : une étiquette impossible à confondre »Voici un bug qui arrive tout le temps dans les vrais systèmes. Les ID d’utilisateurs et les ID de commandes ne sont que des nombres, donc rien ne vous empêche de passer un ID d’utilisateur à cancel_order. Vous aimeriez que le type porte une étiquette : Id<User> contre Id<Order>. Votre premier essai ressemblera peut-être à ceci :
struct Id<T> { value: u32,}error[E0392]: type parameter `T` is never used --> src/main.rs:1:11 |1 | struct Id<T> { | ^ unused type parameter | = help: consider removing `T`, referring to it in a field, or using a marker such as `PhantomData`Rust refuse un paramètre de type qu’aucun champ n’utilise. La solution est celle que propose le compilateur. std::marker::PhantomData<T> est un champ qui déclare « cette struct est liée à T », mais qui ne contient rien. Il occupe zéro octet :
use std::marker::PhantomData;
struct User;struct Order;
struct Id<T> { value: u32, _marker: PhantomData<T>, // zéro octet, juste une étiquette}
impl<T> Id<T> { fn new(value: u32) -> Self { Id { value, _marker: PhantomData } }}
fn find_user(id: &Id<User>) -> &'static str { if id.value == 7 { "Amara" } else { "inconnu" }}
fn cancel_order(id: &Id<Order>) { println!("annulation de la commande n°{}", id.value);}
fn main() { let user_id: Id<User> = Id::new(7); let order_id: Id<Order> = Id::new(7);
println!("l'utilisateur n°{} est {}", user_id.value, find_user(&user_id)); cancel_order(&order_id); println!("un Id occupe {} octets", std::mem::size_of::<Id<User>>());}Sortie :
l'utilisateur n°7 est Amaraannulation de la commande n°7un Id occupe 4 octetsLes deux ID contiennent le nombre 7, et tous deux occupent 4 octets, exactement comme un simple u32. Mais ajoutez maintenant cancel_order(&user_id); comme dernière ligne de main :
error[E0308]: mismatched types --> src/main.rs:32:18 |32 | cancel_order(&user_id); | ------------ ^^^^^^^^ expected `&Id<Order>`, found `&Id<User>` | | | arguments to this function are incorrectLe compilateur attendait un &Id<Order> et a trouvé un &Id<User> : la confusion est détectée avant même que le programme tourne. PhantomData est à la fois une valeur et un type, c’est pourquoi on écrit _marker: PhantomData quand on construit la struct. Le _ au début n’est qu’une habitude de nommage courante pour dire « on ne lit jamais ce champ ».
Unités de mesure : des mètres ne sont pas des pieds
Section intitulée « Unités de mesure : des mètres ne sont pas des pieds »En 1999, la NASA a perdu la sonde Mars Climate Orbiter parce qu’un logiciel produisait des valeurs en unités impériales alors qu’un autre attendait des unités métriques. Dans les deux cas, ce n’étaient « que des nombres ». Avec PhantomData, l’unité fait partie du type, et un seul Length<U> générique couvre toutes les unités :
use std::marker::PhantomData;use std::ops::Add;
struct Meters;struct Feet;
struct Length<U> { value: f64, _unit: PhantomData<U>,}
impl<U> Length<U> { fn new(value: f64) -> Self { Length { value, _unit: PhantomData } }}
// Un seul impl pour toutes les unités, mais les deux côtés doivent avoir la mêmeimpl<U> Add for Length<U> { type Output = Length<U>; fn add(self, other: Length<U>) -> Length<U> { Length::new(self.value + other.value) }}
impl Length<Feet> { fn to_meters(self) -> Length<Meters> { Length::new(self.value * 0.3048) }}
fn main() { let run: Length<Meters> = Length::new(400.0) + Length::new(250.0); let climb: Length<Feet> = Length::new(1000.0);
let climb_m = climb.to_meters(); println!("montée : {:.1} m", climb_m.value);
let total = run + climb_m; println!("total : {:.1} m", total.value);}Sortie :
montée : 304.8 mtotal : 954.8 mÉcrivez run + climb (en oubliant to_meters()), et Rust refuse :
error[E0308]: mismatched types --> src/main.rs:39:23 |39 | let total = run + climb; | ^^^^^ expected `Length<Meters>`, found `Length<Feet>`Sur la page Traits avancés, Meters et Centimeters étaient deux structs séparées, chacune avec son propre impl de Add. Ici, un seul impl<U> Add for Length<U> sert pour toutes les unités que vous additionnerez un jour.
Typestate : une porte qu’on ne peut pas verrouiller ouverte
Section intitulée « Typestate : une porte qu’on ne peut pas verrouiller ouverte »La même astuce permet de suivre l’état de quelque chose : c’est le typestate (l’état encodé dans le type). Une porte peut être ouverte, fermée ou verrouillée. On peut verrouiller une porte fermée, mais verrouiller une porte ouverte n’a aucun sens. Avec une étiquette d’état dans le type, les transitions invalides n’existent tout simplement pas :
use std::marker::PhantomData;
struct Open;struct Closed;struct Locked;
struct Door<State> { room: String, _state: PhantomData<State>,}
// Disponible dans tous les étatsimpl<State> Door<State> { fn room(&self) -> &str { &self.room }
// La même porte, avec une nouvelle étiquette d'état fn into_state<Next>(self, action: &str) -> Door<Next> { println!("{} : {action}", self.room); Door { room: self.room, _state: PhantomData } }}
impl Door<Closed> { fn new(room: &str) -> Self { Door { room: room.to_string(), _state: PhantomData } } fn open(self) -> Door<Open> { self.into_state("ouverte") } fn lock(self) -> Door<Locked> { self.into_state("verrouillée") }}
impl Door<Open> { fn close(self) -> Door<Closed> { self.into_state("fermée") }}
impl Door<Locked> { fn unlock(self) -> Door<Closed> { self.into_state("déverrouillée") }}
fn main() { let door = Door::new("salle des serveurs").open().close().lock(); println!("{} : verrouillée pour la nuit", door.room());
let door = door.unlock().open(); println!("{} : de nouveau ouverte", door.room());}Sortie :
salle des serveurs : ouvertesalle des serveurs : ferméesalle des serveurs : verrouilléesalle des serveurs : verrouillée pour la nuitsalle des serveurs : déverrouilléesalle des serveurs : ouvertesalle des serveurs : de nouveau ouverteEssayez maintenant de verrouiller une porte ouverte, Door::new("salle des serveurs").open().lock() :
error[E0599]: no method named `lock` found for struct `Door<Open>` in the current scope --> src/main.rs:50:55 | 7 | struct Door<State> { | ------------------ method `lock` not found for this struct...50 | let door = Door::new("salle des serveurs").open().lock(); | ^^^^ method not found in `Door<Open>` | = note: the method was found for `Door<Closed>`Le message dit qu’il n’existe pas de méthode lock pour Door<Open>, et la note ajoute que cette méthode existe bien pour Door<Closed>. Chaque méthode prend self par valeur : l’ancienne porte est consommée, et vous ne pouvez pas continuer à utiliser une porte « fermée » après l’avoir ouverte. La page Patrons de conception montre la même idée de typestate, avec des états qui contiennent en plus leurs propres données. PhantomData en est la version légère, quand les états ne sont que des étiquettes.
Des méthodes pour une seule taille, et les constantes associées
Section intitulée « Des méthodes pour une seule taille, et les constantes associées »Vous avez vu impl Pair<f64> sur la page Les génériques en Rust : des méthodes pour un seul type concret. Cela marche aussi avec les const generics. Certaines méthodes n’ont de sens que pour certaines tailles :
struct Matrix<const R: usize, const C: usize> { cells: [[i32; C]; R],}
// Uniquement les matrices carrées : lignes et colonnes valent le même Nimpl<const N: usize> Matrix<N, N> { fn trace(&self) -> i32 { let mut sum = 0; for i in 0..N { sum += self.cells[i][i]; } sum }}
// Uniquement les matrices 2x2impl Matrix<2, 2> { fn determinant(&self) -> i32 { let [[a, b], [c, d]] = self.cells; a * d - b * c }}
fn main() { let small = Matrix { cells: [[3, 8], [4, 6]] }; let big = Matrix { cells: [[1, 2, 3], [4, 5, 6], [7, 8, 9]] };
println!("petite : trace {}, déterminant {}", small.trace(), small.determinant()); println!("grande : trace {}", big.trace());}Sortie :
petite : trace 9, déterminant -14grande : trace 15big.determinant() ne compile pas, pas plus que trace() sur une matrice 2x3 : vous obtenez des erreurs comme error[E0599]: no method named trace found for struct Matrix<2, 3>, avec la note the method was found for Matrix<N, N> (la méthode existe, mais seulement pour les matrices carrées). La règle « seules les matrices carrées ont une trace » est désormais écrite dans les types.
Une constante associée est une constante qui appartient à un type ou à un trait, comme une méthode. Dans un trait, chaque implémentation choisit sa propre valeur, et le code générique peut la lire avec U::NAME. Donnons aux unités de tout à l’heure un symbole et une taille :
use std::fmt;use std::marker::PhantomData;
trait Unit { const SYMBOL: &'static str; const IN_METERS: f64; // combien de mètres vaut une unité}
struct Meters;struct Feet;struct Miles;
impl Unit for Meters { const SYMBOL: &'static str = "m"; const IN_METERS: f64 = 1.0;}impl Unit for Feet { const SYMBOL: &'static str = "ft"; const IN_METERS: f64 = 0.3048;}impl Unit for Miles { const SYMBOL: &'static str = "mi"; const IN_METERS: f64 = 1609.344;}
struct Length<U> { value: f64, _unit: PhantomData<U>,}
impl<U: Unit> Length<U> { fn new(value: f64) -> Self { Length { value, _unit: PhantomData } }
// Marche entre deux unités quelconques, grâce à leurs constantes fn convert<To: Unit>(&self) -> Length<To> { Length::new(self.value * U::IN_METERS / To::IN_METERS) }}
impl<U: Unit> fmt::Display for Length<U> { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{:.2} {}", self.value, U::SYMBOL) }}
fn main() { let marathon: Length<Miles> = Length::new(26.2); let everest: Length<Feet> = Length::new(29032.0);
println!("{} = {}", marathon, marathon.convert::<Meters>()); println!("{} = {}", everest, everest.convert::<Meters>()); println!("{} = {}", everest, everest.convert::<Miles>());}Sortie :
26.20 mi = 42164.81 m29032.00 ft = 8848.95 m29032.00 ft = 5.50 miU::IN_METERS est résolu à la compilation : il n’y a aucune table à parcourir à l’exécution. Ajouter une nouvelle unité, disons Kilometers, ne demande qu’un petit bloc impl Unit, et convert comme Display fonctionnent aussitôt avec elle. Un impl inhérent peut lui aussi avoir des constantes associées, par exemple impl<T, const N: usize> RingBuffer<T, N> { const CAPACITY: usize = N; }, qu’on lit avec RingBuffer::<i32, 3>::CAPACITY.
Si vous voulez une seule implémentation de trait pour tous les types qui remplissent une condition, c’est un blanket impl (implémentation générale), présenté sur la page Traits avancés.
Quand ne pas utiliser ces outils
Section intitulée « Quand ne pas utiliser ces outils »Ces fonctionnalités sont puissantes, et c’est justement ce qui les rend tentantes. Quelques conseils honnêtes :
- Une slice ou un
Vecsuffit souvent. Si une fonction ne fait que lire des éléments,&[T]accepte n’importe quelle longueur et se lit plus facilement. Si la longueur n’est connue qu’à l’exécution (un fichier, une saisie de l’utilisateur), il vous faut de toute façon unVec. - Les grands tableaux vivent sur la pile. Un
RingBuffer<u8, 10_000_000>représente dix mégaoctets dans la struct, ce qui peut faire déborder la pile. Mettez-le dans uneBox(voir Pointeurs intelligents) ou utilisez unVec. - Chaque nouvelle taille ou nouveau type crée une nouvelle copie du code. Utiliser un générique avec 20 valeurs différentes de
Nconstruit 20 versions. Cela coûte du temps de compilation et de la taille de binaire. Voir Performance en Rust. - Le typestate ajoute des types et des messages d’erreur plus longs. C’est excellent pour une API que beaucoup de gens utiliseront (un builder, une connexion, un processus de paiement). Pour un état qui dépend de données connues à l’exécution, comme un statut chargé depuis une base de données, utilisez une enum et
match.
Une bonne règle : commencez simple, et sortez ces outils le jour où ils empêcheraient un vrai bug ou un vrai coût.
Erreurs courantes
Section intitulée « Erreurs courantes »Faire un calcul avec un paramètre const dans un type. Cela paraît naturel, mais Rust stable ne le permet pas encore :
struct Pairs<const N: usize> { values: [f32; N * 2],}error: generic parameters may not be used in const operations --> src/main.rs:2:19 |2 | values: [f32; N * 2], | ^ cannot perform const operation using `N` | = help: const parameters may only be used as standalone arguments here, i.e. `N`Solutions : utilisez [[f32; 2]; N] (un tableau de paires), ajoutez un second paramètre, ou prenez un Vec si la taille n’a pas besoin de figurer dans le type.
Oublier les accolades autour d’une expression. Buffer::<2 + 2>::new() donne error: expressions must be enclosed in braces to be used as const generic arguments (les expressions doivent être entre accolades), et la ligne d’aide montre la correction : Buffer::<{ 2 + 2 }>::new(). Un simple nombre ou un nom de const n’a pas besoin d’accolades.
Appeler quelque chose qui n’est pas const dans un contexte const. Une const ne peut pas appeler une fonction normale, et une const fn ne peut pas utiliser de boucle for :
const GREETING: String = String::from("hello");error[E0015]: cannot call non-const associated function `<String as From<&str>>::from` in constants --> src/main.rs:1:26 |1 | const GREETING: String = String::from("hello"); | ^^^^^^^^^^^^^^^^^^^^^ | = note: calls in constants are limited to constant functions, tuple structs and tuple variantsLe compilateur explique que, dans une constante, on ne peut appeler que des fonctions const. Pour du texte, utilisez const GREETING: &str = "hello";. Dans une const fn, remplacez for par une boucle while avec un indice, comme dans la table de correspondance plus haut.
#[derive(Clone, Copy)] sur une struct avec PhantomData. On s’attendrait à ce que Id<User> soit Copy, puisqu’il ne contient qu’un u32. Mais derive ajoute la contrainte T: Copy, et User n’est pas Copy : l’ID est donc déplacé au lieu d’être copié :
error[E0382]: use of moved value: `id` --> src/main.rs:16:10 |14 | let id: Id<User> = Id { value: 1, _marker: PhantomData }; | -- move occurs because `id` has type `Id<User>`, which does not implement the `Copy` trait15 | show(id); | -- value moved here16 | show(id); | ^^ value used here after move |note: if all bounds were met, you could clone the value --> src/main.rs:4:1 | 3 | #[derive(Clone, Copy)] | ----- derived `Clone` adds implicit bounds on type parametersLa solution la plus simple est de dériver aussi Clone, Copy sur les types marqueurs (#[derive(Clone, Copy)] struct User;). Ou bien écrivez les impls à la main, ce qui ne demande aucune contrainte sur T :
impl<T> Clone for Id<T> { fn clone(&self) -> Self { *self }}impl<T> Copy for Id<T> {}Exercices
Section intitulée « Exercices »1. Compléter les scores
Section intitulée « 1. Compléter les scores »Écrivez fn pad_scores<const N: usize>(scores: &[u32]) -> [u32; N], qui copie les scores dans un tableau d’exactement N éléments. Remplissez les places manquantes avec 0, et laissez tomber les scores en trop. Appelez-la avec pad_scores::<5>(&[90, 85]) et avec let top3: [u32; 3] = pad_scores(&[70, 95, 88, 60]);. Indice : partez de [0; N], puis bouclez sur 0..N et utilisez scores.get(i).
Voir la solution
fn pad_scores<const N: usize>(scores: &[u32]) -> [u32; N] { let mut out = [0; N]; for i in 0..N { if let Some(&score) = scores.get(i) { out[i] = score; } } out}
fn main() { let five = pad_scores::<5>(&[90, 85]); let top3: [u32; 3] = pad_scores(&[70, 95, 88, 60]);
println!("{:?}", five); println!("{:?}", top3);}Sortie :
[90, 85, 0, 0, 0][70, 95, 88]Ici, N ne peut pas venir de l’argument (une slice n’a pas de longueur dans son type) : il vient donc du turbofish ::<5> ou du type que vous demandez, [u32; 3].
2. Des délais calculés à la compilation
Section intitulée « 2. Des délais calculés à la compilation »Écrivez deux const fn, minutes(n: u64) -> u64 et hours(n: u64) -> u64, qui renvoient un nombre de secondes. Utilisez-les pour définir const SESSION_TIMEOUT: u64 = hours(1) + minutes(30); et un tableau const RETRY_DELAYS: [u64; 3] de 1, 5 et 15 minutes. Affichez les deux. Indice : hours peut appeler minutes.
Voir la solution
const fn minutes(n: u64) -> u64 { n * 60}
const fn hours(n: u64) -> u64 { minutes(n * 60)}
const SESSION_TIMEOUT: u64 = hours(1) + minutes(30);const RETRY_DELAYS: [u64; 3] = [minutes(1), minutes(5), minutes(15)];
fn main() { println!("expiration de la session : {SESSION_TIMEOUT} secondes"); println!("délais avant nouvel essai : {:?}", RETRY_DELAYS);}Sortie :
expiration de la session : 5400 secondesdélais avant nouvel essai : [60, 300, 900]Le compilateur calcule 5400 et la table des délais pendant la construction, et hours(1) + minutes(30) se lit bien mieux qu’un 5400 tout nu. Si vous retirez const devant minutes, les éléments const ne compilent plus et vous obtenez E0015.
3. De l’argent qu’on ne peut pas mélanger
Section intitulée « 3. De l’argent qu’on ne peut pas mélanger »Créez struct Euro; et struct Dollar;, ainsi qu’un Money<C> avec un champ cents: i64 et un champ PhantomData<C>. Donnez-lui new(cents) et plus(self, other: Money<C>) -> Money<C>. Additionnez un déjeuner et un café en euros, puis affichez le total. Indice : comme other a le même C, impossible d’additionner un Money<Euro> et un Money<Dollar>.
Voir la solution
use std::marker::PhantomData;
struct Euro;struct Dollar;
struct Money<C> { cents: i64, _currency: PhantomData<C>,}
impl<C> Money<C> { fn new(cents: i64) -> Self { Money { cents, _currency: PhantomData } }
fn plus(self, other: Money<C>) -> Money<C> { Money::new(self.cents + other.cents) }}
fn main() { let lunch: Money<Euro> = Money::new(1250); let coffee: Money<Euro> = Money::new(320); let ticket: Money<Dollar> = Money::new(4999);
let total = lunch.plus(coffee); println!("total : {},{:02} EUR", total.cents / 100, total.cents % 100); println!("billet : {},{:02} USD", ticket.cents / 100, ticket.cents % 100);}Sortie :
total : 15,70 EURbillet : 49,99 USDEssayez lunch.plus(ticket) : cela échoue avec expected Money<Euro>, found Money<Dollar>. Stocker les centimes dans un entier plutôt que dans un f64 évite aussi les erreurs d’arrondi avec l’argent.
Récapitulatif
Section intitulée « Récapitulatif »- Les const generics (
const N: usize) font d’un nombre une partie d’un type. Des tableaux de longueurs différentes sont des types différents, etimpl<T, const N: usize>écrit des méthodes pour toutes les tailles. - En Rust stable, les paramètres const peuvent être des entiers, des
boolou deschar. Une expression passée en argument demande des accolades ({ PAGE * 2 }), et les calculs avec unNgénérique dans un type ne sont pas encore permis. - Une
const fnpeut s’exécuter à la compilation dans les contextes const (const,static, longueurs de tableaux). Utilisezwhileau lieu defor, et n’appelez que d’autresconst fn. PhantomData<T>est un champ de taille nulle qui relie une struct à un type qu’elle ne stocke pas. Servez-vous-en pour les ID typés, les unités de mesure et le typestate.impl Matrix<2, 2>etimpl<const N: usize> Matrix<N, N>donnent des méthodes à certaines tailles seulement. Les constantes associées commeU::IN_METERSdonnent à chaque type sa propre valeur connue à la compilation.- Ces outils coûtent du temps de compilation et de la lisibilité. Utilisez-les quand ils évitent un vrai bug.
Petit quiz
Section intitulée « Petit quiz »Petit quiz
1. Pourquoi ne peut-on pas passer un [u32; 4] à une fonction qui prend un [u32; 3] ?
2. Qu'est-ce que PhantomData<T> ajoute à une struct à l'exécution ?
3. Lequel de ces éléments ne peut-on PAS utiliser dans une const fn en Rust stable ?
4. Que signifie impl<const N: usize> Matrix<N, N> ?
Score : 0 / 4
Pour aller plus loin
Section intitulée « Pour aller plus loin »- Const generics — The Rust Reference (en anglais) : les règles exactes des paramètres const, les types autorisés et quand les accolades sont nécessaires.
- Const functions — The Rust Reference (en anglais) : ce que sont les contextes const et ce qu’une
const fna le droit de faire. PhantomData— documentation de la bibliothèque standard (en anglais) : l’explication officielle, avec des exemples de paramètres de type inutilisés.- Phantom type parameters — Rust by Example (en anglais) : de courts exemples exécutables, dont une étude de cas sur les unités de mesure.
- Const generics MVP hits beta — Rust Blog (en anglais) : l’annonce qui explique pourquoi les const generics ont été ajoutés et ce que permettait leur première version.