In Rust cosiddetti generics si basano su una sintassi che, mediante parentesi angolari <...>
, permette di generalizzare il comportamento di strutture sintattiche in modo che sia possibile ritardare, fino al momento del loro effettivo utilizzo, la definizione dei tipi di dato realmente impiegati nelle loro istanze.
Tale costrutto assume particolare rilievo in un linguaggio tipizzato come Rust in cui non solo è fondamentale tenere in considerazione i tipi di dato ma dove vengono applicati meccanismi affinché, al momento della compilazione, non si corra il rischio di immettere involontariamente nel programma dei bug.
Abbiamo già incontrato i generics nel corso della nostra guida su Rust. E' capitato, ad esempio, quando abbiamo parlato di vettori:
let vettore: Vec<i32> = Vec::new();
in cui indicavamo con la notazione Vec<i32>
di che tipo sarebbero stati gli elementi inclusi nelle strutture dati. In questo caso interi con segno a 32 bit.
Li abbiamo incontrati quando abbiamo parlato di enum
:
fn divisione(dividendo: f32, divisore: f32) -> Option<f32> {
...
}
ed abbiamo riscontrato l'utilità di Option
, il contenitore per valori opzionali. Ora però è arrivato il momento di approfondirli e comprenderli a fondo.
Struct generiche in Rust
In realtà quello che faremo davvero sarà capire non tanto come si usano i generics - cosa che possiamo già aver intuito - bensì come si definiscono tipi "generici".
Un caso molto comune potrebbe essere quello di utilizzare delle struct generiche ovvero con l'annotazione di tipi di dato interni non strutturalmente specificati al momento della definizione.
Ad esempio, potremmo creare una struct Contenitore
, generica, in cui andare ad inserire due elementi che potranno (ma come vedremo non dovranno per forza) essere specificati al momento della creazione di un'istanza. Eccola qui:
struct Contenitore<T,V>{
elemento1:T,
elemento2:V
}
Cosa rappresenta questa struct? In generale una qualsiasi accoppiata di elementi ovvero qualsiasi cosa che sia definibile con due elementi insieme come capita per molte grandezze (coordinate terresti, coppie username/password, nome/cognome di un individuo e così via).
Per utilizzarla potremmo, ad esempio, dire che una specifica istanza è composta da due numeri interi, uno più esteso senza segno ed uno più piccolo con segno:
let due_numeri:Contenitore<u32, i8>=Contenitore{elemento1:2500, elemento2:-7};
println!("Contenuto: {},{}", due_numeri.elemento1, due_numeri.elemento2);
Avremo così la stampa del messaggio Contenuto: 2500,-7.
L'aspetto interessante è che non saremo obbligati a specificare i tipi di dato perché, ad esempio, potremo fare qualcosa del genere:
let due_numeri=Contenitore{elemento1:-2500, elemento2:-7};
println!("Contenuto: {},{}", due_numeri.elemento1, due_numeri.elemento2);
dove c'è stata un'induzione automatica dei tipi di dato.
La differenza tra i due approcci sta nel fatto che nel secondo caso saremo liberi di usare ciò che preferiamo (ad esempio anche let due_numeri=Contenitore{elemento1:-2500, elemento2:"Oggi è un bel giorno"};
funzionerà perfettamente) mentre, nel primo caso, una definizione precisa dei tipi di dato da impiegare svolgerà anche una certa azione di controllo in quanto let due_numeri:Contenitore<u32, i8>=Contenitore{elemento1:-2500, elemento2:-7};
, con il primo elemento negativo, restituirà errore sin dall'assegnazione. Senza bisogno di arrivare alla stampa.
Implementazioni con Generics
Come vedremo nelle lezioni successive, il meccanismo dei generics è estremamente esteso in Rust e può essere applicato a molti costrutti sintattici. Tanto che questa lezione vuole essere un'introduzione di base per acquisire i concetti necessari a comprendere le singole applicazioni che incontreremo proseguendo il nostro studio.
I generics possono, ad esempio, essere implementati nel caso delle funzioni in cui è possibile lasciare determinati parametri generici:
fn elabora<T>(info:T){
...
...
}
per poi specificarne il tipo al momento del richiamo:
elabora::<i8>(5);
Si noti che quando andiamo ad invocare la funzione specifichiamo il tipo di dato subito dopo il suo nome ed utilizziamo come separatore una coppia di due punti, ::
.
Ancora più particolare però è il caso dell'implementazione di trait che permettono l'associazione di funzionalità alle struct
.
Riprendendo ad esempio la struct Contenitore<T,V>
vista a inizio lezione, possiamo immaginare di volerla dotare di una funzione che ne stampi il contenuto ma che sia adattabile a qualsiasi tipo di dato le venga passato.
Possiamo a questo punto definire un impl<T,V>
che disponga di un generico metodo stampa_contenuto
. Come si vede nell'esempio che segue, nel main
possiamo definire struct
differenti, popolate con valori di tipo diverso, ma che grazie alla genericità offerta dall'implementazione del trait possiamo, in ogni caso, attivare la funzione chiamandone semplicemente il nome:
use std::fmt::Display;
struct Contenitore<T,V>{
elemento1:T,
elemento2:V
}
impl<T:Display, V:Display> Contenitore<T,V> {
fn stampa_contenuto(&self){
println!("Il contenuto è {} - {}", self.elemento1, self.elemento2);
}
}
fn main() {
// istanza con due numeri
let numeri = Contenitore {elemento1: 120, elemento2: 33.567};
// istanza con due stringhe
let parole = Contenitore {elemento1: "Canarino", elemento2: "Cespuglio"};
// istanza con una stringa e un numero
let misto = Contenitore {elemento1: "Canarino", elemento2: 33.567};
// la stessa invocazione per ogni tipo di istanza!
numeri.stampa_contenuto();
parole.stampa_contenuto();
misto.stampa_contenuto();
}
Leggeremo in output quanto segue:
Il contenuto è 120 - 33.567
Il contenuto è Canarino - Cespuglio
Il contenuto è Canarino - 33.567
Si noti inoltre un aspetto che con il tempo diventerà sempre più familiare. Al momento di indicare i tipi per l'implementazione abbiamo scritto T:Display, V:Display
e non semplicemente T, V
. Questo perché abbiamo richiesto che il vincolo (spesso chiamato bound, letteralmente limite) per questi tipi di dato sia che implementino il trait Display
che importiamo con use std::fmt::Display;
allo scopo di accertarci della loro stampabilità.