Salta ai contenuti

Generici avanzati in Rust: const generics, const fn e PhantomData

Nella pagina sui generici, un parametro generico era sempre un tipo: Pair<T> poteva contenere degli i32 o degli &str. Questa pagina mostra tre strumenti che portano la stessa idea più in là:

  • I const generics permettono a un numero di essere un parametro generico, come il 3 in [i32; 3].
  • const fn permette al compilatore di eseguire la tua funzione mentre costruisce il programma, così il risultato è pronto prima ancora che il programma parta.
  • PhantomData permette a un tipo di portare un’etichetta (come «questo è l’ID di un utente» o «questa lunghezza è in piedi») senza memorizzare nessun dato in più.

Pensa ai contenitori per le uova. Una confezione da 6 e una da 12 sono prodotti diversi, anche se sono entrambe «confezioni di uova». La dimensione fa parte di ciò che sono. I const generics ti permettono di dirlo in Rust. Prendi questa pagina con calma: ogni sezione sta in piedi da sola, e puoi tornare alle ultime quando ti serviranno.

In Rust la lunghezza di un array fa parte del suo tipo. [u32; 3] e [u32; 4] sono diversi quanto i32 e String. Una funzione che prende [u32; 3] non accetta quattro elementi:

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

Il compilatore dice che si aspettava un array di 3 elementi e ne ha trovato uno di 4. Un const generic risolve il problema. Scrivi const N: usize tra le parentesi angolari, e la funzione funziona per un array di qualsiasi lunghezza:

fn summary<const N: usize>(prices: [u32; N]) -> String {
let mut sum = 0;
for price in prices {
sum += price;
}
// Dentro la funzione, N è solo un numero normale
format!("{N} articoli costano {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));
}

Output:

3 articoli costano 16
5 articoli costano 30
▶ Provalo tu

Leggi <const N: usize> come «per qualsiasi numero N di tipo usize». Rust ricava N dall’array che passi, proprio come ricava T per i generici normali. E proprio come con i generici normali, il compilatore crea una copia separata di summary per ogni lunghezza che usi, quindi non c’è nessun costo durante l’esecuzione.

A essere sinceri: per questa piccola funzione una slice (prices: &[u32]) andrebbe altrettanto bene ed è più semplice. Vedi Slice. I const generics ripagano davvero quando la lunghezza deve far parte di un tipo: salvata in una struct senza allocazioni nell’heap, oppure usata in un tipo di ritorno. È il prossimo passo.

Immagina la piantina dei posti di un cinema: alcune file, ognuna con lo stesso numero di posti. Ecco una matrice in cui il numero di righe R e di colonne C fanno entrambi parte del tipo:

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}")
}
// Le righe diventano colonne: una matrice R x C diventa una matrice 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);
}

Output:

2x3 [[1, 2, 3], [4, 5, 6]]
3x2 [[1, 4], [2, 5], [3, 6]]
▶ Provalo tu

Guarda il tipo di ritorno di transpose: Matrix<C, R>. Il compilatore sa che girando una matrice 2x3 si ottiene una 3x2. Se sbagliassi qualcosa all’interno, per esempio costruendo una matrice della forma sbagliata, non compilerebbe. L’intera griglia vive dentro la struct, senza Vec e senza heap. La riga impl<const R: usize, const C: usize> funziona come impl<T>: dice «questi metodi esistono per ogni dimensione».

Un esempio pratico: un buffer circolare di dimensione fissa

Sezione intitolata “Un esempio pratico: un buffer circolare di dimensione fissa”

Una dashcam in auto conserva solo gli ultimi minuti di video. Quando è piena, la registrazione più recente sovrascrive la più vecchia. Questo è un buffer circolare (ring buffer), ed è perfetto per i const generics: la capacità è fissa, quindi puoi conservare gli elementi in un array.

Questo è generico sia su un tipo T sia su una dimensione N, quindi la riga dell’impl dice impl<T, const N: usize>:

