Einführung
Willkommen zu Defining an Enum. Dieser Lab ist ein Teil des Rust Buchs. Du kannst deine Rust-Fähigkeiten in LabEx üben.
In diesem Lab werden wir eine Enumeration namens IpAddrKind definieren, um die möglichen Arten von IP-Adressen darzustellen, einschließlich Version vier (V4) und Version sechs (V6).
Definieren einer Enumeration
Während Structs dir eine Möglichkeit geben, zusammengehörige Felder und Daten zu gruppieren, wie ein Rectangle mit seiner width und height, geben Enums dir eine Möglichkeit, auszudrücken, dass ein Wert einer möglichen Menge von Werten angehört. Beispielsweise möchten wir sagen, dass Rectangle eine der möglichen Formen ist, die auch Circle und Triangle umfasst. Um dies zu tun, erlaubt Rust uns, diese Möglichkeiten als Enum zu kodieren.
Schauen wir uns eine Situation an, die wir in Code ausdrücken möchten, und sehen, warum Enums in diesem Fall nützlich und passender als Structs sind. Nehmen wir an, dass wir mit IP-Adressen arbeiten müssen. Derzeit werden zwei Hauptstandards für IP-Adressen verwendet: Version vier und Version sechs. Da diese die einzigen Möglichkeiten für eine IP-Adresse sind, die unser Programm begegnen wird, können wir alle möglichen Varianten enumerieren, was der Enumeration ihren Namen gibt.
Jede IP-Adresse kann entweder eine Version-vier- oder eine Version-sechs-Adresse sein, aber nicht gleichzeitig beides. Diese Eigenschaft von IP-Adressen macht die Enum-Datenstruktur geeignet, da ein Enum-Wert nur eine seiner Varianten sein kann. Sowohl Version-vier- als auch Version-sechs-Adressen sind immer noch im Grunde IP-Adressen, sodass sie als derselbe Typ behandelt werden sollten, wenn der Code Situationen behandelt, die für jede Art von IP-Adresse gelten.
Wir können diesen Begriff im Code ausdrücken, indem wir eine IpAddrKind-Enumeration definieren und die möglichen Arten auflisten, die eine IP-Adresse haben kann, V4 und V6. Dies sind die Varianten der Enum:
enum IpAddrKind {
V4,
V6,
}
IpAddrKind ist jetzt ein benutzerdefinierter Datentyp, den wir in anderen Teilen unseres Codes verwenden können.
Enum-Werte
Wir können Instanzen von den beiden Varianten von IpAddrKind wie folgt erstellen:
let four = IpAddrKind::V4;
let six = IpAddrKind::V6;
Beachte, dass die Varianten der Enum unter ihrem Bezeichner benannt sind, und wir verwenden einen Doppelpunkt, um die beiden zu trennen. Dies ist nützlich, da jetzt beide Werte IpAddrKind::V4 und IpAddrKind::V6 vom gleichen Typ sind: IpAddrKind. Wir können dann beispielsweise eine Funktion definieren, die einen beliebigen IpAddrKind annimmt:
fn route(ip_kind: IpAddrKind) {}
Und wir können diese Funktion mit jeder Variante aufrufen:
route(IpAddrKind::V4);
route(IpAddrKind::V6);
Das Verwenden von Enums hat noch weitere Vorteile. Wenn wir uns mehr über unseren IP-Adressentyp Gedanken machen, haben wir momentan keine Möglichkeit, die tatsächlichen IP-Adress-Daten zu speichern; wir wissen nur, von welcher Art es handelt. Da Sie gerade in Kapitel 5 über Structs gelernt haben, könnten Sie versucht sein, dieses Problem mit Structs wie in Listing 6-1 anzugehen.
1 enum IpAddrKind {
V4,
V6,
}
2 struct IpAddr {
3 kind: IpAddrKind,
4 address: String,
}
5 let home = IpAddr {
kind: IpAddrKind::V4,
address: String::from("127.0.0.1"),
};
6 let loopback = IpAddr {
kind: IpAddrKind::V6,
address: String::from("::1"),
};
Listing 6-1: Speichern der Daten und der IpAddrKind-Variante einer IP-Adresse mit einem struct
Hier haben wir einen Struct IpAddr [2] definiert, der zwei Felder hat: ein kind-Feld [3], das vom Typ IpAddrKind ist (die Enum, die wir zuvor definiert haben [1]), und ein address-Feld [4] vom Typ String. Wir haben zwei Instanzen dieses Structs. Die erste ist home [5], und sie hat den Wert IpAddrKind::V4 als kind mit zugehörigen Adressdaten von 127.0.0.1. Die zweite Instanz ist loopback [6]. Sie hat die andere Variante von IpAddrKind als kind-Wert, V6, und hat die Adresse ::1 damit assoziiert. Wir haben einen Struct verwendet, um die kind- und address-Werte zusammenzupacken, sodass jetzt die Variante mit dem Wert assoziiert ist.
Allerdings ist die Darstellung desselben Konzepts mit nur einer Enum kürzer: anstatt eine Enum innerhalb eines Structs zu verwenden, können wir die Daten direkt in jede Enum-Variante einfügen. Diese neue Definition der IpAddr-Enum sagt, dass sowohl die V4- als auch die V6-Varianten String-Werte haben werden:
enum IpAddr {
V4(String),
V6(String),
}
let home = IpAddr::V4(String::from("127.0.0.1"));
let loopback = IpAddr::V6(String::from("::1"));
Wir befestigen die Daten direkt an jeder Variante der Enum, sodass kein zusätzlicher Struct erforderlich ist. Hier ist es auch einfacher, einen weiteren Detail zu sehen, wie Enums funktionieren: Der Name jeder Enum-Variante, die wir definieren, wird auch zu einer Funktion, die eine Instanz der Enum erstellt. Das heißt, IpAddr::V4() ist ein Funktionsaufruf, der einen String-Argument nimmt und eine Instanz des IpAddr-Typs zurückgibt. Wir erhalten diese Konstruktorfunktion automatisch als Ergebnis der Enum-Definition.
Es gibt einen weiteren Vorteil bei der Verwendung einer Enum statt eines Structs: jede Variante kann verschiedene Typen und Mengen an zugehörigen Daten haben. Version-vier-IP-Adressen werden immer vier numerische Komponenten haben, die Werte zwischen 0 und 255 haben werden. Wenn wir V4-Adressen als vier u8-Werte speichern möchten, aber immer noch V6-Adressen als einen String-Wert ausdrücken möchten, könnten wir das mit einem Struct nicht tun. Enums können diesen Fall leicht beherrschen:
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 Wege gezeigt, um Datentypen zu definieren, um Version-vier- und Version-sechs-IP-Adressen zu speichern. Allerdings ist es so, dass das Speichern von IP-Adressen und die Kodierung ihrer Art so üblich ist, dass die Standardbibliothek eine Definition hat, die wir verwenden können! Schauen wir uns an, wie die Standardbibliothek IpAddr definiert: Sie hat die genaue Enum und Varianten, die wir definiert und verwendet haben, aber sie integriert die Adressdaten in die Varianten in Form von zwei verschiedenen Structs, die für jede Variante unterschiedlich definiert sind:
struct Ipv4Addr {
--snip--
}
struct Ipv6Addr {
--snip--
}
enum IpAddr {
V4(Ipv4Addr),
V6(Ipv6Addr),
}
Dieser Code zeigt, dass Sie beliebige Arten von Daten in eine Enum-Variante einfügen können: Strings, numerische Typen oder Structs beispielsweise. Sie können sogar eine andere Enum einschließen! Auch sind die Standardbibliothekstypen oft nicht viel komplizierter als die, die Sie selbst ausdenken könnten.
Beachte, dass auch wenn die Standardbibliothek eine Definition für IpAddr enthält, wir immer noch unsere eigene Definition erstellen und verwenden können, ohne Konflikt zu haben, weil wir die Definition der Standardbibliothek nicht in unseren Geltungsbereich gebracht haben. Wir werden in Kapitel 7 mehr über das Einführen von Typen in den Geltungsbereich sprechen.
Schauen wir uns ein weiteres Beispiel einer Enum in Listing 6-2 an: Diese hat eine Vielzahl von Typen in ihren Varianten eingebettet.
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(i32, i32, i32),
}
Listing 6-2: Eine Message-Enum, deren Varianten jeweils unterschiedliche Mengen und Typen von Werten speichern
Diese Enum hat vier Varianten mit unterschiedlichen Typen:
Quithat überhaupt keine zugehörigen Daten.Movehat benannte Felder, wie ein Struct.Writeenthält einen einzelnenString.ChangeColorenthält dreii32-Werte.
Das Definieren einer Enum mit Varianten wie den in Listing 6-2 ist ähnlich wie das Definieren verschiedener Arten von Struct-Definitionen, nur dass die Enum das struct-Schlüsselwort nicht verwendet und alle Varianten unter dem Message-Typ zusammengefasst sind. Die folgenden Structs könnten die gleichen Daten speichern, die die vorherigen Enum-Varianten speichern:
struct QuitMessage; // unit struct
struct MoveMessage {
x: i32,
y: i32,
}
struct WriteMessage(String); // tuple struct
struct ChangeColorMessage(i32, i32, i32); // tuple struct
Aber wenn wir die verschiedenen Structs verwenden würden, von denen jeder seinen eigenen Typ hat, könnten wir nicht so leicht eine Funktion definieren, um eine beliebige dieser Arten von Nachrichten zu nehmen, wie wir es mit der in Listing 6-2 definierten Message-Enum, einem einzelnen Typ, könnten.
Es gibt noch eine weitere Ähnlichkeit zwischen Enums und Structs: Genauso wie wir in der Lage sind, Methoden auf Structs mit impl zu definieren, sind wir auch in der Lage, Methoden auf Enums zu definieren. Hier ist eine Methode namens call, die wir auf unserer Message-Enum definieren könnten:
impl Message {
fn call(&self) {
1 // method body would be defined here
}
}
2 let m = Message::Write(String::from("hello"));
m.call();
Der Methodenkörper würde self verwenden, um den Wert zu erhalten, auf dem wir die Methode aufgerufen haben. In diesem Beispiel haben wir eine Variable m [2] erstellt, die den Wert Message::Write(String::from("hello")) hat, und das ist das, was self im Körper der call-Methode [1] sein wird, wenn m.call() ausgeführt wird.
Schauen wir uns eine weitere Enum in der Standardbibliothek an, die sehr üblich und nützlich ist: Option.
Die Option-Enumeration und ihre Vorteile gegenüber Null-Werten
In diesem Abschnitt befassen wir uns mit einer Fallstudie zu Option, einer weiteren Enumeration, die von der Standardbibliothek definiert wird. Der Option-Typ kodiert den sehr häufigen Fall, in dem ein Wert vorhanden sein kann oder nicht.
Beispielsweise erhalten Sie einen Wert, wenn Sie das erste Element in einer Liste mit mehreren Elementen anfordern. Wenn Sie das erste Element in einer leeren Liste anfordern, erhalten Sie nichts. Die Darstellung dieses Konzepts im Typensystem bedeutet, dass der Compiler überprüfen kann, ob Sie alle Fälle behandelt haben, die Sie behandeln sollten; diese Funktionalität kann Fehler verhindern, die in anderen Programmiersprachen extrem häufig auftreten.
Die Programmiersprachendesign wird oft in Bezug auf die enthaltenen Funktionen betrachtet, aber auch die ausgeschlossenen Funktionen sind wichtig. Rust hat nicht das Null-Feature, das viele andere Sprachen haben. Null ist ein Wert, der bedeutet, dass kein Wert vorhanden ist. In Sprachen mit Null können Variablen immer in einem der beiden Zustände sein: Null oder nicht-null.
Im Jahr 2009 hielt Tony Hoare, der Erfinder von Null, in seiner Präsentation "Null References: The Billion Dollar Mistake" folgende Rede:
Ich nenne es meinen Fehler im Milliardenbereich. Damals entwarf ich das erste umfassende Typsystem für Referenzen in einer objektorientierten Sprache. Mein Ziel war es, sicherzustellen, dass alle Verwendung von Referenzen absolut sicher ist, mit einer automatischen Prüfung durch den Compiler. Aber ich konnte der Versuchung nicht widerstehen, einen Null-Referenz hinzuzufügen, einfach weil sie so leicht zu implementieren war. Dies hat zu unzähligen Fehlern, Schwachstellen und Systemausfällen geführt, die wahrscheinlich in den letzten vierzig Jahren einen Schaden von Milliarden von Dollar verursacht haben. Das Problem mit Null-Werten ist, dass Sie einen Fehler von irgendwelcher Art erhalten, wenn Sie versuchen, einen Null-Wert als nicht-null-Wert zu verwenden. Da diese Null- oder nicht-null-Eigenschaft allgegenwärtig ist, ist es extrem leicht, diesen Fehler zu begehen.
Allerdings ist das Konzept, das Null ausdrücken möchte, immer noch nützlich: ein Null ist ein Wert, der derzeit aus irgendeinem Grund ungültig oder fehlt.
Das Problem liegt nicht wirklich mit dem Konzept, sondern mit der speziellen Implementierung. Daher hat Rust keine Nulls, hat aber eine Enumeration, die das Konzept eines vorhandenen oder fehlenden Werts kodieren kann. Diese Enumeration ist Option<T>, und sie wird von der Standardbibliothek wie folgt definiert:
enum Option<T> {
None,
Some(T),
}
Die Option<T>-Enumeration ist so nützlich, dass sie sogar im Präludium enthalten ist; Sie müssen sie nicht explizit in den Geltungsbereich bringen. Ihre Varianten sind auch im Präludium enthalten: Sie können Some und None direkt ohne das Option::-Präfix verwenden. Die Option<T>-Enumeration ist immer noch nur eine reguläre Enumeration, und Some(T) und None sind immer noch Varianten vom Typ Option<T>.
Die <T>-Syntax ist ein Feature von Rust, über das wir bisher noch nicht gesprochen haben. Es ist ein generischer Typparameter, und wir werden Generics im Kapitel 10 genauer behandeln. Für jetzt brauchen Sie nur zu wissen, dass <T> bedeutet, dass die Some-Variante der Option-Enumeration ein Datenstück beliebigen Typs enthalten kann, und dass jeder konkrete Typ, der anstelle von T verwendet wird, den gesamten Option<T>-Typ zu einem anderen Typ macht. Hier sind einige Beispiele für die Verwendung von Option-Werten, um Zahlentypen und Zeichenketten zu halten:
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 erfordert Rust, dass wir den gesamten Option-Typ angeben: Der Compiler kann den Typ nicht ableiten, den die entsprechende Some-Variante halten wird, indem er nur einen None-Wert betrachtet. Hier sagen wir Rust, dass wir meinen, dass absent_number vom Typ Option<i32> sein soll.
Wenn wir einen Some-Wert haben, wissen wir, dass ein Wert vorhanden ist und der Wert innerhalb der Some gehalten wird. Wenn wir einen None-Wert haben, bedeutet es in gewisser Weise dasselbe wie Null: wir haben keinen gültigen Wert. Also, warum ist es besser, Option<T> zu haben als Null?
Kurz gesagt, weil Option<T> und T (wobei T beliebigen Typs sein kann) unterschiedliche Typen sind, lässt der Compiler uns keinen Option<T>-Wert so verwenden, als wäre er definitiv ein gültiger Wert. Beispielsweise wird dieser Code nicht kompilieren, weil er versucht, einen i8 zu einem Option<i8> hinzuzufügen:
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:
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`
Intensiv! In der Tat bedeutet diese Fehlermeldung, dass Rust nicht versteht, wie ein i8 und ein Option<i8> addiert werden sollen, weil sie unterschiedliche Typen sind. Wenn wir in Rust einen Wert eines Typs wie i8 haben, wird der Compiler sicherstellen, dass wir immer einen gültigen Wert haben. Wir können mit Zuversicht fortfahren, ohne vorher auf Null zu prüfen, bevor wir diesen Wert verwenden. Erst wenn wir einen Option<i8> (oder einen beliebigen anderen Werttyp, mit dem wir arbeiten) haben, müssen wir uns um die Möglichkeit kümmern, keinen Wert zu haben, und der Compiler wird sicherstellen, dass wir diesen Fall behandeln, bevor wir den Wert verwenden.
Mit anderen Worten, Sie müssen ein Option<T> in ein T umwandeln, bevor Sie mit ihm T-Operationen ausführen können. Im Allgemeinen hilft dies, einen der häufigsten Probleme mit Null zu erkennen: das Annehmen, dass etwas nicht null ist, wenn es tatsächlich null ist.
Das Risiko, ein nicht-null-Wert falsch zu unterstellen, zu eliminieren, hilft Ihnen, sich in Ihrem Code sicherer zu fühlen. Um einen Wert zu haben, der möglicherweise null ist, müssen Sie sich explizit dazu entscheiden, indem Sie den Typ dieses Werts Option<T> machen. Dann, wenn Sie diesen Wert verwenden, müssen Sie explizit den Fall behandeln, wenn der Wert null ist. Überall, wo ein Wert einen Typ hat, der kein Option<T> ist, können Sie sicher annehmen, dass der Wert nicht null ist. Dies war eine bewusste Designentscheidung für Rust, um die Allgegenwärtigkeit von Null zu begrenzen und die Sicherheit von Rust-Code zu erhöhen.
Also, wie bekommen Sie den T-Wert aus einer Some-Variante, wenn Sie einen Wert vom Typ Option<T> haben, um diesen Wert verwenden zu können? Die Option<T>-Enumeration hat eine Vielzahl von Methoden, die in verschiedenen Situationen nützlich sind; Sie können sie in ihrer Dokumentation nachsehen. Das Vertrautmachen mit den Methoden auf Option<T> wird auf Ihrem Weg mit Rust extrem nützlich sein.
Im Allgemeinen müssen Sie Code haben, der jede Variante behandelt, um einen Option<T>-Wert zu verwenden. Sie möchten Code haben, der nur dann ausgeführt wird, wenn Sie einen Some(T)-Wert haben, und dieser Code darf den inneren T verwenden. Sie möchten anderen Code haben, der nur dann ausgeführt wird, wenn Sie einen None-Wert haben, und dieser Code hat keinen T-Wert zur Verfügung. Der match-Ausdruck ist ein Steuerflusskonstrukt, das genau dies tut, wenn er mit Enumerationen verwendet wird: er wird unterschiedlicher Code ausführen, je nachdem, welche Variante der Enumeration er hat, und dieser Code kann die Daten innerhalb des passenden Werts verwenden.
Zusammenfassung
Herzlichen Glückwunsch! Sie haben das Lab Defining an Enum abgeschlossen. Sie können in LabEx weitere Labs absolvieren, um Ihre Fähigkeiten zu verbessern.