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

Implementare un Modello di Design Orientato agli Oggetti

Lo state pattern è un modello di design orientato agli oggetti. Il punto centrale di questo modello è definire un insieme di stati che un valore può avere internamente. Gli stati sono rappresentati da un insieme di oggetti stato, e il comportamento del valore cambia in base allo stato in cui si trova. Vedremo un esempio con una struct di un post del blog che ha un campo per mantenere il suo stato, che può essere uno stato “bozza”, “in revisione” o “pubblicato”.

Gli oggetti stato condividono la funzionalità: in Rust, ovviamente, usiamo struct e trait invece di oggetti e ereditarietà. Ogni oggetto stato è responsabile del proprio comportamento e di decidere quando deve cambiare stato. Il valore che contiene l’oggetto stato non sa nulla del comportamento specifico degli stati o di quando avvengono le transizioni tra stati.

Il vantaggio dello state pattern è che, quando cambiano i requisiti del programma, non serve modificare il codice del valore che contiene lo stato né quello che usa il valore. Basterà aggiornare il codice dentro uno degli oggetti stato per cambiare le regole o aggiungere nuovi stati.

Inizieremo implementando lo state pattern in un modo più tradizionalmente orientato agli oggetti, poi vedremo un approccio più naturale in Rust. Cominciamo implementando passo passo un flusso di lavoro per un post del blog usando lo state pattern.

La funzionalità finale sarà questa:

  1. Un post inizia come bozza vuota.
  2. Quando la bozza è pronta, si richiede la revisione del post.
  3. Quando il post è approvato, viene pubblicato.
  4. Solo i post pubblicati restituiscono contenuto da stampare, in modo che i post non approvati non possano essere pubblicati accidentalmente.

Qualsiasi altra modifica tentata su un post non avrà effetto. Per esempio, se proviamo ad approvare una bozza prima di richiedere la revisione, il post resterà una bozza non pubblicata.

Tentativo in Tradizionale Stile Orientato agli Oggetti

Ci sono infiniti modi per strutturare il codice per risolvere lo stesso problema, ciascuno con compromessi diversi. Questa implementazione è più in uno stile orientato agli oggetti tradizionale, possibile in Rust, ma che non sfrutta appieno i punti di forza di Rust. Più avanti mostreremo una soluzione diversa che usa comunque lo state pattern ma in modo meno familiare a chi ha esperienza solo con OOP. Confronteremo le due soluzioni per capire i compromessi di progettare in Rust in modo diverso da altri linguaggi.

Il Listato 18-11 mostra questo flusso di lavoro in forma di codice: un esempio dell’uso dell’API che implementeremo nel crate blog. Ancora non si compila perché il crate blog non è implementato.

File: src/main.rs
use blog::Post;

fn main() {
    let mut post = Post::new();

    post.aggiungi_testo("Oggi a pranzo ho mangiato un'instalata");
    assert_eq!("", post.contenuto());

    post.richiedi_revisione();
    assert_eq!("", post.contenuto());

    post.approva();
    assert_eq!("Oggi a pranzo ho mangiato un'instalata", post.contenuto());
}
Listato 18-11: Codice che dimostra il comportamento che vogliamo per il crate blog

Vogliamo permettere all’utente di creare una nuova bozza con Post::new. Vogliamo permettere di aggiungere testo al post. Se proviamo a richiedere subito il contenuto del post, prima dell’approvazione, non dobbiamo ricevere alcun testo perché il post è ancora una bozza. Abbiamo aggiunto assert_eq! come dimostrazione, che in un test potrebbe verificare che il metodo contenuto di una bozza restituisca una stringa vuota, ma non scriveremo test in questo esempio.

Vogliamo poi abilitare la richiesta di revisione, e contenuto deve restituire una stringa vuota durante l’attesa di revisione. Quando il post riceve l’approvazione, viene pubblicato e il testo sarà disponibile quando chiamiamo contenuto.

Nota che l’unico type con cui interagiamo dal crate è Post. Questo type userà lo state pattern e conterrà un valore che sarà uno tra tre oggetti stato: bozza, revisione o pubblicato. Il passaggio da uno stato all’altro sarà gestito internamente da Post. Gli stati cambiano rispondendo ai metodi chiamati dall’utente sull’istanza di Post, ma l’utente non gestisce direttamente le transizioni. Inoltre, l’utente non può sbagliare gli stati, per esempio pubblicando senza revisione.

Definire Post e Creare una Nuova Istanza

