Fortgeschrittene Typen
Das Rust-Typsystem weist einige Funktionalitäten auf, die wir bisher erwähnt,
aber noch nicht besprochen haben. Wir beginnen mit einer allgemeinen Diskussion
über Newtypes und untersuchen, warum sie als Typen nützlich sind. Dann gehen wir
zu Typ-Aliasen über, einer Funktionalität, die den Newtypes ähnlich ist, aber
eine etwas andere Semantik hat. Wir werden auch den Typ ! und dynamisch große
Typen besprechen.
Typsicherheit und Abstraktion mit dem Newtype-Muster
Der nächste Abschnitt geht davon aus, dass du den früheren Abschnitt „Externe
Traits mit dem Newtype-Muster implementieren“ gelesen hast. Das
Newtype-Muster ist auch bei Aufgaben hilfreich, die über das bisher Besprochene
hinausgehen, einschließlich um statisch sicherzustellen, dass Werte niemals
verwechselt werden, und dem Angeben von Einheiten eines Wertes. Ein Beispiel für
die Verwendung von Newtypes zum Angeben von Einheiten hast du in Listing 20-16
gesehen: Erinnere dich daran, dass die Strukturen Millimeters und Meters
u32-Werte in einem Newtype einpacken. Wenn wir eine Funktion mit einem
Parameter vom Typ Millimeters schreiben würden, 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 auch das Newtype-Muster verwenden, um einige Implementierungsdetails eines Typs zu abstrahieren: Der neue Typ kann eine öffentliche API bereitstellen, die sich von der API des privaten, inneren Typs unterscheidet.
Newtypes können auch die interne Implementierung verbergen. Zum Beispiel könnten
wir einen Typ People zur Verfügung stellen, um eine HashMap<i32, String>
einzupacken, die die ID einer Person in Verbindung mit ihrem Namen speichert.
Code, der People verwendet, würde nur mit der öffentlichen API interagieren,
die wir zur Verfügung stellen, z.B. eine Methode, um einen Namens-String zur
People-Kollektion hinzuzufügen; dieser Code müsste nicht wissen, dass wir
Namen intern eine i32-ID zuordnen. Das Newtype-Muster ist ein
leichtgewichtiger Weg, eine Kapselung zu erreichen, um Implementierungsdetails
zu verbergen, die wir in „Kapselung, die Implementierungsdetails
verbirgt“ in Kapitel 18 besprochen haben.
Typ-Synonyme und Typ-Aliase
Rust bietet die Möglichkeit, einen Typ-Alias zu deklarieren, um einem
vorhandenen Typ einen anderen Namen zu geben. Hierfür verwenden wir das
Schlüsselwort type. Zum Beispiel können wir den Alias Kilometers für i32
so anlegen:
#![allow(unused)]
fn main() {
type Kilometers = i32;
let x: i32 = 5;
let y: Kilometers = 5;
println!("x + y = {}", x + y);
}
Der Alias Kilometers ist ein Synonym für i32; im Gegensatz zu den Typen
Millimeters und Meters, die wir in Listing 20-16 erstellt haben, ist
Kilometers kein separater, neuer Typ. Werte, die den Typ Kilometers haben,
werden genauso behandelt wie Werte des Typs i32:
#![allow(unused)]
fn main() {
type Kilometers = i32;
let x: i32 = 5;
let y: Kilometers = 5;
println!("x + y = {}", x + y);
}
Da Kilometers und i32 vom gleichen Typ sind, können wir Werte beider Typen
addieren und wir können Kilometers-Werte an Funktionen übergeben, die
i32-Parameter verwenden. Mit dieser Methode erhalten wir jedoch nicht die
Vorteile der Typprüfung, die wir vom zuvor besprochenen Newtype-Muster haben.
Mit anderen Worten, wenn wir irgendwo Kilometers- und i32-Werte
verwechseln, wird uns der Compiler keinen Fehler anzeigen.
Der Hauptanwendungsfall für Typ-Synonyme ist das Reduzieren von Wiederholungen. Zum Beispiel könnten wir einen längeren Typ wie diesen haben:
Box<dyn Fn() + Send + 'static>
Das Schreiben dieses langen Typs in Funktionssignaturen und als Typ-Annotationen im gesamten Code kann ermüdend und fehleranfällig sein. Stelle dir vor, du hättest ein Projekt voller Code wie das in Listing 20-25.
#![allow(unused)]
fn main() {
let f: Box<dyn Fn() + Send + 'static> = Box::new(|| println!("hallo"));
fn takes_long_type(f: Box<dyn Fn() + Send + 'static>) {
// --abschneiden--
}
fn returns_long_type() -> Box<dyn Fn() + Send + 'static> {
// --abschneiden--
Box::new(|| ())
}
}
Listing 20-25: Verwenden eines langen Typs an vielen Stellen
Ein Typ-Alias macht diesen Code leichter handhabbar, indem er die Wiederholung
reduziert. In Listing 20-26 haben wir einen Alias namens Thunk für den
verbosen Typ eingeführt und können alle Verwendungen des Typs durch den
kürzeren Alias Thunk ersetzen.
#![allow(unused)]
fn main() {
type Thunk = Box<dyn Fn() + Send + 'static>;
let f: Thunk = Box::new(|| println!("hallo"));
fn takes_long_type(f: Thunk) {
// --abschneiden--
}
fn returns_long_type() -> Thunk {
// --abschneiden--
Box::new(|| ())
}
}
Listing 20-26: Einführen eines Typ-Alias Thunk zur
Reduzierung von Wiederholungen
Dieser Code ist viel einfacher zu lesen und zu schreiben! Die Wahl eines aussagekräftigen Namens für einen Typ-Alias kann auch helfen, deine Absicht zu kommunizieren (thunk ist ein Wort für Code, der zu einem späteren Zeitpunkt ausgewertet wird, also ein passender Name für einen Closure, der gespeichert wird).
Typ-Aliase werden auch häufig mit dem Typ Result<T, E> verwendet, um
Wiederholungen zu reduzieren. Betrachte das Modul std::io in der
Standardbibliothek. E/A-Operationen geben oft ein Result<T, E> zurück, um
Situationen zu behandeln, in denen Operationen nicht funktionieren. Diese
Bibliothek hat eine Struktur std::io::Error, die alle möglichen E/A-Fehler
repräsentiert. Viele der Funktionen in std::io geben Result<T, E> zurück,
wobei für E der Typ std::io::Error verwendet wird, so wie bei diesen
Funktionen im Trait Write:
#![allow(unused)]
fn main() {
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>;
}
}
Result<..., Error> wird oft wiederholt. Daher hat std::io diese Art von
Alias-Deklaration:
#![allow(unused)]
fn main() {
use std::fmt;
type Result<T> = std::result::Result<T, std::io::Error>;
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<()>;
}
}
Da sich diese Deklaration im Modul std::io befindet, können wir den
vollständig qualifizierten Alias std::io::Result<T> verwenden; das ist ein
Result<T, E> mit E als std::io::Error. Die Funktionssignaturen des Traits
Write sehen am Ende so aus:
#![allow(unused)]
fn main() {
use std::fmt;
type Result<T> = std::result::Result<T, std::io::Error>;
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 Typ-Alias hilft in zweierlei Hinsicht: Er macht es einfacher, Code zu
schreiben und er gibt uns eine konsistente Schnittstelle innerhalb std::io.
Weil es ein Alias ist, ist es nur ein weiteres Result<T, E>, was bedeutet,
dass wir alle Methoden, die mit Result<T, E> funktionieren, mit ihm verwenden
können, einschließlich spezieller Syntax wie der ?-Operator.
Der Niemals-Typ, der niemals zurückkehrt
Rust hat einen speziellen Typ namens !, der im Fachjargon der Typtheorie als
leerer Typ (empty type) bekannt ist, weil er keine Werte hat. Wir ziehen es
vor, ihn den Niemals-Typ (never type) zu nennen, weil er an der Stelle des
Rückgabetyps steht, wenn eine Funktion niemals zurückkehrt. Hier ist ein
Beispiel:
#![allow(unused)]
fn main() {
fn bar() -> ! {
// --abschneiden--
panic!();
}
}
Dieser Code wird als „die Funktion bar kehrt niemals zurück“ gelesen.
Funktionen, die niemals zurückkehren, werden divergierende Funktionen
(diverging functions) genannt. Wir können keine Werte vom Typ ! erzeugen, also
kann bar niemals zurückkehren.
Aber was nützt ein Typ, für den man nie Werte erzeugen kann? Erinnere dich an den Code aus Listing 2-5, der Teil des Zahlenratespiels ist; wir haben einen Teil davon hier in Listing 20-27 wiedergegeben.
use std::cmp::Ordering;
use std::io;
use rand::Rng;
fn main() {
println!("Rate die Zahl!");
let secret_number = rand::thread_rng().gen_range(1..=100);
println!("Die Geheimzahl ist: {secret_number}");
loop {
println!("Bitte gib deine Schätzung ein.");
let mut guess = String::new();
// --abschneiden--
io::stdin()
.read_line(&mut guess)
.expect("Fehler beim Lesen der Zeile");
let guess: u32 = match guess.trim().parse() {
Ok(num) => num,
Err(_) => continue,
};
println!("Du hast geschätzt: {guess}");
// --abschneiden--
match guess.cmp(&secret_number) {
Ordering::Less => println!("Zu klein!"),
Ordering::Greater => println!("Zu groß!"),
Ordering::Equal => {
println!("Du hast gewonnen!");
break;
}
}
}
}
Listing 20-27: Ein match mit einem Zweig, der in
continue endet
Damals haben wir einige Details in diesem Code übersprungen. Im Abschnitt „Das
Kontrollflusskonstrukt match“ in Kapitel 6 haben wir
erwähnt, dass alle Zweige den gleichen Typ zurückgeben müssen. So funktioniert
zum Beispiel der folgende Code nicht:
#![allow(unused)]
fn main() {
let guess = "3";
let guess = match guess.trim().parse() {
Ok(_) => 5,
Err(_) => "hallo",
};
}
Der Typ von guess in diesem Code müsste eine ganze Zahl und ein String sein
und Rust verlangt, dass guess nur einen Typ hat. Was gibt also continue
zurück? Wie war es uns erlaubt, ein u32 von einem Zweig zurückzugeben und
einen anderen Zweig zu haben, der in Listing 20-27 mit continue endet?
Wie du vielleicht schon vermutet hast, hat continue einen !-Wert. Das
heißt, wenn Rust den Typ von guess berechnet, betrachtet es beide
match-Zweige, den ersten mit einem Wert von u32 und den zweiten mit einem
!-Wert. Da ! niemals einen Wert haben kann, entscheidet Rust, dass guess
den Typ u32 hat.
Der formale Weg, dieses Verhalten zu beschreiben, besteht darin, dass Ausdrücke
vom Typ ! in jeden anderen Typ umgewandelt werden können. Es ist uns erlaubt,
diesen match-Zweig mit continue zu beenden, weil continue keinen Wert
zurückgibt; stattdessen bringt es die Kontrolle zurück an den Anfang der
Schleife, sodass wir im Err-Fall guess niemals einen Wert zuweisen.
Der Niemals-Typ ist auch beim Makro panic! nützlich. Erinnere dich an die
Funktion unwrap, die wir auf Option<T>-Werte aufrufen, um einen Wert zu
erzeugen oder das Programm abzubrechen. Hier ist ihre Definition:
enum Option<T> {
Some(T),
None,
}
use crate::Option::*;
impl<T> Option<T> {
pub fn unwrap(self) -> T {
match self {
Some(val) => val,
None => panic!("Aufruf von `Option::unwrap()` auf einem `None`-Wert"),
}
}
}
In diesem Code geschieht dasselbe wie bei match in Listing 20-27: Rust
sieht, dass val den Typ T und panic! den Typ ! hat, sodass das Ergebnis
des gesamten match-Ausdrucks T ist. Dieser Code funktioniert, weil panic!
keinen Wert produziert; es bricht das Programm ab. Im Fall von None geben wir
keinen Wert von unwrap zurück, also ist dieser Code gültig.
Ein letzter Ausdruck, der den Typ ! hat, ist loop:
print!("für immer ");
loop {
print!("und ewig ");
}
Hier endet die Schleife nie, also ist ! der Typ des Ausdrucks. Dies wäre
jedoch nicht der Fall, wenn wir break einfügen würden, da die Schleife enden
würde, wenn sie bei break ankommt.
Dynamisch große Typen und das Trait Sized
Rust muss bestimmte Details über seine Typen kennen, z.B. wie viel Platz für einen Wert eines bestimmten Typs zuzuweisen ist. Das lässt eine Ecke des Typsystems zunächst etwas verwirrend erscheinen: Das Konzept der dynamisch großen Typen (dynamically sized types). Diese Typen, die manchmal als DSTs oder Typen ohne Größe (unsized types) bezeichnet werden, erlauben es uns, Code mit Werten zu schreiben, deren Größe wir nur zur Laufzeit kennen können.
Schauen wir uns die Details eines dynamisch großen Typs namens str an, den wir
im ganzen Buch verwendet haben. Das stimmt, nicht &str, sondern str an sich
ist ein DST. In vielen Fällen, beispielsweise beim Speichern von durch einen
Benutzer eingegebenem Text, können wir erst zur Laufzeit feststellen, wie lang
der String ist. Das bedeutet, dass wir weder eine Variable vom Typ str
erzeugen, noch ein Argument vom Typ str nehmen können. Betrachte den folgenden
Code, der nicht funktioniert:
#![allow(unused)]
fn main() {
let s1: str = "Guten Tag!";
let s2: str = "Wie geht es dir?";
}
Rust muss wissen, wie viel Speicher jedem Wert eines bestimmten Typs zuzuweisen
ist, und alle Werte eines Typs müssen die gleiche Speichermenge verwenden. Wenn
Rust uns erlauben würde, diesen Code zu schreiben, müssten diese beiden
str-Werte die gleiche Speichermenge beanspruchen. Aber sie haben
unterschiedliche Längen: s1 benötigt 10 Bytes Speicherplatz und s2 16
Bytes. Aus diesem Grund ist es nicht möglich, eine Variable zu erzeugen, die
einen dynamisch großen Typ enthält.
Was sollen wir also tun? In diesem Fall kennst du die Antwort bereits: Wir
machen die Typen s1 und s2 zu einem String Slice (&str) anstatt zu einem
str. Erinnere dich, dass wir im Abschnitt „String Slices“ in
Kapitel 4 gesagt haben, dass die Slice-Datenstruktur die Startposition und die
Länge des Slices speichert. Obwohl also &T ein einzelner Wert ist, der die
Speicheradresse des Ortes speichert, an dem sich T befindet, hat ein String
Slice zwei Werte: Die Adresse von str und seine Länge. Als solches können
wir die Größe eines String-Slice-Wertes zur Kompilierzeit kennen: Er ist doppelt
so lang wie ein usize. Das heißt, wir wissen immer die Größe eines String
Slices, egal wie lang der String ist, auf den er sich bezieht. Im Allgemeinen
werden in Rust Typen mit dynamischer Größe auf diese Weise verwendet: Sie haben
ein zusätzliches Stück Metadaten, das die Größe der dynamischen Information
speichert. Die goldene Regel für Typen dynamischer Größe lautet, dass wir Werte
von Typen mit dynamischer Größe immer hinter eine Art Zeiger stellen müssen.
Wir können str mit allen Arten von Zeigern kombinieren: Zum Beispiel
Box<str> oder Rc<str>. Tatsächlich hast du das schon einmal gesehen, aber
mit einem anderen dynamisch großen Typ: Traits. Jedes Trait ist ein dynamisch
großer Typ, auf den wir uns beziehen können, indem wir den Namen des Traits
verwenden. Im Abschnitt „Verwendung von Trait-Objekten zur Abstraktion über
gemeinsames Verhalten“ in Kapitel 18 haben wir erwähnt,
dass wir, um Traits als Trait-Objekte zu verwenden, diese hinter einen Zeiger
setzen müssen, z.B. &dyn Trait oder Box<dyn Trait> (Rc<dyn Trait> würde
auch funktionieren).
Um mit DSTs zu arbeiten, hat Rust das Trait Sized, um zu bestimmen, ob die
Größe eines Typs zur Kompilierzeit bekannt ist oder nicht. Dieses Trait wird
automatisch für alles implementiert, dessen Größe zur Kompilierzeit bekannt ist.
Zusätzlich fügt Rust implizit jeder generischen Funktion eine Trait Bound auf
Sized hinzu. Das heißt, eine generische Funktionsdefinition wie diese:
#![allow(unused)]
fn main() {
fn generic<T>(t: T) {
// --abschneiden--
}
}
wird tatsächlich so behandelt, als hätten wir das geschrieben:
#![allow(unused)]
fn main() {
fn generic<T: Sized>(t: T) {
// --abschneiden--
}
}
Standardmäßig funktionieren generische Funktionen nur bei Typen, die zur Kompilierzeit eine bekannte Größe haben. Du kannst jedoch die folgende spezielle Syntax verwenden, um diese Einschränkung zu lockern:
#![allow(unused)]
fn main() {
fn generic<T: ?Sized>(t: &T) {
// --abschneiden--
}
}
Eine Trait Bound durch ?Sized bedeutet „T kann Sized sein oder nicht“ und
diese Notation hebt die Vorgabe auf, dass generische Typen zur Kompilierzeit
eine bekannte Größe haben müssen. Die Syntax ?Trait mit dieser Bedeutung ist
nur für Sized verfügbar, nicht für andere Traits.
Beachte auch, dass wir den Typ des Parameters t von T auf &T geändert
haben. Da der Typ möglicherweise nicht Sized ist, müssen wir ihn hinter einer
Art Zeiger verwenden. In diesem Fall haben wir eine Referenz gewählt.
Als nächstes werden wir über Funktionen und Closures sprechen!