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

Implementacja Obiektowo Zorientowanego Wzorca Projektowego

Wzorzec stanu to obiektowo zorientowany wzorzec projektowy. Istotą wzorca jest to, że definiujemy zestaw stanów, które wartość może mieć wewnętrznie. Stany są reprezentowane przez zestaw obiektów stanu, a zachowanie wartości zmienia się w zależności od jej stanu. Przejdziemy przez przykład struktury wpisu na blogu, która ma pole do przechowywania swojego stanu, który będzie obiektem stanu z zestawu „szkic”, „do recenzji” lub „opublikowany”.

Obiekty stanu dzielą funkcjonalność: w Rust, oczywiście, używamy struktur i cech zamiast obiektów i dziedziczenia. Każdy obiekt stanu jest odpowiedzialny za własne zachowanie i za to, kiedy powinien zmienić się w inny stan. Wartość, która przechowuje obiekt stanu, nic nie wie o różnych zachowaniach stanów ani o tym, kiedy przechodzić między stanami.

Zaletą stosowania wzorca stanu jest to, że gdy zmienią się wymagania biznesowe programu, nie będziemy musieli zmieniać kodu wartości przechowującej stan ani kodu, który używa tej wartości. Będziemy musieli jedynie zaktualizować kod wewnątrz jednego z obiektów stanu, aby zmienić jego zasady lub ewentualnie dodać więcej obiektów stanu.

Najpierw zaimplementujemy wzorzec stanu w bardziej tradycyjny, obiektowy sposób. Następnie użyjemy podejścia, które jest nieco bardziej naturalne w Rust. Zagłębimy się w stopniową implementację przepływu pracy wpisu na blogu, używając wzorca stanu.

Końcowa funkcjonalność będzie wyglądać tak:

  1. Wpis na blogu zaczyna się jako pusty szkic.
  2. Po zakończeniu szkicu, prosi się o jego recenzję.
  3. Po zatwierdzeniu wpis zostaje opublikowany.
  4. Tylko opublikowane wpisy na blogu zwracają treść do wydrukowania, aby niezaprobowane wpisy nie mogły zostać przypadkowo opublikowane.

Wszelkie inne próby zmian we wpisie nie powinny mieć żadnego efektu. Na przykład, jeśli spróbujemy zatwierdzić szkic wpisu na blogu, zanim poprosimy o recenzję, wpis powinien pozostać nieopublikowanym szkicem.

Próba w Tradycyjnym Stylu Obiektowym

Istnieje nieskończenie wiele sposobów strukturyzowania kodu w celu rozwiązania tego samego problemu, każdy z różnymi kompromisami. Implementacja w tej sekcji jest bardziej tradycyjnym stylem obiektowym, który jest możliwy do napisania w Rust, ale nie wykorzystuje niektórych mocnych stron Rust. Później zademonstrujemy inne rozwiązanie, które nadal używa wzorca projektowego zorientowanego obiektowo, ale jest skonstruowane w sposób, który może wydawać się mniej znajomy programistom z doświadczeniem w programowaniu obiektowym. Porównamy oba rozwiązania, aby doświadczyć kompromisów związanych z projektowaniem kodu Rust inaczej niż w innych językach.

Lista 18-11 pokazuje ten przepływ pracy w formie kodu: jest to przykład użycia API, które zaimplementujemy w bibliotece o nazwie blog. To się jeszcze nie skompiluje, ponieważ nie zaimplementowaliśmy crate blog.

Nazwa pliku: src/main.rs
use blog::Post;

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

    post.add_text("I ate a salad for lunch today");
    assert_eq!("", post.content());

    post.request_review();
    assert_eq!("", post.content());

    post.approve();
    assert_eq!("I ate a salad for lunch today", post.content());
}
Lista 18-11: Kod demonstrujący pożądane zachowanie, które chcemy, aby posiadała nasza biblioteka blog

