Home Rhyylen
Contatto
 
 
 
Rust Language
Capitolo 27
Funzioni generiche

Le funzioni generiche sono un altro elemento importantissimo e assolutamente primario nell'ambito del nostro linguaggio. Si tratta di un argomento non del tutto immediato e che merita i dovuti approfondimenti per essere compreso al meglio. La loro utilità è presto indicata dal seguente esempio:

fn somma (x: i32, y: 32) -> i32 {
    return x + y
}

come sappiamo questa funzione accetta come parametri due interi e ne restituisce uno che è poi la somma dei due pratetri stessi. Il tipo su cui lavora sono gli i32. Ma il problema sorge se invece dovessimo lavorare su un altro tipo di intero oppure sui numeri con virgola. Dovremmo scrivere una funzione per ciascuno di questi tipi. Non è molto pratico. Le funzioni generiche rispondono a questa necessità. La loro definzione formale è la seguente:

fn <'a, 'b> nome_funzione<T: Trait>(argomenti: &T) -> R
dove
'a: Lifetime
'b: Lifetime
T: Trait
R: Tipo
{
// Corpo della funzione
}

tralasciamo per ora il concetto di Lifetime, che merita un corposo capitolo a parte, e concentriamoci sul resto della definizione.

    1)  T: Trait è il tipo principale della funzione e indichiamo il trait o i trait che deve implementare.
    2)  Gli argomenti a loro volta devono essere compatibili con il T al punto precedente.
    3)  R è il tipo di ritorno espresso dal corpo della funzione.

Vediamo un classico esempio:

  Esempio 27.1
1
2
3
4
5
6
7
8
9
fn somma<T: std::ops::Add<T, Output = T>>(x: T, y: T) -> T {
    x + y
}
fn main() {
    let ris_int = somma(5, 6); // Funziona con interi
    println!("Somma degli interi = {}", ris_int);
    let ris_float = somma(3.2, 7.4); // Funziona con i float
    println!("Somma dei numeri con virgola = {}", ris_float);
}

La riga 1 ci dice che il tipo T, quindi generico, deve supportare il trait Add il cui percorso completo nelle librerie standard di Rust è std::ops::Add. Vogliamo che l'output sia dello stesso tipo. Come è evidente il programma funziona con gli interi e i float senza problemi e ovviamente non fa eccezione per qualsiasi tipo della famiglia dei numeri siano essi i8, i16, f32 ecc... naturalmente sono ammessi questi tipi che supportano l'addizione (Add). La sintassi può sembrare un po' contorta ma è piuttosto una questione di pura abitudine. Va invece ribadita come questa possibilità limiti il proliferare di codice praticamente identico e che funge per il medesimo scopo su tipi diversi. Un quasi polimorfismo, insomma che ha la non trascurabile proprietà di non introdurre alcun overhead a livello prestazionale.
Va precisato che la riga 1 poteva anche essere scritta come segue:

fn somma<T: std::ops::Add<Output = T>>(x: T, y: T) -> T   invece di
fn somma<T: std::ops::Add<T, Output = T>>(x: T, y: T) -> T

e avrebbe prodotto lo stesso risultato. La differenza sta nel fatto che nel secondo caso si vuole che la somma avvenga proprio tra i medesimi tipi T mentre nel primo siamo di fronte ad una maggior flessibilità e la somma potrebbe avvenire anche con tipi diversi purchè il risultato sia di tipo T. Nel prossimo esempio vedremo applicata la prima forma.


Se per pura comodità volessimo che il trait std::ops::Add possa essere adottato da più funzioni l'esempio precedente potrebbe essere riscritto come segue:

  Esempio 27.2
1
2
3
4
5
6
7
8
9
10
11
12
use std::ops::Add;

fn somma<T: Add<Output = T>>(x: T, y: T) -> T {
x + y
}

fn main() {
let ris_int = somma(5, 6); // Funziona con interi
println!("Somma degli interi = {}", ris_int);
let ris_float = somma(3.2, 7.4); // Funziona con i float
println!("Somma dei numeri float = {}", ris_float);
}

ovvero presentando il trait a disposizione di tutto quello che segue.
Si possono usare un po' tutti i trait che vogliamo, esempio classico è quello che ci presenta il prossimo programma che ci consente di effettuare un confronto tra numeri:

  Esempio 27.3
1
2
3
4
5
6
7
8
9
10
11
12
fn main() {
    println!("Il maggiore tra 5 e 3 è {}",confronta(5, 3));
    println!("Il maggiore tra 2.3 e 5.4 è {}",confronta(2.3, 5.4));
}
fn confronta<T:PartialOrd>(a:T,b:T)->T {
    if a>b {
        return a;
    }
    else {
        return b;
    }
}

