Ein objektorientiertes Entwurfsmuster implementieren
Das Zustandsmuster (state pattern) ist ein objektorientiertes Entwurfsmuster. Der Kernpunkt des Musters besteht darin, dass wir eine Reihe von Zuständen definieren, die ein Wert intern annehmen kann. Die Zustände werden durch eine Reihe von Zustandsobjekten (state objects) dargestellt, und das Verhalten des Wertes ändert sich je nach Zustand. Wir werden ein Beispiel für eine Blog-Post-Struktur durcharbeiten, die ein Feld für ihren Status hat, das ein Statusobjekt mit den Möglichkeiten „Entwurf“, „Überprüfung“ und „Veröffentlicht“ sein wird.
Die Zustandsobjekte haben eine gemeinsame Funktionalität: In Rust verwenden wir Strukturen (structs) und Merkmale (traits) und nicht Objekte und Vererbung. Jedes Zustandsobjekt ist für sein eigenes Verhalten verantwortlich und bestimmt, wann es in einen anderen Zustand übergehen soll. Der Wert, den ein Zustandsobjekt enthält, weiß nichts über das unterschiedliche Verhalten der Zustände oder den Zeitpunkt des Übergangs zwischen den Zuständen.
Der Vorteil der Verwendung des Zustandsmusters besteht darin, dass wir, wenn sich die geschäftlichen Anforderungen des Programms ändern, weder den Code des Werts, der den Zustand hält, noch den Code, der den Wert verwendet, ändern müssen. Wir müssen nur den Code in einem der Zustandsobjekte aktualisieren, um seine Regeln zu ändern oder vielleicht weitere Zustandsobjekte hinzuzufügen.
Zunächst werden wir das Zustandsmuster auf eine traditionellere objektorientierte Weise implementieren, dann werden wir einen Ansatz verwenden, der in Rust etwas natürlicher ist. Beginnen wir mit der inkrementellen Implementierung eines Blogpost-Workflows unter Verwendung des Zustandsmusters.
Die finale Funktionalität des Blogs wird wie folgt aussehen:
- Ein Blog-Beitrag (post) beginnt als leerer Entwurf.
- Wenn der Entwurf fertig ist, wird um eine Überprüfung des Beitrags gebeten.
- Wenn der Beitrag genehmigt ist, wird er veröffentlicht.
- Nur veröffentlichte Blog-Beiträge geben anzuzeigenden Inhalt zurück, sodass nicht genehmigte Beiträge nicht versehentlich veröffentlicht werden können.
Alle anderen Änderungen, die an einem Beitrag versucht werden, sollten keine Auswirkungen haben. Wenn wir zum Beispiel versuchen, den Entwurf eines Blog-Beitrags zu genehmigen, bevor wir eine Überprüfung beantragt haben, sollte der Beitrag ein unveröffentlichter Entwurf bleiben.
Codeblock 17-11 zeigt diesen Workflow in Codeform: Dies ist eine
Beispielverwendung der API, die wir in einer Bibliothekskiste (library crate)
blog
implementieren werden. Dieser Code wird sich noch nicht kompilieren
lassen, da wir die Kiste (crate) blog
noch nicht implementiert haben.
Dateiname: src/main.rs
use blog::Post;
fn main() {
let mut post = Post::new();
post.add_text("Ich habe heute Mittag einen Salat gegessen");
assert_eq!("", post.content());
post.request_review();
assert_eq!("", post.content());
post.approve();
assert_eq!("Ich habe heute Mittag einen Salat gegessen", post.content());
}
Wir möchten dem Benutzer erlauben, einen neuen Entwurf eines Blog-Beitrags mit
Post::new
zu erstellen. Wir möchten dem Blog-Beitrag erlauben, Text
hinzuzufügen. Wenn wir versuchen, den Inhalt des Beitrags sofort, also vor der
Genehmigung, abzurufen, sollten wir keinen Text erhalten, da der Beitrag noch
ein Entwurf ist. Wir haben zu Demonstrationszwecken assert_eq!
in den Code
eingefügt. Ein ausgezeichneter Modultest dafür wäre die Zusicherung, dass ein
Entwurf eines Blog-Beitrags eine leere Zeichenkette aus der Methode content
zurückgibt, aber wir werden für dieses Beispiel keine Tests schreiben.
Als nächstes wollen wir einen Antrag auf Überprüfung des Beitrags ermöglichen
und wir wollen, dass content
eine leere Zeichenkette zurückgibt, solange wir
auf die Überprüfung warten. Wenn der Beitrag die Genehmigung erhält, soll er
veröffentlicht werden, d.h. der Text des Beitrags wird zurückgegeben, wenn
content
aufgerufen wird.
Beachte, dass der einzige Typ, mit dem wir von der Kiste aus interagieren, der
Post
-Typ ist. Dieser Typ verwendet das Zustandsmuster und enthält einen Wert,
der eines von drei Zustandsobjekten ist, die die verschiedenen Zustände
repräsentieren, in denen sich ein Beitrag im Entwurf befinden, auf eine
Überprüfung warten oder veröffentlicht werden kann. Der Wechsel von einem
Zustand in einen anderen wird intern innerhalb des Post
-Typs verwaltet. Die
Zustände ändern sich als Reaktion auf die Methoden, die von den Benutzern
unserer Bibliothek auf der Post
-Instanz aufgerufen werden, aber sie müssen
die Zustandsänderungen nicht direkt verwalten. Auch können die Benutzer keinen
Fehler mit den Zuständen machen, z.B. einen Beitrag veröffentlichen, bevor er
überprüft wurde.
Definieren von Post
und Erstellen einer neuen Instanz im Entwurfszustand
Fangen wir mit der Implementierung der Bibliothek an! Wir wissen, dass wir eine
öffentliche Struktur Post
benötigen, die einige Inhalte enthält, also
beginnen wir mit der Definition der Struktur und einer zugehörigen öffentlichen
Funktion new
, um eine Instanz von Post
zu erzeugen, wie in Codeblock 17-12
gezeigt. Wir werden auch ein privates Merkmal State
erstellen, das das
Verhalten definiert, das alle Zustandsobjekte für einen Post
haben müssen.
Dann wird Post
ein Merkmalsobjekt (trait object) von Box<dyn State>
innerhalb einer Option<T>
in einem privaten Feld namens state
halten, um
das Zustandsobjekt zu halten. Du wirst gleich sehen, warum die Option<T>
notwendig ist.
Dateiname: src/lib.rs
#![allow(unused)] fn main() { 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 {} }
Das Merkmal State
definiert das Verhalten, das die verschiedenen
Beitragszustände gemeinsam haben. Die Zustandsobjekte sind Draft
,
PendingReview
und Published
und sie werden alle das Merkmal State
implementieren. Im Moment hat das Merkmal noch keine Methoden und wir werden
damit beginnen, nur den Zustand Draft
zu definieren, weil das der Zustand
ist, in dem ein Beitrag beginnen soll.
Wenn wir einen neuen Post
erstellen, setzen wir sein state
-Feld auf einen
Some
-Wert, der eine Box
enthält. Diese Box
verweist auf eine neue Instanz
der Struktur Draft
. Dies stellt sicher, dass jedes Mal, wenn wir eine neue
Instanz von Post
erzeugen, diese als Entwurf beginnt. Da das Feld state
von
Post
privat ist, gibt es keine Möglichkeit, einen Post
in einem anderen
Zustand zu erzeugen! In der Funktion Post::new
setzen wir das Feld content
auf einen neuen, leeren String
.
Speichern des Textes des Beitragsinhalts
Wir haben in Codeblock 17-11 gesehen, dass wir in der Lage sein wollen, eine
Methode namens add_text
aufzurufen und ihr einen &str
zu übergeben, die
dann als Textinhalt des Blog-Beitrags hinzugefügt wird. Wir implementieren dies
als Methode, anstatt das Feld content
mit pub
offenzulegen, damit wir
später eine Methode implementieren können, die steuert, wie die Daten des
Feldes content
gelesen werden. Die Methode add_text
ist ziemlich einfach,
also lass uns die Implementierung in Codeblock 17-13 zum Block impl Post
hinzufügen:
Dateiname: src/lib.rs
#![allow(unused)] fn main() { pub struct Post { state: Option<Box<dyn State>>, content: String, } impl Post { // --abschneiden-- 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 {} }
Die Methode add_text
nimmt eine veränderbare Referenz auf self
, weil wir
die Post
-Instanz, auf der wir add_text
aufrufen, ändern. Dann rufen wir
push_str
auf den String
in content
auf und übergeben das Argument text
,
um es zum gespeicherten content
hinzuzufügen. Dieses Verhalten hängt nicht
vom Zustand ab, in dem sich der Beitrag befindet, es ist also nicht Teil des
Zustandsmusters. Die Methode add_text
interagiert überhaupt nicht mit dem
Feld state
, aber sie ist Teil des Verhaltens, das wir unterstützen wollen.
Sicherstellen, dass der Inhalt eines Beitragsentwurfs leer ist
Selbst nachdem wir add_text
aufgerufen und unserem Beitrag etwas Inhalt
hinzugefügt haben, wollen wir immer noch, dass die Methode content
einen
leeren Zeichenkettenanteilstyp (string slice) zurückgibt, weil sich der Beitrag
noch im Entwurfszustand befindet, wie in Zeile 7 von Codeblock 17-11 gezeigt
wird. Lass uns fürs Erste die content
-Methode mit der einfachsten Sache
implementieren, die diese Anforderung erfüllt: Immer einen leeren
Zeichenkettenanteilstyp zurückgeben. Wir werden dies später ändern, sobald wir
die Möglichkeit implementiert haben, den Zustand eines Beitrags zu ändern,
damit er veröffentlicht werden kann. Bislang können Beiträge nur im
Entwurfszustand sein, daher sollte der Beitragsinhalt immer leer sein.
Codeblock 17-14 zeigt diese Platzhalter-Implementierung:
Dateiname: src/lib.rs
#![allow(unused)] fn main() { pub struct Post { state: Option<Box<dyn State>>, content: String, } impl Post { // --abschneiden-- 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 {} }
Mit dieser zusätzlichen Methode content
funktioniert alles in Codeblock 17-11
bis hin zu Zeile 7 wie beabsichtigt.
Antrag auf Überprüfung des Beitrags ändert seinen Zustand
Als nächstes müssen wir eine Funktionalität hinzufügen, um eine Überprüfung
eines Beitrags zu beantragen, die seinen Zustand von Draft
in PendingReview
ändern sollte. Codeblock 17-15 zeigt diesen Code:
Dateiname: src/lib.rs
#![allow(unused)] fn main() { pub struct Post { state: Option<Box<dyn State>>, content: String, } impl Post { // --abschneiden-- 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 } } }
Wir geben Post
eine öffentliche Methode namens request_review
, die eine
veränderbare Referenz auf self
nimmt. Dann rufen wir eine interne
request_review
-Methode über den aktuellen Zustand von Post
auf und diese
zweite request_review
-Methode konsumiert den aktuellen Zustand und gibt einen
neuen Zustand zurück.
Wir fügen die Methode request_review
zum Merkmal State
hinzu; alle Typen,
die das Merkmal implementieren, müssen nun die Methode request_review
implementieren. Beachte, dass wir statt self
, &self
oder &mut self
als
ersten Parameter der Methode self: Box<Self>
haben. Diese Syntax bedeutet,
dass die Methode nur gültig ist, wenn sie auf einer Box
mit dem Typ
aufgerufen wird. Diese Syntax übernimmt die Eigentümerschaft von Box<Self>
,
wodurch der alte Zustand ungültig wird, sodass der Zustandswert von Post
in
einen neuen Zustand transformiert werden kann.
Um den alten Zustand zu konsumieren, muss die request_review
-Methode die
Eigentümerschaft des Zustandswerts übernehmen. Hier kommt die Option
im Feld
state
von Post
ins Spiel: Wir rufen die Methode take
auf, um den
Some
-Wert aus dem state
-Feld zu nehmen und an seiner Stelle ein None
zu
hinterlassen, weil Rust es nicht zulässt, dass wir unbestückte Felder in
Strukturen haben. Dadurch können wir den Wert state
aus Post
herausnehmen,
anstatt ihn auszuleihen. Dann setzen wir den Wert state
des Beitrags auf das
Ergebnis dieser Operation.
Wir müssen state
vorübergehend auf None
setzen, anstatt es direkt mit Code
wie self.state = self.state.request_review();
zu setzen, um die
Eigentümerschaft des state
-Wertes zu erhalten. Das stellt sicher, dass Post
nicht den alten state
-Wert verwenden kann, nachdem wir ihn in einen neuen
Zustand transformiert haben.
Die Methode request_review
auf Draft
gibt eine neue, in einer Box
gespeicherte Instanz einer neuen PendingReview
-Struktur zurück, die den
Zustand darstellt, in dem ein Beitrag auf eine Überprüfung wartet. Die Struktur
PendingReview
implementiert auch die Methode request_review
, führt aber
keine Transformationen durch. Vielmehr gibt sie sich selbst zurück, denn wenn
wir eine Überprüfung für einen Beitrag anfordern, der sich bereits im
PendingReview
-Zustand befindet, sollte er im PendingReview
-Zustand bleiben.
Jetzt können wir anfangen, die Vorteile des Zustandsmusters zu erkennen: Die
Methode request_review
auf Post
ist die gleiche, unabhängig von ihrem
state
-Wert. Jeder Zustand ist für seine eigenen Regeln verantwortlich.
Wir lassen die Methode content
auf Post
so wie sie ist und geben einen
leeren Zeichenkettenanteilstyp zurück. Wir können jetzt einen Post
sowohl im
Zustand PendingReview
als auch im Zustand Draft
haben, aber wir wollen das
gleiche Verhalten im Zustand PendingReview
. Codeblock 17-11 funktioniert
jetzt bis Zeile 10!
Hinzufügen von approve
, um das Verhalten von content
zu ändern
Die Methode approve
ähnelt der Methode request_review
: Sie setzt den
state
auf den Wert, den der aktuelle Zustand nach der Genehmigung haben
sollte, wie in Codeblock 17-16 gezeigt:
Dateiname: src/lib.rs
#![allow(unused)] fn main() { pub struct Post { state: Option<Box<dyn State>>, content: String, } impl Post { // --abschneiden-- 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 { // --abschneiden-- 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 { // --abschneiden-- 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 } } }
Wir fügen die Methode approve
zum Merkmal State
hinzu und fügen eine neue
Struktur State
hinzu, die den Zustand Published
implementiert.
Ähnlich wie request_review
bei PendingReview
funktioniert, hat der Aufruf
der Methode approve
bei einem Draft
keine Wirkung, weil approve
den Wert
self
zurückgibt. Wenn wir die Methode approve
bei PendingReview
aufrufen,
gibt sie eine neue, geschlossene Instanz der Struktur Published
zurück. Die
Struktur Published
implementiert das Merkmal State
und sowohl bei der
Methode request_review
als auch bei der Methode approve
gibt sie sich
selbst zurück, weil der Beitrag in diesen Fällen im Zustand Published
bleiben
sollte.
Jetzt müssen wir die Methode content
auf Post
aktualisieren: Wir wollen,
dass der von content
zurückgegebene Wert vom aktuellen Zustand von Post
abhängt, also delegieren wir Post
an eine content
-Methode, die auf seinen
state
definiert ist, wie in Codeblock 17-17 gezeigt:
Dateiname: src/lib.rs
#![allow(unused)] fn main() { pub struct Post { state: Option<Box<dyn State>>, content: String, } impl Post { // --abschneiden-- 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) } // --abschneiden-- 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 } } }
Da das Ziel darin besteht, all diese Regeln innerhalb der Strukturen zu halten,
die State
implementieren, rufen wir eine Methode content
auf dem Wert in
state
auf und übergeben die Post-Instanz (d.h. self
) als Argument. Dann
geben wir den Wert zurück, der von der Verwendung der Methode content
für den
state
-Wert zurückgegeben wird.
Wir rufen die Methode as_ref
auf Option
auf, weil wir eine Referenz auf den
Wert innerhalb Option
wollen und nicht die Eigentümerschaft am Wert. Weil
State
eine Option<Box<dyn State>>
ist, wird beim Aufruf von as_ref
eine
Option<&Box<dyn State>>
zurückgegeben. Würden wir nicht as_ref
aufrufen,
bekämen wir einen Fehler, weil wir state
nicht aus dem ausgeliehenen &self
im Funktionsparameter herausverschieben können.
Wir rufen dann die unwrap
-Methode auf, von der wir wissen, dass sie das
Programm niemals abstürzen lassen wird, weil wir wissen, dass die Methoden auf
Post
sicherstellen, dass state
stets einen Some
-Wert enthält, wenn diese
Methoden zu Ende sind. Dies ist einer der Fälle, über die wir im Abschnitt
„Fälle, in denen du mehr Informationen als der Compiler
hast“ in Kapitel 9 gesprochen haben, wenn wir wissen,
dass ein None
-Wert niemals möglich ist, obwohl der Compiler nicht in der Lage
ist, das zu verstehen.
Wenn wir nun content
auf der &Box<dyn State>
aufrufen, wird eine
automatische Umwandlung (deref coercion) auf &
und Box
stattfinden, sodass
die content
-Methode letztlich auf dem Typ aufgerufen wird, der das Merkmal
State
implementiert. Das bedeutet, dass wir die Definition des Merkmals
State
um content
erweitern müssen, und hier werden wir die Logik dafür
unterbringen, welcher Inhalt je nach Zustand zurückgegeben wird, wie in
Codeblock 17-18 gezeigt wird:
Dateiname: src/lib.rs
#![allow(unused)] fn main() { 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 { // --abschneiden-- 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 { "" } } // --abschneiden-- 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 { // --abschneiden-- 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 } } }
Wir fügen eine Standard-Implementierung für die Methode content
hinzu, die
einen leeren Zeichenkettenanteilstyp zurückgibt. Das bedeutet, dass wir
content
in den Strukturen Draft
und PendingReview
nicht implementieren
müssen. Die Struktur Published
überschreibt die Methode content
und gibt
den Wert in post.content
zurück.
Beachte, dass wir Lebensdauer-Annotationen bei dieser Methode benötigen, wie
wir in Kapitel 10 besprochen haben. Wir nehmen eine Referenz auf ein post
als
Argument und geben eine Referenz auf einen Teil dieses post
zurück, sodass
die Lebensdauer der zurückgegebenen Referenz mit der Lebensdauer des
post
-Arguments zusammenhängt.
Und wir sind fertig – der Codeblock 17-11 funktioniert jetzt! Wir haben
das Zustandsmuster mit den Regeln des Blog-Beitrags-Workflows implementiert.
Die Logik, die sich auf die Regeln bezieht, lebt in den Zustandsobjekten und
ist nicht über den gesamten Post
verstreut.
Warum nicht eine Aufzählung?
Vielleicht hast du dich gefragt, warum wir nicht ein
enum
mit den verschiedenen möglichen Poststatus als Varianten verwendet haben. Das ist sicherlich eine mögliche Lösung. Probiere es aus und vergleiche die Endergebnisse, um zu sehen, was du bevorzugst! Ein Nachteil der Verwendung einer Aufzählung ist, dass jede Stelle, die den Wert der Aufzählung prüft, einenmatch
-Ausdruck oder ähnliches benötigt, um jede mögliche Variante zu behandeln. Dies könnte zu mehr Wiederholungen führen als die Lösung mit dem Merkmals-Objekt.
Kompromisse des Zustandsmusters
Wir haben gezeigt, dass Rust in der Lage ist, das objektorientierte
Zustandsmuster zu implementieren, um die verschiedenen Verhaltensweisen, die
ein Beitrag im jeweiligen Zustand haben sollte, zu kapseln. Die Methoden auf
Post
wissen nichts über die verschiedenen Verhaltensweisen. So, wie wir den
Code organisiert haben, müssen wir nur an einem einzigen Ort suchen, um zu
wissen, wie sich ein veröffentlichter Beitrag verhalten kann: Die
Implementierung des Merkmals State
auf der Struktur Published
.
Wenn wir eine alternative Implementierung erstellen würden, die nicht das
Zustandsmuster verwendet, könnten wir stattdessen match
-Ausdrücke in den
Methoden auf Post
oder sogar im main
-Code verwenden, die den Zustand des
Beitrags überprüfen und das Verhalten an diesen Stellen ändern. Das würde
bedeuten, dass wir an mehreren Stellen nachschauen müssten, um alle
Auswirkungen eines Beitrags im veröffentlichten Zustand zu verstehen! Dies
würde sich nur noch erhöhen, je mehr Zustände wir hinzufügen: Jeder dieser
match
-Ausdrücke würde einen weiteren Zweig benötigen.
Mit dem Zustandsmuster, den Post
-Methoden und den Stellen, an denen wir
Post
verwenden, brauchen wir keine match
-Ausdrücke, und um einen neuen
Zustand hinzuzufügen, müssten wir nur eine neue Struktur hinzufügen und die
Merkmalsmethoden auf dieser einen Struktur implementieren.
Die Implementierung unter Verwendung des Zustandsmusters ist leicht zu erweitern, um weitere Funktionalität hinzuzufügen. Um zu sehen, wie einfach es ist, Code zu pflegen, der das Zustandsmuster verwendet, probiere einige dieser Vorschläge aus:
- Füge eine
reject
-Methode hinzu, die den Zustand des Beitrags vonPendingReview
zurück zuDraft
ändert. - Verlange zwei
approve
-Aufrufe, bevor der Zustand inPublished
geändert werden kann. - Erlaube Benutzern das Hinzufügen von Textinhalten nur dann, wenn sich ein
Beitrag im Zustand
Draft
befindet. Hinweis: Lasse das Zustandsobjekt dafür verantwortlich sein, was sich am Inhalt ändern könnte, aber nicht für die Änderung des Beitrags.
Ein Nachteil des Zustandsmusters besteht darin, dass einige der Zustände
miteinander gekoppelt sind, weil die Zustände die Übergänge zwischen den
Zuständen implementieren. Wenn wir einen weiteren Zustand zwischen
PendingReview
und Published
hinzufügen, z.B. Scheduled
, müssten wir den
Code in PendingReview
ändern und stattdessen zu Scheduled
übergehen. Es
wäre weniger Arbeit, wenn PendingReview
nicht mit dem Hinzufügen eines neuen
Zustands geändert werden müsste, aber das würde bedeuten, zu einem anderen
Entwurfsmuster zu wechseln.
Ein weiterer Nachteil ist, dass wir eine gewisse Logik dupliziert haben. Um
einen Teil der Duplikation zu eliminieren, könnten wir versuchen,
Standard-Implementierungen für die Methoden request_review
und approval
für
das Merkmal State
zu erstellen, die self
zurückgeben; dies würde jedoch die
Objektsicherheit verletzen, da das Merkmal nicht weiß, was das konkrete self
genau sein wird. Wir wollen in der Lage sein, State
als Merkmalsobjekt zu
verwenden, deshalb müssen seine Methoden objektsicher sein.
Eine weitere Duplikation sind die ähnlichen Implementierungen der Methoden
request_review
und approve
auf Post
. Beide Methoden delegieren die
Implementierung der gleichen Methode auf den Wert im Feld state
von Option
und setzen den neuen Wert des Feldes state
auf das Ergebnis. Wenn wir eine
Menge Methoden auf Post
hätten, die diesem Muster folgen, könnten wir in
Erwägung ziehen, ein Makro zu definieren, um die Wiederholung zu eliminieren
(siehe den Abschnitt „Makros“ in Kapitel 19).
Indem wir das Zustandsmuster genau so implementieren, wie es für
objektorientierte Sprachen definiert ist, nutzen wir die Stärken Rusts nicht so
aus, wie wir es könnten. Sehen wir uns einige Änderungen an, die wir an der
Kiste blog
vornehmen können, die ungültige Zustände und Übergänge in
Kompilierzeitfehler verwandeln können.
Kodieren von Zuständen und Verhalten als Typen
Wir werden dir zeigen, wie du das Zustandsmuster überdenken kannst, um andere Kompromisse zu erzielen. Anstatt die Zustände und Übergänge vollständig zu kapseln, sodass Außenstehende keine Kenntnis von ihnen haben, werden wir die Zustände in verschiedene Typen kodieren. Folglich wird Rusts Typprüfungssystem Versuche verhindern, Entwurfsbeiträge zu verwenden, bei denen nur veröffentlichte Beiträge erlaubt sind, indem ein Kompilierfehler ausgegeben wird.
Betrachten wir den ersten Teil von main
in Codeblock 17-11:
Dateiname: src/main.rs
use blog::Post;
fn main() {
let mut post = Post::new();
post.add_text("Ich habe heute Mittag einen Salat gegessen");
assert_eq!("", post.content());
post.request_review();
assert_eq!("", post.content());
post.approve();
assert_eq!("Ich habe heute Mittag einen Salat gegessen", post.content());
}
Wir ermöglichen nach wie vor das Erstellen neuer Beiträge im Entwurfsstadium
unter Verwendung von Post::new
und der Möglichkeit, dem Inhalt des Beitrags
Text hinzuzufügen. Aber anstatt eine content
-Methode bei einem
Beitragsentwurf zu haben, die eine leere Zeichenkette zurückgibt, werden wir
es so einrichten, dass Beitragsentwürfe überhaupt keine content
-Methode
haben. Wenn wir auf diese Weise versuchen, den Inhalt eines Beitragsentwurfs
zu erhalten, erhalten wir einen Kompilierfehler, der uns sagt, dass die Methode
nicht existiert. Infolgedessen wird es für uns unmöglich, versehentlich den
Inhalt eines Beitragsentwurfs in der Produktion anzuzeigen, weil sich dieser
Code nicht einmal kompilieren lässt. Codeblock 17-19 zeigt die Definition einer
Struktur Post
und einer Struktur DraftPost
sowie die Methoden dieser
Strukturen:
Dateiname: src/lib.rs
#![allow(unused)] fn main() { 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); } } }
Die beiden Strukturen Post
und DraftPost
haben ein privates Feld content
,
in dem der Text des Blog-Beitrags gespeichert wird. Die Strukturen haben nicht
mehr das state
-Feld, weil wir die Kodierung des Zustands auf die Typen der
Strukturen verlagert haben. Die Struktur Post
wird einen veröffentlichten
Beitrag repräsentieren und sie hat eine Methode content
, die den content
zurückgibt.
Wir haben immer noch die Funktion Post::new
, aber anstatt eine Instanz von
Post
zurückzugeben, gibt sie eine Instanz von DraftPost
zurück. Da
content
privat ist und es keine Funktion gibt, die Post
zurückgibt, ist es
im Moment nicht möglich, eine Instanz von Post
zu erzeugen.
Die Struktur DraftPost
hat eine Methode add_text
, sodass wir wie bisher
Text zum content
hinzufügen können, aber beachte, dass DraftPost
keine
Methode content
definiert hat! Daher stellt das Programm jetzt sicher, dass
alle Beiträge als Beitragsentwürfe beginnen und dass der Inhalt von
Beitragsentwürfen nicht zur Anzeige verfügbar ist. Jeder Versuch, diese
Einschränkungen zu umgehen, führt zu einem Kompilierfehler.
Umsetzen von Übergängen als Transformationen in verschiedene Typen
Wie bekommen wir also einen veröffentlichten Beitrag? Wir wollen die Regel
durchsetzen, dass ein Beitragsentwurf geprüft und genehmigt werden muss, bevor
er veröffentlicht werden kann. Ein Beitrag, der sich im Stadium der Überprüfung
befindet, sollte noch immer keinen Inhalt haben. Lass uns diese Bedingung
implementieren, indem wir eine weitere Struktur PendingReviewPost
hinzufügen,
indem wir die Methode request_review
auf DraftPost
definieren, um einen
PendingReviewPost
zurückzugeben, und eine Methode approve
auf
PendingReviewPost
, um einen Post
zurückzugeben, wie in Codeblock 17-20
gezeigt:
Dateiname: src/lib.rs
#![allow(unused)] fn main() { 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 { // --abschneiden-- 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, } } } }
Die Methoden request_review
und approve
übernehmen die Eigentümerschaft von
self
, wodurch die Instanzen DraftPost
und PendingReviewPost
verbraucht
und in einen PendingReviewPost
bzw. einen veröffentlichten Post
umgewandelt
werden. Auf diese Weise werden wir keine DraftPost
-Instanzen mehr haben,
nachdem wir request_review
darauf aufgerufen haben, und so weiter. Die
PendingReviewPost
-Struktur hat keine content
-Methode definiert, sodass der
Versuch, ihren Inhalt zu lesen, zu einem Kompilierfehler führt, wie bei
DraftPost
. Da der einzige Weg, eine veröffentlichte Post
-Instanz zu
erhalten, die eine content
-Methode definiert hat, der Aufruf der
approve
-Methode auf einem PendingReviewPost
ist, und der einzige Weg, einen
PendingReviewPost
zu erhalten, der Aufruf der request_review
-Methode auf
einem DraftPost
ist, haben wir jetzt den Blog-Beitrags-Workflow in das
Typsystem kodiert.
Aber wir müssen auch einige kleine Änderungen an main
vornehmen. Die Methoden
request_review
und approve
geben neue Instanzen zurück, anstatt die
Struktur, auf der sie aufgerufen werden, zu modifizieren, sodass wir mehr let post =
Verschattungs-Zuweisungen (shadowing assignments) hinzufügen müssen, um
die zurückgegebenen Instanzen zu speichern. Wir können auch nicht zulassen,
dass die Zusicherungen über den Inhalt des Entwurfs und der anstehenden
Überprüfungsbeiträge leere Zeichenketten sind, und wir brauchen sie auch nicht:
Wir können keinen Code mehr kompilieren, der versucht, den Inhalt von Beiträgen
in diesen Zuständen zu verwenden. Der aktualisierte Code in main
ist in
Codeblock 17-21 aufgeführt:
Dateiname: src/main.rs
use blog::Post;
fn main() {
let mut post = Post::new();
post.add_text("Ich habe heute Mittag einen Salat gegessen");
let post = post.request_review();
let post = post.approve();
assert_eq!("Ich habe heute Mittag einen Salat gegessen", post.content());
}
Die Änderungen, die wir an main
vornehmen mussten, um post
neu zuzuweisen,
bedeuten, dass diese Implementierung nicht mehr ganz dem objektorientierten
Zustandsmuster folgt: Die Transformationen zwischen den Zuständen sind nicht
mehr vollständig in der Post
-Implementierung gekapselt. Unser Vorteil ist
jedoch, dass ungültige Zustände aufgrund des Typsystems und der Typprüfung, die
zur Kompilierzeit stattfindet, jetzt unmöglich sind! Dadurch wird
sichergestellt, dass bestimmte Fehler, z.B. das Anzeigen des Inhalts eines
unveröffentlichten Beitrags, entdeckt werden, bevor sie in die Produktion
gelangen.
Versuche es mit den Aufgaben, die wir zu Beginn dieses Abschnitts über die
Kiste blog
nach Codeblock 17-20 erwähnt haben, um zu sehen, was du über das
Design dieser Version des Codes denkst. Beachte, dass einige der Aufgaben
möglicherweise bereits in diesem Entwurf abgeschlossen sind.
Wir haben gesehen, dass, obwohl Rust in der Lage ist, objektorientierte Entwurfsmuster zu implementieren, auch andere Muster, z.B. das Kodieren des Zustands in das Typsystem, in Rust verfügbar sind. Diese Muster weisen unterschiedliche Kompromisse auf. Auch wenn du mit objektorientierten Mustern sehr vertraut bist, kann ein Überdenken des Problems, um die Funktionen von Rust zu nutzen, Vorteile bringen, z.B. das Vermeiden einiger Fehler zur Kompilierzeit. Objektorientierte Muster werden in Rust nicht immer die beste Lösung sein, da objektorientierte Sprachen bestimmte Funktionalitäten, z.B. Eigentümerschaft, nicht haben.
Zusammenfassung
Unabhängig davon, ob du nach der Lektüre dieses Kapitels der Meinung bist, dass Rust eine objektorientierte Sprache ist oder nicht, weißt du jetzt, dass du Merkmalsobjekte verwenden kannst, um einige objektorientierte Funktionalitäten in Rust zu erhalten. Dynamische Aufrufe können deinem Code eine gewisse Flexibilität im Austausch gegen ein wenig Laufzeitperformanz verleihen. Du kannst diese Flexibilität nutzen, um objektorientierte Muster zu implementieren, die die Wartbarkeit deines Codes verbessern können. Rust hat auch andere Funktionalitäten, z.B. Eigentümerschaft, die objektorientierte Sprachen nicht haben. Ein objektorientiertes Muster wird nicht immer der beste Weg sein, um die Stärken von Rust zu nutzen, ist aber eine verfügbare Option.
Als nächstes werden wir uns mit Mustern befassen, die eine weitere Funktionalität von Rust sind und viel Flexibilität ermöglichen. Wir haben sie uns im Laufe des Buches kurz angeschaut, haben aber noch nicht ihre volle Leistungsfähigkeit gesehen. Los geht's!