SE VUOI PRENDERE LA CERTIFICAZIONE PER QUESTO CORSO CLICCA QUI
Caratteristiche dei Linguaggi Orientati agli Oggetti
Nella comunità di programmatori non c’è consenso su quali caratteristiche debba avere un linguaggio per essere considerato orientato agli oggetti. Rust è influenzato da molti paradigmi di programmazione, incluso l’OOP; ad esempio, abbiamo esplorato le caratteristiche che provengono dalla programmazione funzionale nel Capitolo 13. Si potrebbe dire che i linguaggi OOP condividono certe caratteristiche comuni, ovvero gli oggetti, l’incapsulamento e l’ereditarietà. Vediamo cosa significa ciascuna di queste caratteristiche e se Rust le supporta. Gli Oggetti Contengono Dati e Comportamento
Il libro Design Patterns: Elements of Reusable Object-Oriented Software di Erich Gamma, Richard Helm, Ralph Johnson e John Vlissides (Addison-Wesley Professional, 1994), colloquialmente noto come libro del Gang of Four, è un catalogo di design pattern orientati agli oggetti. Definisce l’OOP in questo modo:
css
I programmi orientati agli oggetti sono composti da oggetti. Un oggetto incapsula sia i dati che le procedure che operano su tali dati. Le procedure sono tipicamente chiamate metodi o operazioni.
Utilizzando questa definizione, Rust è orientato agli oggetti: le strutture e le enumerazioni contengono dati, e i blocchi impl forniscono metodi su strutture e enumerazioni. Anche se le strutture e le enumerazioni con metodi non sono chiamate oggetti, forniscono la stessa funzionalità, secondo la definizione del Gang of Four di oggetti. L’Incapsulamento Nasconde i Dettagli di Implementazione
Un altro aspetto comunemente associato all’OOP è l’idea di incapsulamento, che significa che i dettagli di implementazione di un oggetto non sono accessibili al codice che utilizza quell’oggetto. Pertanto, l’unico modo per interagire con un oggetto è attraverso la sua API pubblica; il codice che utilizza l’oggetto non dovrebbe essere in grado di accedere agli interni dell’oggetto e modificare dati o comportamento direttamente. Ciò consente al programmatore di modificare e rifattorizzare gli interni di un oggetto senza dover modificare il codice che utilizza l’oggetto.
Abbiamo discusso su come controllare l’incapsulamento nel Capitolo 7: possiamo utilizzare la parola chiave pub per decidere quali moduli, tipi, funzioni e metodi nel nostro codice dovrebbero essere pubblici, e per impostazione predefinita tutto il resto è privato. Ad esempio, possiamo definire una struttura AveragedCollection che ha un campo contenente un vettore di valori i32. La struttura può anche avere un campo che contiene la media dei valori nel vettore, il che significa che la media non deve essere calcolata su richiesta ogni volta che qualcuno ne ha bisogno. In altre parole, AveragedCollection memorizzerà la media calcolata per noi. Qui c’è la definizione della struttura AveragedCollection:
Filename: src/lib.rs
pub struct AveragedCollection { list: Vec<i32>, average: f64, }
La struttura è contrassegnata pub in modo che altro codice possa utilizzarla, ma i campi all’interno della struttura rimangono privati. Questo è importante in questo caso perché vogliamo garantire che ogni volta che un valore viene aggiunto o rimosso dalla lista, anche la media venga aggiornata. Facciamo questo implementando metodi add, remove e average sulla struttura, come mostrato qui:
Filename: src/lib.rs
impl AveragedCollection { pub fn add(&mut self, value: i32) { self.list.push(value); self.update_average(); }
rust
pub fn remove(&mut self) -> Option<i32> {
let result = self.list.pop();
match result {
Some(value) => {
self.update_average();
Some(value)
}
None => None,
}
} pub fn average(&self) -> f64 {
self.average
}
fn update_average(&mut self) {
let total: i32 = self.list.iter().sum();
self.average = total as f64 / self.list.len() as f64;
}
}
I metodi pubblici add, remove e average sono gli unici modi per accedere o modificare i dati in un’istanza di AveragedCollection. Quando un elemento viene aggiunto alla lista utilizzando il metodo add o rimosso utilizzando il metodo remove, le implementazioni di ciascuno richiamano il metodo update_average privato che gestisce anche l’aggiornamento del campo della media.
Lasciamo i campi lista e media privati in modo che non ci sia modo per il codice esterno di aggiungere o rimuovere elementi alla o dalla lista direttamente; altrimenti, il campo della media potrebbe non essere sincronizzato quando la lista cambia. Il metodo average restituisce il valore nel campo della media, consentendo al codice esterno di leggere la media ma non modificarla.
Poiché abbiamo incapsulato i dettagli di implementazione della struttura AveragedCollection, possiamo facilmente cambiare aspetti, come la struttura dei dati, in futuro. Ad esempio, potremmo usare un HashSet<i32> invece di un Vec<i32> per il campo lista. Finché le firme dei metodi pubblici add, remove e average rimangono le stesse, il codice che utilizza AveragedCollection non dovrebbe cambiare. Se rendessimo lista pubblica invece, questo non sarebbe necessariamente il caso: HashSet<i32> e Vec<i32> hanno metodi diversi per aggiungere e rimuovere elementi, quindi il codice esterno probabilmente dovrebbe cambiare se stesse modificando lista direttamente.
Se l’incapsulamento è un aspetto richiesto per un linguaggio per essere considerato orientato agli oggetti, allora Rust soddisfa tale requisito. L’opzione di utilizzare pub o no per diverse parti del codice consente l’incapsulamento dei dettagli di implementazione. Ereditarietà come Sistema di Tipi e Condivisione del Codice
L’ereditarietà è un meccanismo mediante il quale un oggetto può ereditare elementi dalla definizione di un altro oggetto, guadagnando così i dati e il comportamento dell’oggetto genitore senza doverli definire nuovamente.
Se un linguaggio deve avere l’ereditarietà per essere un linguaggio orientato agli oggetti, allora Rust non lo è. Non c’è modo di definire una struttura che eredita i campi e le implementazioni dei metodi della struttura genitore senza utilizzare una macro.
Tuttavia, se sei abituato ad avere l’ereditarietà nel tuo set di strumenti di programmazione, puoi utilizzare altre soluzioni in Rust, a seconda del motivo per cui hai optato per l’ereditarietà in primo luogo.
Sceglieresti l’ereditarietà per due motivi principali. Uno è per il riutilizzo del codice: puoi implementare un comportamento particolare per un tipo e l’ereditarietà ti consente di riutilizzare tale implementazione per un tipo diverso. Puoi fare questo in modo limitato nel codice Rust utilizzando le implementazioni di metodi di tratti predefiniti, che hai visto nel Listato 10-14 quando abbiamo aggiunto un’implementazione predefinita del metodo summarize sul tratto Summary. Qualsiasi tipo che implementi il tratto Summary avrà il metodo summarize disponibile senza ulteriore codice. Questo è simile a una classe genitore che ha un’implementazione di un metodo e una classe figlia che eredita anche l’implementazione del metodo. Possiamo anche sovrascrivere l’implementazione predefinita del metodo summarize quando implementiamo il tratto Summary, il che è simile a una classe figlia che sovrascrive l’implementazione di un metodo ereditato da una classe genitore.
L’altro motivo per utilizzare l’ereditarietà riguarda il sistema di tipi: per consentire a un tipo figlio di essere utilizzato negli stessi luoghi del tipo genitore. Questo è anche chiamato polimorfismo, il che significa che puoi sostituire più oggetti l’uno con l’altro a runtime se condividono determinate caratteristiche.
css
Polimorfismo
Per molte persone, il polimorfismo è sinonimo di ereditarietà. Ma in realtà è un concetto più generale che si riferisce al codice che può funzionare con dati di tipi multipli. Per l'ereditarietà, quei tipi sono generalmente sottoclassi.
Rust invece utilizza i generici per astrarre su diversi tipi possibili e i vincoli dei tratti per imporre vincoli su ciò che quei tipi devono fornire. Questo viene chiamato talvolta polimorfismo parametrico limitato.
L’ereditarietà è di recente caduta in disuso come soluzione di progettazione del programma in molti linguaggi di programmazione perché spesso rischia di condividere più codice del necessario. Le sottoclassi non dovrebbero sempre condividere tutte le caratteristiche della classe genitore ma lo faranno con l’ereditarietà. Questo può rendere il design di un programma meno flessibile. Introduce anche la possibilità di chiamare metodi sulle sottoclassi che non hanno senso o che causano errori perché i metodi non si applicano alla sottoclasse. Inoltre, alcuni linguaggi consentiranno solo l’ereditarietà singola (il che significa che una sottoclasse può ereditare solo da una classe), limitando ulteriormente la flessibilità del design di un programma.
Per questi motivi, Rust adotta un approccio diverso utilizzando gli oggetti di tratto invece dell’ereditarietà. Vediamo come gli oggetti di tratto consentono il polimorfismo in Rust.