Cominciamo l’implementazione della libreria! Sappiamo di aver bisogno di una struct pubblica Post che tenga un contenuto, quindi partiamo dalla definizione della struct e da una funzione associata pubblica new per creare istanze di Post, come mostrato nel Listato 18-12. Creeremo anche un trait privato Stato che definisce il comportamento che tutti gli oggetti stato per Post devono avere.

Post terrà un oggetto trait Box<dyn Stato> dentro un Option<T> in un campo privato chiamato stato per memorizzare l’oggetto stato. Vediamo dopo perché serve Option<T>.

File: src/lib.rs
pub struct Post {
    stato: Option<Box<dyn Stato>>,
    contenuto: String,
}

impl Post {
    pub fn new() -> Post {
        Post {
            stato: Some(Box::new(Bozza {})),
            contenuto: String::new(),
        }
    }
}

trait Stato {}

struct Bozza {}

impl Stato for Bozza {}
Listato 18-12: Definizione di una struct Post, con funzione new che crea una nuova istanza di Post, un trait Stato, e una struct Bozza

Il trait Stato definisce il comportamento condiviso tra gli stati del post. Gli oggetti stato sono Bozza, AttesaRevisione e Pubblicato, e implementeranno tutti il trait Stato. Per ora il trait non ha alcun metodo; inizieremo definendo Bozza perché è lo stato iniziale del post.

Quando creiamo un nuovo Post, impostiamo il campo stato con Some che contiene una Box che punta a un’istanza della struct Bozza. Questo assicura che quando creiamo una nuova istanza di Post, inizia sempre come bozza. Poiché il campo stato è privato, non si può creare un Post in altri stati! La funzione Post::new imposta il campo contenuto come una String vuota.

Memorizzare il Testo del Post

Abbiamo visto nel Listato 18-11 che vogliamo la possibilità di chiamare un metodo aggiungi_testo che prende un &str e lo aggiunge al contenuto del post. Lo facciamo come metodo, non esponendo il campo contenuto come pub, così poi possiamo in seguito implementare un metodo per controllare come leggere contenuto. Il metodo aggiungi_testo è semplice; lo aggiungiamo nel blocco impl Post come nel Listato 18-13.

File: src/lib.rs
pub struct Post {
    stato: Option<Box<dyn Stato>>,
    contenuto: String,
}

impl Post {
    // --taglio--
    pub fn new() -> Post {
        Post {
            stato: Some(Box::new(Bozza {})),
            contenuto: String::new(),
        }
    }

    pub fn aggiungi_testo(&mut self, testo: &str) {
        self.contenuto.push_str(testo);
    }
}

trait Stato {}

struct Bozza {}

impl Stato for Bozza {}
Listato 18-13: Implementazione del metodo aggiungi_testo per aggiungere testo al contenuto del post

Il metodo aggiungi_testo prende un reference mutabile a self perché stiamo modificando l’istanza Post su cui chiamiamo il metodo aggiungi_testo. Chiamiamo poi push_str sulla stringa contenuto aggiungendovi testo. Questo comportamento non dipende dallo stato in cui si trova il post, quindi non fa parte dello state pattern. Il metodo aggiungi_testo non interagisce affatto con il campo stato, ma fa parte del comportamento che vogliamo supportare.

Garantire che il Contenuto di una Bozza sia Vuoto

Anche dopo aver chiamato aggiungi_testo aggiungendo del contenuto al nostro post, vogliamo che il metodo contenuto ritorni una slice vuota perché il post è ancora una bozza, come mostrato dal primo assert_eq! nel Listato 18-11. Per ora implementiamo il metodo contenuto con la cosa più semplice che possa soddisfare questo requisito: restituire semplicemente una slice vuota. Lo cambieremo in seguito quando aggiungeremo la possibilità di cambiare stato per la pubblicazione. Per ora, i post possono essere solo bozze, e quindi il contenuto del post è sempre vuoto. Il Listato 18-14 mostra questa implementazione temporanea.

File: src/lib.rs
pub struct Post {
    stato: Option<Box<dyn Stato>>,
    contenuto: String,
}

impl Post {
    // --taglio--
    pub fn new() -> Post {
        Post {
            stato: Some(Box::new(Bozza {})),
            contenuto: String::new(),
        }
    }

    pub fn aggiungi_testo(&mut self, testo: &str) {
        self.contenuto.push_str(testo);
    }

    pub fn contenuto(&self) -> &str {
        ""
    }
}

