Zum Inhalt springen

Entwurfsmuster in Rust

Ein Entwurfsmuster (Design Pattern) ist ein bewährter Weg, ein Problem zu lösen, das immer wieder auftaucht. Muster sind keine Bibliotheken, die du installierst. Sie sind eher Rezepte: „Wenn du dieses Problem hast, bau deinen Code so auf.“

Viele Muster aus Java oder C# sehen in Rust anders aus oder werden gar nicht gebraucht. Rust hat seine eigenen Lieblinge. Sie bauen auf dem starken Typsystem auf, auf Enums, Traits und Ownership (Eigentum). Hinter den meisten steckt dieselbe große Idee: Lass den Compiler die Fehler für dich finden, damit sie nie bei deinen Nutzern ankommen. Jeder Abschnitt unten beginnt mit dem Problem und zeigt dann das Muster, das es löst.

Das Problem. Ein einfacher f64 kann alles bedeuten: Meter, Fuß, Sekunden, Euro. Wenn eine Funktion einen f64 in Metern erwartet und du Fuß übergibst, kann der Compiler nicht helfen – beides ist einfach f64. (Genau so eine Verwechslung von Einheiten hat die NASA 1999 bekanntlich den Mars Climate Orbiter gekostet.)

Das Muster. Verpack den Wert in ein winziges Struct mit einem einzigen Feld. Jetzt sind Meters und Feet verschiedene Typen, und Rust lässt dich sie nicht verwechseln:

#[derive(Debug, Clone, Copy)]
struct Meters(f64);
#[derive(Debug, Clone, Copy)]
struct Feet(f64);
impl Feet {
fn to_meters(self) -> Meters {
Meters(self.0 * 0.3048)
}
}
fn safe_altitude(height: Meters) -> bool {
height.0 >= 300.0
}
fn main() {
let tower = Meters(330.0);
let plane = Feet(900.0);
println!("Turm sicher? {}", safe_altitude(tower));
println!("900 Fuß sind {:.1} Meter", plane.to_meters().0);
println!("Flugzeug sicher? {}", safe_altitude(plane.to_meters()));
}

Ausgabe:

Turm sicher? true
900 Fuß sind 274.3 Meter
Flugzeug sicher? false
▶ Selbst ausprobieren

Wenn du die Umrechnung vergisst und safe_altitude(plane) schreibst, kompiliert das Programm gar nicht erst:

error[E0308]: mismatched types
--> src/main.rs:13:34
|
13 | println!("{}", safe_altitude(plane));
| ------------- ^^^^^ expected `Meters`, found `Feet`
| |
| arguments to this function are incorrect

Der Compiler meldet „mismatched types“ (Typen passen nicht zusammen): Er hat Meters erwartet, aber Feet bekommen. Ein Newtype kostet zur Laufzeit nichts: Im Speicher ist Meters genau ein f64. Nutze Newtypes für IDs (UserId(u32) gegenüber OrderId(u32)), Einheiten und geprüfte Strings. Außerdem kommst du damit um die Orphan Rule herum (siehe Fortgeschrittene Traits).

Das Problem. Manche Structs haben viele Felder, und die meisten sind optional. Ein Konstruktor wie Email::new("[email protected]", "Hi", "", vec![], false) ist schwer zu lesen – welches false ist was? Und was, wenn die Adresse fehlt oder falsch ist?

Das Muster. Ein eigenes Builder-Struct sammelt die Einstellungen über kleine Methoden mit klaren Namen. Jede Methode gibt den Builder zurück, sodass du die Aufrufe verketten kannst. Ein abschließendes build() prüft alles und gibt ein Result zurück:

struct Email {
to: String,
subject: String,
body: String,
urgent: bool,
}
#[derive(Default)]
struct EmailBuilder {
to: Option<String>,
subject: Option<String>,
body: String,
urgent: bool,
}
impl EmailBuilder {
fn new() -> Self {
EmailBuilder::default()
}
fn to(mut self, address: &str) -> Self {
self.to = Some(address.to_string());
self
}
fn subject(mut self, subject: &str) -> Self {
self.subject = Some(subject.to_string());
self
}
fn body(mut self, text: &str) -> Self {
self.body = text.to_string();
self
}
fn urgent(mut self) -> Self {
self.urgent = true;
self
}
fn build(self) -> Result<Email, String> {
let to = self.to.ok_or("eine E-Mail braucht einen Empfänger")?;
if !to.contains('@') {
return Err(format!("'{}' ist keine E-Mail-Adresse", to));
}
let subject = self.subject.unwrap_or_else(|| String::from("(kein Betreff)"));
Ok(Email { to, subject, body: self.body, urgent: self.urgent })
}
}
fn show(result: Result<Email, String>) {
match result {
Ok(e) => println!("Fertig: to={} subject={} body={:?} urgent={}", e.to, e.subject, e.body, e.urgent),
Err(msg) => println!("Fehler: {}", msg),
}
}
fn main() {
let email = EmailBuilder::new()
.subject("Mittagessen?")
.body("Pizza um 12?")
.urgent()
.build();
show(email);
show(EmailBuilder::new().to("[email protected]").build());
show(EmailBuilder::new().subject("Hallo").build());
show(EmailBuilder::new().to("ana.example.com").build());
}