struct RingBuffer<T, const N: usize> {
items: [T; N],
next: usize, // dove va il prossimo push
len: usize, // quanti elementi sono salvati (mai più di 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; // quando è pieno, sovrascrive l'elemento più vecchio
self.next = (self.next + 1) % N;
if self.len < N {
self.len += 1;
}
}
// Gli elementi salvati, dal più vecchio
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() {
// Tieni solo le ultime 3 letture della temperatura
let mut recent: RingBuffer<i32, 3> = RingBuffer::new();
for temp in [18, 19, 21, 24, 22] {
recent.push(temp);
println!("dopo {temp}: {:?}", recent.to_vec());
}
}

Output:

dopo 18: [18]
dopo 19: [18, 19]
dopo 21: [18, 19, 21]
dopo 24: [19, 21, 24]
dopo 22: [21, 24, 22]
▶ Provalo tu

[T::default(); N] riempie l’array con N copie di un valore predefinito (0 per i32). Ripetere un valore in questo modo richiede T: Copy, ed è per questo che l’impl chiede Copy + Default. Un RingBuffer<i32, 3> e un RingBuffer<i32, 60> sono tipi diversi, quindi non puoi mai passare un buffer da 3 posti dove ne serve uno da 60. Se invece vuoi la versione espandibile della libreria standard, vedi VecDeque e BinaryHeap.

Un parametro const può avere un valore predefinito, scritto const N: usize = 4. Puoi anche passare una costante con un nome, oppure un’espressione tra parentesi graffe:

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(); // nessun numero: il predefinito, 4
let page = Buffer::<PAGE>::new(); // una costante con un nome
let double = Buffer::<{ PAGE * 2 }>::new(); // un'espressione ha bisogno di { }
println!("{} {} {}", small.data.len(), page.data.len(), double.data.len());
}

Output:

4 8 16
▶ Provalo tu

Il valore predefinito entra in gioco solo quando scrivi il tipo senza un numero, come in let small: Buffer. Un semplice let small = Buffer::new(); fallisce con error[E0284]: type annotations needed for Buffer<_>, perché Rust non ricava un predefinito da un’espressione.

Nel Rust stabile i const generics sono utili, ma volutamente limitati. Conviene conoscerne i confini prima di sbatterci contro:

  • Tipi ammessi: solo interi (usize, u8, i32, …), bool e char. Provare const NAME: &'static str dà «&'static str is forbidden as the type of a const generic parameter».
  • Niente calcoli con un parametro generico in un tipo. [f32; N * 2] dentro una struct o una firma generica è un errore («generic parameters may not be used in const operations»). N da solo va bene, e va bene anche N * 2 dentro il corpo di una funzione, dove N è solo un valore. La funzionalità che permetterebbe N * 2 nei tipi (generic_const_exprs) è ancora solo nightly.
  • Controllare un valore. Non puoi scrivere un vincolo come «N deve essere maggiore di 0». Il massimo che puoi fare è un blocco const inline in una funzione: const { assert!(N > 0, "need at least one slot") };. Fallisce quando compili il programma con un N sbagliato (cargo check potrebbe non accorgersene).

Alcuni negozi stampano il listino una volta sola, invece di calcolare ogni prezzo alla cassa. Una const fn è una funzione che il compilatore può eseguire mentre costruisce il programma, così il risultato è «stampato» nel programma in anticipo.

Hai già incontrato gli elementi const nella pagina sulle variabili. Il loro valore deve essere noto in fase di compilazione, quindi di solito puoi usare solo letterali e calcoli semplici. Con const fn lì puoi chiamare anche le tue funzioni:

const fn square(n: usize) -> usize {
n * n
}
const BOARD_SIDE: usize = 4;
const CELLS: usize = square(BOARD_SIDE); // calcolato dal compilatore
fn main() {
// La lunghezza degli array deve essere nota in compilazione, e CELLS lo è
let board = [0u8; CELLS];
println!("una scacchiera {BOARD_SIDE}x{BOARD_SIDE} ha {} caselle", board.len());
println!("square(12) durante l'esecuzione vale {}", square(12));
}

Output:

una scacchiera 4x4 ha 16 caselle
square(12) durante l'esecuzione vale 144
▶ Provalo tu

Una const fn resta comunque una funzione normale. square(12) in main è una normale chiamata durante l’esecuzione. Deve girare in fase di compilazione solo quando viene usata in un contesto const: un elemento const o static, la lunghezza di un array, un argomento const generic o un blocco const { ... }.