Chcemy umożliwić użytkownikowi utworzenie nowego szkicu wpisu na blogu za pomocą Post::new. Chcemy umożliwić dodawanie tekstu do wpisu na blogu. Jeśli spróbujemy natychmiast uzyskać treść wpisu, przed zatwierdzeniem, nie powinniśmy otrzymać żadnego tekstu, ponieważ wpis jest nadal szkicem. Dodaliśmy assert_eq! w kodzie w celach demonstracyjnych. Doskonałym testem jednostkowym byłoby sprawdzenie, czy szkic wpisu na blogu zwraca pusty ciąg z metody content, ale nie będziemy pisać testów dla tego przykładu.

Następnie chcemy umożliwić prośbę o recenzję wpisu i chcemy, aby content zwracało pusty ciąg, podczas gdy czekamy na recenzję. Kiedy wpis zostanie zatwierdzony, powinien zostać opublikowany, co oznacza, że tekst wpisu zostanie zwrócony, gdy wywołana zostanie metoda content.

Zauważ, że jedynym typem, z którym wchodzimy w interakcję z biblioteki, jest typ Post. Ten typ będzie używał wzorca stanu i będzie przechowywał wartość, która będzie jednym z trzech obiektów stanu reprezentujących różne stany, w jakich może znajdować się wpis — szkic, do recenzji lub opublikowany. Zmiana z jednego stanu na drugi będzie zarządzana wewnętrznie w typie Post. Stany zmieniają się w odpowiedzi na metody wywoływane przez użytkowników naszej biblioteki na instancji Post, ale nie muszą oni bezpośrednio zarządzać zmianami stanu. Ponadto użytkownicy nie mogą popełnić błędu ze stanami, na przykład publikując wpis przed jego zrecenzowaniem.

Definiowanie Post i Tworzenie Nowej Instancji

Rozpocznijmy implementację biblioteki! Wiemy, że potrzebujemy publicznej struktury Post, która przechowuje pewną zawartość, więc zaczniemy od definicji struktury i powiązanej publicznej funkcji new do tworzenia instancji Post, jak pokazano na Liście 18-12. Stworzymy również prywatną cechę State, która zdefiniuje zachowanie, które muszą mieć wszystkie obiekty stanu dla Post.

Następnie Post będzie przechowywać obiekt cechy Box<dyn State> wewnątrz Option<T> w prywatnym polu o nazwie state, aby przechowywać obiekt stanu. Za chwilę zobaczysz, dlaczego Option<T> jest konieczny.

Nazwa pliku: src/lib.rs
pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }
}

trait State {}

struct Draft {}

impl State for Draft {}
Lista 18-12: Definicja struktury Post i funkcji new, która tworzy nową instancję Post, cechy State i struktury Draft

Cecha State definiuje zachowanie dzielone przez różne stany wpisu. Obiekty stanu to Draft, PendingReview i Published, i wszystkie one będą implementować cechę State. Na razie cecha nie ma żadnych metod, a zaczniemy od zdefiniowania tylko stanu Draft, ponieważ to jest stan, w którym chcemy, aby wpis zaczynał się.

Kiedy tworzymy nowy Post, ustawiamy jego pole state na wartość Some, która zawiera Box. Ten Box wskazuje na nową instancję struktury Draft. To zapewnia, że za każdym razem, gdy tworzymy nową instancję Post, zaczyna ona jako szkic. Ponieważ pole state w Post jest prywatne, nie ma możliwości utworzenia Post w żadnym innym stanie! W funkcji Post::new ustawiamy pole content na nowy, pusty String.

Przechowywanie Tekstu Treści Wpisu

Widzieliśmy w Liście 18-11, że chcemy mieć możliwość wywołania metody add_text i przekazania jej &str, który jest następnie dodawany jako tekstowa zawartość wpisu na blogu. Implementujemy to jako metodę, zamiast udostępniać pole content jako pub, aby później móc zaimplementować metodę, która będzie kontrolować, w jaki sposób odczytywane są dane pola content. Metoda add_text jest dość prosta, więc dodajmy implementację z Listy 18-13 do bloku impl Post.

