Eine Aufzählung (enum) definieren

Während Strukturen (structs) eine Möglichkeit bieten, zusammengehörige Felder und Daten zu gruppieren, wie ein Rectangle mit seiner width und height, bieten Aufzählungen (enums) eine Möglichkeit, einen Wert als einen aus einer möglichen Gruppe von Werten anzugeben. Wir können zum Beispiel sagen, dass Rectangle eine von mehreren möglichen Formen ist, zu denen auch Circle und Triangle gehören. Um dies zu tun, erlaubt Rust uns, diese Möglichkeiten als Aufzählung zu kodieren.

Schauen wir uns eine Situation an, die wir vielleicht in Code ausdrücken wollen, und sehen wir, warum Aufzählungen in diesem Fall nützlich und besser geeignet sind als Strukturen. Angenommen, wir müssen mit IP-Adressen arbeiten. Aktuell werden zwei Hauptstandards für IP-Adressen verwendet: Version vier und Version sechs. Da dies die einzigen Möglichkeiten für eine IP-Adresse sind, auf die unser Programm stößt, können wir alle möglichen Varianten aufzählen, woher die Aufzählung ihren Namen hat.

Jede IP-Adresse kann entweder eine Adresse der Version vier oder der Version sechs sein, aber nicht beides gleichzeitig. Diese Eigenschaft der IP-Adressen passt zur Aufzählungs-Datenstruktur, da ein Aufzählungswert nur eine seiner Varianten sein kann. Sowohl die Adressen der Version vier als auch der Version sechs sind grundsätzlich immer noch IP-Adressen, sodass sie als der gleiche Typ behandelt werden sollten, wenn der Code mit Situationen zu tun hat, die für beide IP-Adressenarten gelten.

Wir können dieses Konzept im Code ausdrücken, indem wir eine Aufzählung IpAddrKind definieren und die möglichen Varianten auflisten, die eine IP-Adresse haben kann, V4 und V6. Hier die Varianten der Aufzählung:

enum IpAddrKind {
    V4,
    V6,
}

fn main() {
    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;

    route(IpAddrKind::V4);
    route(IpAddrKind::V6);
}

fn route(ip_kind: IpAddrKind) {}

IpAddrKind ist jetzt ein benutzerdefinierter Datentyp, den wir an anderer Stelle in unserem Code verwenden können.

Werte in Aufzählungen

Wir können Instanzen von beiden Varianten von IpAddrKind wie folgt erstellen:

enum IpAddrKind {
    V4,
    V6,
}

fn main() {
    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;

    route(IpAddrKind::V4);
    route(IpAddrKind::V6);
}

fn route(ip_kind: IpAddrKind) {}

Beachte, dass die Varianten der Aufzählung mit dem Namensraum des Bezeichners angegeben sind und wir einen doppelten Doppelpunkt verwenden, um die beiden zu trennen. Das ist sinnvoll, weil beide Werte IpAddrKind::V4 und IpAddrKind::V6 vom gleichen Typ sind: IpAddrKind. Wir können dann zum Beispiel eine Funktion definieren, die jedes IpAddrKind annimmt:

enum IpAddrKind {
    V4,
    V6,
}

fn main() {
    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;

    route(IpAddrKind::V4);
    route(IpAddrKind::V6);
}

fn route(ip_kind: IpAddrKind) {}

Und wir können diese Funktion mit beiden Varianten aufrufen:

enum IpAddrKind {
    V4,
    V6,
}

fn main() {
    let four = IpAddrKind::V4;
    let six = IpAddrKind::V6;

    route(IpAddrKind::V4);
    route(IpAddrKind::V6);
}

fn route(ip_kind: IpAddrKind) {}

Aufzählungen haben noch weitere Vorteile. Wenn wir weiter über unseren IP-Adresstyp nachdenken, haben wir im Moment keine Möglichkeit, den Wert der tatsächlichen IP-Adresse zu speichern; wir wissen nur, um welche Variante es sich handelt. Mit dem was du gerade erst in Kapitel 5 über Strukturen gelernt hast, könntest du versucht sein, dieses Problem mit Strukturen zu lösen, wie in Codeblock 6-1.