Un uso classico è una tabella di consultazione (lookup table): calcoli un elenco di valori una volta, in fase di compilazione, e durante l’esecuzione ti limiti a leggerlo. Qui il compilatore costruisce la tabella «in che giorno dell’anno comincia ogni mese» per un anno non bisestile:

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
}
// Costruita una volta dal compilatore. Durante l'esecuzione sono solo 12 numeri.
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!("il 1° marzo è il giorno {}", day_of_year(3, 1));
println!("il 25 dicembre è il giorno {}", day_of_year(12, 25));
}

Output:

[0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334]
il 1° marzo è il giorno 60
il 25 dicembre è il giorno 359
▶ Provalo tu

Nota il ciclo while invece di un ciclo for. Non è una questione di stile. Ecco cosa puoi usare oggi dentro una const fn:

  • let e let mut, aritmetica, confronti, if, match, loop e while
  • array, tuple, struct ed enum, e indicizzazioni come table[month]
  • chiamate ad altre const fn (molte funzioni standard sono const, come u32::pow o slice.len())
  • riferimenti &mut e calcoli in virgola mobile (stabili da Rust 1.83 e 1.82)
  • panic! e assert!, che diventano errori di compilazione quando scattano in fase di compilazione

E cosa non puoi ancora usare: i cicli for (hanno bisogno del trait Iterator, e nel Rust stabile i metodi dei trait non sono const), le chiamate a funzioni normali come String::from o println!, e qualsiasi cosa allochi nell’heap, come fare push in un Vec.

const o static? Sono entrambi valori globali che non cambiano mai, ma vengono memorizzati in modo diverso:

const static
Dove vive copiato in ogni punto in cui lo usi un unico posto fisso in memoria
Indirizzo può cambiare ogni volta sempre lo stesso, vive per tutto il programma ('static)
Può essere mutabile mai esiste static mut, ma richiede unsafe
Usalo per numeri, piccole tabelle, impostazioni una tabella grande da memorizzare una volta sola, o un globale di cui ti serve un riferimento

Nel dubbio, usa const. Se ti serve un globale che puoi modificare, usa uno static con dentro un atomico o un Mutex invece di static mut. Vedi Stato condiviso.

PhantomData: un’etichetta che non puoi confondere

Sezione intitolata “PhantomData: un’etichetta che non puoi confondere”

Ecco un bug che capita di continuo nei sistemi reali. Gli ID degli utenti e gli ID degli ordini sono entrambi solo numeri, quindi niente ti impedisce di passare l’ID di un utente a cancel_order. Vorresti che il tipo portasse un’etichetta: Id<User> contro Id<Order>. Il tuo primo tentativo potrebbe essere questo:

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 rifiuta un parametro di tipo che nessun campo usa. La soluzione è quella che suggerisce il compilatore. std::marker::PhantomData<T> è un campo che dice «questa struct è collegata a T» ma non contiene niente. Occupa zero byte:

use std::marker::PhantomData;
struct User;
struct Order;
struct Id<T> {
value: u32,
_marker: PhantomData<T>, // zero byte, solo un'etichetta
}
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 { "sconosciuto" }
}
fn cancel_order(id: &Id<Order>) {
println!("annullo l'ordine #{}", id.value);
}
fn main() {
let user_id: Id<User> = Id::new(7);
let order_id: Id<Order> = Id::new(7);
println!("l'utente #{} è {}", user_id.value, find_user(&user_id));
cancel_order(&order_id);
println!("un Id occupa {} byte", std::mem::size_of::<Id<User>>());
}

Output:

l'utente #7 è Amara
annullo l'ordine #7
un Id occupa 4 byte
▶ Provalo tu

Entrambi gli ID contengono il numero 7, ed entrambi occupano 4 byte, esattamente come un semplice u32. Ma ora aggiungi cancel_order(&user_id); come ultima riga di 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

Il compilatore si aspettava un &Id<Order> e ha trovato un &Id<User>: lo scambio viene scoperto prima ancora che il programma parta. PhantomData è sia un valore sia un tipo, ed è per questo che scrivi _marker: PhantomData quando costruisci la struct. Il _ iniziale è solo un’abitudine comune per dire «questo campo non lo leggi mai».