trait Stato {}

struct Bozza {}

impl Stato for Bozza {}
Listato 18-14: Implementazione temporanea del metodo contenuto di Post che restituisce sempre una slice vuota

Con l’aggiunta del metodo contenuto, tutto nel Listato 18-11 fino al primo assert_eq! funziona come previsto.

Richiedere una Revisione, Che Cambia lo Stato del Post

Ora dobbiamo aggiungere la funzionalità per richiedere una revisione di un post, che dovrebbe cambiare il suo stato da Bozza a AttesaRevisione. Il Listato 18-15 mostra questo codice.

File: src/lib.rs
pub struct Post {
    stato: Option<Box<dyn Stato>>,
    contenuto: String,
}

impl Post {
    // --taglio--
    pub fn new() -> Post {
        Post {
            stato: Some(Box::new(Bozza {})),
            contenuto: String::new(),
        }
    }

    pub fn aggiungi_testo(&mut self, testo: &str) {
        self.contenuto.push_str(testo);
    }

    pub fn contenuto(&self) -> &str {
        ""
    }

    pub fn richiedi_revisione(&mut self) {
        if let Some(s) = self.stato.take() {
            self.stato = Some(s.richiedi_revisione())
        }
    }
}

trait Stato {
    fn richiedi_revisione(self: Box<Self>) -> Box<dyn Stato>;
}

struct Bozza {}

impl Stato for Bozza {
    fn richiedi_revisione(self: Box<Self>) -> Box<dyn Stato> {
        Box::new(AttesaRevisione {})
    }
}

struct AttesaRevisione {}

impl Stato for AttesaRevisione {
    fn richiedi_revisione(self: Box<Self>) -> Box<dyn Stato> {
        self
    }
}
Listato 18-15: Implementazione dei metodi richiedi_revisione su Post e il trait Stato

Diamo a Post un metodo pubblico chiamato richiedi_revisione che prende un reference mutabile a self. Poi chiamiamo un metodo interno richiedi_revisione sullo stato corrente di Post, e questo secondo metodo consuma lo stato corrente e restituisce un nuovo stato.

Aggiungiamo il metodo richiedi_revisione al trait Stato; tutti i type che implementano il trait dovranno implementare questo metodo. Nota che invece di avere self, &self o &mut self come primo parametro del metodo, abbiamo self: Box<Self>. Questa sintassi significa che il metodo è valido solo chiamandolo su una Box che contiene il type. Questa sintassi prende ownership di Box<Self>, invalidando il vecchio stato in modo che il valore di stato del Post possa trasformarsi in un nuovo stato.

Per consumare il vecchio stato, il metodo richiedi_revisione prende ownership del valore di stato. Qui entra in gioco l’Option nel campo stato di Post: chiamiamo il metodo take per estrarre il valore Some dal campo stato e sostituirlo con un None al suo posto, perché Rust non permette campi non popolati nelle struct. Questo ci permette di spostare il valore stato fuori da Post invece di prenderlo in prestito. Poi assegniamo al campo stato del post il risultato di questa operazione.

Dobbiamo impostare temporaneamente stato a None invece di assegnarlo direttamente con codice come self.stato = self.stato.richiedi_revisione(); per ottenere la ownership del valore stato. Questo evita che Post usi il vecchio stato dopo averlo trasformato.

Il metodo richiedi_revisione su Bozza restituisce una nuova istanza incapsulata in Box di una nuova struct AttesaRevisione, che rappresenta lo stato di un post in attesa di revisione. Anche la struct AttesaRevisione implementa il metodo richiedi_revisione ma senza trasformazioni, semplicemente restituisce sé stessa perché richiedere una revisione su un post già in stato AttesaRevisione lo mantiene nello stesso stato.

Qui si cominciano a comprendere i vantaggi dello state pattern: il metodo richiedi_revisione su Post è lo stesso qualunque sia il valore di stato. Ogni stato gestisce le sue regole.

Lasciamo il metodo contenuto su Post così com’è, che restituisce una slice di stringa vuota. Ora possiamo avere un Post sia nello stato AttesaRevisione sia nello stato Bozza, ma vogliamo lo stesso comportamento in entrambi gli stati. Il Listato 18-11 funziona ora fino al secondo assert_eq!!

Aggiungere approva per Cambiare il Comportamento di contenuto

Il metodo approva sarà simile a richiedi_revisione: imposterà stato al valore che lo stato corrente dice debba avere quando è stato approvato, come mostrato nel Listato 18-16.