Nazwa pliku: src/lib.rs
pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    // --snip--
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }

    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }
}

trait State {}

struct Draft {}

impl State for Draft {}
Lista 18-13: Implementacja metody add_text do dodawania tekstu do content wpisu

Metoda add_text przyjmuje zmienną referencję do self, ponieważ zmieniamy instancję Post, na której wywołujemy add_text. Następnie wywołujemy push_str na String w content i przekazujemy argument text, aby dodać go do zapisanej content. To zachowanie nie zależy od stanu, w jakim znajduje się wpis, więc nie jest częścią wzorca stanu. Metoda add_text w ogóle nie wchodzi w interakcje z polem state, ale jest częścią zachowania, które chcemy wspierać.

Zapewnienie, że Zawartość Szkicu Wpisu Jest Pusta

Nawet po wywołaniu add_text i dodaniu treści do naszego wpisu, nadal chcemy, aby metoda content zwracała pusty fragment ciągu, ponieważ wpis jest nadal w stanie szkicu, jak pokazano przez pierwsze assert_eq! na Liście 18-11. Na razie zaimplementujmy metodę content w najprostszy sposób, który spełni to wymaganie: zawsze zwracając pusty fragment ciągu. Zmienimy to później, gdy zaimplementujemy możliwość zmiany stanu wpisu, tak aby mógł zostać opublikowany. Do tej pory wpisy mogą być tylko w stanie szkicu, więc zawartość wpisu powinna być zawsze pusta. Lista 18-14 pokazuje tę implementację zastępczą.

Nazwa pliku: src/lib.rs
pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    // --snip--
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }

    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }

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

trait State {}

struct Draft {}

impl State for Draft {}
Lista 18-14: Dodanie implementacji zastępczej dla metody content w Post, która zawsze zwraca pusty fragment ciągu

Dzięki tej dodanej metodzie content wszystko z Listy 18-11 aż do pierwszego assert_eq! działa zgodnie z przeznaczeniem.

Zlecenie recenzji, która zmienia stan wpisu

Następnie musimy dodać funkcjonalność do żądania recenzji wpisu, co powinno zmienić jego stan ze Draft na PendingReview. Lista 18-15 pokazuje ten kod.

Nazwa pliku: src/lib.rs
pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    // --snip--
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }

    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }

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

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

trait State {
    fn request_review(self: Box<Self>) -> Box<dyn State>;
}

struct Draft {}

impl State for Draft {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        Box::new(PendingReview {})
    }
}

struct PendingReview {}

impl State for PendingReview {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }
}
Lista 18-15: Implementacja metod request_review w Post i cechy State

Dajemy Post publiczną metodę request_review, która przyjmuje zmienną referencję do self. Następnie wywołujemy wewnętrzną metodę request_review na bieżącym stanie Post, a ta druga metoda request_review konsumuje bieżący stan i zwraca nowy stan.

Dodajemy metodę request_review do cechy State; wszystkie typy, które implementują tę cechę, będą teraz musiały zaimplementować metodę request_review. Zauważ, że zamiast self, &self lub &mut self jako pierwszego parametru metody, mamy self: Box<Self>. Ta składnia oznacza, że metoda jest prawidłowa tylko wtedy, gdy jest wywoływana na Box zawierającym ten typ. Ta składnia przejmuje własność Box<Self>, unieważniając stary stan, tak aby wartość stanu Post mogła przekształcić się w nowy stan.

Aby skonsumować stary stan, metoda request_review musi przejąć własność wartości stanu. To tutaj wchodzi w grę Option w polu state struktury Post: wywołujemy metodę take, aby pobrać wartość Some z pola state i pozostawić None na jej miejscu, ponieważ Rust nie pozwala nam mieć niezapelnionych pól w strukturach. To pozwala nam przenieść wartość state z Post, zamiast jej pożyczać. Następnie ustawimy wartość state wpisu na wynik tej operacji.

Musimy tymczasowo ustawić state na None, zamiast ustawiać go bezpośrednio kodem takim jak self.state = self.state.request_review();, aby uzyskać własność wartości state. Zapewnia to, że Post nie może używać starej wartości state po tym, jak przekształciliśmy ją w nowy stan.