Nel 1999 la NASA ha perso il Mars Climate Orbiter perché un software produceva numeri in unità imperiali e un altro si aspettava unità metriche. Erano entrambi «solo numeri». Con PhantomData, l’unità diventa parte del tipo, e un unico Length<U> generico copre tutte le unità:

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 solo impl per ogni unità, ma i due lati devono usare la stessa unità
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!("salita: {:.1} m", climb_m.value);
let total = run + climb_m;
println!("totale: {:.1} m", total.value);
}

Output:

salita: 304.8 m
totale: 954.8 m
▶ Provalo tu

Scrivi run + climb (dimenticando to_meters()) e Rust rifiuta:

error[E0308]: mismatched types
--> src/main.rs:39:23
|
39 | let total = run + climb;
| ^^^^^ expected `Length<Meters>`, found `Length<Feet>`

Nella pagina sui trait avanzati, Meters e Centimeters erano due struct separate, ognuna con il suo impl di Add. Qui un solo impl<U> Add for Length<U> serve per tutte le unità che sommerai.

Typestate: una porta che non si può chiudere a chiave se è aperta

Sezione intitolata “Typestate: una porta che non si può chiudere a chiave se è aperta”

Lo stesso trucco può tenere traccia dello stato di qualcosa. Una porta può essere aperta, chiusa o chiusa a chiave. Puoi chiudere a chiave una porta chiusa, ma chiudere a chiave una porta aperta non ha senso. Con un’etichetta di stato nel tipo, le mosse non valide semplicemente non esistono:

use std::marker::PhantomData;
struct Open;
struct Closed;
struct Locked;
struct Door<State> {
room: String,
_state: PhantomData<State>,
}
// Disponibile in ogni stato
impl<State> Door<State> {
fn room(&self) -> &str {
&self.room
}
// Stessa porta, nuova etichetta di stato
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("aperta")
}
fn lock(self) -> Door<Locked> {
self.into_state("chiusa a chiave")
}
}
impl Door<Open> {
fn close(self) -> Door<Closed> {
self.into_state("chiusa")
}
}
impl Door<Locked> {
fn unlock(self) -> Door<Closed> {
self.into_state("sbloccata")
}
}
fn main() {
let door = Door::new("sala server").open().close().lock();
println!("{} è chiusa a chiave per la notte", door.room());
let door = door.unlock().open();
println!("{} è di nuovo aperta", door.room());
}

Output:

sala server: aperta
sala server: chiusa
sala server: chiusa a chiave
sala server è chiusa a chiave per la notte
sala server: sbloccata
sala server: aperta
sala server è di nuovo aperta
▶ Provalo tu

Ora prova a chiudere a chiave una porta aperta, Door::new("sala server").open().lock():

error[E0599]: no method named `lock` found for struct `Door<Open>` in the current scope
--> src/main.rs:50:48
|
50 | let door = Door::new("sala server").open().lock();
| ^^^^ method not found in `Door<Open>`
|
= note: the method was found for `Door<Closed>`

Il compilatore non trova nessun metodo lock per Door<Open>, e nota che il metodo esiste per Door<Closed>. Ogni metodo prende self per valore, quindi la vecchia porta viene consumata e non puoi continuare a usare una porta «chiusa» dopo averla aperta. La pagina sui design pattern mostra la stessa idea di typestate, in cui ogni stato contiene anche dati suoi. PhantomData è la versione leggera per quando gli stati sono pure etichette.

Metodi per una sola dimensione e costanti associate

Sezione intitolata “Metodi per una sola dimensione e costanti associate”

Nella pagina sui generici hai visto impl Pair<f64>: metodi per un solo tipo concreto. Lo stesso funziona con i const generics. Alcuni metodi hanno senso solo per certe dimensioni:

struct Matrix<const R: usize, const C: usize> {
cells: [[i32; C]; R],
}
// Solo matrici quadrate: righe e colonne sono lo stesso 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
}
}
// Solo matrici 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!("piccola: traccia {}, determinante {}", small.trace(), small.determinant());
println!("grande: traccia {}", big.trace());
}

Output:

piccola: traccia 9, determinante -14
grande: traccia 15
▶ Provalo tu

big.determinant() non compila, e nemmeno trace() su una matrice 2x3: ottieni errori come error[E0599]: no method named trace found for struct Matrix<2, 3>, con la nota the method was found for Matrix<N, N>. La regola «solo le matrici quadrate hanno una traccia» ora è scritta nei tipi.