File: src/lib.rs
pub struct Post {
    stato: Option<Box<dyn Stato>>,
    contenuto: String,
}

impl Post {
    // --taglio--
    pub fn new() -> Post {
        Post {
            stato: Some(Box::new(Bozza {})),
            contenuto: String::new(),
        }
    }

    pub fn aggiungi_testo(&mut self, testo: &str) {
        self.contenuto.push_str(testo);
    }

    pub fn contenuto(&self) -> &str {
        ""
    }

    pub fn richiedi_revisione(&mut self) {
        if let Some(s) = self.stato.take() {
            self.stato = Some(s.richiedi_revisione())
        }
    }

    pub fn approva(&mut self) {
        if let Some(s) = self.stato.take() {
            self.stato = Some(s.approva())
        }
    }
}

trait Stato {
    fn richiedi_revisione(self: Box<Self>) -> Box<dyn Stato>;
    fn approva(self: Box<Self>) -> Box<dyn Stato>;
}

struct Bozza {}

impl Stato for Bozza {
    // --taglio--
    fn richiedi_revisione(self: Box<Self>) -> Box<dyn Stato> {
        Box::new(AttesaRevisione {})
    }

    fn approva(self: Box<Self>) -> Box<dyn Stato> {
        self
    }
}

struct AttesaRevisione {}

impl Stato for AttesaRevisione {
    // --taglio--
    fn richiedi_revisione(self: Box<Self>) -> Box<dyn Stato> {
        self
    }

    fn approva(self: Box<Self>) -> Box<dyn Stato> {
        Box::new(Pubblicato {})
    }
}

struct Pubblicato {}

impl Stato for Pubblicato {
    fn richiedi_revisione(self: Box<Self>) -> Box<dyn Stato> {
        self
    }

    fn approva(self: Box<Self>) -> Box<dyn Stato> {
        self
    }
}
Listato 18-16: Implementazione del metodo approva su Post e il trait Stato

Aggiungiamo il metodo approva al trait Stato e una nuova struct che implementa Stato, lo stato Pubblicato.

Simile a come funziona richiedi_revisione su AttesaRevisione, se chiamiamo il metodo approva su una Bozza, non avrà effetto perché approva restituirà self. Quando chiamiamo approva su AttesaRevisione, restituisce una nuova istanza incapsulata in Box di Pubblicato. La struct Pubblicato implementa il trait Stato, e sia per richiedi_revisione che per approva restituisce se stessa perché il post dovrebbe rimanere nello stato Pubblicato in quei casi.

Ora dobbiamo aggiornare il metodo contenuto su Post. Vogliamo che il valore restituito da contenuto dipenda dallo stato corrente di Post, quindi faremo in modo che Post deleghi a un metodo contenuto definito sul suo stato, come mostrato nel Listato 18-17.

File: src/lib.rs
pub struct Post {
    stato: Option<Box<dyn Stato>>,
    contenuto: String,
}

impl Post {
    // --taglio--
    pub fn new() -> Post {
        Post {
            stato: Some(Box::new(Bozza {})),
            contenuto: String::new(),
        }
    }

    pub fn aggiungi_testo(&mut self, testo: &str) {
        self.contenuto.push_str(testo);
    }

    pub fn contenuto(&self) -> &str {
        self.stato.as_ref().unwrap().contenuto(self)
    }
    // --taglio--

    pub fn richiedi_revisione(&mut self) {
        if let Some(s) = self.stato.take() {
            self.stato = Some(s.richiedi_revisione())
        }
    }

    pub fn approva(&mut self) {
        if let Some(s) = self.stato.take() {
            self.stato = Some(s.approva())
        }
    }
}

trait Stato {
    fn richiedi_revisione(self: Box<Self>) -> Box<dyn Stato>;
    fn approva(self: Box<Self>) -> Box<dyn Stato>;
}

struct Bozza {}

impl Stato for Bozza {
    fn richiedi_revisione(self: Box<Self>) -> Box<dyn Stato> {
        Box::new(AttesaRevisione {})
    }

    fn approva(self: Box<Self>) -> Box<dyn Stato> {
        self
    }
}

struct AttesaRevisione {}

impl Stato for AttesaRevisione {
    fn richiedi_revisione(self: Box<Self>) -> Box<dyn Stato> {
        self
    }

    fn approva(self: Box<Self>) -> Box<dyn Stato> {
        Box::new(Pubblicato {})
    }
}

struct Pubblicato {}