Metoda request_review w Draft zwraca nową, opakowaną instancję nowej struktury PendingReview, która reprezentuje stan, gdy wpis oczekuje na recenzję. Struktura PendingReview również implementuje metodę request_review, ale nie wykonuje żadnych transformacji. Zamiast tego zwraca siebie, ponieważ gdy prosimy o recenzję wpisu, który jest już w stanie PendingReview, powinien on pozostać w stanie PendingReview.

Teraz możemy zacząć dostrzegać zalety wzorca stanu: metoda request_review w Post jest taka sama niezależnie od jej wartości state. Każdy stan jest odpowiedzialny za własne reguły.

Pozostawimy metodę content w Post w niezmienionej postaci, zwracając pusty fragment ciągu. Możemy teraz mieć Post w stanie PendingReview, a także w stanie Draft, ale chcemy tego samego zachowania w stanie PendingReview. Lista 18-11 działa teraz aż do drugiego wywołania assert_eq!!

Dodawanie approve w celu zmiany zachowania content

Metoda approve będzie podobna do metody request_review: ustawi state na wartość, którą bieżący stan powinien mieć po zatwierdzeniu, jak pokazano na Liście 18-16.

Nazwa pliku: src/lib.rs
pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    // --snip--
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }

    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }

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

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

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

trait State {
    fn request_review(self: Box<Self>) -> Box<dyn State>;
    fn approve(self: Box<Self>) -> Box<dyn State>;
}

struct Draft {}

impl State for Draft {
    // --snip--
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        Box::new(PendingReview {})
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
}

struct PendingReview {}

impl State for PendingReview {
    // --snip--
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        Box::new(Published {})
    }
}

struct Published {}

impl State for Published {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
}
Lista 18-16: Implementacja metody approve w Post i cechy State

Dodajemy metodę approve do cechy State i nową strukturę, która implementuje State, czyli stan Published.

Podobnie jak działa request_review w PendingReview, jeśli wywołamy metodę approve na Draft, nie będzie to miało żadnego efektu, ponieważ approve zwróci self. Kiedy wywołamy approve na PendingReview, zwróci nową, spakowaną instancję struktury Published. Struktura Published implementuje cechę State, a dla obu metod request_review i approve zwraca siebie, ponieważ w tych przypadkach wpis powinien pozostać w stanie Published.

Teraz musimy zaktualizować metodę content w Post. Chcemy, aby wartość zwracana przez content zależała od bieżącego stanu Post, dlatego Post będzie delegować do metody content zdefiniowanej na swoim state, jak pokazano na Liście 18-17.

Nazwa pliku: src/lib.rs
pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    // --snip--
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }

    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }

    pub fn content(&self) -> &str {
        self.state.as_ref().unwrap().content(self)
    }
    // --snip--

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

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

trait State {
    fn request_review(self: Box<Self>) -> Box<dyn State>;
    fn approve(self: Box<Self>) -> Box<dyn State>;
}

struct Draft {}

impl State for Draft {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        Box::new(PendingReview {})
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
}

struct PendingReview {}

impl State for PendingReview {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        Box::new(Published {})
    }
}

struct Published {}

impl State for Published {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
}
Lista 18-17: Aktualizacja metody content w Post w celu delegowania do metody content w State

Ponieważ celem jest utrzymanie wszystkich tych zasad wewnątrz struktur implementujących State, wywołujemy metodę content na wartości w state i przekazujemy instancję wpisu (czyli self) jako argument. Następnie zwracamy wartość zwróconą przez użycie metody content na wartości state.

Wywołujemy metodę as_ref na Option, ponieważ chcemy referencję do wartości wewnątrz Option, a nie własności wartości. Ponieważ state jest Option<Box<dyn State>>, po wywołaniu as_ref zwracane jest Option<&Box<dyn State>>. Gdybyśmy nie wywołali as_ref, otrzymalibyśmy błąd, ponieważ nie możemy przenieść state poza pożyczone &self z parametru funkcji.

