Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.

File: src/main.rs
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');
}
    
Listato 10-4: Due funzioni che differiscono solo per i nomi e per i type nelle loro firme

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.

File: src/main.rs
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}");
}
Listato 10-5: La funzione maggiore che utilizza parametri di type generico; non è ancora compilabile

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

File: src/main.rs
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 };
}
Listato 10-6: Una struct 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.

File: src/main.rs
struct Punto<T> {
    x: T,
    y: T,
}

fn main() {
    let non_funzionante = Punto { x: 5, y: 4.0 };
}
Listato 10-7: I campi 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.

File: src/main.rs
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 };
}
Listato 10-8: Un Punto<T, U> generico su due type in modo che x e y possano essere valori di type diversi

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

File: src/main.rs
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());
}
Listato 10-9: Implementazione di un metodo denominato 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.

File: src/main.rs
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());
}
Listato 10-10: Un blocco 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).

File: src/main.rs
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);
}
Listato 10-11: Un metodo che utilizza type generici che sono diversi dalla definizione della sua struct

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

File: src/main.rs
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.


  1. Wiki CamelCase