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

Für robustere Rust-Objekte wagt die Kolumne einen Spagat: die Kombination eines alten Entwurfsmusters (Builder Pattern) mit der Mainstream-Technik Typestate.

In Pocket speichern vorlesen Druckansicht 9 Kommentare lesen
Ferris Talk – Neuigkeiten zu Rust. Eine Heise-Kolumne von Rainer Stropek und Stefan Baumgartner für Rustaceans
Lesezeit: 11 Min.
Von
  • Stefan Baumgartner
Inhaltsverzeichnis

In der heutigen Ausgabe des Ferris Talk wollen wir uns mit alten Bekannten treffen. Vor beinahe 30 Jahren hatte die Gang of Four schon einmal versucht, C++-Code zu bändigen, und eine Kollektion von wiederverwendbaren Entwurfsmustern präsentiert. Das bot Stoff für unzählige Universitätskurse. Bei einigen bedient sich die Softwareentwicklung heute noch, und manche davon erleben in Rust einen zweiten Frühling.

Ein Beispiel dafür ist das Builder Pattern, bei dem es darum geht, das Erstellen komplexer Objekte von der eigentlichen Struktur des Objekts zu trennen. Dadurch erwarten Developer eine einfachere Verwendung bei potenziell umfangreichen Argumentlisten.

Ferris Talks – die Kolumne für Rustaceans

Ein prominentes Beispiel aus dem Rust-Ökosystem ist die HTTP-Bibliothek hyper. HTTP-Requests und -Responses können sehr umfangreich sein – vor allem, wenn unterschiedliche Header zu setzen sind. Der Request-Builder gibt hierbei eine angenehme Schnittstelle vor, mit der Entwicklerinnen und Entwickler auch im Kontrollfluss reagieren können. Mit folgendem Listing geht es direkt zur Sache:

// Den Builder erzeugen und gleich loslegen
let mut builder = Request::builder();
builder.method("GET")
    .uri("https://www.rust-lang.org/");
    .header("X-Custom-Foo", "Bar");

// Weitere Header dranhängen, die irgendwo gespeichert sind
for (head, val) in preset_headers {
    builder.header(head, val)
}

// Nachdem der Body gesetzt wurde, bekommen
// wir auch gleich einen Request, den wir absetzen können
let request = builder.body(()).unwrap();

Listing 1: Das Builder-Pattern beim Request Builder der Hyper-Bibliothek

Die Idee des Builders ist, über mehrere Schritte hinweg das Zielobjekt zu konfigurieren (hier mit header und uri), um mit einem finalen Baubefehl body das Zielobjekt – hier: den Request – zu erhalten.

Mit dieser Technik kann man der Einschränkung, dass es in Rust keine optionalen oder voreingestellten Werte für Funktionsargumentlisten gibt, etwas entgegensetzen. Jeden Wert gilt es explizit zu setzen, auch wenn nur ein None oder Default::default() aufzurufen ist.

Ein fiktiver Konstruktor zum std::process::Command-Struct aus der Standardbibliothek, das der Ausführung von Kommandozeilenbefehlen dient, wird mit der vollen Liste an Parametern ziemlich umfangreich:

struct Command {
    command: String,
    args: Vec<String>,
    env: Vec<String>,
    stdin: Stdin,
    stdout: Stdout,
    stderr: Stderr,
    cwd: Option<String>,
}

impl Command {
    pub fn new(
        command: String,
        args: Option<Vec<String>>,
        env: Option<Vec<String>>,
        stdin: Option<Stdin>,
        stdout: Option<Stdout>,
        stderr: Option<Stderr>,
        current_dir: Option<String>,
    ) -> Self {
        Self {
            command,
            args: args.unwrap_or_default(),
            env: env.unwrap_or_default(),
            stdin: stdin.unwrap_or(io::stdin()),
            stdout: stdout.unwrap_or(io::stdout()),
            stderr: stderr.unwrap_or(io::stderr()),
            cwd: current_dir,
        }
    }
}

// Verwendung
let command = Command::new(
    "ls".into(),
    Some(vec!["-l".into(), "-a".into()]),
    None,
    None,
    None,
    None,
    None,
).spawn()

Listing 2: Ein Konstruktor zum Command-Struct

Eleganter in der Nutzung ist die tatsächliche Implementierung aus der Standardbibliothek:

let command = Command::new("ls").arg("-l").arg("-a").spawn();

Da Kommandozeilenaufrufe ausgesprochen variabel sein können, bietet sich hier das Builder Pattern geradezu an. Zusätzlich lassen sich Argumentlisten und Umgebungsparameter im Kontrollfluss ein- und ausschalten.

