Ferris Talk #9: Vom Builder Pattern und anderen Typestate-Abenteuern
Seite 2: Reicht das API-Design aus?
Alternativ könnte man statt eines konkreten Workers im build
-Schritt ein Result
Enum zurückgeben, das in der Ok
-Variante den Worker enthält, der im Fehlerfall eine sprechende Fehlermeldung liefert. Bei dieser Möglichkeit sollte man allerdings infrage stellen, ob das API-Design ausreichend ist.
Im dritten Fall könnte man den erforderlichen Workload beim Erstellen des WorkerBuilder
verlangen, ähnlich wie Command es mit dem auszuführenden Programm tut. Das Vorgehen eignet sich gut für Situationen, in denen nur ein Parameter nötig ist. Sind hingegen mehrere erforderlich, kommt es erneut zu langen Parameterlisten, die man mit dem Builder Pattern ja eigentlich vermeiden möchte.
Die letzte und vielleicht spannendste Möglichkeit ist, das Typsystem zu nutzen, um den build
-Schritt nur unter bestimmten Voraussetzungen zuzulassen.
Das gelingt durch die Einführung eines Unit-Structs – eines Structs, das über keine Felder verfügt und nur als Marker für den Compiler existiert. In folgendem Beispiel trägt es die Bezeichnung NoWorkload: struct NoWorkload;
Im WorkerBuilder
kommt statt des Option<String>
ein generischer Parameter zum Einsatz.
struct WorkerBuilder<W> {
workload: W,
memsize: u128,
keep_alive: bool,
}
Listing 8: Der WorkerBuilder als generische Implementierung
Nun gilt es, die Konstruktorfunktion und die workload
-Konstruktionsmethode für WorkerBuilder
zu implementieren. In dieser Implementierung wird der generische Parameter durch das konkrete Struct NoWorkload ersetzt.
impl WorkerBuilder<NoWorkload> {
pub fn new() -> Self {
Self {
workload: NoWorkload,
memsize: 128 * 1024,
keep_alive: false,
}
}
pub fn workload(&self, workload: impl Into<String>) -> WorkerBuilder<String> {
WorkerBuilder {
workload: workload.into(),
memsize: self.memsize,
keep_alive: self.keep_alive
}
}
}
Listing 9: Der erste Implementierungsblock für den konkreten Fall von NoWorkload
Das Spannende passiert bei der workload
-Methode: Hier gibt es nun einen konkreten Sourcecode in der Form eines Strings. Statt diesen String in eine Option zu packen, ändern wir den Typ des workload
von NoWorkload zu String.
An dieser Stelle möge man den Rückgabewert der Methode beachten. Es ist üblich, bei Builder Structs mit dem Typ Self
zu arbeiten. Self
ist ein Alias, der den aktuellen Implementierungsblock repräsentiert, in diesem Beispiel WorkerBuilder<NoWorkload>
. Bei der workload
-Methode wird allerdings nicht Self
zurückgegeben, sondern WorkerBuilder<String>
. Das ist der gleiche Typ, allerdings mit einem anderen Wert für den generischen Parameter. So erlaubt uns Rust, den Zustand des Structs über diesen Typparameter zu steuern.
Doch wo ist die build
-Methode hin? Sie rutscht in einen zweiten Implementierungsblock. Hier wird die generische Variable von WorkerBuilder
durch den konkreten Typ String
ersetzt.
impl WorkerBuilder<String> {
pub fn build(&mut self) -> Worker {
let workload = self.workload.clone();
Worker {
workload,
memsize: self.memsize,
keep_alive: self.keep_alive,
}
}
}
Listing 10: Der zweite Implementierungsblock für den konkreten Fall von String
Empfohlener redaktioneller Inhalt
Mit Ihrer Zustimmmung wird hier ein externes YouTube-Video (Google Ireland Limited) geladen.
Ich bin damit einverstanden, dass mir externe Inhalte angezeigt werden. Damit können personenbezogene Daten an Drittplattformen (Google Ireland Limited) übermittelt werden. Mehr dazu in unserer Datenschutzerklärung.
Damit wird sichergestellt, dass build
sich nur aufrufen lässt, wenn ein String als Workload vorliegt. In allen anderen Fällen erlaubt der Compiler nicht, build
aufzurufen.
Typestate Pattern nutzt Rusts ausdrucksstarkes Typsystem
Dieses Muster wird Typestate Pattern genannt. Man nutzt dabei das ausdrucksstarke Typsystem von Rust, um den Zustand eines Structs in generischen Parametern festzuhalten. Durch die Möglichkeit, unterschiedliche Implementierungsblöcke zu definieren, lässt sich sehr genau festhalten, welche Methoden in welchen Zuständen verfügbar sein dürfen.
Für Methoden, die in allen Fällen existieren sollen, lässt sich ein weiterer, generischer Implementierungsblock definieren: Sämtliche Methoden, die für beide Fälle gelten, sind darin enthalten, und "W" wird nicht durch konkrete Fälle substituiert.
impl<W> WorkerBuilder<W> {
pub fn memsize(&mut self, memsize: u128) -> &mut Self {
self.memsize = memsize;
self
}
pub fn keep_alive(&mut self, keepalive: bool) -> &mut Self {
self.keep_alive = keepalive;
self
}
}
Listing 11: Der letzte Implementierungsblock
Der Anwendung ist nicht anzumerken, ob Entwicklerinnen und Entwickler den Workload zu Beginn setzen oder später:
let worker_workload_first = WorkerBuilder::new()
.workload("...")
.keep_alive(true)
.memsize(8 * 1024)
.build();
let worker_workload_last = WorkerBuilder::new()
.keep_alive(true)
.memsize(8 * 1024)
.workload("...")
.build();
Listing 12: Der WorkerBuilder in Aktion