Ausgabe:

Fertig: [email protected] subject=Mittagessen? body="Pizza um 12?" urgent=true
Fertig: [email protected] subject=(kein Betreff) body="" urgent=false
Fehler: eine E-Mail braucht einen Empfänger
Fehler: 'ana.example.com' ist keine E-Mail-Adresse
▶ Selbst ausprobieren

Jeder Setter nimmt mut self (er übernimmt also den Builder), ändert ein Feld und gibt den Builder zurück. Genau dadurch funktioniert die Kette. Aufrufer setzen nur, was ihnen wichtig ist, die Namen erklären jeden Wert, und build() ist die einzige Stelle, die die Regeln prüft. Darin macht ok_or aus dem None für den fehlenden Empfänger einen Err, und ? gibt diesen Fehler früh zurück. Builder begegnen dir in vielen Crates – zum Beispiel std::thread::Builder und std::process::Command (siehe Umgebung und Prozesse).

Das Problem. Ein Builder ist eine Menge Code. Wenn dein Struct keine Regeln prüfen muss, sondern nur sinnvolle Startwerte braucht, ist er übertrieben.

Das Muster. Implementiere Default mit guten Startwerten und nutze dann die Struct-Update-Syntax .., um nur die Felder zu ändern, die dich interessieren:

#[derive(Debug, Clone)]
struct ServerConfig {
host: String,
port: u16,
workers: u32,
verbose: bool,
}
impl Default for ServerConfig {
fn default() -> Self {
ServerConfig {
host: String::from("127.0.0.1"),
port: 8080,
workers: 4,
verbose: false,
}
}
}
fn main() {
let dev = ServerConfig { verbose: true, ..Default::default() };
let prod = ServerConfig {
host: String::from("0.0.0.0"),
workers: 16,
..ServerConfig::default()
};
// Von einer anderen Konfiguration statt vom Default ausgehen:
let staging = ServerConfig { port: 9090, ..prod.clone() };
println!("{:?}", dev);
println!("{:?}", prod);
println!("{:?}", staging);
}

Ausgabe:

ServerConfig { host: "127.0.0.1", port: 8080, workers: 4, verbose: true }
ServerConfig { host: "0.0.0.0", port: 8080, workers: 16, verbose: false }
ServerConfig { host: "0.0.0.0", port: 9090, workers: 16, verbose: false }
▶ Selbst ausprobieren

..etwas bedeutet „nimm jedes Feld, das ich nicht aufgeführt habe, aus etwas“. Warum prod.clone()? Struct-Update verschiebt (move) die übrigen Felder, und host ist ein String. Ohne das Klonen wäre prod teilweise verschoben, und du könntest es danach nicht mehr ausgeben. Eine gute Faustregel: Nimm Default + .., wenn jede Kombination von Werten in Ordnung ist, und einen Builder, wenn manche Kombinationen ungültig sind.

Typestate: ein Zustandsautomat, den der Compiler prüft

Abschnitt betitelt „Typestate: ein Zustandsautomat, den der Compiler prüft“

Das Problem. Manche Dinge müssen in einer bestimmten Reihenfolge passieren. Eine Web-Anfrage braucht eine URL, bevor du sie senden kannst. Mit einem normalen Struct würdest du das zur Laufzeit prüfen („Ist die URL gesetzt? Wenn nicht, gib einen Fehler zurück“) – und hoffen, dass jeder Aufrufer diesen Fehler behandelt.

Das Muster. Mach den Zustand zum Teil des Typs. Ein Request<NoUrl> hat einfach keine Methode send. Erst wenn du die URL setzt, wird daraus ein Request<HasUrl>, und der hat sie:

// Die zwei Zustände. Sie halten keine Daten (oder nur das, was dieser Zustand braucht).
struct NoUrl;
struct HasUrl(String);
struct Request<U> {
url: U,
headers: Vec<(String, String)>,
}
impl Request<NoUrl> {
fn new() -> Self {
Request { url: NoUrl, headers: Vec::new() }
}
// Ändert den Typ: Request<NoUrl> -> Request<HasUrl>
fn url(self, url: &str) -> Request<HasUrl> {
Request { url: HasUrl(url.to_string()), headers: self.headers }
}
}
// In jedem Zustand verfügbar
impl<U> Request<U> {
fn header(mut self, key: &str, value: &str) -> Self {
self.headers.push((key.to_string(), value.to_string()));
self
}
}
// Erst verfügbar, wenn eine URL gesetzt ist
impl Request<HasUrl> {
fn send(self) -> String {
format!("GET {} mit {} Header(n)", self.url.0, self.headers.len())
}
}
fn main() {
let response = Request::new()
.header("Accept", "text/html")
.url("https://example.com")
.header("User-Agent", "rust-tutorial")
.send();
println!("{}", response);
}

Ausgabe:

GET https://example.com mit 2 Header(n)
▶ Selbst ausprobieren

Versuch jetzt, ohne URL zu senden – Request::new().send() –, und der Compiler hält dich auf:

error[E0599]: no method named `send` found for struct `Request<NoUrl>` in the current scope
--> src/main.rs:22:35
|
22 | let response = Request::new().send();
| ^^^^ method not found in `Request<NoUrl>`
|
= note: the method was found for `Request<HasUrl>`

Die Meldung sagt: Für Request<NoUrl> gibt es keine Methode send, wohl aber für Request<HasUrl>. In einem Programm, das kompiliert, kann dieser Bug also gar nicht existieren. Es gibt keine Prüfung zur Laufzeit und keine Zusatzkosten: NoUrl belegt null Bytes. Weil url() self als Wert nimmt, ist der alte Request<NoUrl> danach verbraucht. So kann niemand aus Versehen mit dem „Vorher“-Zustand weiterarbeiten. Das Muster nutzt Generics auf clevere Art. Probier im Playground eine kleine Änderung aus, um ein Gefühl dafür zu bekommen.

RAII und Drop-Guards: Aufräumen, das du nicht vergessen kannst

Abschnitt betitelt „RAII und Drop-Guards: Aufräumen, das du nicht vergessen kannst“

Das Problem. Ressourcen müssen aufgeräumt werden: die Datei schließen, die Sperre lösen, den temporären Ordner löschen, „fertig“ ausgeben. Wenn das Aufräumen eine Funktion ist, an deren Aufruf du denken musst, wird es irgendwer vergessen – besonders bei einem frühen return oder einem Fehler.

Das Muster. Leg das Aufräumen in Drop. Rust ruft drop automatisch auf, wenn ein Wert seinen Gültigkeitsbereich (Scope) verlässt – und zwar auf jedem Weg nach draußen: normales Ende, frühes return, ?, sogar bei einer Panic. Das nennt man RAII („Resource Acquisition Is Initialization“: Eine Ressource zu bekommen und einen Wert zu besitzen ist derselbe Schritt). Einen Wert, dessen einzige Aufgabe das Aufräumen ist, nennt man Guard (Wächter):

struct Guard {
name: String,
}
impl Guard {
fn new(name: &str) -> Guard {
println!("Start: {}", name);
Guard { name: name.to_string() }
}
}
impl Drop for Guard {
fn drop(&mut self) {
println!("Aufräumen: {}", self.name);
}
}
fn risky(fail: bool) -> Result<(), String> {
let _guard = Guard::new("riskante Aufgabe");
if fail {
return Err(String::from("etwas ist schiefgelaufen")); // der Guard räumt trotzdem auf
}
println!("Aufgabe erledigt");
Ok(())
}
fn main() {
{
let _block = Guard::new("Block");
println!("im Block");
} // hier wird _block freigegeben
println!("{:?}", risky(false));
println!("{:?}", risky(true));
}

Ausgabe:

Start: Block
im Block
Aufräumen: Block
Start: riskante Aufgabe
Aufgabe erledigt
Aufräumen: riskante Aufgabe
Ok(())
Start: riskante Aufgabe
Aufräumen: riskante Aufgabe
Err("etwas ist schiefgelaufen")
▶ Selbst ausprobieren