Una costante associata è una costante che appartiene a un tipo o a un trait, come un metodo. In un trait, ogni implementazione sceglie il proprio valore, e il codice generico può leggerlo come U::NOME. Diamo alle unità di prima un simbolo e una dimensione:

use std::fmt;
use std::marker::PhantomData;
trait Unit {
const SYMBOL: &'static str;
const IN_METERS: f64; // quanti metri vale una di queste 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 }
}
// Funziona tra due unità qualsiasi, usando le loro costanti
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>());
}

Output:

26.20 mi = 42164.81 m
29032.00 ft = 8848.95 m
29032.00 ft = 5.50 mi
▶ Provalo tu

U::IN_METERS viene letto in fase di compilazione, quindi durante l’esecuzione non c’è nessuna tabella in cui cercare. Aggiungere una nuova unità, per esempio Kilometers, richiede un piccolo blocco impl Unit, e convert e Display funzionano subito anche per lei. Anche un impl inerente può avere costanti associate, per esempio impl<T, const N: usize> RingBuffer<T, N> { const CAPACITY: usize = N; }, che si legge come RingBuffer::<i32, 3>::CAPACITY.

Se vuoi un’unica implementazione di un trait per ogni tipo che soddisfa una condizione, quella è una blanket impl, trattata nella pagina sui trait avanzati.

Queste funzionalità sono potenti, e proprio per questo invitanti. Qualche consiglio sincero:

  • Spesso basta una slice o un Vec. Se una funzione legge soltanto gli elementi, &[T] accetta qualsiasi lunghezza ed è più facile da leggere. Se la lunghezza si conosce solo durante l’esecuzione (un file, l’input dell’utente), ti serve comunque un Vec.
  • Gli array grandi vivono nello stack. Un RingBuffer<u8, 10_000_000> sono dieci megabyte dentro la struct, che possono far traboccare lo stack. Mettilo in un Box (vedi Smart pointer) o usa un Vec.
  • Ogni nuova dimensione o tipo è una nuova copia del codice. Usare un generico con 20 valori diversi di N costruisce 20 versioni. Costa tempo di compilazione e dimensione del binario. Vedi Prestazioni.
  • Il typestate aggiunge tipi e messaggi di errore più lunghi. È ottimo per un’API che useranno in tanti (un builder, una connessione, un flusso di pagamento). Per uno stato che cambia in base a dati di runtime, come uno stato letto da un database, usa un enum e match.

Una buona regola: parti semplice, e usa questi strumenti quando compare un bug reale o un costo reale che eviterebbero.

