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:
- Un post inizia come bozza vuota.
- Quando la bozza è pronta, si richiede la revisione del post.
- Quando il post è approvato, viene pubblicato.
- 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.
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());
}
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>
.
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 {}
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.
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 {}
aggiungi_testo
per aggiungere testo al contenuto
del postIl 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.
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 {}
contenuto
di Post
che restituisce sempre una slice vuotaCon 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.
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
}
}
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.
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
}
}
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.
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
}
}
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.
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
}
}
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 daAttesaRevisione
aBozza
. - Richiedi due chiamate al metodo
approva
prima che lo stato possa essere cambiato inPubblicato
. - 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 modificarePost
.
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:
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.
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);
}
}
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.
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,
}
}
}
PostAttesaRevisione
creato chiamando richiedi_revisione
su PostBozza
e un metodo approva
che trasforma un PostAttesaRevisione
in un Post
pubblicatoI 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.
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());
}
main
per usare la nuova implementazione del flusso di lavoro del blogI 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.
Riepilogo
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!