impl Stato for Pubblicato {
    fn richiedi_revisione(self: Box<Self>) -> Box<dyn Stato> {
        self
    }

    fn approva(self: Box<Self>) -> Box<dyn Stato> {
        self
    }
}
Listato 18-17: Aggiornamento del metodo contenuto su Post per delegare a un metodo contenuto su Stato

Poiché l’obiettivo è tenere tutte queste regole dentro le struct che implementano Stato, chiamiamo un metodo contenuto sul valore in stato passando l’istanza del post (self) come argomento. Quindi restituiamo il valore restituito dall’uso del metodo contenuto sul valore stato.

Chiamiamo il metodo as_ref sull’Option perché vogliamo un reference al valore dentro l’Option piuttosto che la ownership del valore. Poiché stato è un Option<Box<dyn Stato>>, chiamando as_ref otteniamo un Option<&Box<dyn Stato>>. Se non chiamassimo as_ref, otterremmo un errore perché non possiamo spostare stato fuori dal parametro in prestito &self della funzione.

Chiamiamo poi il metodo unwrap, che sappiamo non andrà mai in panic perché i metodi su Post assicurano che stato conterrà sempre un valore Some quando quei metodi sono terminati. Questo è uno di quei casi discussi in “Quando Hai Più Informazioni Del Compilatore” nel Capitolo 9 quando sappiamo che un valore None è impossibile, anche se il compilatore non è in grado di inferirlo.

A questo punto, quando chiamiamo contenuto su &Box<dyn Stato>, la de-referenziazione forzata agirà su & e Box così che il metodo contenuto sarà chiamato sul type che implementa il trait Stato. Ciò significa che dobbiamo aggiungere contenuto alla definizione del trait Stato e lì metteremo la logica per cosa restituire in base allo stato, come mostrato nel Listato 18-18.

File: src/lib.rs
pub struct Post {
    stato: Option<Box<dyn Stato>>,
    contenuto: String,
}

impl Post {
    pub fn new() -> Post {
        Post {
            stato: Some(Box::new(Bozza {})),
            contenuto: String::new(),
        }
    }

    pub fn aggiungi_testo(&mut self, testo: &str) {
        self.contenuto.push_str(testo);
    }

    pub fn contenuto(&self) -> &str {
        self.stato.as_ref().unwrap().contenuto(self)
    }

    pub fn richiedi_revisione(&mut self) {
        if let Some(s) = self.stato.take() {
            self.stato = Some(s.richiedi_revisione())
        }
    }

    pub fn approva(&mut self) {
        if let Some(s) = self.stato.take() {
            self.stato = Some(s.approva())
        }
    }
}

trait Stato {
    // --taglio--
    fn richiedi_revisione(self: Box<Self>) -> Box<dyn Stato>;
    fn approva(self: Box<Self>) -> Box<dyn Stato>;

    fn contenuto<'a>(&self, post: &'a Post) -> &'a str {
        ""
    }
}

// --taglio--

struct Bozza {}

impl Stato for Bozza {
    fn richiedi_revisione(self: Box<Self>) -> Box<dyn Stato> {
        Box::new(AttesaRevisione {})
    }

    fn approva(self: Box<Self>) -> Box<dyn Stato> {
        self
    }
}

struct AttesaRevisione {}

impl Stato for AttesaRevisione {
    fn richiedi_revisione(self: Box<Self>) -> Box<dyn Stato> {
        self
    }

    fn approva(self: Box<Self>) -> Box<dyn Stato> {
        Box::new(Pubblicato {})
    }
}

struct Pubblicato {}

impl Stato for Pubblicato {
    // --taglio--
    fn richiedi_revisione(self: Box<Self>) -> Box<dyn Stato> {
        self
    }

    fn approva(self: Box<Self>) -> Box<dyn Stato> {
        self
    }

    fn contenuto<'a>(&self, post: &'a Post) -> &'a str {
        &post.contenuto
    }
}
Listato 18-18: Aggiunta del metodo contenuto al trait Stato

Aggiungiamo un’implementazione di default per il metodo contenuto che restituisce una slice di stringa vuota. Ciò significa che non dobbiamo implementare contenuto nelle struct Bozza e AttesaRevisione. La struct Pubblicato sovrascriverà il metodo contenuto e restituirà il valore in post.contenuto. Anche se comodo, avere il metodo contenuto su Stato che determina il contenuto di Post sfuma i confini tra la responsabilità di Stato e quella di Post.

