Schnittstellen – in Rust heißen sie Traits – sind das A und O, um verständliche Programme zu schreiben. In eigenen Programmen machen selbst erstellte Traits den Code wartungsfreundlicher.
“Bevorzugen Sie Lasagne oder Spaghetti?”, fragte mich ein Dozent auf einer Programmierfortbildung. Es war kurz vor zwölf Uhr, Appetit hatte ich durchaus. Der Begriff Spaghetti-Code für strukturlose Programme, in denen alles durcheinandergeht, kannte ich. Aber Lasagne? Am Ende entschied ich mich aufgrund des Ausschlussverfahrens für Lasagne als Antwort. “Genau, richtig”, setzte der Dozent freudig fort, “Von Lasagne- oder Schichtenarchitektur spricht man, wenn ein Programm aus klar getrennten Schichten aufgebaut ist, also aus Komponenten. Die kann man einfacher austauschen.” Aha, wieder was gelernt.
Die einfachste Schichtenarchitektur für Programme besteht aus drei Schichten (englisch Layer oder Tier): Präsentationsschicht (GUI, User Interface), Domain-Schicht (Anwendungslogik, Geschäftsregeln) und Persistenzschicht (Daten, Datenbank). Laut der strengen Lehre dürfen höhere Schichten nur mit den direkt darunter liegenden kommunizieren – die Präsentationsschicht also nur mit der Domain-Schicht, aber nicht direkt mit der Persistenzschicht. Möchten Sie mehr darüber und über andere Programmarchitekturen erfahren, sollten Sie sich das Buch “Patterns of Enterprise Application Architecture” von Martin Fowler ansehen [1]. Der Kasten “Für und Wider der Schichtenarchitektur” ist davon inspiriert.
Für und Wider der Schichtenarchitektur
Für die Schichtenarchitektur spricht, dass Sie nicht alle Layers oder den gesamten Code kennen müssen. Einzelne Schichten beziehungsweise Komponenten lassen sich einfacher verstehen. Ein Layer lässt sich einfach ersetzen, da die Abhängigkeiten zu anderen Schichten klar definiert sind. Andere Programme können Schichten wiederverwenden.
Dem stehen jedoch einige Contra-Punkte gegenüber. So lassen sich Schichten nicht immer konsequent trennen. Änderungen in einer Schicht können durchaus Modifikationen in einer anderen erzwingen. Zudem wirken sich zu viele Schichten in einem Programm nachteilig auf die Performance aus.
Sehen wir uns im nächsten Schritt an, wie diese Schichtenarchitektur bei dem Siebzehn-und-Vier-Programm aussähe, das uns in den letzten Episoden von Planet Rust als Beispiel gedient hat. Die Präsentationsschicht würde sich um die Ein- und Ausgabe auf Kommandozeilenebene kümmern. Die Domain-Schicht wäre zuständig für die Anwendungslogik, also für den gesamten Spielablauf. Das umfasst unter anderem das Bewerten, Mischen und Ziehen von Karten. Die Persistenzschicht konzentriert sich auf die Daten – das wären im Beispiel die Karten. Den Kartenstapel könnten wir als Array oder Liste abbilden.
Die Herausforderung liegt darin, das Siebzehn-und-Vier-Beispielprogramm auf eine einfache Schichtenarchitektur umzustellen. Das Ergebnis finden Sie am Ende der Episode. Egal, für welche Architektur Sie sich bei einem Programm entscheiden: Definierte Schnittstellen (Traits) sind immer eine gute Idee.
Fortsetzung folgt
In der letzten Episode ging es darum, wie Sie in Rust Datentypen mit Structs (Strukturen) und Enums (Aufzählungen) definieren. Als Beispiel diente das Kartenspiel Siebzehn und Vier.
Versuchen Sie, mit den Definitionen aus Listing 1 das Programm per »println!(“card {}”, c);« eine Karte auf dem Bildschirm ausgeben zu lassen (Abbildung 1), beschwert sich der Compiler. Unter anderem weist er darauf hin, dass die Struktur »Card« eine Schnittstelle (Trait) mit dem Namen »Display« braucht, damit der Befehl »println!« in Aktion treten kann:
Card doesn't implement 'std::fmt::Display'
Listing 1
Karten
pub struct Card {
rank: Rank, //Wert
suit: Suit //Farbe
}
enum Suit {
Diamonds, Clubs, Hearts, Spades
}
enum Rank {
King, Queen, Jack, Ten, Nine, Eight, Seven, Six, Five, Four, Three, Two, Ace
}
Eine Schnittstelle in Rust besteht grundsätzlich aus einer oder mehreren Funktionen, die Sie konkret für Ihre Struktur implementieren müssen. Beim Trait »Display« ist es die Funktion »fmt()« (Listing 2). Eine Schnittstelle zu implementieren, funktioniert im Prinzip ähnlich wie eine Methode für eine Struktur zu erstellen.
Listing 2
Schnittstelle
impl Display for Card {
fn fmt(&self, f: &mut Formatter) -> std::fmt::Result {
todo!()
}
}
Sie starten mit dem Befehl »impl«, gefolgt vom Namen des Traits (»Display«). Daran schließt sich an, wofür der Trait gilt – hier die Struktur »Card«. Innerhalb der geschweiften Klammern steht die Definition der Funktion »fmt()« mitsamt der Parameter und Rückgabewerte. Beim Parameter »f« handelt es sich um den Ausgabekanal, in den Sie die gewünschte Ausgabe schreiben. Der Rückgabewert ist vom Rust-Standardtyp »Result« – dazu gleich mehr.
Hinter dem Befehl »todo!« steckt ein Platzhalter für die Programmzeilen, die Sie noch konkret ausformulieren müssen. Praktischerweise generiert der Compiler das Programm erst einmal, ohne sich aufzuregen. Gelangt es an die Stelle mit dem Platzhalter, bricht es mit einer entsprechenden Meldung ab:
thread 'main' panicked at 'not yet implemented', src/main.rs:67:9
Auf diese Weise können Sie sich darauf verlassen, dass Sie keine Lücke im Programm übersehen. An die Stelle von »todo!« tritt im fertigen Programm der Trait »Display« mit dem Befehl »write!«. Er nutzt den Ausgabekanal »f«, der als Parameter in die Funktion »fmt« einfließt, und danach einen Formatierungs-String, wie ihn der Befehl »println!« verwendet.
write!(f, "[ {} {} ]", self.rank.to_string(), self.suit.to_string())
Am Schluss folgen die auszugebenden Werte, hier je eine Zeichenkette für Kartenwert und Farbe. Damit ist die Schnittstelle komplett.
Resultate!
Der Rust-Datentyp »Result«, wie ihn im Beispiel die Funktion »fmt« zurückliefert, ist eine Aufzählung (»enum«):
enum Result<T, E> {
Ok(T)
Err(E)
}
Er dient dazu, im positiven Fall einen Wert zurückzugeben (»Ok«) oder, wenn irgendetwas schiefgeht, einen Fehlerwert (»Err«). Fehler können bei Ein- und Ausgabeoperationen immer auftreten, etwa wenn eine zu lesende Datei nicht existiert.
Eine Besonderheit von Rust: Ein Enum besteht nicht nur aus konstanten Werten, sondern jeder Wert kann zusätzliche Informationen haben. Die Variante »Ok« hat einen Wert vom Datentyp »T«. Wäre der Wert bei »Ok« ein String, würde die folgende Definition für »Result« genügen:
enum Result{ Ok(String) ... }
Würde der Wert bei »Ok« einen anderen Datentyp als »String« liefern, bräuchten wir eine andere Definition für »Result«. Um das zu verallgemeinern, geben wir in spitzen Klammern (»<T,E>«) an, dass hier jeder Datentyp (»T«) möglich ist. Genauso verhält es sich im Fehlerfall »Err«, wo ebenfalls jeder Datentyp (»E«) funktioniert.
Es stellt sich die Frage, wie eine Funktion auf einen Rückgabewert des Typs »Result« reagieren könnte. Passt alles, liefert »match« die Zeichenkette zurück. Im Fehlerfall ruft das Programm den Befehl »panic!« auf, der eine Meldung ausgibt und das Programm sofort abbricht (Listing 3).
Listing 3
Fehlerbehandlung
match function_with_result() {
Ok(zeichenkette) => zeichenkette,
Err(error) => panic!(?Fehler: {:?}?,error)
} ;
Dahinter verbirgt sich die grundsätzliche Vorgehensweise bei Fehlern in Rust. Einen weiteren Fehlermechanismus wie in anderen Programmiersprachen (zum Beispiel »raise«/»catch«) gibt es in Rust nicht.
Kartenstapel
Einen Stapel von Karten würde man in vielen Programmiersprachen als Array abbilden. In Rust kann man bei einem solchen jedoch die Anzahl der Elemente nicht ändern. Das ist bei einem Kartenstapel ungünstig, da hier immer wieder Karten weggenommen werden oder hinzukommen. Besser eignet sich für unsere Anwendung der Datentyp »Vec« (Vektor), der dem entspricht, was man in anderen Programmiersprachen als Liste bezeichnet.
Listing 4
Vektor
let v: Vec<i32> = Vec::new(); let v = vec![1, 2, 3]; [...] let mut cards:Vec<Card> = Vec::new(); cards.push(Card::new(Rank::Ace, Suit::Hearts)); let c = cards[0]; let c = cards.pop().unwrap();
Bei »Vec« kann die Anzahl der Elemente variieren, allerdings müssen alle vom selben Datentyp sein. Die Variable »v« ist eine Liste von 32-Bit-Ganzzahlen (»i32«). Den Datentyp der Werte im Vektor geben Sie in spitzen Klammern an (Listing 4, erste Zeile). Die Funktion »Vec:new()« erzeugt einen neuen, leeren Vektor. Alternativ erzeugen Sie mit dem Befehl »vec!« (klein geschrieben) einen Vektor, den Sie gleich mit Werten füllen (zweite Zeile). Den Datentyp für die Elemente des Vektors leitet Rust dann automatisch aus den Werten ab (Zeile 4).
In unserem Beispiel enthält die Variable »cards« ursprünglich eine leere Liste. Mit der Methode »push« hängen Sie ein neues Element ans Ende der Liste (Zeile 5). Zugriff auf die Elemente des Vektors bekommen Sie über den Index, den Sie in eckigen Klammern schreiben. Wie in der Informatik üblich, beginnt Rust bei der Aufzählung der Elemente mit 0 (Zeile 6). Das erste Element erhalten Sie also mit dem Index 0, das zweite mit dem Index 1, und so weiter.
Für den Datentyp »Vec« gibt es zahlreiche Methoden. Beispielsweise bestimmt die Methode »len« die Anzahl der Elemente, die Methode »remove« löscht ein Element. Beim Kartenstapel erweist sich die Methode »pop« als praktisch (Zeile 7). Sie liefert das letzte Element der Liste zurück – in unserem Fall die oberste Karte im Stapel – und löscht es aus der Liste. Das entspricht dem Ziehen einer Karte vom Stapel. Allerdings liefert »pop« nicht direkt ein Element des Typs »Card« zurück, sondern ein Element des Rust-Standarddatentyps »Option«:
enum Option<T> {
None,
Some(T) }
Beim Enum »Option« gibt es zum einen die Variante »None«, die für den Leerwert steht, und zum anderen die Variante »Some«, die irgendetwas zurückgibt. In unserem Beispiel steht der Typ »T« in »Option« für den Datentyp »Card«. Die Methode »unwrap« nach »pop« holt aus »Some(card)« die Karte heraus. Gibt es im Stapel keine Karte mehr, ist der Wert »None«, und das Programm bricht ab.
Die Auflistung »Option« entspricht der Art, wie Rust mit dem Leerwert umgeht, der in anderen Programmiersprachen None oder Nil heißt. Im Zusammenhang mit Leerwerten können viele Programmfehler entstehen, was Rust dementsprechend vermeidet. Aus Perspektive der Schichtenarchitektur haben wir an dieser Stelle für unser Kartenbeispiel den untersten Layer fertiggestellt, die Persistenzschicht.
Anwendungslogik
Die Schicht der Anwendungslogik besteht in unserem Beispiel aus dem Spielablauf, also der Funktion »main()«, dem Erzeugen und Mischen des Kartenstapels, der Funktion »create_cards()« sowie der Bewertung eines Stapels (»sum()«). Aber wie lautet aktuell die Punktezahl? Liegt sie schon über 21? Die Funktion »sum()« bekommt als Parameter den Kartenstapel des Spielers respektive der Bank und ermittelt in einer For-Schleife den Wert jeder einzelnen Karte. Die Summe daraus liefert sie als Rückgabewert:
fn sum(cards: &Vec<Card>)->i32;
Als Parameter dient hier nicht den Kartenstapel selbst, sondern ein Zeiger, also einen Verweis darauf. Das hängt mit der speziellen Speicherverwaltung von Rust zusammen – mehr dazu erfahren Sie in einer späteren Episode von Planet Rust. Möchten Sie alle Elemente eines Vektors durchlaufen, klappt das am einfachsten mit einer For-Schleife (»for c in cards{ … }«).
Die gesamte Funktion »sum()« sowie die komplette Anwendungsschicht finden Sie im Beispielprogramm »17und4.rs« im Download-Bereich zu diesem Artikel.
Präsentationsschicht
In unserem Programm läuft die Ein- und Ausgabe über ein einfaches Kommandozeilen-Interface. Das wollen wir irgendwann durch eine grafische Oberfläche ersetzen (Abbildung 2). Um den Austausch zu vereinfachen, bietet sich dafür die Definition einer eigenen Schnittstelle an. Sie können diesen Trait in unterschiedlichen Varianten implementieren, ohne etwas an der Programmlogik ändern zu müssen.

