Tipi generici

Come molti linguaggi di programmazione, Rust supporta la definizione di funzioni e strutture dati in modo generico rispetto ai tipi. Questo è utile perché così possiamo evitare la duplicazione di codice, scrivendo una sola implementazione per ogni tipo. Il meccanismo dei generici in Rust è molto simile ai generici in Java o i templates in C++.

Funzioni generiche

Prendiamo l'esempio della funzione che ritorna il primo elemento di un array, se vogliamo scriverlo per vari tipi di elementi nell'array dovremmo scrivere più funzioni.

#![allow(unused)]
fn main() {
fn first_u32(arr: &[i32]) -> Option<i32> {
    if arr.is_empty() {
        None
    } else {
        Some(arr[0])
    }
}

fn first_char(arr: &[char]) -> Option<char> {
    if arr.is_empty() {
        None
    } else {
        Some(arr[0])
    }
}
}

Entrambe le versioni sono identiche perché in realtà non stiamo sfruttando niente del fatto che gli elementi siano degli i32 oppure dei char. L'implementazione è completamente generica rispetto ai tipi degli elementi. Possiamo quindi rendere la funzione generica tramite la seguente notazione.

fn first_generic<T>(arr: &[T]) -> Option<&T> {
    if arr.is_empty() {
        None
    } else {
        Some(&arr[0])
    }
}

fn main() {
    let arr = [1, 2, 3];
    let first = first_generic(&arr);
    println!("Il primo elemento è {:?}", first);
    
    let arr2 = ["ciao", "mondo"];
    let first2 = first_generic(&arr2);
    println!("Il primo elemento è {:?}", first2);
}

L'output del programma è il seguente.

Il primo elemento è Some(1)
Il primo elemento è Some("ciao")

Il parametro T è un parametro di tipo, e indica un tipo generico. Rust provvederà a specializzare la funzione quando verrà usata su dei tipi concreti. Possiamo notare che stiamo in realtà ritornando una reference al primo elemento, perché il compilatore non ha nessuna informazione rispetto a T (potrebbe essere una struttura dati complessa), quindi non può assumere che sia banalmente copiabile. Se proviamo a sostituire &T con T otteniamo il seguente errore:

error[E0508]: cannot move out of type `[T]`, a non-copy slice
  |
7 |         Some(arr[0])
  |              ^^^^^^
  |              |
  |              cannot move out of here
  |              move occurs because `arr[_]` has type `T`, which does not implement the `Copy` trait

Ovvero il tipo T non è copiabile, e quindi verrebbe trasferita la ownership al chiamante. Questo però viola le regole della ownership, perché avremmo nello stesso momento due owner dello stesso valore:

  • l'array perché il valore è il primo elemento
  • il valore di ritorno della funzione.

Strutture ed enumerazioni generiche

Come in molti altri linguaggi, possiamo creare delle strutture dati generiche rispetto a uno o più tipi. Per esempio una struttura che abbiamo già visto è la struttura Option, che può essere definita così.

#![allow(unused)]
fn main() {
enum Option<T> {
    Some(T),
    None
}
}

Come nel caso delle funzioni generiche, T rappresenta una variabile di tipo, la variante Some contiene un elemento di tipo T, mentre la variante None non contiene valori. Possiamo istanziare un valore di una struttura generica esplicitando il parametro di tipo, ma spesso il compilatore può inferirlo dal contesto. Quando non può farlo ci restituirà un errore.

fn main() {
    let x: Option<i32> = None;
    // OK: tipo esplicito Option<i32>

    let y = Some(5);
    // OK: tipo inferito Option<i32> 
    
    let z = None;
    // errore: impossibile inferire il tipo di z
}

Possiamo definire anche delle strutture generiche, come ad esempio una coppia di valori.

struct Pair<T, V> {
    first: T,
    second: V,
}

fn main() {
    let pair = Pair {
        first: 1,
        second: "stringa",
    };
    println!("La coppia è <{},{}>", pair.first, pair.second);
}

Il programma ci restituisce il seguente output.

La coppia è <1,stringa>

Metodi generici

Possiamo definire dei metodi generici per le strutture generiche, per esempio se volessimo implementare le funzioni first e second per una coppia possiamo farlo in modo completamente generico.

struct Pair<T, V> {
    first: T,
    second: V,
}

impl<T, V> Pair<T, V> {
    fn first(&self) -> &T {
        &self.first
    }

    fn second(&self) -> &V {
        &self.second
    }
}

fn main() {
    let pair = Pair {
        first: 1,
        second: "ciao",
    };

    println!("Primo elemento: {}", pair.first());
    println!("Secondo elemento: {}", pair.second());
}

Il programma ci restituisce il seguente output.

Primo elemento: 1
Secondo elemento: ciao

Per chi ha già programmato in linguaggi come Java o C++ questa modalità per definire i generici dovrebbe risultare familiare. Stiamo definendo una implementazione generica nei tipi T e V per la struttura Pair<T, V>. Per esempio la funzione first restituisce una reference al primo elemento, che è quindi di tipo generico &T.