Następnie wywołujemy metodę unwrap, o której wiemy, że nigdy nie spowoduje paniki, ponieważ wiemy, że metody w Post zapewniają, że state zawsze będzie zawierać wartość Some po zakończeniu tych metod. Jest to jeden z przypadków, o których mówiliśmy w sekcji „Kiedy masz więcej informacji niż kompilator” w Rozdziale 9, kiedy wiemy, że wartość None nigdy nie jest możliwa, mimo że kompilator nie jest w stanie tego zrozumieć.

W tym momencie, gdy wywołamy content na &Box<dyn State>, nastąpi koercja dereferencji na & i Box, tak że metoda content zostanie ostatecznie wywołana na typie, który implementuje cechę State. Oznacza to, że musimy dodać content do definicji cechy State, i to tam umieścimy logikę dotyczącą tego, jaką zawartość zwrócić w zależności od posiadanego stanu, jak pokazano na Liście 18-18.

Nazwa pliku: src/lib.rs
pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }

    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }

    pub fn content(&self) -> &str {
        self.state.as_ref().unwrap().content(self)
    }

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

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

trait State {
    // --snip--
    fn request_review(self: Box<Self>) -> Box<dyn State>;
    fn approve(self: Box<Self>) -> Box<dyn State>;

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

// --snip--

struct Draft {}

impl State for Draft {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        Box::new(PendingReview {})
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
}

struct PendingReview {}

impl State for PendingReview {
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        Box::new(Published {})
    }
}

struct Published {}

impl State for Published {
    // --snip--
    fn request_review(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }

    fn content<'a>(&self, post: &'a Post) -> &'a str {
        &post.content
    }
}
Lista 18-18: Dodanie metody content do cechy State

Dodajemy domyślną implementację metody content, która zwraca pusty fragment ciągu. Oznacza to, że nie musimy implementować content w strukturach Draft i PendingReview. Struktura Published nadpisze metodę content i zwróci wartość z post.content. Choć wygodne, posiadanie metody content w State, która określa zawartość Post, zaciera granice między odpowiedzialnością State a odpowiedzialnością Post.

Zauważ, że potrzebujemy adnotacji dotyczących czasu życia w tej metodzie, jak omówiliśmy w Rozdziale 10. Przyjmujemy referencję do post jako argument i zwracamy referencję do części tego post, więc czas życia zwróconej referencji jest związany z czasem życia argumentu post.

I gotowe — cała Lista 18-11 działa! Zaimplementowaliśmy wzorzec stanu z zasadami przepływu pracy wpisu na blogu. Logika związana z zasadami znajduje się w obiektach stanu, a nie jest rozproszona po Post.

Dlaczego Nie Wyliczenie (Enum)?

Być może zastanawiałeś się, dlaczego nie użyliśmy wyliczenia (enum) z różnymi możliwymi stanami wpisu jako wariantami. To z pewnością możliwe rozwiązanie; spróbuj go i porównaj ostateczne wyniki, aby zobaczyć, które wolisz! Jedną z wad używania wyliczenia jest to, że każde miejsce, które sprawdza wartość wyliczenia, będzie potrzebowało wyrażenia match lub podobnego, aby obsłużyć każdy możliwy wariant. To mogłoby stać się bardziej powtarzalne niż to rozwiązanie z obiektem cechy.

Ocena Wzorca Stanu

Pokazaliśmy, że Rust jest zdolny do implementacji obiektowo zorientowanego wzorca stanu w celu hermetyzacji różnych rodzajów zachowań, jakie powinien mieć wpis w każdym stanie. Metody w Post nic nie wiedzą o różnych zachowaniach. Dzięki temu, jak zorganizowaliśmy kod, musimy patrzeć tylko w jedno miejsce, aby poznać różne sposoby zachowania opublikowanego wpisu: implementację cechy State na strukturze Published.