#![allow(unused)]
fn main() {
enum IpAddrKind {
    V4,
    V6,
}

struct IpAddr {
    kind: IpAddrKind,
    address: String,
}

let home = IpAddr {
    kind: IpAddrKind::V4,
    address: String::from("127.0.0.1"),
};

let loopback = IpAddr {
    kind: IpAddrKind::V6,
    address: String::from("::1"),
};
}

Codeblock 6-1: Speichern des Wertes und der IpAddrKind-Variante einer IP-Adresse mittels struct

Hier haben wir eine Struktur IpAddr definiert, die zwei Felder hat: Ein Feld kind vom Typ IpAddrKind (die zuvor definierte Aufzählung) und ein Feld address vom Typ String. Wir haben zwei Instanzen dieser Struktur erzeugt. Die erste ist home und hat die Variante IpAddrKind::V4 und die zugehörige Adresse 127.0.0.1. Die zweite Instanz ist loopback und hat die Variante V6 von IpAddrKind als ihren Wert für kind und die zugehörige Adresse ::1. Wir haben eine Struktur verwendet, um die Werte kind und address zu bündeln, sodass jetzt die Variante mit dem Wert verbunden ist.

Allerdings ist die Darstellung desselben Konzepts mit einer Aufzählung prägnanter: Anstelle einer Aufzählung innerhalb einer Struktur können wir die Daten direkt in jede Aufzählungsvariante einfügen. Diese neue Definition der Aufzählung IpAddr legt fest, dass sowohl die Variante V4 als auch V6 zugehörige String-Werte haben:

#![allow(unused)]
fn main() {
enum IpAddr {
    V4(String),
    V6(String),
}

let home = IpAddr::V4(String::from("127.0.0.1"));

let loopback = IpAddr::V6(String::from("::1"));
}

Wir hängen die Daten direkt an jede Variante der Aufzählung an, so dass keine zusätzliche Struktur erforderlich ist. Hier ist es auch einfacher, ein weiteres Detail der Funktionsweise von Aufzählungen zu betrachten: Der Name jeder Aufzählungs-Variante, die wir definieren, wird auch zu einer Funktion, die eine Instanz der Aufzählung konstruiert. Das heißt, IpAddr::V4() ist ein Funktionsaufruf der ein String-Argument entgegennimmt und eine Instanz des Typs IpAddr zurückgibt. Diese Konstruktorfunktion wird automatisch definiert als Ergebnis der Definition der Aufzählung.

Es gibt noch einen weiteren Vorteil, eine Aufzählung statt einer Struktur zu verwenden: Jede Variante kann verschiedene Typen und verschieden viele zugehöriger Daten haben. IP-Adressen der Version vier haben stets vier numerische Komponenten, die Werte zwischen 0 und 255 haben. Wenn wir V4-Adressen als vier u8-Werte speichern und V6-Adressen als einen String-Wert ausdrücken wollten, wäre das mit einer Struktur nicht möglich. Aufzählungen lösen diesen Fall ganz einfach:

#![allow(unused)]
fn main() {
enum IpAddr {
    V4(u8, u8, u8, u8),
    V6(String),
}

let home = IpAddr::V4(127, 0, 0, 1);

let loopback = IpAddr::V6(String::from("::1"));
}

Wir haben verschiedene Möglichkeiten zur Definition von Datenstrukturen gezeigt, die Version vier und sechs einer IP-Adresse speichern können. Wie sich jedoch herausstellt, ist der Wunsch, IP-Adressen inklusive deren Variante zu speichern, so verbreitet, dass die Standardbibliothek eine Definition bereitstellt, die wir verwenden können! Schauen wir uns an, wie die Standardbibliothek IpAddr definiert: Es hat genau die Aufzählung und die Varianten, die wir definiert und verwendet haben, aber es bettet die Adressdaten innerhalb der Varianten in Form von zwei verschiedenen Strukturen ein, die für jede Variante unterschiedlich definiert sind:

#![allow(unused)]
fn main() {
struct Ipv4Addr {
    // --abschneiden--
}

struct Ipv6Addr {
    // --abschneiden--
}

enum IpAddr {
    V4(Ipv4Addr),
    V6(Ipv6Addr),
}
}

