Einführung
Willkommen zu Advanced Types. Dieser Lab ist Teil des Rust Buchs. Du kannst deine Rust-Fähigkeiten in LabEx üben.
In diesem Lab werden wir neue Typen, Typaliase, den !-Typ und dynamisch angepasste Typen im Rust-Typsystem besprechen.
Erweiterte Typen
Das Rust-Typsystem hat einige Funktionen, die wir bisher erwähnt haben, aber noch nicht besprochen haben. Wir beginnen mit der allgemeinen Diskussion von neuen Typen, indem wir untersuchen, warum neue Typen als Typen nützlich sind. Anschließend werden wir zu Typaliasen übergehen, einer Funktion, die ähnlich zu neuen Typen ist, aber mit leicht unterschiedlichen Semantik. Wir werden auch den !-Typ und dynamisch angepasste Typen besprechen.
Verwendung des Newtype-Patterns für Typsicherheit und Abstraktion
Hinweis: Dieser Abschnitt setzt voraus, dass Sie den früheren Abschnitt "Verwendung des Newtype-Patterns zur Implementierung externer Traits" gelesen haben.
Das Newtype-Pattern ist auch für Aufgaben nützlich, die wir bisher noch nicht besprochen haben, darunter die statische Vergewisserung, dass Werte niemals verwechselt werden, und die Angabe der Einheiten eines Werts. Sie haben in Listing 19-15 ein Beispiel gesehen, wie Newtypes verwendet werden, um Einheiten anzugeben: erinnern Sie sich, dass die Millimeters- und Meters-Strukturen u32-Werte in einem Newtype umschlossen haben. Wenn wir eine Funktion mit einem Parameter vom Typ Millimeters schreiben, könnten wir kein Programm kompilieren, das versehentlich versucht, diese Funktion mit einem Wert vom Typ Meters oder einem einfachen u32 aufzurufen.
Wir können das Newtype-Pattern auch verwenden, um einige Implementierungsdetails eines Typs zu abstrahieren: Der neue Typ kann eine öffentliche Schnittstelle bereitstellen, die von der Schnittstelle des privaten inneren Typs unterschiedlich ist.
Newtypes können auch interne Implementierungen verbergen. Beispielsweise könnten wir einen People-Typ bereitstellen, um eine HashMap<i32, String> zu umschließen, die die ID einer Person in Verbindung mit ihrem Namen speichert. Code, der People verwendet, würde nur mit der öffentlichen Schnittstelle interagieren, die wir bereitstellen, wie z. B. eine Methode, um einen Namensstring zur People-Sammlung hinzuzufügen; dieser Code müsste nicht wissen, dass wir intern einer i32-ID zu Namen zuweisen. Das Newtype-Pattern ist eine leichtgewichtige Möglichkeit, die Kapselung zu erreichen, um Implementierungsdetails zu verstecken, über die wir in "Kapselung, die Implementierungsdetails versteckt" diskutiert haben.
Erstellen von Typsynonymen mit Typalias
Rust bietet die Möglichkeit, einen Typalias zu deklarieren, um einem bestehenden Typ einen anderen Namen zu geben. Dazu verwenden wir das Schlüsselwort type. Beispielsweise können wir den Alias Kilometers für i32 wie folgt erstellen:
type Kilometers = i32;
Jetzt ist der Alias Kilometers ein Synonym für i32; im Gegensatz zu den Millimeters- und Meters-Typen, die wir in Listing 19-15 erstellt haben, ist Kilometers kein separater, neuer Typ. Werte vom Typ Kilometers werden genauso behandelt wie Werte vom Typ i32:
type Kilometers = i32;
let x: i32 = 5;
let y: Kilometers = 5;
println!("x + y = {}", x + y);
Da Kilometers und i32 der gleiche Typ sind, können wir Werte beider Typen addieren und Kilometers-Werte an Funktionen übergeben, die i32-Parameter akzeptieren. Mit dieser Methode erhalten wir jedoch nicht die Typsicherheitsvorteile, die wir beim zuvor diskutierten Newtype-Pattern erhalten. Mit anderen Worten, wenn wir Kilometers- und i32-Werte irgendwo vermischen, gibt der Compiler uns keine Fehlermeldung.
Der Hauptanwendungsfall für Typsynonyme ist die Reduzierung von Wiederholungen. Beispielsweise könnte wir einen längeren Typ wie diesen haben:
Box<dyn Fn() + Send + 'static>
Das Schreiben dieses langen Typs in Funktionssignaturen und als Typanmerkungen überall im Code kann mühsam und fehleranfällig sein. Stellen Sie sich vor, ein Projekt voller Code wie in Listing 19-24.
let f: Box<dyn Fn() + Send + 'static> = Box::new(|| {
println!("hi");
});
fn takes_long_type(f: Box<dyn Fn() + Send + 'static>) {
--snip--
}
fn returns_long_type() -> Box<dyn Fn() + Send + 'static> {
--snip--
}
Listing 19-24: Verwenden eines langen Typs an vielen Stellen
Ein Typalias macht diesen Code leichter zu verwalten, indem die Wiederholungen reduziert werden. In Listing 19-25 haben wir einen Alias namens Thunk für den umständlichen Typ eingeführt und können alle Verwendung des Typs durch den kürzeren Alias Thunk ersetzen.
type Thunk = Box<dyn Fn() + Send + 'static>;
let f: Thunk = Box::new(|| println!("hi"));
fn takes_long_type(f: Thunk) {
--snip--
}
fn returns_long_type() -> Thunk {
--snip--
}
Listing 19-25: Einführung eines Typaliases Thunk zur Reduzierung von Wiederholungen
Dieser Code ist viel einfacher lesbar und zu schreiben! Die Wahl eines aussagekräftigen Namens für einen Typalias kann auch dazu beitragen, Ihre Absicht zu kommunizieren (Thunk ist ein Begriff für Code, der zu einem späteren Zeitpunkt ausgewertet werden soll, also ein passender Name für eine Closure, die gespeichert wird).
Typalias werden auch häufig mit dem Result<T, E>-Typ verwendet, um Wiederholungen zu reduzieren. Betrachten Sie das std::io-Modul in der Standardbibliothek. I/O-Operationen geben oft ein Result<T, E> zurück, um Situationen zu behandeln, wenn die Operationen fehlschlagen. Diese Bibliothek hat eine std::io::Error-Struktur, die alle möglichen I/O-Fehler darstellt. Viele der Funktionen in std::io werden Result<T, E> zurückgeben, wobei das E std::io::Error ist, wie diese Funktionen im Write-Trait:
use std::fmt;
use std::io::Error;
pub trait Write {
fn write(&mut self, buf: &[u8]) -> Result<usize, Error>;
fn flush(&mut self) -> Result<(), Error>;
fn write_all(&mut self, buf: &[u8]) -> Result<(), Error>;
fn write_fmt(
&mut self,
fmt: fmt::Arguments,
) -> Result<(), Error>;
}
Das Result<..., Error> wird oft wiederholt. Daher hat std::io diese Typaliasdeklaration:
type Result<T> = std::result::Result<T, std::io::Error>;
Da diese Deklaration im std::io-Modul ist, können wir den vollqualifizierten Alias std::io::Result<T> verwenden; das heißt, ein Result<T, E>, wobei das E als std::io::Error ausgefüllt ist. Die Funktionssignaturen des Write-Traits sehen so aus:
pub trait Write {
fn write(&mut self, buf: &[u8]) -> Result<usize>;
fn flush(&mut self) -> Result<()>;
fn write_all(&mut self, buf: &[u8]) -> Result<()>;
fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<()>;
}
Der Typalias hilft auf zwei Wegen: Er macht den Code einfacher zu schreiben und er gibt uns eine konsistente Schnittstelle überall in std::io. Da es ein Alias ist, ist es einfach nur ein weiteres Result<T, E>, was bedeutet, dass wir alle Methoden verwenden können, die auf Result<T, E> funktionieren, sowie besondere Syntax wie den ?-Operator.
Der niemals-kehrende Typ
Rust hat einen speziellen Typ namens !, der in der Typentheorie als leerer Typ bekannt ist, weil er keine Werte hat. Wir bevorzugen es, ihn als niemals-Typ zu nennen, weil er an der Stelle des Rückgabetyps steht, wenn eine Funktion niemals zurückkehrt. Hier ist ein Beispiel:
fn bar() ->! {
--snip--
}
Dieser Code wird als "die Funktion bar gibt niemals zurück" gelesen. Funktionen, die niemals zurückkehren, werden als divergierende Funktionen bezeichnet. Wir können keine Werte des Typs ! erstellen, also kann bar niemals tatsächlich zurückkehren.
Aber wofür ist ein Typ, für den man keine Werte erstellen kann? Erinnern Sie sich an den Code aus Listing 2-5, einem Teil des Zahlerratspiels; wir haben hier einen Ausschnitt davon in Listing 19-26 wiedergegeben.
let guess: u32 = match guess.trim().parse() {
Ok(num) => num,
Err(_) => continue,
};
Listing 19-26: Ein match mit einem Zweig, der mit continue endet
Damals haben wir einige Details in diesem Code übersprungen. Im Abschnitt "Der match-Steuerflusskonstrukt" haben wir diskutiert, dass die Zweige von match alle den gleichen Typ zurückgeben müssen. Also funktioniert beispielsweise der folgende Code nicht:
let guess = match guess.trim().parse() {
Ok(_) => 5,
Err(_) => "hello",
};
Der Typ von guess in diesem Code müsste ein Integer und eine Zeichenkette sein, und Rust erfordert, dass guess nur einen Typ hat. Also was gibt continue zurück? Wie konnten wir in Listing 19-26 von einem Zweig einen u32 zurückgeben und einen anderen Zweig haben, der mit continue endet?
Wie Sie vielleicht schon erraten haben, hat continue einen !-Wert. Das heißt, wenn Rust den Typ von guess berechnet, betrachtet es beide match-Zweige, den einen mit einem Wert vom Typ u32 und den anderen mit einem !-Wert. Da ! niemals einen Wert haben kann, entscheidet Rust, dass der Typ von guess u32 ist.
Die formale Weise, um dieses Verhalten zu beschreiben, ist, dass Ausdrücke vom Typ ! in jeden anderen Typ umgewandelt werden können. Wir dürfen diesen match-Zweig mit continue beenden, weil continue keinen Wert zurückgibt; stattdessen bewegt es den Steuerfluss zurück zum Anfang der Schleife, also im Err-Fall weisen wir niemals einen Wert an guess zu.
Der niemals-Typ ist auch nützlich mit der panic!-Makro. Erinnern Sie sich an die unwrap-Funktion, die wir auf Option<T>-Werten aufrufen, um einen Wert zu erhalten oder mit dieser Definition zu abstürzen:
impl<T> Option<T> {
pub fn unwrap(self) -> T {
match self {
Some(val) => val,
None => panic!(
"called `Option::unwrap()` on a `None` value"
),
}
}
}
In diesem Code passiert dasselbe wie im match in Listing 19-26: Rust sieht, dass val den Typ T hat und panic! den Typ !, also ist das Ergebnis des gesamten match-Ausdrucks T. Dieser Code funktioniert, weil panic! keinen Wert produziert; es beendet das Programm. Im None-Fall werden wir keinen Wert von unwrap zurückgeben, also ist dieser Code gültig.
Ein letzter Ausdruck, der den Typ ! hat, ist eine loop:
print!("forever ");
loop {
print!("and ever ");
}
Hier endet die Schleife niemals, also ist ! der Wert des Ausdrucks. Dies wäre jedoch nicht der Fall, wenn wir eine break einfügen würden, weil die Schleife dann beendet würde, wenn sie auf die break stieß.
Dynamisch angepasste Typen und das Sized-Trait
Rust muss bestimmte Details über seine Typen kennen, wie viel Speicherplatz beispielsweise für einen Wert eines bestimmten Typs zuzuweisen ist. Dies lässt einen kleinen Winkel seines Typsystems zunächst etwas verwirrend: den Begriff der dynamisch angepassten Typen. Manchmal auch als DSTs oder unsized types bezeichnet, erlauben diese Typen es uns, Code mit Werten zu schreiben, deren Größe wir erst zur Laufzeit kennen können.
Lassen Sie uns die Details eines dynamisch angepassten Typs namens str untersuchen, den wir bereits im ganzen Buch verwendet haben. Richtig, nicht &str, sondern str allein ist ein DST. Wir können die Länge der Zeichenkette erst zur Laufzeit wissen, was bedeutet, dass wir keine Variable vom Typ str erstellen können, und wir können auch keinen Parameter vom Typ str akzeptieren. Betrachten Sie den folgenden Code, der nicht funktioniert:
let s1: str = "Hello there!";
let s2: str = "How's it going?";
Rust muss wissen, wie viel Speicherplatz für jeden Wert eines bestimmten Typs zuzuweisen ist, und alle Werte eines Typs müssen den gleichen Speicherplatz verwenden. Wenn Rust uns diesen Code schreiben würde, müssten diese beiden str-Werte den gleichen Speicherplatz beanspruchen. Aber sie haben unterschiedliche Längen: s1 benötigt 12 Bytes Speicher und s2 benötigt 15. Deshalb ist es nicht möglich, eine Variable zu erstellen, die einen dynamisch angepassten Typ enthält.
Was tun wir also? In diesem Fall kennen Sie bereits die Antwort: wir machen die Typen von s1 und s2 zu einem &str anstatt einem str. Erinnern Sie sich aus "String Slices", dass die Slicedatenstruktur nur die Startposition und die Länge des Slices speichert. Also, obwohl ein &T ein einzelner Wert ist, der die Speicheradresse des Ortes speichert, an dem der T gespeichert ist, ist ein &str zwei Werte: die Adresse des str und seine Länge. Daher können wir die Größe eines &str-Werts zur Compilezeit bestimmen: es ist das Doppelte der Länge eines usize. Das heißt, wir wissen immer die Größe eines &str, unabhängig davon, wie lang die Zeichenkette ist, auf die er verweist. Im Allgemeinen ist dies die Art und Weise, wie dynamisch angepasste Typen in Rust verwendet werden: Sie haben ein zusätzliches Metadatenbit, das die Größe der dynamischen Informationen speichert. Die goldene Regel für dynamisch angepasste Typen ist, dass wir Werte von dynamisch angepassten Typen immer hinter einem Zeiger von irgendeiner Art platzieren müssen.
Wir können str mit allen möglichen Zeigern kombinieren: beispielsweise Box<str> oder Rc<str>. Tatsächlich haben Sie das bereits zuvor gesehen, aber mit einem anderen dynamisch angepassten Typ: Traits. Jeder Trait ist ein dynamisch angepasster Typ, auf den wir uns mit dem Namen des Traits beziehen können. Im Abschnitt "Verwendung von Trait-Objekten, die Werte unterschiedlicher Typen zulassen" haben wir erwähnt, dass wir Traits als Trait-Objekte verwenden müssen, indem wir sie hinter einem Zeiger platzieren, wie &dyn Trait oder Box<dyn Trait> (Rc<dyn Trait> würde ebenfalls funktionieren).
Um mit DSTs umzugehen, stellt Rust das Sized-Trait bereit, um zu bestimmen, ob die Größe eines Typs zur Compilezeit bekannt ist oder nicht. Dieses Trait wird automatisch für alles implementiert, dessen Größe zur Compilezeit bekannt ist. Darüber hinaus fügt Rust implizit eine Begrenzung auf Sized zu jeder generischen Funktion hinzu. Das heißt, eine generische Funktionsdefinition wie diese:
fn generic<T>(t: T) {
--snip--
}
wird tatsächlich so behandelt, als hätten wir dies geschrieben:
fn generic<T: Sized>(t: T) {
--snip--
}
Standardmäßig funktionieren generische Funktionen nur mit Typen, deren Größe zur Compilezeit bekannt ist. Sie können jedoch die folgende spezielle Syntax verwenden, um diese Einschränkung zu lockern:
fn generic<T:?Sized>(t: &T) {
--snip--
}
Eine Trait-Begrenzung auf ?Sized bedeutet "T kann Sized sein oder auch nicht", und diese Notation überschreibt die Standard-Einschränkung, dass generische Typen zur Compilezeit eine bekannte Größe haben müssen. Die ?Trait-Syntax mit dieser Bedeutung ist nur für Sized verfügbar, nicht für andere Traits.
Beachten Sie auch, dass wir den Typ des t-Parameters von T auf &T umgeschaltet haben. Da der Typ möglicherweise nicht Sized ist, müssen wir ihn hinter einem Zeiger von irgendeiner Art verwenden. In diesem Fall haben wir eine Referenz gewählt.
Als nächstes werden wir über Funktionen und Closures sprechen!
Zusammenfassung
Herzlichen Glückwunsch! Sie haben das Labor zu fortgeschrittenen Typen abgeschlossen. Sie können in LabEx weitere Labore absolvieren, um Ihre Fähigkeiten zu verbessern.