Funzionalità comuni tramite i traits
I trait in Rust ci permettono di definire delle funzionalità comuni di oggetti in un modo astratto. Intuitivamente sono simili a quello che molti linguaggi come Java chiamano interfacce, ma hanno alcune differenze. In generale però lo scopo è lo stesso: definire delle interfacce comuni per diverse strutture dati, così da poter implementare delle operazioni generiche. Ad esempio tutte le strutture dati che contengono una collezione di valori potrebbero implementare una interfaccia che permette d'iterare su questa collezione. In questo modo potremmo implementare ad esempio la funzione che restituisce la dimensione della collezione in modo completamente generico rispetto alla struttura concreta che memorizza i valori.
Definizione di trait
Un trait è semplicemente una collezione di metodi che è possibile invocare su un oggetto. Diversi oggetti condividono lo stesso comportamento se possiamo chiamare gli stessi metodi su essi.
Per esempio supponiamo di voler scrivere un aggregatore di dati, dove ogni dato è una struttura. Vogliamo per esempio esprimere il fatto che da un dato possiamo estrarre un identificatore univoco. Possiamo definire il trait
#![allow(unused)] fn main() { trait DataIdentifier { fn identifier(&self) -> String; } }
Abbiamo definito così una "interfaccia" che vari oggetti possono implementare.
Questa contiene solo una funzione identifier che prende in ingresso una reference all'oggetto e restituisce una stringa.
Se abbiamo due strutture che rappresentano un dato di una stazione metereologica, e un dato di un termostato, possiamo implementare per entrambi il trait DataIdentifier.
#![allow(unused)] fn main() { trait DataIdentifier { fn identifier(&self) -> String; } struct IndoorTemperature { location: String, timestamp: u64, value: f32, } impl DataIdentifier for IndoorTemperature { fn identifier(&self) -> String { format!("indoor-{}-{}", self.location, self.timestamp) } } struct WeatherStation { location: String, timestamp: u64, temperature: f32, humidity: f32, } impl DataIdentifier for WeatherStation { fn identifier(&self) -> String { format!("weather-{}-{}", self.location, self.timestamp) } } }
Trait come parametri
Una delle feature più importanti per utilizzare i trait nei programmi è quella di creare funzioni o strutture generiche, che abbiano dei constraint sul tipo generico sotto forma di trait.
Ad esempio, potremmo volere una funzione generica che accetta solamente oggetti che implementano il trait che abbiamo definito sopra DataIdentifier.
Questo può essere espresso in Rust così.
#![allow(unused)] fn main() { trait DataIdentifier { fn identifier(&self) -> String; } fn display_id(x: &impl DataIdentifier) -> String { String::from("The id is ") + x.identifier().as_str() } }
Intuitivamente prendiamo in ingresso un qualsiasi tipo che implementi DataIdentifier. In realtà questa notazione, pur essendo molto concisa, è zucchero sintattico che nasconde una funzione generica: x deve avere tipo T, con T tale che implementi il trait DataIdentifier.
Una sintassi equivalente e più completa è quella dei trait bounds:
#![allow(unused)] fn main() { trait DataIdentifier { fn identifier(&self) -> String; } fn display_id<T: DataIdentifier> (x: &T) -> String { String::from("The id is ") + x.identifier().as_str() } }
In questo modo stiamo esplicitando il fatto di definire una funzione generica in T, e che T deve implementare il trait DataIdentifier.
Possiamo aggiungere ulteriori vincoli al tipo con l'operatore +, ad esempio se vogliamo che T implementi sia DataIdentifier che Display, possiamo scrivere così.
#![allow(unused)] fn main() { trait DataIdentifier { fn identifier(&self) -> String; } use std::fmt::Display; fn display_id<T: DataIdentifier + Display> (x: &T) -> String { String::from("The id is ") + x.identifier().as_str() } }