Dieser Code veranschaulicht, dass du jede Art von Daten in eine Aufzählungsvariante einfügen kannst: Zeichenketten, numerische Typen, Strukturen usw. Du kannst sogar eine weitere Aufzählung einfügen! Außerdem sind Standardbibliothekstypen oft nicht viel komplizierter als das, was du dir vielleicht ausdenkst. Beachte, dass wir, obwohl die Standardbibliothek eine Definition für IpAddr enthält, konfliktfrei unsere eigene Definition erstellen und verwenden können, da wir die Definition der Standardbibliothek nicht in unseren Gültigkeitsbereich aufgenommen haben. Wir werden in Kapitel 7 mehr darauf eingehen, wie man Typen in den Gültigkeitsbereich aufnimmt.

Schauen wir uns ein weiteres Beispiel für eine Aufzählung in Codeblock 6-2 an: In dieser Aufzählung ist eine Vielzahl von Typen in ihren Varianten eingebettet.

#![allow(unused)]
fn main() {
enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}
}

Codeblock 6-2: Eine Aufzählung Message, deren Varianten jeweils eine unterschiedliche Anzahl an Werttypen speichern

Diese Aufzählung hat vier Varianten mit unterschiedlichen Typen:

  • Quit hat überhaupt keine Daten.
  • Move hat benannte Felder wie eine Struktur.
  • Write enthält einen einzelnen String.
  • ChangeColor enthält drei i32-Werte.

Die Definition einer Aufzählung mit Varianten wie in Codeblock 6-2 ist ähnlich zur Definition verschiedener Arten von Strukturdefinitionen, außer dass die Aufzählung nicht das Schlüsselwort struct verwendet und alle Varianten unter dem Typ Message zusammengefasst sind. Die folgenden Strukturen könnten die gleichen Daten aufnehmen wie die vorhergehenden Aufzählungsvarianten:

#![allow(unused)]
fn main() {
struct QuitMessage; // leere Struktur
struct MoveMessage {
    x: i32,
    y: i32,
}
struct WriteMessage(String); // Tupelstruktur
struct ChangeColorMessage(i32, i32, i32); // Tupelstruktur
}

Aber wenn wir die verschiedenen Strukturen verwenden würden, die jeweils ein eigener Typ sind, könnten wir nicht so einfach eine Funktion definieren, die eine dieser Nachrichtenarten entgegennimmt, wie wir es mit der in Codeblock 6-2 definierten Aufzählung Message gemacht haben, bei der es sich um einen einzigen Typ handelt.

Es gibt noch eine weitere Ähnlichkeit zwischen Aufzählungen und Strukturen: So wie wir Methoden für Strukturen mit impl definieren können, können wir auch Methoden für Aufzählungen definieren. Hier ist eine Methode namens call, die wir für unsere Aufzählung Message definieren könnten:

fn main() {
    enum Message {
        Quit,
        Move { x: i32, y: i32 },
        Write(String),
        ChangeColor(i32, i32, i32),
    }

    impl Message {
        fn call(&self) {
            // Methodenrumpf
        }
    }

    let m = Message::Write(String::from("hallo"));
    m.call();
}

Der Methodenrumpf würde self benutzen, um den Wert zu erhalten, auf den wir die Methode aufgerufen haben. In diesem Beispiel haben wir eine Variable m erstellt, die den Wert Message::Write(String::from("hallo")) hat. Genau diesen Wert wird self im Rumpf der Methode call haben, wenn m.call() ausgeführt wird.

Sehen wir uns eine weitere Aufzählung in der Standardbibliothek an, die sehr verbreitet und hilfreich ist: Option

Die Aufzählung Option und ihre Vorteile gegenüber Nullwerten

Dieser Abschnitt befasst sich mit einer Fallstudie zu Option, einer weiteren Aufzählung, die von der Standardbibliothek definiert wird. Der Typ Option kodiert das sehr häufige Szenario, in dem ein Wert etwas oder nichts sein kann.

Wenn du zum Beispiel das erste Element einer nichtleeren Liste anforderst, erhältst du einen Wert. Wenn du das erste Element einer leeren Liste abfragst, erhältst du nichts. Im Sinne des Typsystems bedeutet das, dass der Compiler überprüfen kann, ob du alle Fälle behandelt hast, die du behandelt haben solltest. Diese Funktionalität kann Fehler vermeiden, die in anderen Programmiersprachen extrem häufig auftreten.