Nota che abbiamo bisogno di annotazioni di lifetime su questo metodo, come discusso nel Capitolo 10. Stiamo prendendo un reference a un post come argomento e restituiamo un reference ad una parte di quel post, quindi la longevità del reference restituito è legata alla longevità dell’argomento post.

E abbiamo finito: tutto il Listato 18-11 ora funziona! Abbiamo implementato lo state pattern con le regole del flusso di lavoro del blog. La logica relativa alle regole vive negli oggetti di stato invece di essere sparsa in Post.

Perché Non un’enum?

Potresti chiederti perché non abbiamo usato un’enum con i diversi possibili stati del post come varianti. È certamente una soluzione possibile; provalo e confronta i risultati finali per vedere cosa preferisci! Uno svantaggio dell’uso di un’enum è che ogni posto che verifica il valore dell’enum avrà bisogno di un’espressione match o simile per gestire ogni variante possibile. Questo potrebbe diventare più ripetitivo rispetto a questa soluzione con un oggetto trait.

Valutazione dello State Pattern

Abbiamo mostrato che Rust è capace di implementare lo state pattern orientato agli oggetti per incapsulare i diversi tipi di comportamento che un post dovrebbe avere in ogni stato. I metodi su Post non sanno nulla dei vari comportamenti. Siccome abbiamo organizzando il codice in questo modo, dobbiamo guardare in un solo posto per conoscere i diversi modi in cui un post pubblicato può comportarsi: l’implementazione del trait Stato sulla struct Pubblicato.

Se creassimo un’implementazione alternativa che non usasse lo state pattern, potremmo invece usare espressioni match nei metodi su Post o anche nel codice main che verifica lo stato del post e cambia comportamento in quei posti. Ciò significherebbe dover guardare in più posti per capire tutte le implicazioni di un post che è nello stato “pubblicato”.

Con lo state pattern, i metodi Post e i posti dove usiamo Post non hanno bisogno di espressioni match, e per aggiungere un nuovo stato dovremmo solo aggiungere una nuova struct e implementare i metodi del trait su quella struct in un solo punto.

L’implementazione usando lo state pattern è facile da estendere per aggiungere più funzionalità. Per vedere la semplicità di mantenere codice che usa lo state pattern, prova ad implementare qualcuna di queste proposte:

  • Aggiungi un metodo respingi che cambia lo stato del post da AttesaRevisione a Bozza.
  • Richiedi due chiamate al metodo approva prima che lo stato possa essere cambiato in Pubblicato.
  • Permetti agli utenti di aggiungere testo al contenuto solo quando il post è nello stato Bozza. Suggerimento: fai in modo che l’oggetto stato sia responsabile di cosa può cambiare del contenuto ma non responsabile di modificare Post.

Un lato negativo dello state pattern è che, siccome gli stati implementano le transizioni tra stati, alcuni stati sono accoppiati tra loro. Se aggiungessimo un altro stato tra AttesaRevisione e Pubblicato, come Programmato, dovremmo modificare il codice in AttesaRevisione per passare a Programmato. Sarebbe meno lavoro se AttesaRevisione non dovesse cambiare con l’aggiunta di un nuovo stato, ma ciò significherebbe passare a un altro modello di design.

Un altro lato negativo è che abbiamo duplicato un po’ di logica. Per eliminare la duplicazione, potremmo provare a fare implementazioni predefinite per i metodi richiedi_revisione e approva sul trait Stato che restituiscono self. Tuttavia, questo non funzionerebbe: quando usiamo Stato come oggetto trait, il trait non conosce esattamente il type concreto di self, quindi il type di ritorno non è noto durante la compilazione. (Questa è una delle regole di compatibilità dyn menzionate prima.)

Altra duplicazione è nelle implementazioni simili dei metodi richiedi_revisione e approva su Post. Entrambi i metodi usano Option::take con il campo stato di Post, e se stato è Some, delegano all’implementazione del metodo con lo stesso nome del valore incapsulato e impostano il nuovo valore del campo stato al risultato. Se avessimo molti metodi su Post che seguono questo schema, potremmo considerare di definire una macro per eliminare la ripetizione (vedi la sezione “Macro” nel Capitolo 20).

Implementando lo state pattern esattamente come definito per i linguaggi orientati agli oggetti, non sfruttiamo appieno i punti di forza di Rust come potremmo. Vediamo qualche cambiamento da fare al crate blog che può trasformare stati e transizioni invalide in errori durante la compilazione.