In questo caso abbiamo richiesto che fosse implementato il trait PartialOrd che consente di effettuare un ordinamento (la teoria retrostante è parecchio complessa).
Un esempio che può essere utile nasce dalla seguente considerazione. Abbiamo visto che il T restituito dalla funzione deve essere lo stesso dei parametri in ingresso. Ma se volessimo che fosse diverso? Ad esempio se volessimo che la somma di due numeri, quale che ne siano i valori in ingresso fossero sempre di tipo float? Ecco la soluzione:

  Esempio 27.4
1
2
3
4
5
6
7
8
9
10
11
12
13
fn somma<T: std::convert::Into<f64>>(a: T, b: T) -> f64 {
    let a_f64: f64 = a.into();
    let b_f64: f64 = b.into();
    a_f64 + b_f64
}
fn main() {
    let int_somma = somma(5, 10);
    println!("Somma degli interi è: {}", int_somma);
    println!("{}", std::any::type_name_of_val(&int_somma));
    let float_somma = somma(5.4, 10.5);
    println!("Somma dei float = {}", float_somma);
    println!("{}", std::any::type_name_of_val(&float_somma));
}

Come è evidente dalla riga 1 viene invocata una conversione di qualsiasi parametro verso f64 che è un tipo che a sua volta supporta la somma. Vedremo un paragrafo apposta relativo alle conversioni di tipo. Un altro esempio forse anche più semplice è il seguente che prende in input due numeri, interi o float, e restuisce una stringa che li concatena.

  Esempio 27.5
1
2
3
4
5
6
7
8
9
10
use std::fmt::Display;
fn concatena_numeri<T: Display>(a: T, b: T) -> String {
    format!("{}{}", a, b)
}
fn main() {
    let risultato1 = concatena_numeri(10, 20);
    println!("Concatenazione di interi: {}", risultato1);
    let risultato2 = concatena_numeri(3.14, 1.618);
    println!("Concatenazione di float: {}", risultato2);
}

Ancora più interessante però è quando si richiede di implementare più di un trait al nostro generico tipo T. Vediamo ad esempio il seguente esempio:

  Esempio 27.6
1
2
3
4
5
6
7
8
9
10
11
12
13
14
use std::cmp::PartialOrd;

fn stampa_minimo<T: PartialOrd>(a: T, b: T) {
    if a < b {
        println!("Il minimo è: {}", a);
    } else {
        println!("Il minimo è: {}", b);
    }
}
fn main() {
    stampa_minimo(10, 20);
    stampa_minimo(3.5, 2.5);
    stampa_minimo("aaa", "bbb");
}

Questo programma non compila ed il compilatore, sempre utilissimo ci dice non solo perchè ma anche come risolvere il problema, ecco una parte del messaggio d'errore::

error[E0277]: `T` doesn't implement `std::fmt::Display`
--> r483.rs:7:37
|
7 | println!("Il minimo è: {}", a);
| ^ `T` cannot be formatted with the default formatter
|
= note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead
= note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)
help: consider further restricting this bound
|
5 | fn stampa_minimo<T: PartialOrd + std::fmt::Display>(a: T, b: T) {
| +++++++++++++++++++

ed ecco pure evidenziata la soluzione. Il punto è che se vogliamo che all'interno della funzione vi sia la possibilità di stampare il tipo T deve supportare il trait Display (la funzione di stampa avviene all'interno della funzione stessa e non nel main). La soluzione comunque è molto semplice, basta modificare il codice come segue:

use std::fmt::Display;
use std::cmp::PartialOrd;
fn stampa_minimo<T: Display + PartialOrd>(a: T, b: T) {

e tutto funziona. L'operatore che ci permette di unire più trait, come avrete capito, è il +.

Il poter gestire elementi generici è uno dei punti di forza di Rust e in generale dei linguaggi moderni, avremo modo di occuparci anche di altri costrutti generici nel corso del nostro cammino.
Vale la pena osservare che le funzioni generiche introducono il concetto di monomorfizzazione: Rust crea versioni specializzate della funzione per ogni tipo concreto utilizzato, ottimizzando le prestazioni anche se questo potrebbe causare un aumento delle dimensioni del binario generato proprio per la necessità di incorporare nuove funzioni specializzate. Per quanto ne so quest'ultimo non è considerato un grosso problema.
Non mi sono dimenticato della questione dei lifetime che abbiamo visto all'inizio nel primo formalismo presentato. E' un discorso abbastanza complesso, come abbiamo detto ne parleremo in apposito paragrafo, così come parleremo in appositi paragrafi dei tipi generici.