Aller au contenu

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 3 dans [i32; 3].
  • const fn permet 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.
  • PhantomData permet à 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 incorrect

Le 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 16
5 articles coûtent 30
▶ Essayez par vous-même

Lisez <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]]
▶ Essayez par vous-même

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]
▶ Essayez par vous-même

[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 16
▶ Essayez par vous-même

La 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…), bool et char. Essayer const NAME: &'static str donne « &'static str is 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 »). N seul ne pose aucun problème, et N * 2 non plus dans le corps d’une fonction, où N n’est qu’une valeur. La fonctionnalité qui autoriserait N * 2 dans les types (generic_const_exprs) n’existe encore qu’en nightly.
  • Vérifier une valeur. Vous ne pouvez pas écrire une contrainte du genre « N doit être supérieur à 0 ». Le plus proche, c’est un bloc const en 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 mauvais N (cargo check peut 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 cases
square(12) à l'exécution donne 144
▶ Essayez par vous-même

Une 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 60
le 25 décembre est le jour 359
▶ Essayez par vous-même

Remarquez 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 :

  • let et let mut, l’arithmétique, les comparaisons, if, match, loop et while
  • les tableaux, tuples, structs et enums, et l’indexation comme table[month]
  • les appels à d’autres const fn (beaucoup de fonctions standard le sont, comme u32::pow ou slice.len())
  • les références &mut et les calculs en virgule flottante (stables depuis Rust 1.83 et 1.82)
  • panic! et assert!, 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 Amara
annulation de la commande n°7
un Id occupe 4 octets
▶ Essayez par vous-même

Les 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 incorrect

Le 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ême
impl<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 m
total : 954.8 m
▶ Essayez par vous-même

É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 états
impl<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 : ouverte
salle des serveurs : fermée
salle des serveurs : verrouillée
salle des serveurs : verrouillée pour la nuit
salle des serveurs : déverrouillée
salle des serveurs : ouverte
salle des serveurs : de nouveau ouverte
▶ Essayez par vous-même

Essayez 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 N
impl<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 2x2
impl 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 -14
grande : trace 15
▶ Essayez par vous-même

big.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 m
29032.00 ft = 8848.95 m
29032.00 ft = 5.50 mi
▶ Essayez par vous-même

U::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.

Ces fonctionnalités sont puissantes, et c’est justement ce qui les rend tentantes. Quelques conseils honnêtes :

  • Une slice ou un Vec suffit 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 un Vec.
  • 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 une Box (voir Pointeurs intelligents) ou utilisez un Vec.
  • Chaque nouvelle taille ou nouveau type crée une nouvelle copie du code. Utiliser un générique avec 20 valeurs différentes de N construit 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.

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 variants

Le 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` trait
15 | show(id);
| -- value moved here
16 | 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 parameters

La 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> {}

É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]
▶ Essayez par vous-même

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].

É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 secondes
délais avant nouvel essai : [60, 300, 900]
▶ Essayez par vous-même

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.

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 EUR
billet : 49,99 USD
▶ Essayez par vous-même

Essayez 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.

  • 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, et impl<T, const N: usize> écrit des méthodes pour toutes les tailles.
  • En Rust stable, les paramètres const peuvent être des entiers, des bool ou des char. Une expression passée en argument demande des accolades ({ PAGE * 2 }), et les calculs avec un N générique dans un type ne sont pas encore permis.
  • Une const fn peut s’exécuter à la compilation dans les contextes const (const, static, longueurs de tableaux). Utilisez while au lieu de for, et n’appelez que d’autres const 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> et impl<const N: usize> Matrix<N, N> donnent des méthodes à certaines tailles seulement. Les constantes associées comme U::IN_METERS donnent à 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

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