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-Alias ü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
Merkmale mit dem Newtype-Muster implementieren“ gelesen hast. Das
Newtype-Muster ist auch für Aufgaben nützlich, die über die bisher besprochenen
hinausgehen, einschließlich 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 Codeblock
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 eine
Namenszeichenkette 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 Codeblock 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 Codeblock 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(|| ())
}
}
Codeblock 20-25: Verwenden eines langen Typs an vielen Stellen
Ein Typ-Alias macht diesen Code leichter handhabbar, indem er die Wiederholung
reduziert. In Codeblock 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(|| ())
}
}
Codeblock 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 Funktionsabschluss (closure), der gespeichert wird).
Typ-Alias 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 Merkmal (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
Merkmals 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 Codeblock 2-5, der Teil des Zahlenratespiels ist; wir haben einen Teil davon hier in Codeblock 20-27 wiedergegeben.
use std::cmp::Ordering;
use std::io;
use rand::Rng;
fn main() {
println!("Guess the number!");
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;
}
}
}
}
Codeblock 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 eine
Zeichenkette 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 Codeblock 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 abstürzen zu lassen. 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 Codeblock 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 beendet das Programm. 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 Merkmal Sized
Rusts 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 die Zeichenkette ist. Das bedeutet, dass wir weder eine Variable vom Typ
str erzeugen, noch wir 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 Zeichenkettenanteilstyp (&str)
anstatt zu einem str. Erinnere dich, dass wir im Abschnitt
„Zeichenkettenanteilstypen (string slices)“ in Kapitel 4
gesagt haben, dass die Anteilstypen-Datenstruktur die Startposition und die
Länge des Anteilstyps speichert. Obwohl also &T ein einzelner Wert ist, der
die Speicheradresse des Ortes speichert, an dem sich T befindet, hat ein
Zeichenkettenanteilstyp zwei Werte: Die Adresse von str und seine Länge.
Als solches können wir die Größe eines Zeichenkettenanteilstyp-Wertes zur
Kompilierzeit kennen: Er ist doppelt so lang wie ein usize. Das heißt, wir
wissen immer die Größe eine Zeichenkettenanteilstyps, egal wie lang die
Zeichenkette ist, auf die 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: Merkmale (traits). Jedes Merkmal ist
ein dynamisch großer Typ, auf den wir uns beziehen können, indem wir den Namen
des Merkmals verwenden. Im Abschnitt „Verwendung von Merkmals-Objekten zur
Abstraktion über gemeinsames Verhalten“ in Kapitel 18
haben wir erwähnt, dass wir, um Merkmale als Merkmalsobjekte 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 Merkmal Sized, um zu bestimmen, ob die
Größe eines Typs zur Kompilierzeit bekannt ist oder nicht. Dieses Merkmal wird
automatisch für alles implementiert, dessen Größe zur Kompilierzeit bekannt
ist. Zusätzlich fügt Rust implizit jeder generischen Funktion eine
Merkmalsabgrenzung 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 Merkmalsabgrenzung 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 Merkmale.
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 Funktionsabschlüsse sprechen!