Systemnahe Sprachen sorgen für hohe Performance des Codes, sind aber oft umständlich zu schreiben. Ganz anders Rust, das versucht, Geschwindigkeit und Programmierkomfort unter einen Hut zu bringen.
Als ich ihr beharrlich von Rust vorschwärmte, erwiderte mir eine Kollegin: “Da gibt es nicht einmal Klassen?” Das stimmte irgendwie, denn das Schlüsselwort »class« findet sich in Rust tatsächlich nicht. Sie setzte prompt noch einen drauf: “Ist Rust dann überhaupt objektorientiert?”. Darüber hatte ich mir bis zu diesem Zeitpunkt keine großen Gedanken gemacht. Das Einzige, was mir dazu einfiel, war: “Was genau verstehst du unter Objektorientierung?” Sie zögerte etwas. “Na ja, Vererbung, Klassen und das Übliche halt.” Meine Neugier war geweckt. Das musste ich genauer unter die Lupe nehmen und herausfinden, wie objektorientiert Rust überhaupt ist und was das in der täglichen Arbeit bringt.
Erst einmal ging ich der Frage nach, was genau hinter Objektorientierung steckt. Wikipedia liefert dazu eine gute Zusammenfassung. Andere Seiten im Internet verstehen darunter wiederum andere Dinge – so gänzlich einig ist die Fachwelt sich hier offenbar nicht. Den wesentlichen Konsens, den ich zur Objektorientierung gefunden habe, fasst der Kasten “Objektorientierte Programmierung” zusammen. Damit haben wir eine Checkliste, die ich in einem Beispiel in Rust abarbeite.
Objektorientierte Programmierung
Zur den Kernmerkmalen objektorientierter Programmierung zählen nach Ansicht der meisten Fachleute Klassen, Methoden, Objekte, Datenkapselung, Vererbung und Polymorphie.
Bei Klassen handelt es sich um Datenstrukturen, die Entwickler zusätzlich zu den in einer Programmiersprache bereits vorhandenen Datentypen definieren können. Eine Klasse umfasst Attribute (Datenfelder, Variablen), die Informationen enthalten. Als Methoden bezeichnet man die Funktionen einer Klasse, die diese in der Regel im Zusammenhang mit ihren Attributen ausführt. Objekte (Instanzen einer Klasse) sind quasi Variablen, die die Datenstruktur einer bestimmten Klasse aufweisen.
Bei der Datenkapselung kann die Außenwelt nicht direkt auf die internen Daten einer Klasse zugreifen. Das klappt nur über definierte Schnittstellen. Vererbung bedeutet, dass eine Klasse die Attribute und Methoden einer anderen Klasse (Basisklasse) übernimmt (erbt) und selbst neue Bestandteile hinzufügen kann.
Bei Polymorphie haben mehrere Klassen Methoden gleichen Namens. So könnten die Klassen »Circle« und »Box« beide eine Methode »draw()« zum Zeichnen definieren. Beim Aufruf »mein_objekt.draw()« spielt es dann keine Rolle, ob es sich um ein Objekt der Klasse »Circle« handelt oder eines der Klasse »Box«: Der Befehl »draw()« passt immer.
Ran ans Programm
In der letzten Folge von Planet Rust diente als Beispielprogramm eine einfache Version des Kartenspiels Siebzehn und Vier. Es gab allerdings keine Datenstruktur Karten, sondern nur Zufallszahlen, die das Programm zusammenzählte.
Im Zusammenhang mit Objektorientierung bietet es sich an, die Karten und alles, was damit zusammenhängt wie Mischen oder Ziehen, in diesem Sinne umzusetzen. Eine Spielkarte besitzt zwei wesentliche Eigenschaften: den Kartenwert (Zwei, Drei, …, König, Ass), englisch »rank«, und die Farbe (Herz, Karo, Pik, Kreuz), englisch »suit«. In einer klassischen objektorientierten Programmiersprache würde eine Spielkarte so wie in Listing 1 aussehen.
Listing 1
Spielkarte
Class Card {
rank: Rank;
suit: Suit;
}
Die Klasse »Card« umfasst zwei Werte (Attribute): den Kartenwert »rank« und die Farbe »suit«. Der Wert »rank« gehört wiederum zur Klasse »Rank«, die Farbe »suit« zur Klasse »Suit«. Das klingt im ersten Moment etwas kompliziert. Dahinter verbirgt sich jedoch im Grunde genommen lediglich eine Variable, die sich aus zwei Teilen zusammensetzt.
Damit, dass es das Schlüsselwort »Class« in Rust nicht gibt, hatte meine eingangs erwähnte Kollegin vollkommen recht. Allerdings existiert der Begriff »struct« (Structure). Mit dessen Hilfe definieren Sie neue Datentypen, die aus mehreren Werten bestehen (Listing 2). Jetzt fehlen noch die Datentypen »Rank« und »Suit«.
Listing 2
struct
struct Card {
rank: Rank,
suit: Suit
}
Aufzählen mit <C>enum<C>
Die vier möglichen Farbwerte einer Karte – Herz, Karo, Pik, Kreuz – könnte man in einer »struct« (Structure) abbilden. Rust hält für solche Fälle etwas Passenderes parat: eine »enum« (Enumeration), eine Aufzählung von Werten (Listing 3).
Listing 3
Farbwerte
enum Suit {
Diamonds,
Clubs,
Hearts,
Spades
}
Auf das Kommando »enum« folgen der Name für die Aufzählung (»Suit«) und danach jeweils durch Kommas getrennt die möglichen Werte. Sie heißen im Rust-Slang Variants (Varianten). Analog kann man die Kartenwerte erfassen (Listing 4). Anschließend kann das Beispielprogramm die Spielkarten verwenden. Wenn eine Variable »c« die Spielkarte Herz-Ass enthalten soll, sieht das so aus wie in Listing 5.
Listing 4
Kartenwerte
enum Rank {
King,
Queen,
Jack,
Ten,
Nine,
Eight,
Seven,
Six,
Five,
Four,
Three,
Two,
Ace
}
Listing 5
Herz-Ass
let mut c = Card {
suit: Suit::Hearts,
rank: Rank::Ace
};
Beim Erzeugen einer neuen Struktur (»struct«) kommt zuerst deren Name »card« und danach in geschweiften Klammern die einzelnen Bestandteile. Diese als Felder (Fields) bezeichneten Werte der Aufzählung bestehen immer aus dem Namen der Aufzählung (»Suit«), »::« als Trenner und dem Namen des Mitglieds (»Hearts«). In unserem Fall entsteht so »Suit::Hearts«.
Generiert der Compiler einen neuen Wert vom Typ »struct«, achtet er darauf, dass wirklich jedes Feld einen Wert zugewiesen bekommt. Anderenfalls erhalten Sie eine Fehlermeldung. Das dient der Sicherheit, damit Sie kein Feld vergessen, das dann einen undefinierten Wert hätte. Um an ein bestimmtes Feld heranzukommen, hängen Sie an die Variable einen Punkt mit dem Namen des Felds an:
c.suit = Suit::Clubs;
In diesem Fall bekommt das Feld »suit« der Variablen »c« den neuen Wert »Suit::Clubs«. Ein Feld einer Struktur in Rust können Sie nur ändern, wenn Sie die gesamte Struktur als »mut« (mutable) definiert haben. Structs und Enums verkörpern die wesentlichen Bausteine zum Erstellen neuer Datentypen.
Methoden für <C>struct<C>
Der Versuch, die Karte »c« jetzt mit dem Befehl »println!(“card {}”, c);« auszudrucken, provoziert eine Fehlermeldung des Compilers, da nirgends festgelegt ist, wie eine Karte gedruckt aussieht. Der einfachste Weg, das Problem zu beheben, besteht darin, der Karte eine aussagekräftige Zeichenkette zuzuweisen, beispielsweise »[ A ♦ ]« für das Karo-Ass. Die kann man an den Befehl »println!« durchreichen, der weiß, wie er Zeichenketten zu drucken hat.
Für diese Aufgabe brauchen wir die neue Funktion »to_string«, die die struct »Card« entsprechend in eine Zeichenkette umwandelt. Sie können »Card« direkt mit der Struktur definieren. Dann wissen Sie, wozu die Funktion gehört – das sorgt für Übersicht. Der Aufruf einer solchen Funktion lautet:
println!("{}",c.to_string());
Erst kommt die Variable mit der Struktur »c«, dann der Punkt ».« und danach der Funktionsname »to_string« mit Klammern. Das ähnelt dem Zugriff auf Attribute (»c.rank«) einer Struktur. Die beiden Klammern machen den Unterschied. Nun aber zurück zur Definition der Funktion »to_string« für den Datentyp »Card« (Listing 6).
Listing 6
to_string
impl Card {
pub fn to_string(&self)->String {
format!("[ {} {} ]",self.rank.to_string(),self.suit.to_string())
}
}
Der Befehl »impl« startet den Abschnitt für die Funktionsdefinition. Danach folgt der Name der Struktur (»Card«), für die diese Funktion gilt. Die eigentliche Definition der Funktion »fn« sieht genauso aus wie bei jeder anderen Funktion in Rust. Vielleicht erinnern Sie sich noch an die Funktion »main« aus der letzten Folge dieser Serie. Das »pub« vor »fn« steht für public und legt fest, dass jeder andere Programmteil diese Funktion aufrufen darf. Fehlt es, können Sie darauf nur aus den Programmzeilen in der Datei zugreifen, in der die Funktionsdefinition steht. Funktionen auf Structs bezeichnet man häufig als Methoden.
Das erste Argument der Funktionsdefinition »&self« ist der Verweis auf das Element, für das Rust die Funktion ausführt. Beim Aufruf »c.to_string()« wäre dies die Variable »c«. Der Typ für den Rückgabewert folgt nach »->«. In diesem Fall handelt es sich um eine Zeichenkette:
format!( "[ {} {} ]",
self.rank.to_string(),
self.suit.to_string()
)
Das Kommando »format!« ist eng mit »println!« verwandt. Statt etwas auszudrucken, gibt es sein Ergebnis als String zurück. Da nach dieser Zeile kein Strichpunkt steht, handelt es sich bei diesem Ergebnis um den Rückgabewert der Methode »to_string()«. Das ist die vereinfachte Schreibweise von Rückgabewerten in Rust. Bei anderen Programmiersprachen müsste davor beispielsweise »return« stehen.
Mit »self.rank.to_string()« und »self.suit.to_string()« delegiert die Methode »to_string« das Umwandeln von Kartenwert und -farbe in Strings an weitere Methoden, die es noch zu definieren gilt. Doch Achtung: Die Datentypen »Rank« und »Suit« sind keine Strukturen, sondern Aufzählungen (Enums). Da stellt sich die Frage, ob dafür Methoden möglich sind. Ja, in Rust funktioniert das praktischerweise.
Methoden für <C>enum<C>
Wie Listing 7 zeigt, sind Definitionen von Methoden für Aufzählungen (»enum«) genauso aufgebaut wie die für Strukturen (»struct«). Neu kommt hier der Befehl »match« hinzu, der dem »case« in anderen Programmiersprachen ähnelt. An »match« schließt sich der Ausdruck an, hier die Kartenfarbe »Suit«. Dessen Wert bestimmt, welche Abzweigung Rust nimmt. Sie könnten das Ganze genauso mit mehreren If-Befehlen nachbauen; das wäre aber umständlicher und nicht so übersichtlich.
Listing 7
Methoden für Aufzählungen
impl Suit {
fn to_string(&self)->String{
match *self {
Suit::Diamonds => "♦".to_string(),
Suit::Clubs => "♣".to_string(),
Suit::Hearts=> "♥".to_string(),
Suit::Spades => "♠".to_string()
}
}
}
Eine mögliche Abzweigung bezeichnet Rust als Arm. Ein Arm besteht immer aus dem Wert, einem Pfeil »=>« und dem, was Rust dann ausführt. In Beispiel aus Listing 7 ist es die Rückgabe des Kartensymbols als Zeichenkette. Diese Zeichenkonstante zusammen mit der Rust-Funktion erzeugt eine Zeichenkette des Datentyps »String«, wie wir sie hier benötigen. Rust kennt unterschiedliche Datentypen für Zeichenketten. Diesem Thema widmet sich später noch eine eigene Folge von Planet Rust, deshalb belassen wir es hier dabei.
Nach dem Pfeil »=>« könnte nicht nur ein Befehl folgen, sondern in geschweiften Klammern beliebig viele. Zwei Abzweigungen (Arms) sind durch ein Komma getrennt. Jeder Arm liefert ein definiertes Ergebnis zurück. Der Rust-Compiler prüft beim Befehl »match«, ob es für jeden möglichen Wert eine Verzweigung gibt (Listing 8).
Listing 8
match
match *self {
Suit::Diamonds => "♦".to_string(),
_ => "weiß nicht".to_string()
}
Würde in diesem Fall eine Kartenfarbe fehlen, bräche der Compiler mit einer Fehlermeldung ab. Dahinter steckt eine der Sicherheitsmaßnahmen von Rust: Sie können nicht versehentlich einen möglichen Wert vergessen. Wollen Sie absichtlich für nicht explizit genannte Werte eine eigene Abzweigung nehmen, symbolisieren Sie das mit einem Unterstrich (Listing 8, dritte Zeile).
Neue Karten
Neue Karten zu erzeugen, fällt etwas umständlich aus. Je mehr Teile eine Struktur umfasst, desto komplizierter gestaltet sich das Ganze. Für das Erzeugen von neuen Elementen fehlen Rust spezielle Methoden, wie sie bei vielen anderen Programmiersprachen üblich sind. Warum also dafür nicht selbst eine Funktion definieren, wenn dies doch im Programm sinnvoll ist? In der Rust-Community hat sich die Tradition entwickelt, diese Funktion »new()« zu nennen – sie muss aber nicht so heißen:
c = Card::new(Rank::Ace, Suit::Hearts);
Die Methode »new()« ruft das Programm ausschließlich für den Datentyp »Card« auf, nicht für eine Struktur oder Aufzählung. Deshalb stehen die zwei Doppelpunkte zwischen dem Datentyp »Card« und dem Namen der Methode. Solche Funktionen auf Datentypen bezeichnet Rust als assoziierte Funktionen (Associated Functions). Wie Sie Listing 9 entnehmen können, darf »new()« so viele Parameter haben, wie der konkrete Fall es erfordert.
Listing 9
new()
impl Card {
pub fn new(rank:Rank, suit:Suit) -> Self {
Self {
rank: rank,
suit: suit
}
}
}
Der Aufbau der Definition der Funktion »new()« erinnert stark an die Funktion »to_string()«. Er startet ebenfalls mit dem Befehl »impl«, danach beginnt die eigentliche Funktion. Der entscheidende Unterschied liegt im groß geschriebenen »Self«, einem Alias für den Datentyp. Bitte verwechseln Sie das nicht mit dem klein geschriebenen »self« aus dem vorherigen Abschnitt.
Die Methode »new()« nutzt als Rückgabewert »Self«, was dem Datentyp »Card« entspricht. Man könnte hier genauso direkt »Card« schreiben; bei umfangreichen Programmen mit vielen unterschiedlichen Datentypen zeigt »Self« als Alias allerdings durchaus seine Vorteile.
»Self {….}« generiert ein neues Element des Typs »Self«, im konkreten Fall also eine »Card«. Jede Struktur oder Aufzählung kann beliebig viele »impl«-Blöcke mit beliebig vielen Funktionen umfassen.
Schnittstellen
Beim Versuch, mittels »println!(“card {}”, c);« eine Karte zu drucken, erscheint ein Compilerfehler (Abbildung 1). Zu den Vorteilen des Rust-Compilers zählt, dass er nicht nur anzeigt, wo genau Fehler auftraten, sondern zusätzlich Tipps gibt, was Sie in diesem Fall tun können. Diese Handreichungen erweisen sich in den meisten Fällen als sehr hilfreich, besonders Rust-Anfänger profitieren davon. Manchmal fühlt man sich buchstäblich vom Compiler an die Hand genommen. Aber die Philosophie von Rust besagt ja auch, dass der Compiler Fehler verhindern und den Programmierer unterstützen soll.

