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.