let mut command = Command::new("ls");
command.arg("-l").arg("-a");
    
if let Some(working_dir) = working_dir {
    command.current_dir(working_dir);
}

let command = command.spawn();

Listing 3: Command als Builder Pattern erlaubt das Setzen von Argumenten im Kontrolfluss.

Bei Command ist das Builder Pattern als Entwurfsmuster eine gute Wahl: Die umfangreichen Parameter sind großteils optional – man muss nur einsetzen, was unbedingt nötig ist, und die Schnittstelle kümmert sich um eine komfortable Bedienung.

Die Ownership-Regeln von Rust spielen in Builders ebenfalls eine Rolle. Wachsamen Augen wird aufgefallen sein, dass im vorherigen Beispiel der Konstruktionsschritt von den weiteren Befehlen getrennt war – dafür gibt es einen Grund.

Command ist als Non-consuming Builder implementiert und muss zum Setzen weiterer Befehle als Mutable Reference &mut self vorliegen. Trennt man den Konstruktionsschritt von weiteren Schritten, erhält die Variable command die Ownership des Structs:

let mut command = Command::new("ls"); // Typ: Command

Nach dem Hinzufügen der ersten Parameterketten ist das Ergebnis lediglich eine veränderbare Referenz auf Command:

let command = Command::new("ls").arg("-l"); // Typ: &mut Command

Diese Zeile provoziert einen Compile-Fehler. Mit einer Fehlermeldung "temporary value dropped while borrowed" erklärt uns der Compiler, dass der tatsächliche Besitzer des Structs nirgends zugewiesen ist. Für Rust ist also unklar, wer nun die Ownership erhält.

Dennoch lassen sich damit verkettete Einzeiler gestalten, sofern man an den wichtigen Baubefehl denkt:

let command = Command::new("ls").arg("-l").arg("-a").spawn();

Mit spawn schließt man die Konfigurationsschritte ab. Der Rückgabewert dieser Funktion ist ein Struct, das den gestarteten Betriebssystem-Prozess repräsentiert. Dieses Struct ist in ein Result Enum eingepackt, da der spawn Vorgang fehlschlagen kann. Das Result ist nun ein besitzbarer Wert.

Non-consuming Builders sind die bevorzugte Variante und tauchen "in der freien Wildnis" am häufigsten auf. Als Alternative dazu gibt es aber auch Consuming Builders. Hier arbeiten die Konfigurationsschritte nicht mit einer veränderbaren Referenz, sondern übernehmen die Ownership über sich selbst (mut self).

Das ist vor allem dann nötig, wenn das Zielobjekt nicht nur die Ownership über einzelne Bestandteile benötigt, sondern sie auch weitergeben muss. Ein Beispiel aus der Standardbibliothek sind die Thread Builder.

let mut thread = std::thread::Builder::new();
thread = thread.name("demo".into());

if let Some(stack_size) = stack_size {
    thread = thread.stack_size(stack_size);
}

let handle = thread.spawn(|| {
    println!("Hello world");
});

Listing 4: Der Thread Builder ist ein Consuming Builder

Komplexe Konfigurationsschritte verlangen, nach jedem Schritt den Wert einer Variable neu zuzuweisen. Ohne Zuweisung des Rückgabewerts der Methode name gibt es keinen Besitzer des Structs. Der Compiler warnt zum Glück davor.

Einzeiler funktionieren aber nach wie vor, da der letzte Schritt wieder das Zielobjekt liefert, über das man Ownership erlangen kann:

let handle = std::thread::Builder::new().name("demo".into()).spawn(|| {
    println!("Hello world!");
});

Listing 5: Der Thread Builder als Einzeiler

Prima!

Im folgenden Szenario kommt das Builder Pattern für eine Plattform zum Einsatz, in der sich unterschiedliche Python-Programmteile in sogenannten Workers ausführen lassen.

Ein Worker merkt sich den Python-Quellcode als workload, eine Konfiguration zum verfügbaren Arbeitsspeicher, und einen Hinweis, ob der Worker nach der Ausführung noch für weitere Aufgaben verfügbar sein soll.

pub struct Worker {
    workload: String,
    memsize: u128,
    keep_alive: bool,
}

Listing 6: Objekt, das durch einen Builder erzeugt werden soll

Ein WorkerBuilder konstruiert den Worker. Für keep_alive und memsize gibt es Default-Werte, den Quellcode als String hingegen gilt es erst später zu setzen. Deswegen gibt es eine Option, die zu Beginn auf None zu setzen ist. Der folgende Ausschnitt ist abgekürzt, die Konstruktionsschritte für eine andere Arbeitsspeicher-Größe und das keep_alive-Flag lassen wir vorerst aus, sie sind am Ende des Beispiels zu finden.