Du benutzt dieses Muster längst ständig. Eine File schließt sich selbst, wenn sie freigegeben wird. Ein MutexGuard (aus mutex.lock(), siehe Nebenläufigkeit) entsperrt den Mutex, wenn er freigegeben wird. Eine Box gibt ihren Speicher frei, wenn sie freigegeben wird (Smart Pointer). Deshalb siehst du in Rust-Code fast nie Aufrufe wie „close“ oder „free“.

Das Problem. Deine Funktion nimmt einen String. Wer einen &str hat, muss also jedes Mal String::from("Ana") oder "Ana".to_string() schreiben. Oder deine Funktion nimmt eine UserId, und die Aufrufer haben nur einen u32. Eine Kleinigkeit, aber sie macht den Code überall unruhiger.

Das Muster. Implementiere From für deine Typen und nimm in deinen Funktionen impl Into<T> an. Dann dürfen Aufrufer alles übergeben, was sich umwandeln lässt:

#[derive(Debug)]
struct UserId(u32);
impl From<u32> for UserId {
fn from(n: u32) -> Self {
UserId(n)
}
}
#[derive(Debug)]
struct User {
id: UserId,
name: String,
}
impl User {
fn new(id: impl Into<UserId>, name: impl Into<String>) -> Self {
User { id: id.into(), name: name.into() }
}
}
fn main() {
let ana = User::new(1, "Ana"); // u32 und &str
let ben = User::new(UserId(2), String::from("Ben")); // schon die richtigen Typen
println!("{:?}", ana);
println!("{:?}", ben);
}

Ausgabe:

User { id: UserId(1), name: "Ana" }
User { id: UserId(2), name: "Ben" }
▶ Selbst ausprobieren

Implementiere From, nie direkt Into – Into bekommst du dann geschenkt. Derselbe Trick steckt hinter der Fehlerbehandlung: ? ruft From::from auf dem Fehler auf. Wenn du also From<io::Error> for MyError implementierst, wandelt ? die Fehler für dich um (siehe Eigene Fehlertypen). Nutze das für Umwandlungen, die nicht fehlschlagen können. Wenn eine Umwandlung fehlschlagen kann, nimm TryFrom, das ein Result zurückgibt.

Zustandsautomaten mit Enums und ungültige Zustände

Abschnitt betitelt „Zustandsautomaten mit Enums und ungültige Zustände“

Das Problem. Eine Bestellung geht von „im Warenkorb“ über „bezahlt“ zu „versendet“. Ein erster Versuch sieht oft so aus:

struct Order {
paid: bool,
shipped: bool,
tracking_number: Option<String>,
}

Das erlaubt aber Unsinn: shipped: true mit paid: false, oder eine Sendungsnummer bei einer Bestellung, die noch gar nicht versendet ist. Jede Funktion muss auf diese unmöglichen Kombinationen prüfen.

Das Muster. Mach ungültige Zustände undarstellbar. Nimm ein Enum mit einer Variante pro Zustand und gib jeder Variante nur die Daten, die dieser Zustand wirklich hat. Übergänge sind Methoden, die den alten Zustand nehmen und den neuen zurückgeben:

#[derive(Debug)]
enum Order {
Cart { items: Vec<String> },
Paid { items: Vec<String>, amount: u32 },
Shipped { tracking: String },
Cancelled,
}
impl Order {
fn pay(self, amount: u32) -> Order {
match self {
Order::Cart { items } => Order::Paid { items, amount },
other => other, // nur ein Warenkorb kann bezahlt werden; alles andere bleibt, wie es ist
}
}
fn ship(self, tracking: &str) -> Order {
match self {
Order::Paid { .. } => Order::Shipped { tracking: tracking.to_string() },
other => other,
}
}
fn cancel(self) -> Order {
match self {
Order::Shipped { .. } => self, // zu spät zum Stornieren
_ => Order::Cancelled,
}
}
fn status(&self) -> String {
match self {
Order::Cart { items } => format!("Warenkorb mit {} Artikel(n)", items.len()),
Order::Paid { items, amount } => format!("{} bezahlt für {} Artikel", amount, items.len()),
Order::Shipped { tracking } => format!("versendet, Sendungsnummer {}", tracking),
Order::Cancelled => String::from("storniert"),
}
}
}
fn main() {
let order = Order::Cart { items: vec![String::from("Buch"), String::from("Stift")] };
println!("{}", order.status());
let order = order.ship("XYZ"); // noch nicht bezahlt: nichts passiert
println!("{}", order.status());
let order = order.pay(25).ship("TRACK-42");
println!("{}", order.status());
let order = order.cancel(); // schon versendet
println!("{}", order.status());
let other = Order::Cart { items: vec![] }.cancel();
println!("{}", other.status());
}