Codifica di Stati e Comportamenti Come Type

Ti mostreremo come ripensare lo state pattern per ottenere un diverso set di compromessi. Invece di incapsulare completamente gli stati e le transizioni così che il codice esterno non ne sappia nulla, codificheremo gli stati in type differenti. Di conseguenza, il sistema di controllo dei type di Rust impedirà tentativi di usare post in bozze dove sono permessi solo post pubblicati, generando un errore di compilazione.

Consideriamo la prima parte di main nel Listato 18-11:

File: src/main.rs
use blog::Post;

fn main() {
    let mut post = Post::new();

    post.aggiungi_testo("Oggi a pranzo ho mangiato un'instalata");
    assert_eq!("", post.contenuto());

    post.richiedi_revisione();
    assert_eq!("", post.contenuto());

    post.approva();
    assert_eq!("Oggi a pranzo ho mangiato un'instalata", post.contenuto());
}

Continuiamo a permettere la creazione di nuovi post nello stato bozza usando Post::new e la possibilità di aggiungere testo al contenuto del post. Ma invece di avere un metodo contenuto su un post bozza che restituisce una stringa vuota, facciamo in modo che i post bozza non abbiano affatto il metodo contenuto. In questo modo, se proviamo a ottenere il contenuto di un post bozza, otterremo un errore di compilazione che ci dice che il metodo non esiste. Di conseguenza, sarà impossibile mostrare accidentalmente il contenuto di un post bozza in produzione perché quel codice nemmeno si compila. Il Listato 18-19 mostra la definizione di una struct Post e una PostBozza, oltre ai metodi su ciascuna.

File: src/lib.rs
pub struct Post {
    contenuto: String,
}

pub struct PostBozza {
    contenuto: String,
}

impl Post {
    pub fn new() -> PostBozza {
        PostBozza {
            contenuto: String::new(),
        }
    }

    pub fn contenuto(&self) -> &str {
        &self.contenuto
    }
}

impl PostBozza {
    pub fn aggiungi_testo(&mut self, testo: &str) {
        self.contenuto.push_str(testo);
    }
}
Listato 18-19: Un Post con un metodo contenuto e un PostBozza senza metodo contenuto

Sia le struct Post che PostBozza hanno un campo privato contenuto che memorizza il testo del post. Le struct non hanno più il campo stato perché stiamo spostando la codifica dello stato nei type delle struct. La struct Post rappresenta un post pubblicato e ha un metodo contenuto che restituisce il contenuto.

Abbiamo ancora una funzione Post::new, ma invece di restituire un’istanza di Post, restituisce un’istanza di PostBozza. Poiché contenuto è privato e non ci sono funzioni che restituiscono Post, al momento non è possibile creare un’istanza di Post.

La struct PostBozza ha un metodo aggiungi_testo, quindi possiamo aggiungere testo a contenuto come prima, ma nota che PostBozza non ha un metodo contenuto definito! Quindi ora il programma assicura che tutti i post inizino come bozze, e i post bozza non hanno il loro contenuto disponibile per la visualizzazione. Qualsiasi tentativo di aggirare questi vincoli causerà un errore di compilazione.

Come facciamo allora a ottenere un post pubblicato? Vogliamo imporre la regola che un post bozza deve essere revisionato e approvato prima di poter essere pubblicato. Un post nello stato di attesa di revisione non dovrebbe comunque mostrare contenuti. Implementiamo questi vincoli aggiungendo un’altra struct, PostAttesaRevisione, definendo il metodo richiedi_revisione su PostBozza che restituisce un PostAttesaRevisione e un metodo approva su PostAttesaRevisione che restituisce un Post, come mostrato nel Listato 18-20.

File: src/lib.rs
pub struct Post {
    contenuto: String,
}

pub struct PostBozza {
    contenuto: String,
}

impl Post {
    pub fn new() -> PostBozza {
        PostBozza {
            contenuto: String::new(),
        }
    }

    pub fn contenuto(&self) -> &str {
        &self.contenuto
    }
}

impl PostBozza {
    // --taglio--
    pub fn aggiungi_testo(&mut self, testo: &str) {
        self.contenuto.push_str(testo);
    }

    pub fn richiedi_revisione(self) -> PostAttesaRevisione {
        PostAttesaRevisione {
            contenuto: self.contenuto,
        }
    }
}

pub struct PostAttesaRevisione {
    contenuto: String,
}