Bei der Entwicklung von Programmiersprachen wird oft überlegt, welche Funktionalität aufgenommen werden soll, aber auch die auszuschließende Funktionalität ist wichtig. Rust hat nicht die Funktionalität „null“, die es in vielen anderen Sprachen gibt. Null ist ein Wert, der bedeutet, dass kein Wert vorhanden ist. In Sprachen mit null können sich Variablen immer in einem von zwei Zuständen befinden: null oder nicht null.

In seinem Vortrag „Nullreferenzen: Der milliardenschwere Fehler“ von 2009 hat Tony Hoare, der Erfinder von null, folgendes gesagt:

Ich nenne es meinen milliardenschweren Fehler. Zu dieser Zeit entwarf ich das erste umfangreiche Typsystem für Referenzen in einer objektorientierten Sprache. Mein Ziel war es, sicherzustellen, dass jede Verwendung von Referenzen absolut sicher sein sollte, wobei die Überprüfung automatisch durch den Compiler durchgeführt wird. Aber ich konnte der Versuchung nicht widerstehen, eine Nullreferenz einzuführen, nur weil sie so einfach umzusetzen war. Dies hat zu unzähligen Fehlern, Schwachstellen und Systemabstürzen geführt, die in den letzten vierzig Jahren wahrscheinlich eine Milliarde Dollar Schmerz und Schaden verursacht haben.

Das Problem mit Nullwerten besteht darin, dass du einen Fehler erhältst, wenn du versuchst, einen Nullwert als Nicht-Nullwert zu verwenden. Da diese Null- oder Nicht-Null-Eigenschaft allgegenwärtig ist, ist es extrem einfach, einen derartigen Fehler zu machen.

Das Konzept, das die Null zum Ausdruck bringen will, ist jedoch nach wie vor nützlich: Null ist ein Wert, der aktuell ungültig ist oder aus irgendeinem Grund nicht vorhanden ist.

Das Problem liegt nicht wirklich im Konzept, sondern in der konkreten Umsetzung. Als solches hat Rust keine Nullen, aber es hat eine Aufzählung, die das Konzept des Vorhandenseins oder Nichtvorhandenseins eines Wertes abbilden kann. Diese Aufzählung heißt Option<T> und ist in der Standardbibliothek wie folgt definiert:

#![allow(unused)]
fn main() {
enum Option<T> {
    None,
    Some(T),
}
}

Die Aufzählung Option<T> ist so nützlich, dass sie sogar im Präludium enthalten ist; du musst sie nicht explizit in den Gültigkeitsbereich bringen. Ihre Varianten sind ebenfalls im Präludium enthalten: Du kannst Some und None direkt ohne Präfix Option:: verwenden. Die Aufzählung Option<T> ist dennoch nur eine normale Aufzählung, und Some(T) und None sind nur Varianten des Typs Option<T>.

Die Syntax <T> ist eine Funktionalität von Rust, über die wir noch nicht gesprochen haben. Es handelt sich um einen generischen Typparameter, auf den wir in Kapitel 10 näher eingehen werden. Für den Moment musst du nur wissen, dass <T> bedeutet, dass die Variante Some der Aufzählung Option einen Wert eines beliebigen Typs enthalten kann und dass jeder konkrete Typ, der anstelle von T verwendet wird, den Gesamttyp Option<T> zu einem anderen Typ macht. Hier sind einige Beispiele für die Verwendung von Option-Werten zur Aufnahme von Zahlentypen und Zeichenkettentypen:

#![allow(unused)]
fn main() {
let some_number = Some(5);
let some_char = Some('e');

let absent_number: Option<i32> = None;
}

Der Typ von some_number ist Option<i32>. Der Typ von some_char ist Option<char>, was ein anderer Typ ist. Rust kann diese Typen ableiten, weil wir einen Wert innerhalb der Some-Variante angegeben haben. Für absent_number verlangt Rust den gesamten Typ Option zu annotieren: Der Compiler kann den Typ, den die entsprechende Some-Variante haben wird, nicht ableiten, wenn sie nur einen None-Wert enthält. Hier sagen wir Rust, dass absent_number vom Typ Option<i32> sein soll.

Wenn wir einen Wert Some haben, wissen wir, dass ein Wert vorhanden ist und der Wert innerhalb von Some gehalten wird. Wenn wir einen Wert None haben, bedeutet das in gewisser Weise dasselbe wie Null: Wir haben keinen gültigen Wert. Warum ist nun besser Option<T> anstelle von Null zu verwenden?