Gdybyśmy stworzyli alternatywną implementację, która nie używałaby wzorca stanu, moglibyśmy zamiast tego użyć wyrażeń match w metodach w Post lub nawet w kodzie main, który sprawdza stan wpisu i zmienia zachowanie w tych miejscach. Oznaczałoby to, że musielibyśmy patrzeć w kilku miejscach, aby zrozumieć wszystkie implikacje bycia w stanie opublikowanym.

Przy użyciu wzorca stanu, metody Post i miejsca, w których używamy Post, nie potrzebują wyrażeń match, a aby dodać nowy stan, wystarczyłoby dodać nową strukturę i zaimplementować metody cech na tej jednej strukturze w jednym miejscu.

Implementacja wykorzystująca wzorzec stanu jest łatwa do rozszerzenia o dodatkową funkcjonalność. Aby zobaczyć prostotę utrzymywania kodu, który używa wzorca stanu, wypróbuj kilka z tych sugestii:

  • Dodaj metodę reject, która zmienia stan wpisu z PendingReview z powrotem na Draft.
  • Wymagaj dwóch wywołań approve, zanim stan będzie mógł zostać zmieniony na Published.
  • Zezwalaj użytkownikom na dodawanie treści tekstowej tylko wtedy, gdy wpis jest w stanie Draft. Wskazówka: niech obiekt stanu będzie odpowiedzialny za to, co może się zmienić w treści, ale nie za modyfikowanie Post.

Jedną z wad wzorca stanu jest to, że ponieważ stany implementują przejścia między stanami, niektóre stany są ze sobą powiązane. Gdybyśmy dodali inny stan między PendingReview a Published, taki jak Scheduled, musielibyśmy zmienić kod w PendingReview, aby przejść do Scheduled. Byłoby to mniej pracy, gdyby PendingReview nie wymagało zmian wraz z dodaniem nowego stanu, ale to oznaczałoby przejście na inny wzorzec projektowy.

Inną wadą jest to, że powieliliśmy trochę logiki. Aby wyeliminować część powtórzeń, moglibyśmy spróbować stworzyć domyślne implementacje dla metod request_review i approve w cechy State, które zwracają self. Jednak to by nie zadziałało: używając State jako obiektu cechy, cecha nie wie dokładnie, czym będzie konkretny self, więc typ zwracany nie jest znany w czasie kompilacji. (To jedna z wcześniej wspomnianych reguł kompatybilności dyn).

Inne powtórzenia obejmują podobne implementacje metod request_review i approve w Post. Obie metody używają Option::take z polem state z Post, a jeśli state jest Some, delegują do implementacji tej samej metody przez owiniętą wartość i ustawiają nową wartość pola state na wynik. Gdybyśmy mieli wiele metod w Post, które postępowałyby zgodnie z tym wzorcem, moglibyśmy rozważyć zdefiniowanie makra, aby wyeliminować powtórzenia (patrz sekcja „Makrodefinicje” w Rozdziale 20).

Implementując wzorzec stanu dokładnie tak, jak jest zdefiniowany dla języków zorientowanych obiektowo, nie wykorzystujemy w pełni mocnych stron Rust. Przyjrzyjmy się kilku zmianom, które możemy wprowadzić w bibliotece blog, aby nieprawidłowe stany i przejścia stały się błędami w czasie kompilacji.

Kodowanie Stanów i Zachowania jako Typy

Pokażemy, jak przemyśleć wzorzec stanu, aby uzyskać inny zestaw kompromisów. Zamiast całkowicie hermetyzować stany i przejścia, tak aby zewnętrzny kod nie miał o nich wiedzy, zakodujemy stany w różnych typach. W konsekwencji system sprawdzania typów Rust zapobiegnie próbom użycia szkiców wpisów tam, gdzie dozwolone są tylko opublikowane wpisy, zgłaszając błąd kompilacji.

Rozważmy pierwszą część main na Liście 18-11:

Nazwa pliku: src/main.rs
use blog::Post;

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

    post.add_text("I ate a salad for lunch today");
    assert_eq!("", post.content());

    post.request_review();
    assert_eq!("", post.content());

    post.approve();
    assert_eq!("I ate a salad for lunch today", post.content());
}