Abbildung 1: Der Rust-Compiler versorgt Sie bei Fehlern stets mit nützlichen Tipps zur Fehlerbehebung.
Einer der Tipps lautet: Format string … to use ‘{:?}’ – man soll also den Format-String »?« verwenden. Beim Befehl »format!« können wir mit Format-Strings Einfluss auf die Form der Ausgabe nehmen. Das Fragezeichen »?« steht für die Debug-Ausgabe, eine Form, die vor allem für Entwickler gedacht ist und nicht für die ausgelieferte Anwendung:
println!("card {:?}", c);
Der Compiler antwortet erneut mit einer anderen Fehlermeldung: The trait ‘Debug’ is not implemented for ‘Card’. Die Schnittstelle »Debug« ist demzufolge für »Card« nicht implementiert. Ein Trait markiert eine definierte Schnittstelle. Der Befehl »println!« erwartet von einem Datentyp eine bestimmte Schnittstelle – mehr oder weniger eine Funktion – in einer definierten Form. Die würde der Befehl »println!« in diesem Fall aufrufen.
Ein weiterer Hinweis des Compilers besagt: Add ‘#[derive(Debug)]’ to ‘Card’ or manually ‘impl Debug for Card’. Wir sollen entweder »#[derive(Debug)]« bei »Card« hinzufügen oder eine Schnittstelle mit »impl Debug for Card« implementieren. Wenn Sie »#[derive(Debug)]« vor die Definition des Datentyps »Card« setzen, erzeugt der Compiler selbstständig den Trait, die Schnittstelle und Debug (Listing 10).
Listing 10
#[derive(Debug)]
#[derive(Debug)]
struct Card {
rank: Rank,
suit: Suit
}
Beim nächsten Compileraufruf flattert direkt eine weitere Meldung ins Haus: The trait ‘Debug’ is not implemented for ‘Rank’, für den Datentyp »Rank« existiert keine Schnittstelle »Debug«. Wenn »println!« den Datentyp »Card« drucken will, muss es jedoch ebenfalls wissen, wie es »Rank« und »Suit« zu drucken hat. Kopieren Sie einfach vor diese beiden Datentypen »#[derive(Debug)]«, und die Ausgabe passt.
Schöner wäre es, wenn der Kartenwert mit dem Kartensymbol zu sehen ist, wie in der Methode »to_string« definiert (Abbildung 2). Einer der ersten Tipps des Compilers war: The trait ‘std::fmt::Display’ is not implemented for ‘Card’ – die Schnittstelle »Display« gibt es nicht für »Card«.
Der Trait »Display« ist also die gewöhnliche Schnittstelle, die der Befehl »println!« für die Ausgabe verwendet, sofern Sie nicht explizit »?« (Debug) angeben. Diese Schnittstelle bekommen Sie allerdings nicht vom Compiler geschenkt, Sie müssen sie selbst erstellen. Wie das funktioniert, lesen Sie in der nächsten Folge von Planet Rust.
Fazit: Test bestanden
Die Frage, die meine Kollegin eingangs zur Objektorientierung in Rust aufgeworfen hat, kann ich aufgrund der Beispiele inzwischen fundierter beantworten. Einige detaillierte Anmerkungen finden Sie in der Tabelle “Objektorientierung: 5:1 für Rust”.
|
OO-Merkmal |
vorhanden |
Anmerkung |
|---|---|---|
|
Klassen |
ja |
Datenstrukturen mit Attributen heißen in Rust nicht »class«, sondern »struct« und »enum«. |
|
Methoden |
ja |
Funktionen der Klasse. |
|
Objekte |
ja |
Variablen, die eine Datenstruktur aufweisen. |
|
Datenkapselung |
ja |
Die Außenwelt kann nicht direkt auf die internen Daten des Objekts zugreifen. Das erfordert beispielsweise das Befehlswort »pub«. |
|
Vererbung |
nein |
– |
|
Polymorphie |
bedingt |
Klassen und Methoden mit demselben Namen lassen sich in Rust mit Schnittstellen (Traits) abbilden. Echte Polymorphie-Philosophen rümpfen darüber allerdings die Nase. |
Meiner Meinung nach können Sie mit Rust in der Praxis objektorientiert arbeiten – und das ohne viel Aufwand. Zusätzlich bekommen Sie am Ende schnelle Programme. Wie das meine Kollegin beurteilen würde? Gute Frage.
In der nächsten Folge unserer Rust-Einführung geht es um Traits. Sie erfahren, wie Sie einen Kartenstapel abbilden und eine komplette Version des Siebzehn-und-Vier-Spiels mit der Datenstruktur »Card« realisieren. Wie sich die Karten grafisch darstellen lassen, klären wir danach ebenfalls in einer weiteren Folge. (csi)
Rust-Kodex: Zero Cost Abstraction
Der im Zusammenhang mit Rust häufig verwendete Begriff Zero Cost Abstraction bedeutet, dass das Einsetzen von komplexeren Programmierkonzepten sich in dieser Sprache nicht auf Sicherheit und Laufzeit auswirkt. Die Programmierkonzepte von Rust, darunter auch die in diesem Artikel gezeigten, erleichtern die Arbeit. Der Compiler setzt komplexe Anweisungen in möglichst einfachen und schnell auszuführenden Code um. Zero Cost Abstraction erspart dementsprechend Schweiß, an der Laufzeit ändert sich nichts. Allerdings hat der Compiler mehr zu tun.