impl PostAttesaRevisione {
    pub fn approva(self) -> Post {
        Post {
            contenuto: self.contenuto,
        }
    }
}
Listato 18-20: Un PostAttesaRevisione creato chiamando richiedi_revisione su PostBozza e un metodo approva che trasforma un PostAttesaRevisione in un Post pubblicato

I metodi richiedi_revisione e approva prendono ownership di self, consumando così le istanze PostBozza e PostAttesaRevisione trasformandole rispettivamente in un PostAttesaRevisione e un Post pubblicato. In questo modo non avremo istanze residue di PostBozza dopo aver chiamato richiedi_revisione su di loro, e così via. La struct PostAttesaRevisione non ha un metodo contenuto definito, quindi tentare di leggere il suo contenuto causa un errore di compilazione, come per PostBozza. Poiché l’unico modo per ottenere un’istanza di Post pubblicato che ha un metodo contenuto definito è chiamare approva su un PostAttesaRevisione, e l’unico modo per ottenere un PostAttesaRevisione è chiamare richiedi_revisione su un PostBozza, abbiamo ora codificato il flusso di lavoro del blog col sistema dei type.

Dobbiamo anche fare qualche piccolo cambiamento in main. I metodi richiedi_revisione e approva restituiscono nuove istanze invece di modificare la struct su cui sono chiamati, quindi dobbiamo aggiungere più assegnazioni di shadowing let post = per salvare le istanze restituite. Non possiamo nemmeno avere le asserzioni sui contenuti vuoti dei post bozza e revisione pendente, né ne abbiamo bisogno: non possiamo più compilare codice che tenta di usare il contenuto dei post in quegli stati. Il codice aggiornato in main è mostrato nel Listato 18-21.

File: src/main.rs
use blog::Post;

fn main() {
    let mut post = Post::new();

    post.aggiungi_testo("Oggi a pranzo ho mangiato un'instalata");

    let post = post.richiedi_revisione();

    let post = post.approva();

    assert_eq!("Oggi a pranzo ho mangiato un'instalata", post.contenuto());
}
Listato 18-21: Modifiche a main per usare la nuova implementazione del flusso di lavoro del blog

I cambiamenti necessari per riassegnare post significano che questa implementazione non segue più esattamente lo state pattern orientato agli oggetti: le trasformazioni tra gli stati non sono più completamente incapsulate nella implementazione di Post. Tuttavia, abbiamo guadagnato che stati invalidi ora sono impossibili grazie al sistema dei type e al controllo durante la compilazione! Questo assicura che alcuni bug, come la visualizzazione del contenuto di un post non pubblicato, vengano scoperti prima che arrivino in produzione.

Prova a realizzare i compiti suggeriti all’inizio di questa sezione sul crate blog così com’è dopo il Listato 18-21 per vedere cosa pensi del design di questa versione del codice. Nota che alcune attività potrebbero essere già implementate con questo design.

Abbiamo visto che anche se Rust è capace di implementare modelli di design orientati agli oggetti, altri modelli, come la codifica dello stato nel sistema dei type, sono disponibili in Rust. Questi modelli hanno diversi compromessi. Anche se potresti essere molto familiare con i modelli orientati agli oggetti, ripensare il problema per sfruttare le caratteristiche di Rust può offrire benefici, come prevenire alcuni bug già in fase di compilazione. I modelli orientati agli oggetti non saranno sempre la miglior soluzione in Rust a causa di alcune caratteristiche come la ownership, che i linguaggi orientati agli oggetti non hanno.

Indipendentemente dal fatto che tu pensi che Rust sia un linguaggio orientato agli oggetti dopo aver letto questo capitolo, ora sai che puoi usare oggetti trait per ottenere alcune funzionalità orientate agli oggetti in Rust. Il dynamic dispatch può dare al tuo codice un po’ di flessibilità in cambio di una piccola perdita in prestazioni durante l’esecuzione. Puoi usare questa flessibilità per implementare modelli orientati agli oggetti che possono aiutare nella manutenibilità del tuo codice. Rust ha anche altre caratteristiche, come la ownership, che i linguaggi orientati agli oggetti non hanno. Un modello orientato agli oggetti non sarà sempre il modo migliore per sfruttare i punti di forza di Rust, ma è un’opzione disponibile.

In seguito parleremo di pattern, un’altra delle caratteristiche di Rust che consente molta flessibilità. Li abbiamo visti brevemente in precedenza nel libro, ma non ne abbiamo ancora visto tutto il potenziale. Cominciamo!