Abbildung 2: Eine einfache grafische Oberfläche für Siebzehn und Vier setzen Sie mithilfe eines eigenen Traits um.
Die Schnittstelle für die Präsentationsschicht besteht aus drei Funktionen (Listing 5, Zeilen 1 bis 5). Die Schnittstellendefinition beginnt immer mit dem Befehl »trait«. Davor steht hier »pub«, damit das Programm die Schnittstelle überall verwendet. Hinter »trait« schließt sich der Name der Schnittstelle an (»PresentationLayer«).
Am Ende finden sich in geschweiften Klammern die Funktionen, aus denen »PresentationLayer« besteht. Dabei bildet »show()« die ausgeteilten Karten auf dem Bildschirm ab. Die Funktion »another_card()« fragt ab, ob der Spieler eine weitere Karte wünscht. Schließlich gibt »show_winner()« den Gewinner respektive die Gewinnerin aus.
Listing 5
Präsentationsschicht
pub trait PresentationLayer{
fn show(&self, cards_player: &Vec<Card>, sum_player:i32, cards_bank: &Vec<Card>, sum_bank:i32)->();
fn another_card(&self)->bool;
fn show_winner(&self,player_win:bool)->();
}
[...]
impl PresentationLayer for PresentationLayerCli { ... }
Eine selbst definierte Schnittstelle lässt sich genauso implementieren, wie eine bereits in der Rust-Standard-Bibliothek vorhandene. Die Struktur »PresentationLayerCli« (letzte Zeile) implementiert die Schnittstelle für die Kommandozeile.
No Rust 2.0!
No Rust 2.0 – so lautet das Versprechen des Rust-Compilers. Das bedeutet, dass Programme, die Sie heute korrekt kompilieren können, sich auch in Zukunft problemlos kompilieren lassen. Rust hat sich als Ziel auf die Fahne geschrieben, sehr verlässlich zu sein, und verspricht daher eine konsequente Rückwärtskompatibilität. Eine Rust-Version 2.0, die völlig anders arbeitet als die aktuelle, soll es nicht geben.
Ausblick
In der nächsten Folge von Planet Rust werfen wir einen genaueren Blick auf grafische Oberflächen und sehen uns an, wie sich der Trait »PresentationLayer« mit grafischen Kartenbildern umsetzen lässt.
In der eingangs erwähnten Pasta-Theorie der Programmierung gibt es neben Spaghetti und Lasagne übrigens noch weitere Formen. In der Objektorientierung stoßen Sie beispielsweise auf Ravioli-Code: Er beschreibt strukturierte Klassensysteme, deren einzelne Komponenten sich gut verstehen lassen (die Ravioli-Füllung), bei denen aber die Beziehung der Teile untereinander (der ganze Teller) sehr komplex und unverständlich ausfällt. (csi/jlu)
Infos
- “Patterns of Enterprise Application Architecture”: https://www.martinfowler.com/books/eaa.html