Kurz gesagt, weil Option<T> und T (wobei T ein beliebiger Typ sein kann) unterschiedliche Typen sind, erlaubt es der Compiler nicht Option<T> so zu verwenden als wäre es definitiv ein gültiger Wert. Beispielsweise lässt sich dieser Code nicht kompilieren, weil er versucht, ein i8 mit einem Option<i8> zu addieren:

#![allow(unused)]
fn main() {
let x: i8 = 5;
let y: Option<i8> = Some(5);

let sum = x + y;
}

Wenn wir diesen Code ausführen, erhalten wir eine Fehlermeldung wie diese:

$ cargo run
   Compiling enums v0.1.0 (file:///projects/enums)
error[E0277]: cannot add `Option<i8>` to `i8`
 --> src/main.rs:5:17
  |
5 |     let sum = x + y;
  |                 ^ no implementation for `i8 + Option<i8>`
  |
  = help: the trait `Add<Option<i8>>` is not implemented for `i8`
  = help: the following other types implement trait `Add<Rhs>`:
            <i8 as Add>
            <i8 as Add<&i8>>
            <&'a i8 as Add<i8>>
            <&i8 as Add<&i8>>

For more information about this error, try `rustc --explain E0277`.
error: could not compile `enums` (bin "enums") due to 1 previous error

Stark! Tatsächlich bedeutet diese Fehlermeldung, dass Rust nicht versteht, wie man ein i8 und eine Option<i8> addiert, da es sich um unterschiedliche Typen handelt. Wenn wir einen Wert eines Typs wie i8 in Rust haben, stellt der Compiler sicher, dass wir immer einen gültigen Wert haben. Wir können getrost fortfahren, ohne vor der Verwendung dieses Wertes auf Null prüfen zu müssen. Nur wenn wir eine Option<i8> (oder einen anderen Werttyp) haben, müssen wir befürchten, dass wir möglicherweise keinen Wert haben, und der Compiler wird sicherstellen, dass wir diesen Fall behandeln, bevor wir den Wert verwenden.

Mit anderen Worten musst du eine Option<T> in ein T konvertieren, bevor du T-Operationen darauf durchführen kannst. Im Allgemeinen hilft dies, eines der häufigsten Probleme mit Null abzufangen: Anzunehmen, dass etwas nicht null ist, obwohl es tatsächlich null ist.

Durch Vermeiden des Risikos, fälschlicherweise einen Nicht-Null-Wert anzunehmen, gewinnst du mehr Vertrauen in deinen Code. Um einen Wert zu haben, der möglicherweise null sein kann, musst du dich explizit dafür entscheiden, indem du als Typ Option<T> verwendest. Wenn du dann diesen Wert verwendest, musst du den Fall null explizit behandeln. Überall dort, wo ein Wert nicht den Typ Option<T> hat, kannst du sicher sein, dass der Wert nicht null ist. Dies war eine bewusste Konstruktionsentscheidung bei Rust, um die Verbreitung von Null einzuschränken und die Sicherheit von Rust-Code zu erhöhen.

Wie erhältst du nun den T-Wert aus einer Variante Some, wenn du einen Wert vom Typ Option<T> hast? Die Aufzählung Option<T> enthält eine große Anzahl von Methoden, die in einer Vielzahl von Situationen nützlich sind; mehr dazu findest du in der Dokumentation. Sich mit den Methoden von Option<T> vertraut zu machen, wird dir auf deiner Reise mit Rust äußerst nützlich sein.

Um einen Option<T>-Wert zu verwenden, benötigst du im Allgemeinen Code, der jede Variante behandelt. Du möchtest einen Code, der nur läuft, wenn du einen Wert Some(T) hast, und dieser Code darf das innere T benutzen. Du möchtest, dass ein anderer Code ausgeführt wird, wenn du einen Wert None hast, und dieser Code hat keinen T-Wert. Der Ausdruck match ist ein Kontrollflusskonstrukt, das genau dies tut, wenn es mit Aufzählungen verwendet wird: Es führt unterschiedlichen Code aus, je nachdem, welche Variante der Aufzählung es hat, und dieser Code kann die Daten innerhalb des passenden Wertes verwenden.