Tipi di Dati Generici
Utilizziamo i type generici per creare definizioni per elementi come firme di funzioni o struct, che possiamo poi utilizzare con molti tip di dati concreti diversi. Vediamo prima come definire funzioni, struct, enum e metodi utilizzando i type generici. Poi discuteremo di come i generici influiscono sulle prestazioni del codice.
Nella Definizione delle Funzioni
Quando definiamo una funzione che utilizza i type generici, li inseriamo nella firma della funzione, dove normalmente specificheremmo i type dei parametri e il type del valore restituito. In questo modo il nostro codice diventa più flessibile e fornisce maggiori funzionalità ai chiamanti della nostra funzione, evitando al contempo la duplicazione del codice.
Continuando con la nostra funzione maggiore
, il Listato 10-4 mostra due
funzioni che trovano entrambe il valore più grande in una slice. Le
combineremo quindi in un’unica funzione che utilizza i type generici.
fn maggior_i32(lista: &[i32]) -> &i32 { let mut maggiore = &lista[0]; for elemento in lista { if elemento > maggiore { maggiore = elemento; } } maggiore } fn maggior_char(lista: &[char]) -> &char { let mut maggiore = &lista[0]; for elemento in lista { if elemento > maggiore { maggiore = elemento; } } maggiore } fn main() { let lista_numeri = vec![34, 50, 25, 100, 65]; let risultato = maggior_i32(&lista_numeri); println!("Il numero maggiore è {risultato}"); assert_eq!(*risultato, 100); let lista_caratteri = vec!['y', 'm', 'a', 'q']; let risultato = maggior_char(&lista_caratteri); println!("Il carattere maggiore è {risultato}"); assert_eq!(*risultato, 'y'); }
La funzione maggior_i32
è quella che abbiamo estratto nel Listato 10-3 e che
trova l’i32
più grande in una slice. La funzione maggior_char
trova il
char
più grande in una slice. I corpi delle funzioni hanno lo stesso codice,
quindi eliminiamo la duplicazione introducendo un parametro di type generico
in una singola funzione.
Per parametrizzare i type in una nuova singola funzione, dobbiamo assegnare un
nome al parametro di type, proprio come facciamo per i parametri di valore di
una funzione. È possibile utilizzare qualsiasi identificatore come nome di
parametro di type. Ma useremo T
perché, per convenzione, i nomi dei
parametri di type in Rust sono brevi, spesso di una sola lettera, e la
convenzione di denominazione dei type di Rust è CamelCase1 (nello
specifico UpperCamelCase). Abbreviazione di type, T
è la scelta predefinita
della maggior parte dei programmatori Rust.
Quando utilizziamo un parametro nel corpo della funzione, dobbiamo dichiarare il
nome del parametro nella firma in modo che il compilatore ne conosca il
significato. Allo stesso modo, quando usiamo un type come parametro nella
firma di una funzione, dobbiamo dichiarare il nome del type prima di
utilizzarlo. Per definire la funzione generica maggiore
, inseriamo le
dichiarazioni del nome del type tra parentesi angolari, <>
, tra il nome
della funzione e l’elenco dei parametri, in questo modo:
fn maggiore<T>(lista: &[T]) -> &T {
Leggiamo questa definizione come “La funzione maggiore
è generica su un certo
type T
”. Questa funzione ha un parametro denominato lista
, che è una
slice di valori di type T
. La funzione maggiore
restituirà un
reference a un valore dello stesso type T
.
Il Listato 10-5 mostra la definizione combinata della funzione maggiore
utilizzando il type di dati generico nella sua firma. Il Listato mostra anche
come possiamo chiamare la funzione con una slice di valori i32
o char
.
Nota che questo codice non verrà ancora compilato.
fn maggiore<T>(lista: &[T]) -> &T {
let mut maggiore = &lista[0];
for elemento in lista {
if elemento > maggiore {
maggiore = elemento;
}
}
maggiore
}
fn main() {
let lista_numeri = vec![34, 50, 25, 100, 65];
let risultato = maggiore(&lista_numeri);
println!("Il numero maggiore è {risultato}");
let lista_caratteri = vec!['y', 'm', 'a', 'q'];
let risultato = maggiore(&lista_caratteri);
println!("Il carattere maggiore è {risultato}");
}
maggiore
che utilizza parametri di type generico; non è ancora compilabileSe compiliamo questo codice adesso, otterremo questo errore:
$ cargo run
Compiling capitolo10 v0.1.0 (file:///progetti/capitolo10)
error[E0369]: binary operation `>` cannot be applied to type `&T`
--> src/main.rs:5:21
|
5 | if elemento > maggiore {
| -------- ^ -------- &T
| |
| &T
|
help: consider restricting type parameter `T` with trait `PartialOrd`
|
1 | fn maggiore<T: std::cmp::PartialOrd>(lista: &[T]) -> &T {
| ++++++++++++++++++++++
For more information about this error, try `rustc --explain E0369`.
error: could not compile `capitolo10` (bin "capitolo10") due to 1 previous error
Il testo di aiuto menziona std::cmp::PartialOrd
, che è un trait, e parleremo
dei trait nella prossima sezione. Per ora, sappi che questo errore indica che
il corpo di maggiore
non funzionerà per tutti i possibili type di T
.
Poiché vogliamo confrontare valori di type T
nel corpo, possiamo utilizzare
solo type i cui valori possono essere ordinati. Per abilitare i confronti, la
libreria standard include il trait std::cmp::PartialOrd
che è possibile
implementare sui type (vedere l’Appendice C per maggiori informazioni
su questo trait). Per correggere il Listato 10-5, possiamo seguire il
suggerimento del testo di aiuto e limitare i type validi per T
solo a quelli
che implementano PartialOrd
. Il Listato verrà quindi compilato, poiché la
libreria standard implementa PartialOrd
sia su i32
che su char
.
Nella Definizione delle Struct
Possiamo anche definire struct per utilizzare un parametro di type generico
in uno o più campi utilizzando la sintassi <>
. Il Listato 10-6 definisce una
struct Punto<T>
per contenere i valori delle coordinate x
e y
di
qualsiasi type.
struct Punto<T> { x: T, y: T, } fn main() { let integer = Point { x: 5, y: 10 }; let float = Point { x: 1.0, y: 4.0 }; }
Punto<T>
che contiene i valori x
e y
di type T
La sintassi per l’utilizzo di type generici nelle definizioni di struct è simile a quella utilizzata nelle definizioni di funzione. Per prima cosa dichiariamo il nome del type tra parentesi angolari subito dopo il nome della struct. Quindi utilizziamo il type generico nella definizione della struct, dove altrimenti specificheremmo type di dati concreti.
Nota che, poiché abbiamo utilizzato un solo type generico per definire
Punto<T>
, questa definizione afferma che la struct Punto<T>
è generica su
un type T
e che i campi x
e y
sono entrambi dello stesso type,
qualunque esso sia. Se creiamo un’istanza di Punto<T>
che ha valori di type
diversi, come nel Listato 10-7, il nostro codice non verrà compilato.
struct Punto<T> {
x: T,
y: T,
}
fn main() {
let non_funzionante = Punto { x: 5, y: 4.0 };
}
x
e y
devono essere dello stesso type perché entrambi hanno lo stesso type di dati generico T
In questo esempio, quando assegniamo il valore integer 5
a x
, comunichiamo
al compilatore che il type generico T
sarà un integer per questa istanza
di Punto<T>
. Quindi, quando specifichiamo 4.0
per y
, che abbiamo definito
come dello stesso type di x
, otterremo un errore di mancata corrispondenza
di type come questo:
$ cargo run
Compiling capitolo10 v0.1.0 (file:///progetti/capitolo10)
error[E0308]: mismatched types
--> src/main.rs:7:44
|
7 | let non_funzionante = Punto { x: 5, y: 4.0 };
| ^^^ expected integer, found floating-point number
For more information about this error, try `rustc --explain E0308`.
error: could not compile `capitolo10` (bin "capitolo10") due to 1 previous error
Per definire una struct Punto
in cui x
e y
sono entrambi type generici
ma potrebbero avere type diversi, possiamo utilizzare più parametri di type
generico. Ad esempio, nel Listato 10-8, modifichiamo la definizione di Punto
in modo che sia generico sui type T
e U
, dove x
è di type T
e y
è
di type U
.
struct Punto<T, U> { x: T, y: U, } fn main() { let entrambi_interi = Punto { x: 5, y: 10 }; let entrambi_float = Punto { x: 1.0, y: 4.0 }; let intero_e_float = Punto { x: 5, y: 4.0 }; }
Punto<T, U>
generico su due type in modo che x
e y
possano essere valori di type diversiOra tutte le istanze di Punto
mostrate sono consentite! Puoi usare tutti i
parametri di type generico che vuoi in una definizione, ma usarne di più rende
il codice difficile da leggere. Se ti accorgi di aver bisogno di molti type
generici nel tuo codice, potrebbe essere necessario riscriverlo in parti più
piccole.
Nella Definizione delle Enum
Come abbiamo fatto con le struct, possiamo definire le enum per contenere
type di dati generici nelle loro varianti. Diamo un’altra occhiata all’enum
Option<T>
fornito dalla libreria standard, che abbiamo usato nel Capitolo 6:
#![allow(unused)] fn main() { enum Option<T> { Some(T), None, } }
Questa definizione dovrebbe ora esserti più chiara. Come puoi vedere, l’enum
Option<T>
è generico sul type T
e ha due varianti: Some
, che contiene un
valore di type T
, e una variante None
che non contiene alcun valore.
Utilizzando l’enum Option<T>
, possiamo esprimere il concetto astratto di un
valore opzionale e, poiché Option<T>
è generico, possiamo usare questa
astrazione indipendentemente dal type del valore opzionale.
Anche le enum possono usare più type generici. La definizione dell’enum
Result
che abbiamo usato nel Capitolo 9 ne è un esempio:
#![allow(unused)] fn main() { enum Result<T, E> { Ok(T), Err(E), } }
L’enum Result
è generico su due type, T
ed E
, e ha due varianti: Ok
,
che contiene un valore di type T
, e Err
, che contiene un valore di type
E
. Questa definizione rende comodo usare l’enum Result
ovunque abbiamo
un’operazione che potrebbe avere successo (restituire un valore di type T
) o
fallire (restituire un errore di type E
). In effetti, questo è ciò che
abbiamo usato per aprire un file nel Listato 9-3, dove T
veniva riempito con
il type std::fs::File
quando il file veniva aperto correttamente ed E
veniva riempito con il type std::io::Error
quando si verificavano problemi
durante l’apertura del file.
Quando trovi situazioni nel codice con più definizioni di struct o enum che differiscono solo per il type dei valori che contengono, è possibile evitare la duplicazione utilizzando invece type generici.
Nella Definizione dei Metodi
Possiamo implementare metodi su struct ed enum (come abbiamo fatto nel
Capitolo 5) e utilizzare type generici anche nella loro definizione. Il
Listato 10-9 mostra la struct Punto<T>
definita nel Listato 10-6 con un
metodo denominato x
implementato su di essa.
struct Punto<T> { x: T, y: T, } impl<T> Punto<T> { fn x(&self) -> &T { &self.x } } fn main() { let p = Punto { x: 5, y: 10 }; println!("p.x = {}", p.x()); }
x
sulla struct Punto<T>
che restituirà un reference al campo x
di type T
Qui, abbiamo definito un metodo denominato x
su Punto<T>
che restituisce un
reference ai dati nel campo x
.
Nota che dobbiamo dichiarare T
subito dopo impl
in modo da poter usare T
per specificare che stiamo implementando metodi sul type Punto<T>
.
Dichiarando T
come type generico dopo impl
, Rust può identificare che il
type tra parentesi angolari in Punto
è un type generico piuttosto che un
type concreto. Avremmo potuto scegliere un nome diverso per questo parametro
generico rispetto al parametro generico dichiarato nella definizione della
struct, ma utilizzare lo stesso nome è convenzionale. Se si scrive un metodo
all’interno di un impl
che dichiara un type generico, tale metodo verrà
definito su qualsiasi istanza del type, indipendentemente dal type concreto
che finisce per sostituire il type generico.
Possiamo anche specificare vincoli sui type generici quando si definiscono
metodi sul type. Ad esempio, potremmo implementare metodi solo su istanze di
Punto<f32>
piuttosto che su istanze di Punto<T>
con qualsiasi type
generico. Nel Listato 10-10 utilizziamo il type concreto f32
, il che
significa che non dichiariamo alcun type dopo impl
.
struct Punto<T> { x: T, y: T, } impl<T> Punto<T> { fn x(&self) -> &T { &self.x } } impl Punto<f32> { fn distanza_da_origine(&self) -> f32 { (self.x.powi(2) + self.y.powi(2)).sqrt() } } fn main() { let p = Punto{ x: 5, y: 10 }; println!("p.x = {}", p.x()); }
impl
che si applica solo a una struct con un particolare type concreto per il parametro di type generico T
Questo codice indica che il type Punto<f32>
avrà un metodo
distanza_da_origine
; altre istanze di Punto<T>
in cui T
non è di type
f32
non avranno questo metodo definito. Il metodo misura la distanza del
nostro punto dal punto alle coordinate (0.0, 0.0) e utilizza operazioni
matematiche disponibili solo per i type a virgola mobile.
I parametri di type generico nella definizione di una struct non sono sempre
gli stessi di quelli utilizzati nelle firme dei metodi della stessa struct. Il
Listato 10-11 utilizza i type generici X1
e Y1
per la struct Punto
e
X2
e Y2
per la firma del metodo misto
per rendere l’esempio più chiaro. Il
metodo crea una nuova istanza di Punto
con il valore x
dal self
Punto
(di type X1
) e il valore y
dal Punto
passato come argomento (di type
Y2
).
struct Punto<X1, Y1> { x: X1, y: Y1, } impl<X1, Y1> Punto<X1, Y1> { fn misto<X2, Y2>(self, altro: Punto<X2, Y2>) -> Punto<X1, Y2> { Punto { x: self.x, y: altro.y, } } } fn main() { let p1 = Punto { x: 5, y: 10.4 }; let p2 = Punto { x: "Ciao", y: 'c' }; let p3 = p1.misto(p2); println!("p3.x = {}, p3.y = {}", p3.x, p3.y); }
In main
, abbiamo definito un Punto
che ha un i32
per x
(con valore 5
)
e un f64
per y
(con valore 10.4
). La variabile p2
è una struct Punto
che ha una slice di stringa per x
(con valore "Ciao"
) e un char
per y
(con valore c
). Chiamando misto
su p1
con l’argomento p2
otteniamo p3
,
che avrà un i32
per x
perché x
proviene da p1
. La variabile p3
avrà un
char
per y
perché y
proviene da p2
. La chiamata alla macro println!
stamperà p3.x = 5, p3.y = c
.
Lo scopo di questo esempio è dimostrare una situazione in cui alcuni parametri
generici sono dichiarati con impl
e altri con la definizione del metodo. Qui,
i parametri generici X1
e Y1
sono dichiarati dopo impl
perché vanno con la
definizione della struct. I parametri generici X2
e Y2
sono dichiarati
dopo fn misto
perché sono rilevanti solo per il metodo.
Prestazioni del Codice Utilizzando Type Generici
Potresti chiederti se l’utilizzo di parametri di type generico costi in termini prestazionali durante l’esecuzione del codice. La buona notizia è che l’utilizzo di type generici non renderà il tuo programma più lento di quanto lo sarebbe con type concreti.
Rust ottiene questo risultato eseguendo la monomorfizzazione del codice utilizzando i generici in fase di compilazione. La monomorfizzazione è il processo di trasformazione del codice generico in codice specifico inserendo i type concreti utilizzati in fase di compilazione. In questo processo, il compilatore esegue l’opposto dei passaggi che abbiamo utilizzato per creare la funzione generica nel Listato 10-5: il compilatore esamina tutti i punti in cui viene chiamato il codice generico e genera codice per i type concreti con cui viene chiamato il codice generico.
Vediamo come funziona utilizzando l’enum generico Option<T>
della libreria
standard:
#![allow(unused)] fn main() { let integer = Some(5); let float = Some(5.0); }
Quando Rust compila questo codice, esegue la monomorfizzazione. Durante questo
processo, il compilatore legge i valori utilizzati nelle istanze di Option<T>
e identifica due type di Option<T>
: uno è i32
e l’altro è f64
. Pertanto,
espande la definizione generica di Option<T>
in due definizioni specializzate
per i32
e f64
, sostituendo così la definizione generica con quelle
specifiche.
La versione monomorfizzata del codice è simile alla seguente (il compilatore usa nomi diversi da quelli che stiamo usando qui a scopo illustrativo):
enum Option_i32 { Some(i32), None, } enum Option_f64 { Some(f64), None, } fn main() { let integer = Option_i32::Some(5); let float = Option_f64::Some(5.0); }
Il generico Option<T>
viene sostituito con le definizioni specifiche create
dal compilatore. Poiché Rust compila il codice generico in codice che specifica
il type in ogni istanza, non si paga alcun costo prestazionale durante
l’esecuzione per l’utilizzo di type generici. Quando il codice viene eseguito,
si comporta esattamente come se avessimo duplicato ogni definizione manualmente.
Il processo di monomorfizzazione rende i generici di Rust estremamente
efficienti in fase di esecuzione.