Fare calcoli con un parametro const in un tipo. Sembra naturale, ma il Rust stabile non lo permette ancora:

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`

Il compilatore dice che i parametri const possono comparire qui solo da soli, cioè come N. Soluzioni: usa [[f32; 2]; N] (un array di coppie), aggiungi un secondo parametro, o usa un Vec se la dimensione non deve stare nel tipo.

Dimenticare le graffe intorno a un’espressione. Buffer::<2 + 2>::new() dà error: expressions must be enclosed in braces to be used as const generic arguments, e la riga di aiuto mostra la soluzione: Buffer::<{ 2 + 2 }>::new(). Un numero semplice o il nome di una const non hanno bisogno di graffe.

Chiamare qualcosa che non è const in un contesto const. Una const non può chiamare una funzione normale, e una const fn non può usare un ciclo 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

Qui rustc spiega che nelle costanti si possono chiamare solo funzioni costanti. Per il testo, usa const GREETING: &str = "hello";. Dentro una const fn, sostituisci for con un ciclo while e un indice, come nella tabella di consultazione qui sopra.

#[derive(Clone, Copy)] su una struct con PhantomData. Ti aspetteresti che Id<User> sia Copy, visto che contiene solo un u32. Ma derive aggiunge il vincolo T: Copy, e User non è Copy, quindi l’ID viene spostato:

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 nota finale spiega che il Clone derivato aggiunge vincoli impliciti sui parametri di tipo. La soluzione più semplice è derivare Clone, Copy anche sui tipi marcatori (#[derive(Clone, Copy)] struct User;). Oppure scrivi gli impl a mano, che non richiedono nessun vincolo su T:

impl<T> Clone for Id<T> {
fn clone(&self) -> Self {
*self
}
}
impl<T> Copy for Id<T> {}

Scrivi fn pad_scores<const N: usize>(scores: &[u32]) -> [u32; N], che copia i punteggi in un array di esattamente N elementi. Riempi i posti mancanti con 0 e scarta i punteggi in più. Chiamala come pad_scores::<5>(&[90, 85]) e come let top3: [u32; 3] = pad_scores(&[70, 95, 88, 60]);. Suggerimento: parti da [0; N], poi scorri 0..N e usa scores.get(i).

Mostra la soluzione
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);
}

Output:

[90, 85, 0, 0, 0]
[70, 95, 88]
▶ Provalo tu

Qui N non può venire dall’argomento (una slice non ha la lunghezza nel suo tipo), quindi viene dal turbofish ::<5> o dal tipo che chiedi, [u32; 3].

Scrivi due const fn, minutes(n: u64) -> u64 e hours(n: u64) -> u64, che restituiscono un numero di secondi. Usale per definire const SESSION_TIMEOUT: u64 = hours(1) + minutes(30); e un array const RETRY_DELAYS: [u64; 3] con 1, 5 e 15 minuti. Stampali entrambi. Suggerimento: hours può chiamare minutes.

Mostra la soluzione
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!("timeout della sessione: {SESSION_TIMEOUT} secondi");
println!("attese tra i tentativi: {:?}", RETRY_DELAYS);
}

Output:

timeout della sessione: 5400 secondi
attese tra i tentativi: [60, 300, 900]
▶ Provalo tu

Il compilatore calcola 5400 e la tabella delle attese durante la costruzione, e hours(1) + minutes(30) si legge molto meglio di un 5400 buttato lì. Se togli const da minutes, gli elementi const smettono di compilare con E0015.

Crea struct Euro; e struct Dollar;, e un Money<C> con un campo cents: i64 e un campo PhantomData<C>. Dagli new(cents) e plus(self, other: Money<C>) -> Money<C>. Somma un pranzo e un caffè in euro e stampa il totale. Suggerimento: visto che other ha lo stesso C, un Money<Euro> e un Money<Dollar> non si possono sommare.

Mostra la soluzione
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!("totale: {}.{:02} EUR", total.cents / 100, total.cents % 100);
println!("biglietto: {}.{:02} USD", ticket.cents / 100, ticket.cents % 100);
}

Output:

totale: 15.70 EUR
biglietto: 49.99 USD
▶ Provalo tu

Prova lunch.plus(ticket): fallisce con expected Money<Euro>, found Money<Dollar>. Salvare i centesimi come intero invece che come f64 evita anche gli errori di arrotondamento con i soldi.

  • I const generics (const N: usize) rendono un numero parte di un tipo. Array di lunghezze diverse sono tipi diversi, e impl<T, const N: usize> scrive metodi per ogni dimensione.
  • Nel Rust stabile, i parametri const possono essere interi, bool o char. Le espressioni usate come argomenti hanno bisogno delle graffe ({ PAGE * 2 }), e i calcoli con un N generico dentro un tipo non sono ancora permessi.
  • Una const fn può essere eseguita in fase di compilazione nei contesti const (const, static, lunghezze degli array). Usa while invece di for, e chiama solo altre const fn.
  • PhantomData<T> è un campo di dimensione zero che lega una struct a un tipo che non memorizza. Usalo per ID tipizzati, unità di misura e typestate.
  • impl Matrix<2, 2> e impl<const N: usize> Matrix<N, N> danno metodi solo ad alcune dimensioni. Le costanti associate come U::IN_METERS danno a ogni tipo un suo valore calcolato in compilazione.
  • Questi strumenti costano tempo di compilazione e leggibilità. Usali quando prevengono un bug reale.

Verifica veloce

1. Perché non puoi passare un [u32; 4] a una funzione che prende [u32; 3]?

2. Che cosa aggiunge PhantomData<T> a una struct durante l'esecuzione?

3. Quale di queste cose NON puoi usare dentro una const fn nel Rust stabile?

4. Che cosa significa impl<const N: usize> Matrix<N, N>?

Punteggio: 0 / 4