struct WorkerBuilder {
    workload: Option<String>,
    memsize: u128,
    keep_alive: bool,
}

impl WorkerBuilder {
    pub fn new() -> Self {
        Self {
            workload: None,
            memsize: 128 * 1024,
            keep_alive: false,
        }
    }

    pub fn workload(&mut self, workload: impl Into<String>) -> &mut Self {
        self.workload = Some(workload.into());
        self
    }

    pub fn build(&mut self) -> Worker {
        let workload = self.workload.clone();
        Worker {
            workload: workload.unwrap(),
            memsize: self.memsize,
            keep_alive: self.keep_alive,
        }
    }
}

Listing 7: Ein Builder zum Erzeugen des Workers, die erste Variante

Das Listing spiegelt die Werte im WorkerBuilder, was hauptsächlich daran liegt, dass ein Worker zwingend einen String benötigt, der WorkerBuilder das allerdings während der Konstruktion noch nicht garantieren kann.

Was bei diesem Arrangement sofort auffällt, ist, dass es im build-Schritt eine äußerst unsichere Operation mit unwrap gibt. Tatsächlich können Anwenderinnen und Anwender einen build-Schritt ausführen, ohne vorher den Sourcecode gesetzt zu haben. Ein undefinierter Zustand, der sich mit unwrap explizit ignorieren lässt. Im schlimmsten Fall bringt allerdings genau dieser Schritt die Software dann zum Absturz. Das muss doch besser gehen, werden gewiefte Entwickler sich sagen.

In der Tat gibt es eine Reihe anderer Möglichkeiten. Man könnte das Feld im Worker auch als Option darstellen. Allerdings würde das dem Wunsch widersprechen, dass ein Worker eine garantiert ausführbare Einheit sein soll. Mit einem optionalen Workload lässt sich das nicht umsetzen.

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

Typestate ist eine beindruckende Art, den Compiler mit weiteren Informationen zu versorgen, die für eine sichere Anwendung von APIs dienlich sind. Kombiniert man Typestate mit dem Builder Pattern, wird auch die Erzeugung komplexer Objekte elegant und robust.

Dabei gelingt ein interessanter Spagat: Man kombiniert das Builder Pattern mit Typestate – ein Entwurfsmuster (Builder Pattern), das beinahe 30 Jahre alt ist, mit einer Technik, die erst jüngst Einzug in Mainstream-Programmiersprachen gefunden hat (Typestate).

Zum einen ist es erstaunlich, dass die Ideen der Gang of Four auch heute noch in modernen Programmiersprachen mit objektorientierten Features valide sind – und das, obwohl die Objektorientierung in Rust durch das Nichtvorhandensein von Vererbung anders funktioniert als in C++. Das spricht für die Weitsicht der Autoren und ihr Ziel, die Muster nicht überaus clever zu gestalten, sondern eher auf Zugang und Einfachheit zu achten.

Zum anderen zeigt es aber auch, wie eine einfache Idee mit sprachabhängigen Features wie Rusts Typsystem noch weiter reifen kann. Das Resultat ist kein dogmatisches Befolgen von Richtlinien, sondern der sinnvolle und gezielte Einsatz von Entwurfsmustern.

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.

Wer noch mehr über Typestate Programming oder das API-Design in Rust wissen möchte, sollte sich Will Crichtons Vortrag von der letztjährigen Strangeloop auf YouTube ansehen.

Ferris Talk – Neuigkeiten zu Rust. Kolumnist:
Stefan Baumgartner, Dynatrace.at, Rust Meetup Linz, Autor der Ferris Talks, der Kolumne über die Programmiersprache Rust bei Heise Developer

Stefan Baumgartner

lebt und arbeitet als Software-Architekt und Entwickler bei Dynatrace im österreichischen Linz mit Schwerpunkt auf Webentwicklung, Serverless und Cloud-basierte Architekturen.

Für den Smashing Magazine Verlag veröffentlichte er 2020 mit “TypeScript in 50 Lessons” sein zweites Buch, seine Onlinepräsenz fettblog.eu enthält Artikel, Tutorials und Guides zum Thema TypeScript, Rust, React, und Software Engineering im Allgemeinen.

Stefan organisiert Meetups und Konferenzen, wie Stahlstadt.js, die DevOne, ScriptConf, Rust Meetup Linz, und das legendäre Technologieplauscherl. Außerdem ist er regelmäßig Gastgeber im Working Draft, dem deutschsprachigen Podcast über Webtechnologien. Wenn noch ein wenig Freizeit bleibt, genießt er italienische Pasta, belgisches Bier und britischen Rock.

(sih)