Dalej umożliwiamy tworzenie nowych wpisów w stanie szkicu za pomocą Post::new oraz możliwość dodawania tekstu do treści wpisu. Ale zamiast mieć metodę content w szkicu wpisu, która zwraca pusty ciąg, sprawimy, że szkice wpisów w ogóle nie będą miały metody content. W ten sposób, jeśli spróbujemy uzyskać treść szkicu wpisu, otrzymamy błąd kompilatora informujący nas, że metoda nie istnieje. W rezultacie niemożliwe będzie przypadkowe wyświetlenie treści szkicu wpisu w produkcji, ponieważ ten kod nawet się nie skompiluje. Lista 18-19 pokazuje definicję struktury Post i struktury DraftPost, a także metody na każdej z nich.

Nazwa pliku: src/lib.rs
pub struct Post {
    content: String,
}

pub struct DraftPost {
    content: String,
}

impl Post {
    pub fn new() -> DraftPost {
        DraftPost {
            content: String::new(),
        }
    }

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

impl DraftPost {
    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }
}
Lista 18-19: Post z metodą content i DraftPost bez metody content

Zarówno struktury Post, jak i DraftPost mają prywatne pole content, które przechowuje tekst wpisu na blogu. Struktury nie mają już pola state, ponieważ przenosimy kodowanie stanu do typów struktur. Struktura Post będzie reprezentować opublikowany wpis i ma metodę content, która zwraca content.

Dalej mamy funkcję Post::new, ale zamiast zwracać instancję Post, zwraca instancję DraftPost. Ponieważ content jest prywatne i nie ma żadnych funkcji zwracających Post, obecnie nie jest możliwe utworzenie instancji Post.

Struktura DraftPost posiada metodę add_text, więc możemy dodawać tekst do content tak jak wcześniej, ale zauważ, że DraftPost nie ma zdefiniowanej metody content! Teraz program zapewnia, że wszystkie wpisy zaczynają się jako szkice wpisów, a szkice wpisów nie mają dostępnej treści do wyświetlenia. Każda próba obejścia tych ograniczeń spowoduje błąd kompilatora.

Więc jak zdobyć opublikowany wpis? Chcemy wymusić zasadę, że szkic wpisu musi zostać zrecenzowany i zatwierdzony, zanim zostanie opublikowany. Wpis w stanie oczekiwania na recenzję nadal nie powinien wyświetlać żadnej treści. Zaimplementujmy te ograniczenia, dodając kolejną strukturę, PendingReviewPost, definiując metodę request_review w DraftPost, aby zwracała PendingReviewPost, i definiując metodę approve w PendingReviewPost, aby zwracała Post, jak pokazano na Liście 18-20.

Nazwa pliku: src/lib.rs
pub struct Post {
    content: String,
}

pub struct DraftPost {
    content: String,
}

impl Post {
    pub fn new() -> DraftPost {
        DraftPost {
            content: String::new(),
        }
    }

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

impl DraftPost {
    // --snip--
    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }

    pub fn request_review(self) -> PendingReviewPost {
        PendingReviewPost {
            content: self.content,
        }
    }
}

pub struct PendingReviewPost {
    content: String,
}

impl PendingReviewPost {
    pub fn approve(self) -> Post {
        Post {
            content: self.content,
        }
    }
}
Lista 18-20: Struktura PendingReviewPost, która jest tworzona poprzez wywołanie request_review na DraftPost, oraz metoda approve, która zamienia PendingReviewPost w opublikowany Post