Ausgabe:

Warenkorb mit 2 Artikel(n)
Warenkorb mit 2 Artikel(n)
versendet, Sendungsnummer TRACK-42
versendet, Sendungsnummer TRACK-42
storniert
▶ Selbst ausprobieren

Einer versendeten Bestellung kann die Sendungsnummer nicht fehlen – die Variante Shipped hat immer eine. Ein Warenkorb kann keine Sendungsnummer haben – es gibt kein Feld dafür. Und weil match jede Variante abdecken muss, zeigt dir der Compiler beim Hinzufügen eines neuen Zustands (etwa Refunded) jede Stelle, die du anpassen musst.

Enums oder Trait-Objekte? Objektorientierte Sprachen nutzen oft ein „State“-Interface mit einer Klasse pro Zustand. In Rust ginge das mit Trait-Objekten (Box<dyn State>), und das Rust-Buch zeigt, wie. So entscheidest du dich:

  • Enum – du kennst alle Zustände von vornherein. Du bekommst vollständige match-Prüfungen, und die ganze Logik steht an einer Stelle. In Rust ist das die übliche Wahl.
  • Trait-Objekte – andere Leute (oder andere Crates) müssen neue Zustände hinzufügen können, ohne deinen Code zu ändern.
  • Typestate (von weiter oben) – falsche Aufrufreihenfolgen sollen Compilerfehler sein und nicht nur zur Laufzeit ignoriert werden. Das ist strenger, aber der Zustand muss zur Kompilierzeit bekannt sein.

let _ = guard statt let _guard = guard schreiben. Das sieht fast gleich aus, verhält sich aber anders. Ein Name, der mit einem Unterstrich beginnt (wie _guard), hält den Wert bis zum Ende des Scopes am Leben. Ein einfaches _ ist dagegen gar keine Variable, also wird der Wert sofort freigegeben:

struct Guard;
impl Drop for Guard {
fn drop(&mut self) {
println!("Guard freigegeben");
}
}
fn main() {
let _ = Guard;
println!("Arbeit mit `let _`");
let _g = Guard;
println!("Arbeit mit `let _g`");
}

Ausgabe:

Guard freigegeben
Arbeit mit `let _`
Arbeit mit `let _g`
Guard freigegeben
▶ Selbst ausprobieren

Mit let _ lief das Aufräumen vor der Arbeit – bei einer Sperre oder einem Timer ist das genau falsch. Gib der Variable bei Guards also immer einen Namen.

Ein Muster nur benutzen, weil es existiert. Ein Builder für ein Struct mit zwei Feldern oder ein Typestate für etwas, das nie falsch benutzt wird, bringt mehr Code, aber keine zusätzliche Sicherheit. Fang einfach an. Greif zu einem Muster, wenn du das Problem spürst, das es löst.

  • Newtype (struct Meters(f64)) gibt denselben Daten verschiedene Typen. So findet der Compiler Verwechslungen – ohne Kosten zur Laufzeit.
  • Builder sorgt für lesbares, verkettetes Einrichten und ein einziges build(), das prüft und ein Result zurückgibt. Default + .. ist die leichtere Wahl, wenn nichts geprüft werden muss.
  • Typestate packt den Zustand in den Typ (Request<NoUrl> gegenüber Request<HasUrl>). Aufrufe in falscher Reihenfolge kompilieren dann nicht.
  • RAII / Drop-Guards räumen auf jedem Weg aus einem Scope automatisch auf.
  • From/Into lassen Funktionen mit impl Into<T> viele Eingabetypen annehmen.
  • Enums bilden Zustandsautomaten ab und machen ungültige Zustände undarstellbar. Nimm Trait-Objekte nur, wenn andere Zustände hinzufügen müssen.

Kurzer Check

1. Was bringt dir der Newtype struct Meters(f64) gegenüber einem einfachen f64?

2. Was gibt build() im Builder-Muster auf dieser Seite zurück?

3. Warum kannst du im Typestate-Beispiel send() nicht auf Request::new() aufrufen?

4. Wann läuft das Aufräumen eines Drop-Guards?

5. Was macht let _ = Guard;?

Punkte: 0 / 5