Ferris Talk #9: Vom Builder Pattern und anderen Typestate-Abenteuern

Seite 2: Reicht das API-Design aus?

Inhaltsverzeichnis

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&lt;NoWorkload&gt;. Bei der workload-Methode wird allerdings nicht Self zurückgegeben, sondern WorkerBuilder&lt;String&gt;. 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.

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