Metody request_review i approve przejmują własność self, konsumując w ten sposób instancje DraftPost i PendingReviewPost i przekształcając je odpowiednio w PendingReviewPost i opublikowany Post. W ten sposób nie będziemy mieć żadnych pozostałych instancji DraftPost po wywołaniu na nich request_review i tak dalej. Struktura PendingReviewPost nie ma zdefiniowanej metody content, więc próba odczytania jej treści skutkuje błędem kompilacji, podobnie jak w przypadku DraftPost. Ponieważ jedynym sposobem na uzyskanie opublikowanej instancji Post, która ma zdefiniowaną metodę content, jest wywołanie metody approve na PendingReviewPost, a jedynym sposobem na uzyskanie PendingReviewPost jest wywołanie metody request_review na DraftPost, zakodowaliśmy teraz przepływ pracy wpisu na blogu w systemie typów.

Musimy jednak wprowadzić również niewielkie zmiany w main. Metody request_review i approve zwracają nowe instancje zamiast modyfikować strukturę, na której są wywoływane, więc musimy dodać więcej przypisań let post = w celu zapisania zwróconych instancji. Nie możemy też mieć asercji dotyczących pustych ciągów w treści szkiców i wpisów oczekujących na recenzję, ani ich nie potrzebujemy: nie możemy już skompilować kodu, który próbuje użyć treści wpisów w tych stanach. Zaktualizowany kod w main pokazano na Liście 18-21.

Nazwa pliku: src/main.rs
use blog::Post;

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

    post.add_text("I ate a salad for lunch today");

    let post = post.request_review();

    let post = post.approve();

    assert_eq!("I ate a salad for lunch today", post.content());
}
Lista 18-21: Modyfikacje w main w celu użycia nowej implementacji przepływu pracy wpisu na blogu

Zmiany, które musieliśmy wprowadzić w main, aby ponownie przypisać post, oznaczają, że ta implementacja nie do końca już odpowiada wzorcowi stanu zorientowanemu obiektowo: transformacje między stanami nie są już w pełni hermetyzowane w implementacji Post. Jednak naszym zyskiem jest to, że nieprawidłowe stany są teraz niemożliwe dzięki systemowi typów i sprawdzaniu typów, które odbywa się w czasie kompilacji! To zapewnia, że pewne błędy, takie jak wyświetlanie treści nieopublikowanego wpisu, zostaną wykryte, zanim trafią do produkcji.

Wypróbuj zadania sugerowane na początku tej sekcji na bibliotece blog w stanie po Liście 18-21, aby zobaczyć, co myślisz o projekcie tej wersji kodu. Zauważ, że niektóre zadania mogą być już ukończone w tym projekcie.

Widzieliśmy, że chociaż Rust jest w stanie implementować obiektowo zorientowane wzorce projektowe, inne wzorce, takie jak kodowanie stanu w systemie typów, są również dostępne w Rust. Te wzorce mają różne kompromisy. Chociaż możesz być bardzo zaznajomiony z obiektowo zorientowanymi wzorcami, ponowne przemyślenie problemu w celu wykorzystania funkcji Rust może przynieść korzyści, takie jak zapobieganie niektórym błędom w czasie kompilacji. Wzorce obiektowo zorientowane nie zawsze będą najlepszym rozwiązaniem w Rust ze względu na pewne cechy, takie jak własność, których języki obiektowo zorientowane nie posiadają.

Podsumowanie

Niezależnie od tego, czy uważasz Rust za język obiektowy po przeczytaniu tego rozdziału, wiesz już, że możesz używać obiektów cech, aby uzyskać niektóre cechy obiektowe w Rust. Dynamiczne wysyłanie może zapewnić Twojemu kodowi pewną elastyczność w zamian za niewielki koszt wydajności w czasie wykonania. Możesz wykorzystać tę elastyczność do implementacji obiektowych wzorców, które mogą pomóc w utrzymaniu kodu. Rust ma również inne cechy, takie jak własność, których języki obiektowe nie mają. Wzorzec obiektowy nie zawsze będzie najlepszym sposobem na wykorzystanie mocnych stron Rust, ale jest to dostępna opcja.

Następnie przyjrzymy się wzorcom, które są kolejną z funkcji Rust, które umożliwiają dużą elastyczność. Przyglądaliśmy się im krótko w całej książce, ale nie widzieliśmy jeszcze ich pełnych możliwości. Zacznijmy!