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.
- Stefan Baumgartner
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.
Das Grundprinzip des Builder Pattern
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.
Consuming Builders und Non-consuming Builders: Zwei Varianten auf Basis von Rusts Ownership Modell
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!
Erforderliche Parameter und ein Zustandsautomat
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.