Programmieren mit Rust
Vorlesung im
Sommersemester 2023
an der
DHBW Mannheim
Coblitzallee 1-9
68163 Mannheim
von
Behrang Shafei
E-Mail: d228269 < ät > student.dhbw-mannheim.de
Falls du Tipp- oder andere Fehler findest freue ich mich über eine kurze Mitteilung.
Einleitung
Rust ist eine imperative Programmiersprache, die von verschiedenen Sprachen unterschiedlicher Paradigmen inspiriert wird. Man findet Aspekte objektorientierter und funktionaler Programmierung in Rust.
Rust möchte es dem Programmierer einfach machen, robuste und effiziente Programme zu entwickeln. Das bedeutet nicht, dass Rust eine einfache Sprache ist.
Insbesondere wird großen Wert darauf gelegt, dass
- nebenläufige Programme sicher implementiert werden können und
- die Speicherverwaltung nachvollziehbar und effizient ist.
Rust bringt einige Sicherheitsvorkehrungen mit, deren Umgehung möglich ist. In diesem Kurs ignorieren wir alle Umgehungsstraßen. Wenn Rust erwähnt wird, ist sicheres Rust gemeint, solange es nicht explizit als unsicher gekennzeichnet wird.
Vorbereitungen zu Programmieren mit Rust
Installation benötigter Programme
Unter
https://doc.rust-lang.org/book/ch01-01-installation.html
finden sich Installationsanweisungen für die Betriebssysteme Windows, Linux und MacOS.
Am Anfang ist es nicht nötig, eine integrierte Entwicklungsumgebung (IDE) zum Programmieren zu verwenden. Einfache Programme lassen sich mit einem Texteditor bearbeiten und auf der Kommandozeile übersetzen. Wenn man eine IDE verwenden möchte, bietet sich beispielsweise Visual Studio Code mit den Extensions Rust Analyzer und CodeLLDB an.
Falls möglich bringe bitte zur Vorlesung einen Computer mit, auf dem sich ein funktionsfähiger Rust-Compiler und Paketmanager befinden.
Um sicher zu gehen, dass der Rust Compiler rustc
und der Paketmanager Cargo korrekt installiert wurden, bietet sich ein
kleines Projekt an.
cargo new hello_world
cd hello_world
Der Ordner beinhaltet die Dateien
Dateipfad | Bedeutung |
---|---|
.git | Git Ordner, da Cargo auch ein Git Repository angelegt hat. |
.gitignore | kompilierte Dateien sollen von der Versionsverwaltung ignoriert werden |
Cargo.toml | Abhängigkeiten zu anderen Paketen |
src/main.rs | Quellcode des Rust-Programms |
Man kann nun durch
cargo run
oder
cargo run --release
das Programm ausführen, das lediglich Hello, World!
ausgibt. Das ähnelt üblicherweise der folgenden Ausgabe.
cargo run --release
Compiling hello_world v0.1.0 (/home/yourusername/hello_world)
Finished release [optimized] target(s) in 0.89s
Running `target/release/hello_world`
Hello, world!
Versionsverwaltung mit Git
Des Weiteren ist grundlegendes Wissen über Git zur Versionskontrolle hilfreich. Bei Bedarf möge man sich auf https://git-scm.com/book/de/v2 aufschlauen. Die Abschnitte
sind insbesondere relevant.
Links
- https://doc.rust-lang.org/ - komplette Einführung in die Programmiersprache Rust, Grundlage dieser Vorlesung
- https://rustacean-station.org/ - Podcast mit vielen verschiedenen Rust Anwendungsbeispielen
- Spielwiese
Grundlagen
In diesem Kapitel lernen wir viele Grundlagen kennen, die auch in anderen Programmiersprachen vorkommen.
Variablen
Variablen und Konstanten in Rust haben gemeinsam, dass sie im Quelltext per Namen verwendet werden. Variablen werden mit dem Schlüsselwort let
eingeleitet, vor Konstanten findet man ein const
.
fn main() { let x = 5; // Initialisierung einer Variable const C: i32 = 1; // Initialisierung einer Konstanten }
Übrigens ist Text, der sich hinter //
befindet ein Kommentar und wird vom Compiler ignoriert.
Wir können Variablen auch erst deklarieren und später initialisieren.
#![allow(unused)] fn main() { let x; x = 5; }
Wenn wir versuchen auf nicht-initialisierte Variablen zuzugreifen, verweigert der Compiler jedoch die Übersetzung.
#![allow(unused)] fn main() { let x; let y = x; x = 5; }
Laufzeit vs. Kompilierzeit
Variablen sind Adressen im Speicher zugewiesen. So kann während der Laufzeit des Programms der Wert einer Variablen geändert werden, in dem der Wert an dieser Speicheradresse geändert wird. Konstanten werden bereits während des Kompilierens ausgewertet. Sie können zur Laufzeit weder geändert noch initialisiert werden. Oft wird der Wert einer Konstante während des Kompilierens direkt verwendet.
Geltungsbereich
Nicht nur in Rust bezeichnet der Geltungsbereich (engl. scope) einer Variable oder Konstante den Bereich im Programmquelltext, in dem sie mit ihrem Namen referenziert werden kann. Beispielsweise meint der globale Geltungsbereich, dass die Verwendung der Variable an beliebiger Stelle möglich ist.
In Rust können Konstanten in beliebigem Geltungsbereich initialisiert werden. Das Anlegen globaler Variablen mit let
ist jedoch
nicht möglich.
let x = 5; // unzulässig const C: i32 = 1; // Initialisierung einer Konstanten fn main() { }
Die Konstante im vorherigen Schnipsel benötigt eine sogenannte Typannotation : i32
. Mit primitiven Typen
werden wir uns im nächsten Kapitel beschäftigen.
Wir können mit geschweiften Klammern einen neuen Geltungsbereich definieren. Beispielsweise verfügt das Programm
fn main() { let x = 5; { let x = 4; println!("{x}"); } println!("{x}"); }
über einen weiteren inneren Geltungsbereich innerhalb des Geltungsbereichs der Funktion main
.
Sind Variablen wirklich variabel?
Wenn wir in Rust versuchen einer existierenden Variable einen neuen Wert zuzuweisen, dann funktioniert das nicht.
fn main() { let x = 5; x = 4; }
Der Compiler wird die Übersetzung mit dem Hinweis
cannot assign twice to immutable variable
verweigern. Wir können also standarmäßig einer Variablen kein zweites Mal einen Wert zuweisen.
Um das Variieren einer Variablen zu ermöglichen, benötigen wir zusätzlich zu let
das Schlüsselwort mut
, das für das englische
mutable steht.
fn main() { let mut x = 5; x = 4; }
Das Deklarieren und Initialisieren einer neuen Variable mit einem bereits verbrauchten Namen ist ebenfalls möglich. Die alte Variable wird dann nicht mehr verfügbar sein.
fn main() { let x = 5; let x = x + 1; }
Die zweite Verwendunge von x
überschattet (engl. shadows) die Erste. Wir haben die Variable x
im Beispiel also nicht geändert, sondern eine neue Variable x
mit gleichem Namen angelegt.
Primitive Typen
Rust ist eine stark typisierte Sprache. Alle Typen aller Werte stehen zur Kompilierzeit fest. Primitive Typen sind fester Bestandteil der Sprache Rust. Über die Standardbibliothek stellt Rust weitere nicht-primitive Typen zur Verfügung. Einige davon werden wir in späteren Kapiteln kennen lernen.
Primitive Skalare
Rust verfügt über vier Arten primitiver skalarer Typen, die in vielen anderen Programmiersprachen ebenfalls Verwendung finden. Dabei handelt es sich um
- Wahrheitswerte (engl. booleans), die nur
true
oderfalse
sein können, und - Zeichen (engl. characters).
- ganze Zahlen (engl. integers),
- Gleitkommazahlen (engl. floating point numbers),
Wahrheitswerte können mit dem Schlüsselwort bool
annotiert werden. Für Zeichen gibt es char
.
Der Schnipsel
#![allow(unused)] fn main() { let b: bool = true; }
legt fest, dass die Variable b vom Typ bool
ist. Oft ist die Typannotation (: bool
) nicht notwendig und
der Compiler kann den Typ der Variable herleiten.
#![allow(unused)] fn main() { let b = true; }
Nichtsdestotrotz stehen alle Typen zur Kompilierzeit fest, ob hergeleitet oder annotiert. Rust ist eine stark typisierte Sprache.
Bei numerischen Datentypen wird üblicherweise der annehmbare Größenbereich im Namen des Typs kodiert. Beispielsweise ist u8
eine ganze Zahl ohne Vorzeichen, die 8 Bit zur Verfügung hat und
dementsprechend mindestens den Wert 0 und höchstens den Wert 255 annhemen kann. Einen Überblick über ganzzahlige
Typen verschafft die folgende Tabelle.
Typ | Min | Max |
---|---|---|
u8 | 0 | 255 |
u16 | 0 | 65 535 |
u32 | 0 | 4 294 967 295 |
u64 | 0 | 264-1 |
u128 | 0 | 2128-1 |
i8 | -128 | 127 |
i16 | -32 768 | 32 767 |
i32 | -2 147 483 648 | 2 147 483 647 |
i64 | -(263) | 263-1 |
i128 | -(2127) | 2127-1 |
Des Weiteren gibt es die plattformabhängigen ganzzahligen Typen usize
und isize
. Diese haben auf einem System
die gleiche Bitgröße. Der obigen Namensgebung folgend ist
isize
vorzeichenbehaftet, während usize
nur positive Werte und 0 annehmen kann. Speicheradressen können höchstens
so groß sein wie der maximale Wert den isize
annehmen kann. So ist sichergestellt, dass Differenzen
von Speicheradressen immer als isize
darstellbar sind.
Darüber hinaus gibt es die Gleitkommazahlen f32
und f64
, die den Formaten entsprechen, die im Standard IEEE 754-2008
als single und double bezeichnet werden. Gleitkommavariablen in Rust können immer positive und negative Werte annehmen.
Overflows
Wenn beispielsweise einem u8
der Wert 256 zugewiesen wird, entsteht ein sogenannter Overflow, da 256 nicht
als u8
dargestellt werden kann.
#![allow(unused)] fn main() { let a: u8 = 256; }
In Rust können verschiedene Fälle eintreten.
- Der Compiler entdeckt den Overflow und verweigert die Übersetzung, was aber auch ausgeschaltet werden kann.
- Bei einem Debug-Build, der zusätzliche Überprüfungen beinhaltet, wird das Programm mit einem Laufzeitfehler abgebrochen.
- Bei einem Release-Build, der optimiert ist und mit so wenig Überprüfungen wie möglich auskommen will,
läuft das Programm weiter und der Overflow findet stillschweigend statt. Das heißt
a
im oberen Beispiel hat den Wert0
.
Sich auf Overflows zu verlassen und bewusst damit zu rechnen ist keine gute Idee und zu vermeiden, da es zu schwer nachvollziehbaren Programmabläufen führen kann.
Casting primitiver Skalare
Skalare eines numerischen Typs können in einen anderen numerischen Typen umgewandelt werden.
Das funktioniert in Rust nur explizit
mit dem Schlüsselwort as
. Selbst, wenn ich eine u8
-Variable, die als u128
darstellbar ist, einer u128
-Varible
zuweisen will, muss ich casten.
#![allow(unused)] fn main() { let a: u8 = 0; let b: u128 = a as u128; }
Auch umgekehrtes casten ist möglich. Dabei besteht das Risiko eines Overflows.
#![allow(unused)] fn main() { let a: u128 = 256; let b: u8 = a as u8; assert_eq!(b, 0); }
Primitive zusammengesetzte Typen
Der Zweck zusammengesetzter Typen (engl. compound types) ist es, mehrere Werte in einem zu gruppieren. Tupel und Arrays sind die beiden primitiven zusammengesetzten Typen, die Rust mitbringt. Während ein Array mehrere Werte gleichen Typs gruppiert, kann ein Tupel mehrere Werte unterschiedlichen Typs beherbergen. Die Anzahl der Werte muss in beiden Fällen zur Kompilierzeit festgelegt werden.
Arrays
Um Arrays anzulegen verwendet man eckige Klammern.
#![allow(unused)] fn main() { let arr = [5, 3, 2, 5]; }
Die gleichwertige Befüllung kann folgendermaßen abgekürzt werden.
#![allow(unused)] fn main() { let arr = [0; 27]; }
einen Array mit 27 Nullen. Man beachte, dass die 27 zur Kompilierzeit fixiert sein muss. Auf Arrayelemente greift man per Index beginnend mit 0 zu.
#![allow(unused)] fn main() { let arr = [0; 27]; let b = arr[5]; }
Wenn man einen ungültigen Index verwendet, beendet Rust kontrolliert das Programm, anstatt einen Zugriff auf den Speicherbereich zuzulassen, der nicht mehr dem Array zugeordnet ist.
Tupel
Ein Tupel bestehend aus einer ganzen Zahl und einem Wahrheitswert kann folgerndermaßen angelegt werden.
#![allow(unused)] fn main() { let t = (5, true); }
Um auf einzelne Elemente eines Tupels zuzugreifen, gibt es den .
-Operator.
Im obigen Beispiel kann man auf den Wert 5 per t.0
und auf den Wert
true
per t.1
zugreifen. Im Unterschied zu Indizes von Arrays müssen
die Indizes von Tupeln zur Kompilierzeit festgelegt werden.
Auf Tupelelemente per .
-Operator und Positionsindex zuzugreifen, kann unübersichtlich werden.
Oft ist es der Lesbarkeit zuträglich, Tupel in benamte Variablen zu entpacken.
#![allow(unused)] fn main() { let t = (5, true); let (num_of_showers, is_still_dirty) = t; }
Das leere Tupel ()
ist ein zulässiger Typ in Rust und hat eine spezielle Bedeutung, die wir im
Abschnitt über Funktionen kennen lernen werden.
Beispiele für Typ-Annotationen von Variablen
Um den Wert einer Variablen festzulgen, kann man diese annotieren. Beispielsweise erstellt man einen Wahrheitswert mit
#![allow(unused)] fn main() { let x: bool = true; }
ein Zeichen mit
#![allow(unused)] fn main() { let c: char = '🥰'; }
eine Gleitkommazahl mit
#![allow(unused)] fn main() { let f: f64 = 1.5; }
eine ganze Zahl mit
#![allow(unused)] fn main() { let a: i32 = 5; }
ein dreielementiges Array von Gleitkommazahlen mit
#![allow(unused)] fn main() { let numbers: [f32; 3] = [0.0, 0.1, 0.7]; }
und ein Tupel bestehend aus einem Wahrheitswert und einem Zeichen mit
#![allow(unused)] fn main() { let t: (bool, char) = (false, 'x'); }
In vielen Fällen ist eine Annotierung des Typen nicht nötig. Oft kann der Compiler den Typ eines Wertes aus dem Kontext herleiten. Keine der obigen Annotationen ist für eine erfolgreiche Übersetzung des Programmquelltextes notwendig. Später werden uns Beispiele begegnen, bei denen wir dem Compiler werden durch Annotationen helfen müssen.
Funktionen
Funktionen bilden
- wie mathematische Funktionen deterministisch Eingabeparameter auf Ausgabewerte ab,
- führen sogenannte Seiteneffekte aus wie z.B. die Ausgabe von Text auf dem Bildschirm oder
- sind eine Mischung beider vorgenannter Punkte.
Beispiele
Ein Beispiel einer allgegenwärtigen ist die Funktion main
. Sie dient als Einstiegspunkt für Rustprogramme.
Eine Funktionsdefinition beginnt mit dem Schlüsselwort fn
. Darauf folgen der Name, die Argumente in Klammern,
der Rückgabewert nach einem Pfeil und der Funktionsrumpf in geschweiften Klammern. Der Funktionsrumpf definiert das Verhalten
einer Funktion.
Beispielsweise definiert die Funktion
fn main() { println!("Hallo Welt."); }
den Einstiegspunkt eines Rustprogramms, das Hallo Welt.
ausgibt. Aus main
können nun weitere Funktionen
aufgerufen werden.
fn print_hallo_welt() { println!("Hallo Welt."); } fn main() { print_hallo_welt(); }
Folgende Funktion addiert 2 Zahlen und gibt das Ergebnis zurück. Funktionsparameter werden immer mit Typen annotiert. Die Typen der Funktionsrückgabewerte werden hinter einen nach rechts gerichteten Pfeil gesetzt.
#![allow(unused)] fn main() { fn add(x: f32, y: f32) -> f32 { x + y } }
Wenn einer Funktionssignatur ein Rückgabewert fehlt, wird das leere Tupel zurückgegeben. Das heißt
fn main() {}
und
fn main() -> () {}
sind äquivalent.
Man beachte, dass println!
keine Funktion
in Rust ist, sondern ein Macro, das übrigens ebenfalls ()
zurückgibt. Macros erkennt man
am Ausrufezeichen. Wir verschieben das Verständnis von Macros auf später und verwenden
sie für den Moment einfach. Es sei an dieser Stelle nur erwähnt, dass Macros in Rust nicht einfache
Textersetzungswerkzeuge sind wie in der Programmiersprache C. Neben Text kann println!
auch
mit Hilfe der Syntax
#![allow(unused)] fn main() { let x = 5; println!("{x}"); }
oder
#![allow(unused)] fn main() { let x = 5; println!("{}", x); }
den Wert von Variablen und Ausdrücken ausgeben.
Ausdrücke und Anweisungen
Der Rumpf einer Funktion besteht aus einer Reihe von Anweisungen (engl. statements) und endet möglicherweise
mit einem Ausdruck (engl. expression).
Während Anweisungen eine Aktion durchführen ohne einen Wert zurückzugeben, geben Ausdrücke immer auch einen
Wert zurück. Beispielsweise ist die Variablenzuweisung eines Ausdrucks let x = 4;
eine Anweisung. In diesem
Fall ist 4
also ein Ausdruck, während das Semikolon das Ende der Anweisung bedeutet. Eine Zuweisung ist
im Unterschied zu anderen Programmiersprachen wie C kein Ausdruck in Rust. Die folgende Tabelle beinhaltet
Beispiele für Ausdrücke.
Beschreibung | Beispiel |
---|---|
Funktionsaufrufe ohne expliziten Rückgabewert | print_hello_world() |
Funktionsaufrufe mit explizitem Rückgabewert | add(1.0, 1.1) |
Macroaufrufe | println!("Hallo Welt.") |
arithmetische Operationen | 5.0 + 7.5 |
Literale | 4 |
Variablen | x |
Wenn ein Ausdruck mit einem Semikolon terminiert, wird das Zurückgeben des Wertes unterdrückt. Während
#![allow(unused)] fn main() { fn returns_seven() -> usize { let x = 7; x } }
den Wert 7 zurückgibt, bekommt man von
#![allow(unused)] fn main() { fn returns_nothing() { let x = 7; x; } }
keine 7.
Quiz
Welchen Wert hat
x
nachlet x = returns_nothing();
?
Funktionen als Parameter
Funktionen können anderen Funktionen als Parameter übergeben werden oder auch der Rückgabewert einer Funktion sein1. Wir schauen uns ein Beispiel an, in dem wir unseren Code durch die Verwendung von Funktionsparametern modular gestalten. Angenommen wir möchten komponentenweises Addieren und Subtrahieren von 3d-Punkten implementieren. Punkte stellen wir im Code als Array mit 3 Elementen dar. Ein einfacher Weg sieht folgendermaßen aus.
/// Add two arrays fn arr_add(arr1: [f64; 3], arr2: [f64; 3]) -> [f64; 3] { let mut arr_res = [0.0; 3]; arr_res[0] = arr1[0] + arr2[0]; arr_res[1] = arr1[1] + arr2[1]; arr_res[2] = arr1[2] + arr2[2]; arr_res } /// Subtract two arrays fn arr_sub(arr1: [f64; 3], arr2: [f64; 3]) -> [f64; 3] { let mut arr_res = [0.0; 3]; arr_res[0] = arr1[0] - arr2[0]; arr_res[1] = arr1[1] - arr2[1]; arr_res[2] = arr1[2] - arr2[2]; arr_res } fn main() { let arr1 = [1.0, 1.0, 1.0]; let arr2 = [2.0, 3.0, 4.0]; let addition = arr_add(arr1, arr2); println!("{addition:?}"); let subtraction = arr_sub(arr1, arr2); println!("{subtraction:?}"); }
Die beiden Funktionen arr_add
und arr_sub
sind korrekt und addieren bzw. subtrahieren 2
Arrays der Länge 3 voneinander komponentenweise. Bei genauerer Betrachtung fällt auf,
dass beide Funktionsrümpfe bis auf das Plus- bzw. Minuszeichen identisch sind. Eine elegantere Lösung
übergibt einer allgemeinen Funktion für komponentenweise Operationen auf Arrays die Addition oder
Subtraktion als Parameter wie im folgenden Schnipsel ersichtlich.
Der Funktionsparameter
f
beinhaltet genau diese Funktion, die 2 Parameter vom Typ f64
bekommt und eine Gleitkommazahl zurückgibt.
Den Typen dieser Funktion annotieren wir mit fn(f64, f64) -> f64
.
/// Binary array operator fn bin_arr_op( arr1: [f64; 3], arr2: [f64; 3], f: fn(f64, f64) -> f64 ) -> [f64; 3] { let mut arr_res = [0.0; 3]; arr_res[0] = f(arr1[0], arr2[0]); arr_res[1] = f(arr1[1], arr2[1]); arr_res[2] = f(arr1[2], arr2[2]); arr_res } fn add(x: f64, y: f64) -> f64 { x + y } /// Add two arrays fn arr_add(arr1: [f64; 3], arr2: [f64; 3]) -> [f64; 3] { bin_arr_op(arr1, arr2, add) } fn subtract(x: f64, y: f64) -> f64 { x - y } /// Subtract two arrays fn arr_sub(arr1: [f64; 3], arr2: [f64; 3]) -> [f64; 3] { bin_arr_op(arr1, arr2, subtract) } fn main() { let arr1 = [1.0, 1.0, 1.0]; let arr2 = [2.0, 3.0, 4.0]; let addition = arr_add(arr1, arr2); println!("{addition:?}"); let subtraction = arr_sub(arr1, arr2); println!("{subtraction:?}"); }
Wir können jetzt einfach weitere binäre Operationen wie Multiplikation auf Skalaren definieren und diese
mit Hilfe der Funktion bin_arr_op
auf Arrays anwendbar machen.
Rust bietet ein Feature, das es erlaubt Funktionen, die als Parameter übergeben werden, einfach an Ort und Stelle zu definieren.
/// Binary array operator fn bin_arr_op( arr1: [f64; 3], arr2: [f64; 3], f: fn(f64, f64) -> f64 ) -> [f64; 3] { let mut arr_res = [0.0; 3]; arr_res[0] = f(arr1[0], arr2[0]); arr_res[1] = f(arr1[1], arr2[1]); arr_res[2] = f(arr1[2], arr2[2]); arr_res } /// Add two arrays fn arr_add(arr1: [f64; 3], arr2: [f64; 3]) -> [f64; 3] { bin_arr_op(arr1, arr2, |x, y| x + y) } /// Subtract two arrays fn arr_sub(arr1: [f64; 3], arr2: [f64; 3]) -> [f64; 3] { bin_arr_op(arr1, arr2, |x, y| x - y) } fn main() { let arr1 = [1.0, 1.0, 1.0]; let arr2 = [2.0, 3.0, 4.0]; let addition = arr_add(arr1, arr2); println!("{addition:?}"); let subtraction = arr_sub(arr1, arr2); println!("{subtraction:?}"); }
Im letzten Schnipsel sind die Funktionen add
und subtract
verschwunden und wurden durch die anonymen Funktionen
|x, y| x + y
bzw. |x, y| x - y
ersetzt. Mit dieser Syntax kann man eine Funktion innerhalb
einer anderen Funktion in einer Zeile definieren. Der Compiler versucht die Typen der Funktionsparameter
ähnlich zu let
Anweisungen herzuleiten. Das ist aber nicht immer möglich. Manchmal bittet der Compiler um
Typannotationen. Wenn der Rumpf einer anonymen Funktion aus mehreren Anweisungen und Ausdrücken
bestehen soll, kann man geschweifte Klammern verwenden. Die folgende Funktion berechnet
beispielsweise ob die Summe aus ihren Eingaben durch 2 teilbar ist.
#![allow(unused)] fn main() { |x: i32, y: i32| { let sum = x + y; sum % 2 == 0 }; }
Anonyme Funktionen haben zwar keine Namen können jedoch wie im folgenden Beispiel Variablen zugewiesen werden.
#![allow(unused)] fn main() { let is_even = |x, y| { let sum = x + y; sum % 2 == 0 }; let a = 2; let b = 3; if is_even(a, b) { println!("the sum of {a} and {b} is even") } else { println!("the sum of {a} and {b} is odd") } }
Weitere Bezeichnungen anonymer Funktionen in Rust sind Lambda-Funktionen oder Closures. Solange man Closures als einfache Funktionen an andere Funktionen übergeben möchte, muss man darauf achten, dass sie keine Variablen verwenden, die außerhalb ihres Geltungsbereichs liegen. Die beiden folgenden Schnipsel zeigen ein entsprechendes Beispiel.
#![allow(unused)] fn main() { /// Unary array operator fn unary_arr_op( arr: [f64; 3], f: fn(f64) -> f64 ) -> [f64; 3] { let mut arr_res = [0.0; 3]; arr_res[0] = f(arr[0]); arr_res[1] = f(arr[1]); arr_res[2] = f(arr[2]); arr_res } fn increase_by_10(arr: [f64; 3]) -> [f64; 3]{ // kompiliert nicht, da die Variable `ten` außerhalb des Geltungsbereichs // der anonymen Funktion incr ist // und incr somit keine einfache Funktion mehr ist let ten = 10.0; let incr = |x| x + ten; unary_arr_op(arr, incr) } }
Dem vorstehenden Beispiel wird der Compiler mit dem Hinweis, ein fn pointer sei kein closure die Übersetzung verweigern. Der Typ einfacher Funktionen in Rust heißt auch Funktionspointer.
#![allow(unused)] fn main() { /// Unary array operator fn unary_arr_op( arr: [f64; 3], f: fn(f64) -> f64 ) -> [f64; 3] { let mut arr_res = [0.0; 3]; arr_res[0] = f(arr[0]); arr_res[1] = f(arr[1]); arr_res[2] = f(arr[2]); arr_res } fn increase_by_10(arr: [f64; 3]) -> [f64; 3]{ // kompiliert, da 10 innerhalb des Geltungsbereichs ist // und incr somit eine einfache Funktion ist let incr = |x| x + 10.0; unary_arr_op(arr, incr) } }
Closures können durchaus auf den umliegenden Geltungsbereich zugreifen und sind damit ein sehr nützliches Werkzeug aber eben nicht notwendigerweise einfache Funktionen. Wie man Closures, die Variablen außerhalb ihres Geltungsbereichs verwenden, gewinnbringend einsetzen kann, werden wir in einer späteren Einheit erleben.
1: Funktionen, die andere Funktionen als Parameter begrüßen oder Funktionen zurückgeben, heißen Funktionen höherer Ordnung.
Kontrollfluss
if
-Ausdrücke, rekursive Funktionen
und Schleifen ebnen uns verschiedene Wege und Abzweigungen auf dem Pfad durch das Programm, die
wir in diesem Abschnitt kennenlernen werden. Mit der Nutzung von Funktionen als Parameter haben wir
ein weiteres Werkzeug zum Beeinflussen des Kontrollflusses bereits kennengelernt.
if
-Ausdrücke
if
-Ausdrücke beeinflussen den Programmablauf abhängig von einer Bedingung, die entweder wahr
oder falsch ist. Bedingungen haben den Typ bool
. Entsprechend gibt der folgende Schnipsel
#![allow(unused)] fn main() { let x = 4; if x > 2 { println!("x is larger than 2"); } else { println!("x is not larger than 2"); } }
den Text x is larger than 2
aus, während
#![allow(unused)] fn main() { let x = 1; if x > 2 { println!("x is larger than 2"); } else { println!("x is not larger than 2"); } }
zur Textausgabe x is not larger than 2
führt. Es können auch mehrere Bedingungen verwendet werden.
#![allow(unused)] fn main() { let x = 1; if x > 0 { println!("x is positive"); } else if x < 0 { println!("x is negative"); } else { println!("x is 0"); } }
In Rust geben if
-Ausdrücke Werte zurück. Sonst wäre die Bezeichnung Ausdruck schlichtweg falsch.
#![allow(unused)] fn main() { let x = 5; let y = if x < 5 { 2 } else { 1 }; assert_eq!(y, 1); }
Das Macro assert_eq
stellt die Gleichheit seiner Argumente sicher.
Selbst wenn man nur Anweisungen in if
-Ausdrücken verwendet, geben diese das leere Tupel ()
zurück.
#![allow(unused)] fn main() { let x = 6; let mut y = 0; if x % 2 == 0 { let empty_tupel = if x > 7 { y = 1; } else { y = 2; }; assert_eq!(empty_tupel, ()); assert_eq!(y, 2); } }
Wichtig ist, dass alle Zweige eines if
-Ausdrucks den selben Typ zurückgeben. Entsprechend kompilieren
folgende Schnipsel nicht.
#![allow(unused)] fn main() { // kompiliert nicht let x = 1; if x > 1 { 2 // Gibt eine ganze Zahl zurück. } else { (4, 5) // Gibt ein Tupel zurück. } }
#![allow(unused)] fn main() { // kompiliert nicht let x = 1; if x > 1 { 2 // Gibt eine ganze Zahl zurück. } else { let y = 1; // Gibt ein leeres Tupel zurück. } }
#![allow(unused)] fn main() { // kompiliert nicht let x = 1; if x > 1 { 2 // Gibt eine ganze Zahl zurück. } // else block fehlt und gibt somit ein leeres Tupel zurück. }
Code Wiederholen
Möchte man einen größeren oder kleineren Haufen Code mehrfach ausführen, helfen Rekursion von Funktionen oder Schleifen. Beispielsweise gibt es verschiedene Möglichkeiten die Fakultät einer ganzen Zahl zu berechnen. Wir beginnen mit der rekursiven Variante.
fn factorial(x: u128) -> u128 { if x <= 1 { 1 } else { // Die Funktion ruft sich selber auf und ist daher rekursiv. factorial(x - 1) * x } } fn main() { println!("{}", factorial(10)); }
Im folgenden wird das gleiche Problem mit einer for
-Schleife gelöst.
fn factorial(x: u128) -> u128 { let mut res = 1; for i in 2..(x+1) { res *= i; // kurz für res = res * i } res } fn main() { println!("{}", factorial(10)); }
Die Syntax der for
-Schleife beinhaltet ein in
, da die for
-Schleife insbesondere dafür geeignet ist, über
Container wie Arrays zu iterieren. Beispielsweise gibt
#![allow(unused)] fn main() { for i in [0, 7, 4] { println!("{i}"); } }
die Zahlen 0, 7 und 4 aus. Wenn man aber keinen Array hat, sondern über bestimmte Zahlen iterieren
möchte, stellt die Rust Standardbibliothek einen Typen namens Range
bereit. Beispielsweise wird eine Range
von 0 bis 9 durch 0..10
erstellt. Die Zeile for i in 2..(x+1)
sorgt also dafür, dass i
alle ganzzahligen
Werte Zwischen 2 und x
annimmt. Dabei hält der Typ Range
die Zahlen nicht im Speicher vor. Eine weitere Möglichkeit, die Fakultät einer Zahl zu berechnen, ist die
while
-Schleife.
fn factorial(x: u128) -> u128 { let mut i = x; let mut res = 1; while i > 1 { res = res * i; i -= 1; } res } fn main() { println!("{}", factorial(10)); }
Die while
-Schleife iteriert solange ihre Bedingung wahr ist, während man while true
in Rust auch
durch loop
abbkürzen kann. Schleifen kann man mit dem
Schlüsselwort break
abbrechen.
Aufzählungs- und Strukturtypen
Aufzählungstypen
Unter Aufzählungstypen verstehen wir Typen, deren Wert aus einer Aufzählung unterschiedlicher Varianten
angenommen werden muss.
Das entscheidende Schlüsselwort lautet enum
. Beispielsweise repräsentiert der folgende Aufzählungstyp eine der
RGB-Farben.
#![allow(unused)] fn main() { enum Color { Red, Green, Blue } }
Um entsprechend des Wertes einer Variable vom Typ Color
handeln zu können, lernen
wir eine weitere Möglichkeit kennen, den Kontrollfluss zu beeinflussen. Das Schlüsselwort match
wird dafür im folgenden Schnipsel verwendet.
#![allow(unused)] fn main() { enum Color { Red, Green, Blue } let clr = Color::Red; let clr_code = match clr { Color::Red => [255, 0, 0], Color::Green => [0, 255, 0], Color::Blue => [0, 0, 255], }; println!("{}, {}, {}", clr_code[0], clr_code[1], clr_code[2]); }
Auch bei match
handelt es sich um einen Ausdruck. Die entsprechende Verzweigung gibt einen Wert zurück.
Man beachte, dass man bei einem match
alle möglichen Wertemöglichkeiten eines enum
beachten muss.
Dementsprechend kompiliert
#![allow(unused)] fn main() { // kompiliert nicht! enum Color { Red, Green, Blue } let clr = Color::Red; let clr_code = match clr { Color::Red => [255, 0, 0], Color::Green => [0, 255, 0], }; println!("{}, {}, {}", clr_code[0], clr_code[1], clr_code[2]); }
nicht, da Color::Blue
fehlt. Man kann mit _
ein Sammelbecken für alle übrigen
möglichen Werte erzeugen.
#![allow(unused)] fn main() { enum Color { Red, Green, Blue } let clr = Color::Red; let clr_code = match clr { Color::Red => [255, 0, 0], _ => [0, 0, 0], }; println!("{}, {}, {}", clr_code[0], clr_code[1], clr_code[2]); }
Die einzelnen Varianten des enum
s können auch Werte unterschiedlichen Typs beherbergen.
Wir können somit Werte unterschiedlichen Typs in einem Aufzählungstyp packen und dann
mehrere davon in einem Array beherbergen.
#![allow(unused)] fn main() { enum Value { Bool(bool), Float(f64), Int(i32) } let arr = [Value::Bool(true), Value::Float(1.4), Value::Int(4)]; }
Der match
-Ausdruck hilft auch beim auspacken der Werte aus dem enum
.
Dieses Auspacken wird Pattern Matching genannt.
#![allow(unused)] fn main() { enum Value { Bool(bool), Float(f64), Int(i32) } let arr = [Value::Bool(true), Value::Float(1.4), Value::Int(4)]; for a in arr { match a { Value::Bool(b) => println!("{b}"), Value::Float(x) => println!("{x}"), Value::Int(i) => println!("{i}"), } } }
Wenn man sich nur für eine Variante interessiert, kann man auch if
-let
für das Pattern Matching verwenden.
#![allow(unused)] fn main() { enum Value { Bool(bool), Float(f64), Int(i32) } let arr = [Value::Bool(true), Value::Float(1.4), Value::Int(4)]; for a in arr { if let Value::Float(x) = a { println!("{x}"); } } }
Eine while
-let
-Schleife existiert ebenfalls.
#![allow(unused)] fn main() { enum Value { Bool(bool), Float(f64), Int(i32) } let arr = [Value::Bool(true), Value::Float(1.4), Value::Int(4)]; while let Value::Float(x) = a { println!("{x}"); } }
Im Unterschied zum vorherigen Beispiel bricht die while
-let
-Schleife ab, sobald
das Pattern Matching fehl schlägt.
Strukturtypen
Mit einem struct
kann man mehrere Werte in einem Typen gruppieren.
Beispielsweise repräsentiert
#![allow(unused)] fn main() { struct Point { x: f64, y: f64, } }
einen 3d-Punkt. Wir erzeugen nun eine Variable vom Typ Point
.
#![allow(unused)] fn main() { struct Point { x: f64, y: f64, } let v = Point { x: 42.0, y: 73.0, }; }
Man nennt v
eine Instanz von Point
. Rust stellt des Weiteren Tupel-Strukturtypen
bereit. Diese gleichen im Wesentlichen den Tupeln, die wir bereits kennen. Sie haben nur
einen Namen.
#![allow(unused)] fn main() { struct Color(u8, u8, u8); let clr = Color(255, 211, 123); println!("{}, {}, {}", clr.0, clr.1, clr.2); }
Methoden
Nehmen wir an, wir wollen die Länge eines Vektors berechnen. Dabei drängt sich die Organisation des Codes mit einer Funktion auf.
#![allow(unused)] fn main() { struct Point { x: f64, y: f64, } fn squared_dist_to_0(v: Point) -> f64 { v.x * v.x + v.y * v.y } }
Alternativ kann man auch eine Methode der Struktur implementieren. Dazu müssen wir einen separaten Implementierungsblock verwenden.
#![allow(unused)] fn main() { struct Point { x: f64, y: f64, } impl Point { fn squared_dist_to_0(self) -> f64 { self.x * self.x + self.y * self.y } } }
Nun kann man mit
#![allow(unused)] fn main() { struct Point { x: f64, y: f64, } impl Point { fn squared_dist_to_0(self) -> f64 { self.x * self.x + self.y * self.y } } let p = Point{ x: 0.1, y: 0.2 }; let d = p.squared_dist_to_0(); println!("{d}"); }
den Abstand zum Ursprung bestimmen. Die Methode squared_dist_to_0
wird auf der Instanz p
aufgerufen.
Strukturtypen können auch Methoden zugeordnet werden, die keinen
Parameter self
haben. Das heißt, sie benötigen keine Instanz des Typen. Syntaktisch verwendet
man einen doppelten Doppelpunkt ::
anstatt eines einfachen Punktes .
, um eine direkt dem Typen
zugeordnete Methode aufzurufen anstatt eine Methode auf einer Instanz.
Im folgenden Beispiel wird die Methode unit
verwendet, um
einen Einheitsvektor zu instanziieren.
#![allow(unused)] fn main() { struct Point { x: f64, y: f64, } enum Axis { X, Y } impl Point { fn unit(axis: Axis) -> Self { match axis { Axis::X => Point { x: 1.0, y: 0.0 }, Axis::Y => Point { x: 0.0, y: 1.0 }, } } fn squared_dist_to_0(self) -> f64 { self.x * self.x + self.y * self.y } } let e1 = Point::unit(Axis::Y); assert!((e1.x).abs() < 1e-12); assert!((e1.y - 1.0).abs() < 1e-12); }
Der Rückgabewert der Methode unit
ist Self
. Das ist eine Alias für den Strukturtyp, dem die
Methode zugeordnet ist. In diesem Fall könnte man also auch Point
schreiben.
Auch Aufzählungstypen können mit Methoden erweitert werden.
#![allow(unused)] fn main() { enum Color { Red, Green, Blue } impl Color { fn color_code(self) -> [u8; 3] { match self { Color::Red => [255, 0, 0], Color::Green => [0, 255, 0], Color::Blue => [0, 0, 255], } } } let clr = Color::Red; let clr_code = clr.color_code(); println!("{}, {}, {}", clr_code[0], clr_code[1], clr_code[2]); }
Primitive Typen haben ebenfalls Methoden. Beispielsweise bestimmt man durch
die Methode abs
den Betrag einer Zahl (engl. absolute value).
#![allow(unused)] fn main() { let x: f32 = -1.0; println!("{}", x.abs()); }
Empfehlenswerterweise ist zu beachten,
dass sowohl Methoden als auch Funktionen nur auf die Variablen Zugriff bekommen,
die sie auch benötigen. Das lässt sich nicht immer erreichen, darf aber gerne als Ziel dienen.
Gerade Methoden machen es einfach, dieses Ziel zu übersehen, da sich hinter self
sehr viele
Variablen verbergen können. Dabei ist es hilfreich Funktionen und Strukturtypen
überschaubar zu halten.
Wir kehren zurück zu unserem Vektor und seiner Länge zurück. Überraschenderweise führt ein zweiter Aufruf der Längenfunktion zu einem Kompilierfehler.
#![allow(unused)] fn main() { struct Point { x: f64, y: f64, } impl Point { fn squared_dist_to_0(self) -> f64 { self.x * self.x + self.y * self.y } } let v = Point{x: 0.1, y: 0.2}; let l = v.squared_dist_to_0(); let l_again = v.squared_dist_to_0(); }
Warum das passiert und wie es sich leicht verhindern lässt, erfahren wir im nächsten Kapitel über Ownership.
Quiz
Fragen
- Worin unterscheiden sich Variablen und Konstanten?
- Was versteht man unter dem Geltungsbereich?
- Was sind primitive Typen?
- Welche zusammengesetzten primitiven Typen stellt Rust bereit und worin unterscheiden sich diese?
- Was ist der Unterschied zwischen einer Funktion und einer Methode?
- Was passiert wenn man folgendes Programm ausführt?
#![allow(unused)] fn main() { let a: u8 = 0; let b: u32 = a; }
- Was bezeichnet das Schlüsselwort
Self
in einem Strukturtypen? - Welche beiden Möglichkeiten gibt es, um einen Wert aus einem Aufzählungstypen zu extrahieren?
- Welchen Wert hat
x
inlet y; let x = y = 5;
?
Aufgaben
1 Fibonacci Zahlen
Ausgehend von 2 gegebenen natürlichen Zahlen \( a, b \in \mathbb N \) ist die Fibonacci-Folge ist definiert durch \[ x_1=a,\ x_2=b,\ x_{n+1}=x_{n-1} + x_{n-2}. \] Schreibe 2 Programme, dass das \( n \)-te Element der Fibonacci folge für berechnet. Eines der 2 Programme verwendet Rekursion und das andere eine Schleife.
2 Duplikate erkennen
Schreibe ein Programm, dass die Anzahl der unterschiedlichen Elemente in einem 6-elementigen Array bestimmt.
Die Werte können entweder den Typ f64
oder den Typ char
haben. Gleichheit bei Gleitkommazahlen ist mit
einer gewissen Toleranz zu überprüfen (Warum?).
Es folgt eine Tabelle mit Eingabe-Ergebnis-Paaren.
Programmeingabe | Erwartetes Programmergebnis |
---|---|
9.4 , 'c' , 0.15+0.15 , 0.1+0.2 , 9.4 , 'c' | 3 |
'f' , 'x' , 'a' , 'a' , 'f' , 'c' | 4 |
'🤓' , 'x' , 'a' , 'a' , '🤓' , '2.3' | 4 |
Ownership
Das Thema Ownership behandelt wie Speicherverwaltung in Rust-Programmen funktioniert. Es ist das wohl größte Einstellungsmerkmal der Sprache. Als Grundlage gelten folgende Regeln.
- Jeder Wert in Rust hat genau einen Besitzer (engl. owner).
- Wenn der Besitzer den Geltungsbereich verlässt, wird der Speicher, den der Wert belegt, freigegeben.
Die Speicherbereiche Heap und Stack
In vielen Programmiersprachen werden die Konzepte Heap und Stack vom Benutzer ferngehalten. Da in Rust auch systemnahes und effizientes Programmieren möglich ist, hilft dem gemeinen Rustprogrammierer ein grundlegendes Verständnis von Heap und Stack durchaus. Heap und Stack sind unterschiedliche Speicherbereiche die einem Programm zur Laufzeit zur Verfügung stehen.
Statische Speicherverwaltung auf dem Stack
Der Stack funktioniert wie ein Stapel nach dem last-in-first-out-Prinzip. Der Stack besteht aus mehreren Stack-Frames. Für jede Funktion wird ein Stack-Frame auf den Stack gelegt. Der Stack-Frame einer Funktion beinhaltet ihre Parameter, die lokalen Variablen in ihrem Geltungsbereich und ihre Rücksprungadresse. Die Parameter, Variablen und die Rücksprungadresse wird bei Aufruf auf den Stack gelegt. Sobald der aktuelle Gültigsbereich verlassen wird, werden auch die Variablen, die im Stack liegen, aufgeräumt. Nach dem Ende der Funktion läuft das Programm ab er Rücksprungadresse weiter. Im Folgenden betrachten wir ein stark vereinfachtes Beispiel.
Der Kommentar // <<<<<<<<<
zeigt an, welche Stelle im Programm der rechts daneben stehenden Stacktabelle entspricht.
Die lokalen Variablen | |
|
|
Die Rücksprungadresse der Funktion | |
|
|
Wir sind wieder zurück in der ursprünglichen Funktion. Die Parameter | |
|
|
Die Verwendung des Stacks wird bereits zur Kompilierzeit festgelegt. Daher muss auch die Größe aller Objekte, die auf dem Stack landen, zur Kompilierzeit bekannt sein. Diese Tatsache begründet den Namen statische Speicherverwaltung. Der Datentyp Array, den wir bereits kennenlernen durften, lebt beispielsweise wie alle primitiven Typen komplett auf dem Stack. Daher muss seine Größe auch zur Kompilierzeit feststehen. Wie man Arrays dynamischer größe auf dem Heap ablegen kann, lernen wir im nächsten Abschnitt.
Dynamische Speicherwaltung auf dem Heap
Objekte deren Größe erst zur Laufzeit feststeht, müssen auf dem Heap gespeichert werden. Arrays
deren Größe erst während
der Laufzeit festgelegt wird sind ein omnipräsentes Beispiel. Die Rust Standardbibliothek stellt dazu glücklicherweise den Typ
Vec
zur Verfügung1.
Es gibt Platformen vor allem im Embeddedbereich, in denen dem Programmierer kein Heap zur Verfügung steht.
Dementsprechend muss man auch auf Vec
und viele andere Implementierungen aus der Standardbibliothek verzichten
2.
Die Syntax zur Erstellung eines Vec
ist vergleichbar zum Array. Man verwendet zusätzlich jedoch
das Macro vec
.
Der Schnipsel
#![allow(unused)] fn main() { let v = vec![73; 100]; }
legt z.B. einen Vec
mit 100 Nullen an. Im Speicher lässt sich das anlegen des Vec
s folgendermaßen skizzieren.
Stack Heap
| address | name | value | | address | value |
| ------- | ---- | -------- | | ------- | ----- |
| | | | | ... | ... |
| | | | | 0x2044 | 73 |
| | | | | 0x2040 | 73 |
| | | | | 0x2036 | 73 |
| ? | | ? | | 0x2032 | 73 |
| ------ | ---- | -------- | | 0x2028 | 73 |
| 0x1064 | v | len, ... | | 0x2024 | 73 |
| | | 0x2020 | ---> | 0x2020 | 73 |
| ------ | ---- | ------- | | 0x2016 | ? |
| 0x1008 | | ? | | 0x2012 | ? |
Wenn man also einen Vec
als lokale Variable in einer Funktion verwendet,
wird ein kleiner Overhead zur Verwaltung mit fester Größe auf den Stack gelegt. Die damit assoziierten Daten landen
auf dem Heap. Das praktische ist, dass sobald der Verwaltungsbereich eines Vec
s aufgeräumt wird, dieser dafür sorgt,
dass auch der Speicher auf dem Heap freigegeben wird.
Üblicherweise ist auf dem Heap deutlich mehr Speicher vorhanden als auf dem Stack.
Wenn wir versuchen eine Millionen 128-Bit Nullen auf dem Stack anzulegen, ist es gar nicht so unwahrscheinlich,
dass das Programm zur Laufzeit mit der Meldung stack overflow
abbricht. Die Grenze ist platformabhängig.
#![allow(unused)] fn main() { let arr: [i128; 1_000_000] = [0; 1_000_000]; }
Der Heap verträgt deutlich mehr.
#![allow(unused)] fn main() { let v: Vec<i128> = vec![0; 100_000_000]; }
Zugriffe auf den Stack sind deutlich schneller.
1: Vec
ist also kein primitiver Typ, da er nicht von der Sprache Rust sondern von ihrer Standardbibliothek implementiert wird.
2: Auf einem handeslüblichen PC muss man nicht damit rechnen, über keinen Heapspeicher zu verfügen.
Klonen und Verschieben
In Rust können wir durch
#![allow(unused)] fn main() { let s = "Hallo Welt"; }
der Variable s
die Zeichenkette "Hallo Welt"
zuweisen. Der Wert von s
ist ein Literal, das Teil
des Kompilats wird. Dieser lässt sich nicht ändern. Wenn wir den Geltungsbereich von s
verlassen wird
auch kein Speicher freigegeben, da der Wert von s
nicht dynamisch allokiert wurde.
Um in Rust mit Zeichenketten wie "Hallo Welt"
zu arbeiten, die nicht zur Kompilierzeit feststehen,
gibt es die Möglichkeit den Typ String
zu verwenden, der von der Standardbibliothek implementiert wird.
Per
#![allow(unused)] fn main() { let s = String::from("Hallo Welt"); }
kann man eine Zeichenkette erzeugen. String
s legen neben einem Verwaltungsoverhead wie Vec
s ihre Daten
hauptsächlich auf dem Heap an. Und ebenfalls
wird der Speicher eines String
s aufgeräumt, wenn die Variable den Geltungsbereich verlässt. Strings können
geändert werden.
#![allow(unused)] fn main() { let mut s = String::from("Hallo "); s.push_str("Welt"); println!("{s}"); }
Move
Wenn eine Stringvariable einer anderen Variable zugewiesen wird, passiert ein sogenannter Move. Das heißt, der Speicher auf dem Heap bleibt erhalten und nur der verwaltende Speicher auf dem Stack ändert sich. Dazu schauen wir uns das folgende Beispiel an.
#![allow(unused)] fn main() { let s = String::from("Wort"); }
Nehmen wir an, die eigentlich Zeichenkette Wort
befinde sich auf dem Heap an der Speicheradresse 0x9ffe4edb6a34
.
Auf dem Stack werden folgende Daten für s
abgelegt.
| address | name | value |
| ------- | ------------------ | ----- |
| | | |
| 0x7060 | Heap Adresse von s | 0x9004 |
| 0x7058 | Kapazität von s | 4 |
| 0x7050 | Länge von s | 4 |
Nun weisen wir die Variable s
der Variablen t
zu.
#![allow(unused)] fn main() { let s = String::from("Wort"); let t = s; println!("{t}"); }
Nun entspricht der Wert von t
dem obigen Wert von s
.
| address | name | value |
| ------- | ------------------ | ----- |
| | | |
| 0x7060 | Heap Adresse von t | 0x9004 |
| 0x7058 | Kapazität von t | 4 |
| 0x7050 | Länge von t | 4 |
| 0x7048 | Heap Adresse von s | ? |
| 0x7040 | Kapazität von s | ? |
| 0x7038 | Länge von s | ? |
Die Variable s
ist nicht mehr gültig. Das bedeutet, dass ein weiterer Zugriff auf s
zu einem Kompilierfehler führt.
#![allow(unused)] fn main() { // kompiliert nicht let s = String::from("Wort"); let t = s; println!("{s}"); }
Auch bei Funktions- und Methodenparametern wird der Teil auf dem Stack kopiert und der Teil auf dem Heap verschoben.
#![allow(unused)] fn main() { // kompiliert nicht fn f(x: String) { println!("x is {x}"); } let s = String::from("Wort"); f(s); println!("s is {s}"); }
Primitive Typen werden direkt kopiert und nicht verschoben, da sie keinen Heapspeicher belegen. Dementsprechend bleiben sie auch nach Zuweisung oder Übergabe an eine Funktion verwendbar.
#![allow(unused)] fn main() { fn f(x: i32) { println!("x is {x}"); } let a = 23; let b = a; f(a); println!("a is {a}"); println!("b is {b}"); }
Klonen von Daten auf dem Heap
String
s und auch Vec
s implementieren eine Methode clone
.
Den obigen Schnipsel können wir entsprechend erweitern.
#![allow(unused)] fn main() { fn f(x: String) { println!("x is {x}"); } let s = String::from("Wort"); let t = s.clone(); // | // erstellt eine Kopie f(t); println!("s is {s}"); }
Die Daten des Strings s
auf dem Heap werden kopiert und von s2
verwendet. Die Methode clone
kann
also zu signifikantem Ressourcenverbrauch führen.
Übrigens verfügen alle primitiven Typen ebenfalls über die Methode clone
. Es ist üblicherweise
nicht nötig, diese aufzurufen, da entsprechende Variablen eh kopiert und nicht verschoben werden.
Methodenaufrufe
Betrachten wir nochmal das Beispiel aus dem vorherigen Kapitel.
#![allow(unused)] fn main() { // kompiliert nicht struct Point { x: f64, y: f64 } impl Point { fn length(self) -> f64 { self.x * self.x + self.y * self.y } } let v = Point{x: 0.1, y: 0.2}; let length = v.length(); let l_again = v.length(); }
Dieses Beispiel kompiliert nicht, weil wir bei Methoden mit self
-Paremeter die Instanz durch den
Funktionsaufruf verschieben. Die Methode übernimmt Ownership über die Instanz.
Denn self
ist nur eine Kurzform. Wir können äquivalent folgende Signatur verwenden.
#![allow(unused)] fn main() { struct Point { x: f64, y: f64, z: f64 } impl Point { fn length(self: Point) -> f64 { self.x * self.x + self.y * self.y } } let v = Point{x: 0.1, y: 0.2}; let length = v.length(); println!("{length}"); }
Wir können v.length()
also kein zweites Mal aufrufen, da die Instanz verschoben wurde.
Es sollte jedoch kein Problem sein einen Point
zu kopieren, da alle Felder
einfach kopiert werden können. In solchen Fällen können Verhaltensweiten von Typen von
ihren Felder abgeleitet werden. Dazu versehen wir unseren Typen mit dem Attribut derive
und spezifizieren, welche Verhaltensweisen von den Feldern abgeleitet werden sollen. In
diesem Fall wird durch Verwendung von
#[derive(Copy, Clone)]
unser Typ kopierbar und
implementiert ohne unser Zutun die Methode clone
.
#![allow(unused)] fn main() { #[derive(Copy, Clone)] struct Point { x: f64, y: f64, z: f64 } impl Point { fn length(self: Point) -> f64 { self.x * self.x + self.y * self.y } } let v = Point{x: 0.1, y: 0.2}; let length = v.length(); println!("{length}"); }
Uns werden noch weitere Verhaltensweisen begegnen. Diese Verhaltensweisen werden in Rust Traits genannt.
Borrowing
Da die Daten auf dem Heap bei einer Moveoperation erhalten bleiben und der kleine Verwaltungsteil
auf dem Stack kopiert wird, ist das Übergeben von Variablen an Funktionen effizient. Nun kann es
aber passieren, dass eine Variable auch nachdem sie von der Funktion verarbeitet wurde,
trotzdem noch verwendet werden möchte. Dazu kann sich eine Funktion eine Variable ausleihen. In
der Signatur der Funktion tritt in diesem Fall ein &
vor den Typen der Variablen. Beim Aufruf
der Funktion zeigt ein weiteres &
an, dass die Variable ausgeliehen wird (engl. borrowing).
#![allow(unused)] fn main() { fn f(x: &String) { println!("{x}"); } let s = String::from("Wort"); f(&s); println!("{s}"); }
Typen, die mit einem &
beginnen, werden auch Referenzen genannt. Denn eine Variable x
vom Typ &String
ist
eine Referenz auf den String
, den sie sich geliehen hat. Beachte, dass x
nicht der Owner des entsprechenden
Wertes ist, sondern lediglich darauf verweist. Insbesondere folgt daraus, dass das Verlassen des Scopes von x
nicht dazu führt, dass der Wert auf den x
verweist, aufgeräumt wird.
Man kann auch eine ausgeliehene Variable ändern, indem man mut &
dem Typ voranstellt. Typen, die mit mut &
beginnen,
heißen veränderliche Referenz (engl. mutable reference).
#![allow(unused)] fn main() { fn f(x: &mut String) { x.push_str("e"); } let mut s = String::from("Wort"); f(&mut s); println!("{s}"); }
Man kann veränderlichen Referenzen neue Werte zuweisen, indem man sie mit *
derefenziert.
#![allow(unused)] fn main() { fn f(x: &mut String) { *x = String::from("Worte"); } let mut s = String::from("Wort"); f(&mut s); println!("{s}"); }
Im Gegensatz zu C++ kann man in Rust zeitgleich immer nur eine veränderliche Referenz auf einen Wert haben. Sogar eine weitere nicht veränderliche Referenz ist nicht möglich. Die veränderliche Referenz könnte zu Änderung des unterliegenden Speichers führen.
#![allow(unused)] fn main() { // kompiliert nicht let mut s = String::from("Wort"); let x = &mut s; let y = &mut s; println!("{x}, {y}"); }
Dadurch werden bereits zur Kompilierzeit sogenannte Data Races verhindert. Data Races entstehen wenn in verschiedenen parallel laufenden Programmteilen zur gleichen Zeit die gleiche Variable verändert wird. Selbst wenn eine Referenz nicht veränderlich ist, besteht das gleiche Problem. Und auch das ist in Rust nicht möglich. Des Weiteren ist ein Move unmöglich, solange eine Referenz auf einen Wert existiert.
#![allow(unused)] fn main() { // kompiliert nicht let mut s = String::from("Wort"); let x = &s; let y = &mut s; println!("{x}, {y}"); }
Zwei Referenzen, die beide nicht veränderlich sind, stellen dagegen kein Problem dar. Denn wenn zwei Programmteile gleichzeitig den gleichen Wert auslesen aber nicht schreiben, ist das völlig unproblematisch.
#![allow(unused)] fn main() { let mut s = String::from("Wort"); let x = &s; let y = &s; println!("{x}, {y}"); }
Einer der Marketingslogans der Programmiersprache Rust lautet Fearless Concurrency, zu deutsch angstfreie Nebenläufigkeit. Die oben beschriebenen Restriktionen von Referenzen sind ein Baustein dieses Konzepts.
Auch Methoden können &self
als Parameter verlangen. Wir betrachten nochmal unser Vektorbeispiel.
#![allow(unused)] fn main() { struct Point { x: f64, y: f64 } impl Point { fn squared_dist_to_0(&self) -> f64 { self.x * self.x + self.y * self.y } } let p = Point{x: 0.1, y: 0.2}; let dist = v.squared_dist_to_0(); let dist = v.squared_dist_to_0(); println!("{dist}"); }
Ein mehrfacher Aufruf der Methode ist kein Problem mehr, da die Methode nicht Ownership ihrer Instanz übernommen hat sondern sich ihre Instanz nur geliehen hat.
Slices
Manchmal ist man nur an einem Teil einer Zeichenkette interessiert. Da Zeichenketten wie Vec
toren und Àrrays
zusammenhängenden Speicher verwalten, ist es Möglich, druch die &[..]
-Syntax Referenzen auf Teilbereiche
des Speichers zu verwenden.
#![allow(unused)] fn main() { let s = String::from("Hallo Welt."); let s_slice = &s[6..10]; println!("{s_slice}"); }
Das Slice &s[6..10]
verweist auf alle Zeichen von inklusive Index 6 bis exklusive Index 10.
Mit ..=
kann man auch die größere Grenze mitnehmen.
#![allow(unused)] fn main() { let s = String::from("Hallo Welt."); let s_slice = &s[6..=10]; println!("{s_slice}"); }
Auch können Grenzen komplett weggelassen werden.
Dadurch wird &s[..10]
zu Hallo Welt
und &s[6..]
zu Welt.
.
#![allow(unused)] fn main() { let s = String::from("Hallo Welt."); println!("{}", &s[..10]); println!("{}", &s[6..]); }
Eine Referenz auf einen Slice eines String
s hat in Rust den Typ &str
. Wenn wir nun eine Funktion
verwenden, deren Argumente vom Slicetyp &str
sind, können wir auch &String
übergeben. Diese werden automatisch
als &s[..]
aufgefasst.
#![allow(unused)] fn main() { fn f(s: &str) { println!("{s}"); } let s = String::from("Hallo Welt."); f(&s[6..10]); f(&s); }
Eine Funktion die &String
als Parameter hat, kann keine &str
-Parameter annehmen.
Warnung
Das Zerteilen von Zeichenketten funktioniert so erstmal nur für ASCII Zeichen. Für Unicode-Zeichen ist die Situation etwas komplizierter. Das wird Gegenstand eines späteren Kapitels sein.
Slices können durch Aufruf der Funktion to_owned
kopiert werden.
#![allow(unused)] fn main() { let v = [1, 2, 3]; let w = v[1..].to_owned(); // w ist eine Instanz von Vec let s = String::from(""); let s1 = s.to_owned(); }
Lebenszeiten von Referenzen
Der folgende Schnipsel wird in Rust bereits zur Kompilierzeit abgefangen, damit es nicht zu Speicherzugriffsverletzungen zur Laufzeit kommt.
#![allow(unused)] fn main() { let r; { let s = String::from("Welt"); r = &s; } println!("{r}"); }
Quiz
Warum würde es hier zu einer Speicherzugriffsverletzung kommen, wenn der Compiler die Übersetzung nicht verweigert hätte?
Lebenszeiten (engl. lifetimes) von Referenzen oder Zeigern auf Speicheradressen sind in vielen Programmiersprachen wichtig. In Rust wird im Gegensatz zu anderen Programmiersprachen die Gültigkeit einer Referenz bereits zur Kompilierzeit überprüft. Dazu braucht der Compiler in mehrdeutigen Situationen Hilfe. Teile des Typs müssen dann entsprechend annotieren. Das Ziel von Lifetimes ist es, baumelnde Referenzen auf ungültige Speicherbereiche (engl. dangling reference) zu verhindern. Zu diesem Zweck zeigt ein Liftime-Parameter dem Compiler an, wie lange eine Referenz benötigt wird. Der Compiler kann dann entscheiden, ob das Programm diese Anforderung unter allen Umständen erfüllen kann. Diese Überprüfung kann zu restritktiv sein, denn der Rust Compiler verfährt nach dem Motto better safe than sorry.
Generische Lifetime-Parameter von Funktionen
Im folgenden Schnispel versuchen wir eine Referenz auf eine lokale Variable zurückzugeben. Der Compiler verweigert glücklicherweise die Übersetzung.
#![allow(unused)] fn main() { // kompiliert nicht fn f() -> &f32 { let x = 0.0; &x } }
Während man in C++ mit einer entsprechenden Funktion eine Speicherzugriffsverletzung zur Laufzeit produziert hätte, bekommt man von einem bestimmten Teil des Rust Compilers, dem sogenannten Borrow-Checker, die folgende Meldung.
4 | fn f() -> &f32 {
| ^ expected named lifetime parameter
Lifetimes von Referenzen werden mit einem Parameter annotiert. Die Syntax ist etwas unüblich. Lifetime-Parameter
starten immer mit einem '
und sind üblicherweise kurz und klein benamt. Wenn in einer Funktion ein Lifetime-Parameter
verwendet wird, muss dieser Parameter einmal in spitzen Klammern direkt nach dem Funktionsnamen deklariert werden und in
allen benötigten Typen der Argumente und möglicherweise im Rückgabetypen annotiert werden.
#![allow(unused)] fn main() { // kompiliert nicht fn f<'a>() -> &'a f32 { let x = 0.0; &x } }
In diesem Fall sind wir dem Wunsch des Compilers nachgekommen und haben den Liftetime-Parameter 'a
hinzugefügt. Nun sagt
uns der Compiler richtigerweise er könne keine Referenz auf eine lokale Variable zurückgeben. Wenn eine Funktion Referenzen
zurückgeben möchte, muss sicher gestellt sein, dass nach Beendigung der Funktion die Referenzen noch gültig sind. Beispielsweise
können wir den kleineren zweier String
s bestimmen, ohne die Strings zu verschieben oder zu klonen.
#![allow(unused)] fn main() { fn smallest<'a>(s1: &'a str, s2: &'a str) -> &'a str { if s1.len() < s2.len() { s1 } else { s2 } } let s1 = String::from("Hallo"); let s2 = String::from("Welt"); println!("{}", smallest(&s1, &s2)); }
Der Lifetime-Parameter 'a
verbindet die Argumente s1
und s2
mit dem Rückgabewert. Er besagt also,
dass der Rückgabewert solange wie beide Eingabeparameter valide sein muss. Eigentlich muss der Rückgabewert
nur solange valide sein wie der kürzere der beiden String
s. Welcher das bei Aufruf der Funktion sein wird,
kann der Compiler aber im Allgemeinen nicht wissen. Dadurch, dass wir die Lebenszeiten aller 3 Referenzen
verknüpfen, verändern wir nicht, wie lange die dazugehörigen Referenzen valide sind. Wir sagen nur dem Borrow-Checker,
dass er alle Referenzen ablehnen soll, die unsere Anforderungen an Lifetimes nicht erfüllen. Den Parameter 'a
nennt
man auch generischen Parameter. Wenn die Funktion konkret verwendet wird, bekommt 'a
einen konkreten Wert und zwar
die kürzerer der Lebenszeiten der Eingabereferenzen.
Im folgenden Beispiel verwenden wir Eingabereferenzen mit unterschiedlichen Lebenszeiten.
fn smallest<'a>(s1: &'a str, s2: &'a str) -> &'a str { if s1.len() < s2.len() { s1 } else { s2 } } fn main() { let s1 = String::from("Hallo"); { let s2 = String::from("Welt"); println!("{}", smallest(&s1, &s2)); } }
Während Referenzen auf s1
bis ans Ende der Mainfunktion valide sind, begrenzt sich die Lebenszeit von s2
-Referenzen
auf den inneren Geltungsbereich. Die generische Lebenszeit 'a
entspricht in diesem Fall der Lebenszeit von &s2
, da diese
die kürzere der beiden ist. Um das zu verfizieren versuchen wir im folgenden Beispiel auf das Ergebnis der Funktion
außerhalb des Geltungsbereichs von s2
zuzugreifen.
//kompiliert nicht fn smallest<'a>(s1: &'a str, s2: &'a str) -> &'a str { if s1.len() < s2.len() { s1 } else { s2 } } fn main() { let s2 = String::from("Welt"); let result; { let s1 = String::from("Hallo"); result = smallest(&s1, &s2); } println!("{}", result); }
Der Borrow-Checker teilt uns mit, dass s1
nicht lange genug lebt. Wir sehen natürlich, dass die Funktion
smallest
gar nicht s1
sondern s2
zurückgegeben hätte. Der Compiler kann das aber nicht erkennen und ist
an dieser Stelle zu restriktiv.
Aufgabe
Erstelle verschiedene Versionen und Variationen der Funktion
smallest
mit unterschiedlichen Lifetime-Annotationen und versuche vor Ausführung vorherzusagen, ob der Compiler das akzeptieren wird.
Statische Lebenszeiten
Wenn kein Argument sondern nur der Rückgabewert einer Funktion eine Referenz ist, gibt es 2 Möglichkeiten:
- Das Programm wird nicht übersetzt, da eine Referenz auf eine lokale Variable der Funktion zurückgegeben wird.
- Es handelt sich um eine Referenz auf ein Literal mit statischer Lebenszeit. Das wird dem Compiler durch die
Annotation
'static
mitgeteilt.
#![allow(unused)] fn main() { fn f() -> &'static str { let literal = &"Hallo Welt"; literal } println!("{}", f()); }
Wie alle Literale von Zeichenketten hat die Variable literal
den Typ &'static str
.
In den wenigsten Fällen wird eine statische Lebenszeit wirklich benötigt. Das kann
durchaus auch dann der Fall sein, wenn der Compiler vorschlägt eine Lebenszeit als 'static
zu annotieren.
Auslassung von Lebenszeiten
Das folgende Beispiel funktioniert, obwohl keine Lifetime-Parameter verwendet werden.
#![allow(unused)] fn main() { fn f(s: &str, i: usize) -> &str { &s[i..] } println!("{}", f(&"ABC", 1)); }
Dieser Fall ist eindeutig. Da es nur eine Eingabe- und einen Ausgabereferenz gibt, nimmt der Compiler automatisch an, dass die Lebenszeiten identisch sind. Das Weglassen von Lifetime-Parametern wird auch Lifetime Elision genannt. Es gibt 3 Regeln für das Weglassen von Lifetime-Parametern:
- Jedes Argument dessen Typ eine Referenz ist, bekommt einen eigenen Lifetime-Parameter. Das heißt
f(n: &i32)
entsprichtf<'a>(n: &'a i32)
,f(n: &i32, m: &i32)
entsprichtf<'a, 'b>(n: &'a i32, m: &'b i32)
, ... . - Wenn es nur ein Referenz-Argument gibt, werden alle Rückgabereferenzen mit dem Lifetime-Parameter des Arguments
versehen. Beispielsweise erhalten wir
f<'a>(n: &'a i32) -> (&'a i32, &'a i32)
. - Bei Methoden wird der Lifetime-Parameter von
&self
für alle relevanten Rückgabewerte verwendet.
Falls sich aus den drei Regeln eine nicht-eindeutige Situation ergibt, verweigert der Compiler die Übersetzung.
Strukturtypen mit Referenzen
Wenn wir eine Referenz in einem Strukturtypen verwenden wollen, benötigen wir zwingend generische Liftime-Parameter. Das heißt,
#![allow(unused)] fn main() { struct Words { data: Vec<&str>, } }
kompiliert nicht. Wir müssen bei Strukturtypen und auch Aufzählungstypen immer Referenzen angeben.
struct Words<'a> { data: Vec<&'a str>, } fn main() { let data = vec!["Today", "is", "a", "good", "day"]; let words = Words{ data }; }
Damit sagen wir, das Instanzen des Typs Words
nicht länger im Geltungsbereich sein dürfen, als
die Variable data
. Implementierungsblöcke von Aufzählungs- und Strukturtypen benötigen ebenfalls
Lifetime-Parameter.
struct Words<'a> { data: Vec<&'a str> } impl<'a> Words<'a> { fn sort_alphabetically(&'a mut self) { self.data.sort(); } fn sort_by_len(&'a mut self) { self.data.sort_by_key(|v| v.len()); } } fn main() { let data = vec!["Today", "is", "a", "good", "day"]; let mut words = Words{ data }; words.sort_by_len(); words.sort_alphabetically(); }
Das vorherige Beispiel kompiliert allerdings nicht.
error[E0499]: cannot borrow `words` as mutable more than once at a time
Wir haben fälschlicherweise den Lifetime-Parameter 'a
zur Markierung der &self
-Parameter
verwendet. Damit teilen wir dem Compiler mit, dass die Lebenszeiten der veränderlichen Referenzen
auf self
der Länge den Lebenszeiten der Elemente von data
gleichen. Damit denkt der Compiler
zwei veränderliche Referenzen sind im kompletten Geltungsbereich von main
aktiv und mehrere Referenzen
auf eine Instanz sind nicht zulässig, sobald eine veränderlich ist. Wir können die Referenzen von Methoden
unabhängig von der Lebenszeit des Strukturtypen vergeben. In diesem Fall brauchen wir sie gar nicht angeben,
da die Situation eindeutig ist und der Compiler sie herleiten kann. Der folgende Schnipsel kompiliert dementsprechend.
struct Words<'a> { data: Vec<&'a str> } impl<'a> Words<'a> { fn sort_alphabetically(&mut self) { self.data.sort(); } fn sort_by_len(&mut self) { self.data.sort_by_key(|word| word.len()); } } fn main() { let data = vec!["today", "is", "a", "good", "day"]; let mut words = Words{ data }; words.sort_by_len(); println!("{:?}", words.data); words.sort_alphabetically(); println!("{:?}", words.data); }
Übrigens werden die Vec
-Methoden sort
und sort_by_key
freundlicherweise von der Rust Standardbibliothek
bereit gestellt. Während sort
eine dem Datentyp entsprechende Sortierung vornimmt, kann der Programmierer
bei Verwendung von sort_by_key
durch eine anonyme Funktion selber entscheiden, wonach sortiert werden soll.
Die anonyme Funktion bildet ein Element des Vektors auf das gewünschte Krieterium ab. Hier verwenden wir
|word: &str| word.len()
um die Länge der einzelnen Wörter zu berechnen.
Quiz
Fragen
- Welche(n) Vorteil(e) hat der Stack gegenüber dem Heap?
- Welche(n) Vorteil(e) hat der Heap gegenüber dem Stack?
- Was ist ein Stack Overflow (nicht die Webseite)?
- Was passiert bei der Zuweisung
let b = a;
, wenna
Owner von Stack-Speicher ist? - Was passiert bei der Zuweisung
let b = a;
, wenna
Owner von Stack- und Heap-Speicher ist? - Warum kann der Wert einer Variable nicht ausschließlich im Heap-Speicher leben?
- Warum kann die Methode
fn mymethod(self){}
des Struktutypenstruct MyStruct;
nicht zwei Mal auf der gleichen Instanz aufgerufen werden? - Wie kann die Signatur von
mymethod
verändern, um mehrmaliges Aufrufen zu ermöglichen? - Verfügt die Programmiersprache C über das Konzept der Lifetimes?
- Welche Möglichkeiten gibt es, den Compiler zum Übersetzen der folgenden Funktion zu bringen?
#![allow(unused)] fn main() { fn f(s1: &str, s2: &str) -> &str { &"" } }
- Warum hat bei der vorangegangen Funktion Lifetime-Elision nicht funktioniert?
Aufgabe
Schreibe eine Funktion, die das Skalarprodukt zwischen 2 dünnbesetzten Vektoren \( x, y\in\mathbb R^n \) berechnet. Hinweise:
- Das Skalarprodukt zwischen \( x \) und \( y \) ist gegeben durch \( \sum_{i=1}^n x_iy_i \).
- Ein Vektor heißt dünnbesetzt (engl. sparse), wenn sehr viele seiner Elemente 0 sind.
- Wie kann man einen dünnbesetzten Vektor effizient im Speicher halten?
Teste die Funktion für ein beliebiges paar Vektoren und \( n=10^{10} \).
Generische Typen und wichtige Anwendungen
Eines der zentralen Konzepte zum Vermeiden von Codeduplikation sind generische Typen.
Beispielsweise kann man eine Funktion oder einen Typen generisch definieren anstatt
für einen konkreten Typen wie f32
um diese auch mit f64
verwenden zu können.
Der uns bereits bekannte Typ Vec
hat einen generischen Parameter T
der angibt, welchen Typ die Elemente
des Vektors haben. Generische Typparameter schreibt man wie generische Liftime-Parameter in spitze Klammern.
Dementsprechend ist Vec<f32>
ein Vektor mit Elementen des Typs f32
und Vec<f64>
ein Vektor mit Elementen des Typs f64
. Auch die primitiven zusammengesetzten
Typen Array [T; n]
und Tupel (T1, T2)
haben einen generischen Typparameter. Deren Typannotation
verwendet also keine Spitze Klammern. Z.B. ist [u8; 5]
ein 5-elementiger Array natürlicher
Zahlen bis 255. Das Tupel (String, bool, f64)
hat eine Zeichenkette als ersten Typ,
einen Wahrheitswert als zweiten Typ und eine Gleitkommazahl als dritten Typ.
Im Folgenden wollen wir unser Point
-Beispiel aus dem Grundlagenkapitel generalisieren.
struct Point { x: f64, y: f64 } impl Point { fn squared_dist_to_0(&self) -> f64 { self.x * self.x + self.y * self.y } } fn main() { let v = Point{ x: 1.0, y: 1.0 }; let subtraction = v.squared_dist_to_0(); }
Point
und seine Methode length
sind nur für den Typ f64
definiert. Ein f32
-Point
ergibt aber genauso Sinn.
Alles zu duplizieren und nur f64
durch
f32
zu ersetzen, ist keine elegante Option. Wir können einen generischen Typen deklarieren.
struct Point<T> { x: T, y: T, } impl<T> Point<T> { fn squared_dist_to_0(&self) -> T { self.x * self.x + self.y * self.y } } fn main() { let v = Point{ x: 1.0, y: 1.0 }; v.squared_dist_to_0(); }
Wenn wir den obigen Schnipsel ausführen wollen, beschwert sich der Compiler, der Typ T
unterstütze weder Multiplikation
noch Addition. Da wir wissen, dass f64
Multiplikation unterstützt, können wir eine spezielle Implementierung für f64
bereit stellen.
struct Point<T> { x: T, y: T, } impl Point<f64> { fn squared_dist_to_0(&self) -> f64 { self.x * self.x + self.y * self.y } } fn main() { let v = Point{ x: 1.0, y: 1.0 }; v.squared_dist_to_0(); let v: Point<i32> = Point{ x: 0, y: 1 }; // v.squared_dist_to_0(); existiert nicht }
Das ist jedoch nur eine Verschiebung des Problems, denn für f32
müssten wir squared_dist_to_0
ebenfalls separat
implementieren.
Wir brauchen eine allgemeine Bedingung des generischen Typs, die nur diejenigen konkreten Typen erlaubt, die
diese Bedingung erfüllen. Daher werden wir
im nächsten Abschnitt über Traits sprechen.
Typgrenzen mit Traits
Traits definieren Verhaltensweisen die mehrere Typen gemein haben. Für einen generischen Typen werden nur diejenigen konkreten Typen zugelassen, die das Verhalten der entsprechenden Traits implementieren.
Typen implementieren Traits
Für unser Point
-Beispiel müssen wir dem generischen Typen eine Grenze mitteilen. Die entsprechenden Traits
werden von der Rust Standardbibliothek bereit gestellt. Um sie zu verwenden, müssen wir sie importieren. In Rust
verwendet man dazu das Schlüsselwort use
gefolgt vom Pfad der Entität, die man importieren möchte.
Teil des Pfads sind Module. In unserem Fall importieren wir Mul
und Add
aus dem Module std::ops
.
Wir werden uns in einem späteren Abschnitt genauer mit Modulen beschäftigen. Für den Moment nehmen wir hin,
dass Module Funktionalität gruppieren und beschäftigen uns nun weiter mit Traits.
Ein Typ, der den Trait Mul
implementiert,
lässt sich mit *
multiplizieren. Für primitive numerische Typen hat die Standardbibliothek für uns die Implementierung des
Traits übernommen.
Syntaktisch wird die Typgrenze
mit einem Doppelpunkt vom generischen Typen getrennt wie im folgenden Beispiel ersichtlich.
use std::ops::{Mul, Add}; struct Point<T: Mul<Output=T> + Add<Output=T> + Copy> { x: T, y: T, } impl<T: Mul<Output=T> + Add<Output=T> + Copy> Point<T> { fn squared_dist_to_0(&self) -> T { self.x * self.x + self.y * self.y } } fn main() { let v = Point{ x: 1.0, y: 1.0}; v.squared_dist_to_0(); let v: Point<i32> = Point{ x: 0, y: 1}; v.squared_dist_to_0(); }
Wenn wir
Point<T: Mul<Output=T> + Add<Output=T> + Copy>
genauer betrachten, fallen drei Punkte auf.
- Es treten neben den Traits
Mul
undAdd
der TraitCopy
auf. Typen, die den TraitCopy
implementieren, verhalten sich kopierbar. Das heißtself.x * self.x
multipliziert zwei Kopien vonself.x
. Es findet kein Move statt.Quiz
Was würde passieren, wenn
T
nichtCopy
implementierte? - Mehrere Traits, die durch ein Plus separiert werden wie
Mul<Output=T> + Add<Output=T> + Copy
, müssen allesamt implementiert worden sein. - Die Traits
Mul
undAdd
haben ein generisches ArgumentOutput = T
. Das ist ein assoziierter Typ, der den Rückgabewert der Addition festlegt. Oft verwendet man hierSelf
. Beispiele für andere Typen werden wir im Kapitel zur Fehlerbehandlung sehen.
Die Traits Mul
und Add
sowie auch Div
und Sub
haben eine spezielle Bedeutung in Rust. Typen, die diese Traits implementieren,
können in Rust per *
, +
, /
oder -
multipliziert, addiert, dividert oder subtrahiert werden. Wir gehen nun einen Schritt weiter
und implementieren Add
für unseren Strukturtypen Point
.
Die Definition des Add
-Traits in der Standarbibliothek sieht im Wesentlichen folgendermaßen aus.
#![allow(unused)] fn main() { trait Add<Rhs=Self> { type Output; fn add(self, rhs: Rhs) -> Self::Output; } }
Der Kern ist die Funktionssignatur add
. Diese muss von allen Typen, die Add
implementieren möchten, mit Leben gefüllt werden.
Das Schlüsselwort Self
bezeichnet den Typen, der den Trait implementiert. Die Zeile type Output
definiert den bereits erwähnten
assoziierten Typen, der mit Self::Output
referenziert werden kann. Rhs
ist ein generischer Typ des Traits, der dazu verwendet
werden kann, der rechten Seite der Addition einen anderen Typen zu verpassen als Self
. Standardmäßig hat die rechte Seite
den Typ Self
was durch <Rhs=Self>
festgelegt wird. Wenn wir also Add
verwenden ohne Rhs
zu spezifizieren, wird Self
angenommen.
Unser Typ Point
kann Add
nun folgendermaßen implementieren.
use std::ops::{Mul, Add}; struct Point<T: Mul<Output=T> + Add<Output=T> + Copy> { x: T, y: T, } impl<T: Mul<Output=T> + Add<Output=T> + Copy> Point<T> { fn squared_dist_to_0(&self) -> T { self.x * self.x + self.y * self.y } } impl<T: Mul<Output=T> + Add<Output=T> + Copy> Add for Point<T> { type Output = Self; fn add (self, rhs: Self) -> Self { Self { x: self.x + rhs.x, y: self.y + rhs.y, } } } fn main() { let p = Point{ x: 1.0, y: 1.0}; let p = p + p; // äquivalent zu v.add(v) p.squared_dist_to_0(); }
Wenn wir den obigen Schnipsel ausführen, bekommen wir den erwartbaren Fehler, dass v
bereits verschoben wurde,
da Point
nicht direkt kopierbar ist.
Im Kapitel über Ownership haben wir gelernt, dass alle primitive Typen direkt kopiert werden können und nicht verschoben
werden, da sie keinen Speicher auf dem Heap belegen. An dieser Stelle wollen wir etwas genauer sein.
Rust implementiert für alle primitiven Typen den Trait Copy
. Der Trait Copy
kann für alle Typen implementiert werden,
die keine spezielle Aufräumoperation benötigen, wenn sie ihren Geltungsbereich verlassen. Der Typ Vec
gibt beispielsweise
den Heap-Speicher frei, den er belegt, wenn er seinen Geltungsbereich verlässt. Das wird durch Implementierung des Traits
Drop
bewerkstelligt. Desweiteren muss ein Typ, der Copy
implementiert, dem Compiler mitteilen wie die eigentliche Kopie funktioniert.
Das passiert durch Implementierung des Traits Clone
. Der Typ Vec
beispielsweise implementiert Clone
aber nicht Copy
. Wir können
eine Instanz von Vec
durch Aufruf der Methode clone
klonen. Diese Aktion dupliziert den kompletten Speicher auf dem Stack und
auf dem Heap für einen Vec
.
#![allow(unused)] fn main() { let v1 = vec![1, 2, 3]; let v2 = v1.clone(); assert_eq!(v1, v2); }
In Rust können also alle Typen Copy
implementieren, die Clone
implentieren Drop
aber nicht. Unser Typ Point
hat nur primitiv typisierte Felder. Kein primitiver Typ implementiert
Drop
und alle primitiven Typen implementieren Copy.
Daher spricht nichts dagegen, dass Point
ebenfalls Copy
implementiert. In Rust können wir mit Hilfe des
derive
-Attributs die Implementierung eines Traits an seine Felder zu delegieren.
#![allow(unused)] fn main() { #[derive(Copy, Clone)] struct Point<T> where T: Mul<Output=T> + Add<Output=T> + Copy { x: T, y: T, } }
Die Zeile #[derive(Copy, Clone)]
bewirkt, dass die Implementierung der Traits Copy
und Clone
des Typen
Point
sich aus den Implementierungen der Felder von Point
ergibt.
Des Weiteren haben wir die Typgrenzen der Übersicht wegen hinter das Schlüsselwort where
gesetzt. Diese Schreibweise ist
äquivalent zur bisher Verwendeten. Der folgende Schnipsel zeigt uns nun, wie ein addierbarer Point
aussehen kann.
use std::ops::{Mul, Add}; #[derive(Copy, Clone)] struct Point<T> where T: Mul<Output=T> + Add<Output=T> + Copy { x: T, y: T, } impl<T> Point<T> where T: Mul<Output=T> + Add<Output=T> + Copy { fn dist_to_0(&self) -> T { self.x * self.x + self.y * self.y } } impl<T: Mul<Output=T> + Add<Output=T> + Copy> Add for Point<T> { type Output = Self; fn add (self, rhs: Self) -> Self { Self { x: self.x + rhs.x, y: self.y + rhs.y, } } } fn main() { let v = Point{ x: 1.0, y: 1.0}; let v = v + v; v.squared_dist_to_0(); }
Auch Aufzählungstypen können über generische Typparameter verfügen. Wir werden die beiden wichtigen
Beispiele Option
und Result
demnächst kennenlernen.
Generische Funktionen
Wie Typen können auch Funktionen und Methoden über generische Typen verallgemeinert werden.
Nehmen wir an, wir wollen zwei Instanzen des Typen Point
bzgl. ihres Abstands zum Ursprung miteinander vergleichen.
use std::ops::{Mul, Add}; #[derive(Copy, Clone)] struct Point<T> where T: Mul<Output=T> + Add<Output=T> + Copy { x: T, y: T, } impl<T> Point<T> where T: Mul<Output=T> + Add<Output=T> + Copy { fn squared_dist_to_0(&self) -> T { self.x * self.x + self.y * self.y } } impl<T: Mul<Output=T> + Add<Output=T> + Copy> Add for Point<T> { type Output = Self; fn add (self, rhs: Self) -> Self { Self { x: self.x + rhs.x, y: self.y + rhs.y, } } } // kompiliert nicht fn longest_dist_to_0<T>(p1: Point<T>, p2: Point<T>) -> T where T: Mul<Output=T> + Add<Output=T> + Copy { let d1 = p1.squared_dist_to_0(); let d2 = p2.squared_dist_to_0(); if d1 > d2 { d1 } else { d2 } } fn main() { let p1 = Point{ x: 1.0, y: 1.0}; let p2 = Point{ x: 2.0, y: 1.0}; let dist = longest_dist_to_0(p1, p2); println!("{}", dist); }
Das funktioniert so nicht, da wir von T
nicht verlangt haben, per >
verglichen werden zu können.
Rust stellt zu diesem Zweck den Trait std::cmp::PartialOrd
bereit, den alle primitiven numerischen Skalare
implementieren. Es reicht, den generischen Typen T
nur für die Funktion einzuschränken, denn in der Implementierung
von Point
verwenden wir PartialOrd
nicht.
use std::cmp::PartialOrd; use std::ops::{Mul, Add}; #[derive(Copy, Clone)] struct Point<T> where T: Mul<Output=T> + Add<Output=T> + Copy { x: T, y: T, } impl<T> Point<T> where T: Mul<Output=T> + Add<Output=T> + Copy { fn squared_dist_to_0(&self) -> T { self.x * self.x + self.y * self.y } } impl<T: Mul<Output=T> + Add<Output=T> + Copy> Add for Point<T> { type Output = Self; fn add (self, rhs: Self) -> Self { Self { x: self.x + rhs.x, y: self.y + rhs.y, } } } fn longest_dist_to_0<T>(p1: Point<T>, p2: Point<T>) -> T where T: Mul<Output=T> + Add<Output=T> + Copy + PartialOrd // | // | // hier fordern wir, dass T partiell geordnet ist { let d1 = p1.squared_dist_to_0(); let d2 = p2.squared_dist_to_0(); if d1 > d2 { d1 } else { d2 } } fn main() { let p1 = Point{ x: 1.0, y: 1.0}; let p2 = Point{ x: 2.0, y: 1.0}; let dist = longest_dist_to_0(p1, p2); println!("{dist}"); }
Traits zusammenfassen
Unsere Typgrenze T: Mul<Output=T> + Add<Output=T> + Copy
ist etwas länglich. Wir können sie in einem
neuen Trait zusammenfassen und packen Substraktion dazu.
#![allow(unused)] fn main() { use std::ops::{Mul, Add, Sub}; trait Calculate: Mul<Output=Self> + Add<Output=Self> + Sub<Output=Self> + Copy {} }
Wir müssen zusätzlich noch dem Compiler mitteilen, dass alle Typen, die alle Bestandteile des kombinierten
Traits implementieren, auch den kombinierten Trait selbst implementieren. Dazu erstellen wir die Implementierung
von Calculate für alle T
, die unsere Auswahl an Berechnungstraits implementieren.
#![allow(unused)] fn main() { use std::ops::{Mul, Add, Sub}; trait Calculate: Mul<Output=Self> + Add<Output=Self> + Sub<Output=Self> + Copy {} impl<T> Calculate for T where T: Mul<Output=Self> + Add<Output=Self> + Sub<Output=Self> + Copy {} }
Der Trait Calculate
erbt1
das Verhalten der Traits Mul
, Add
und Copy
. Wir werden im Abschnitt über objektorientierte
Programmierung genauer auf die Vererbung von Traits eingehen.
Benutzerdefinierte Traits
Man kann nicht nur bereitgestellte Traits verwenden, man kann auch selbst welche definieren. Wir werden dafür im Folgenden zwei Beispiele sehen.
Auch für einen Strukturtypen, der einen Kreis repräsentiert, kann man den Abstand zum Ursprung bestimmen.
Um den quadratischen Abstand des Kreisrands zur 0 zu bestimmen, benötigen wir allerdings die Wurzel. Die Rust
Standardbibliothek stellt keinen Wurzel-Trait bereit. Daher implementieren wir einen entsprechenden Trait Sqrt
selbst.
Da nur f32
und f64
das Wurzelziehen unterstützen, implementieren wir Sqrt
nur für f32
und f64
.
#![allow(unused)] fn main() { use std::ops::{Mul, Add, Sub}; trait Calculate: Mul<Output=Self> + Add<Output=Self> + Sub<Output=Self> + Copy {} impl<T: Mul<Output=Self> + Add<Output=Self> + Sub<Output=Self> + Copy> Calculate for T {} #[derive(Copy, Clone)] struct Point<T> where T: Calculate { x: T, y: T, } impl<T> Point<T> where T: Calculate { fn squared_dist_to_0(&self) -> T { self.x * self.x + self.y * self.y } } impl<T: Mul<Output=T> + Add<Output=T> + Copy> Add for Point<T> where T: Calculate { type Output = Self; fn add (self, rhs: Self) -> Self { Self { x: self.x + rhs.x, y: self.y + rhs.y, } } } trait Sqrt { fn sqrt(self) -> Self; } impl Sqrt for f64 { fn sqrt(self) -> f64 { self.sqrt() } } impl Sqrt for f32 { fn sqrt(self) -> f32 { self.sqrt() } } struct Circle<T: Calculate> { center: Point<T>, r: T } impl<T> Circle<T> where T: Calculate + Sqrt { fn squared_dist_to_0(&self) -> T { let d = self.center.squared_dist_to_0().sqrt() - self.r; d * d } } }
Um unsere Funktion longest_dist_to_0
auch auf Kreise anwenden zu können, brauchen wir einen weiteren Trait.
#![allow(unused)] fn main() { use std::ops::{Mul, Add, Sub}; trait Calculate: Mul<Output=Self> + Add<Output=Self> + Sub<Output=Self> + Copy {} impl<T: Mul<Output=Self> + Add<Output=Self> + Sub<Output=Self> + Copy> Calculate for T {} trait MeasureDistanceTo0<T: Calculate> { fn squared_dist_to_0(&self) -> T; } }
Nun ändern wir Point
und Circle
so, dass sie squared_dist_to_0
als Funktion von MeasureDistanceTo0
implementieren.
#![allow(unused)] fn main() { use std::ops::{Mul, Add, Sub}; trait Calculate: Mul<Output=Self> + Add<Output=Self> + Sub<Output=Self> + Copy {} impl<T: Mul<Output=Self> + Add<Output=Self> + Sub<Output=Self> + Copy> Calculate for T {} trait MeasureDistanceTo0<T: Calculate> { fn squared_dist_to_0(&self) -> T; } #[derive(Copy, Clone)] struct Point<T> where T: Calculate { x: T, y: T, } trait Sqrt { fn sqrt(self) -> Self; } impl Sqrt for f64 { fn sqrt(self) -> f64 { self.sqrt() } } impl Sqrt for f32 { fn sqrt(self) -> f32 { self.sqrt() } } impl<T> MeasureDistanceTo0<T> for Point<T> where T: Calculate { fn squared_dist_to_0(&self) -> T { self.x * self.x + self.y * self.y } } struct Circle<T: Calculate> { center: Point<T>, r: T } impl<T> MeasureDistanceTo0<T> for Circle<T> where T: Calculate + Sqrt { fn squared_dist_to_0(&self) -> T { self.center.squared_dist_to_0().sqrt() - self.r } } }
Damit können wir longest_dist_to_0
so anpassen, dass sie Punkte mit Kreisen bzgl. ihres
Abstands zum Ursprung vergleichen kann.
use std::ops::{Mul, Add, Sub}; trait Calculate: Mul<Output=Self> + Add<Output=Self> + Sub<Output=Self> + Copy {} impl<T: Mul<Output=Self> + Add<Output=Self> + Sub<Output=Self> + Copy> Calculate for T {} trait MeasureDistanceTo0<T: Calculate> { fn squared_dist_to_0(&self) -> T; } #[derive(Copy, Clone)] struct Point<T> where T: Calculate { x: T, y: T, } impl<T> MeasureDistanceTo0<T> for Point<T> where T: Calculate { fn squared_dist_to_0(&self) -> T { self.x * self.x + self.y * self.y } } struct Circle<T: Calculate> { center: Point<T>, r: T } trait Sqrt { fn sqrt(self) -> Self; } impl Sqrt for f64 { fn sqrt(self) -> f64 { self.sqrt() } } impl Sqrt for f32 { fn sqrt(self) -> f32 { self.sqrt() } } impl<T> MeasureDistanceTo0<T> for Circle<T> where T: Calculate + Sqrt { fn squared_dist_to_0(&self) -> T { self.center.squared_dist_to_0().sqrt() - self.r } } fn longest_dist_to_0<T, M1, M2>(p1: M1, p2: M2) -> T where T: Calculate + PartialOrd, M1: MeasureDistanceTo0<T>, M2: MeasureDistanceTo0<T> { let d1 = p1.squared_dist_to_0(); let d2 = p2.squared_dist_to_0(); if d1 > p2.squared_dist_to_0() { d1 } else { d2 } } fn main() { let p = Point{ x: 1.0, y: 1.0 }; let r = 0.5; let c = Circle{ center: p, r }; let dist = longest_dist_to_0(p, c); println!("{}", dist); }
Zur Ausgabe auf dem Bildschirm gibt es die trait
s std::fmt::Display
und std::fmt::Debug
. Im Falle von Point können
wir sie mittels #[derive(Display, Debug)]
herleiten. Display
definiert, was in einem println!
-Makro innerhalb von {}
angezeigt wird, während Debug
für hilfreiche Debugging-Informationen bei Verwendung von {:?}
oder der pretty-print-Version {:#?}
sorgt.
Quiz
Wofür ist der Trait
Drop
zuständig?Unter welchen Bedingungen kann ein Typ in Rust den Trait
Copy
implementieren und was muss man tun damit er das auch wirklich tut?Erstelle einen Strukturtypen
Line
der eine beliebige Gerade im \( \mathbb R^2 \) repräsentiert. Implementiere fürLine
den TraitMeasureDistanceTo0
und verwendeLine
in der Funktionlongest_dist_to_0
. WennLine
durch ihren Normalenvektor \( n \in \mathbb R^2 \) und einen beliebigen Punkt auf der Geraden \( p \in \mathbb R^2 \) gegeben ist, lässt sich der Abstand \( d \) zum Ursprung durch \( d=\underbrace{<\frac{n}{ |n| }, p>}_{\text{Skalarprodukt}} \) berechnen, wobei \( | \cdot |: \mathbb R^2 \rightarrow \mathbb R \) die euklidische Norm in \( \mathbb R^2 \) bezeichnet. Dementsprechend gilt \[ |n| = \sqrt{n_1^2 + n_2^2}. \]
Default-Implementierungen
Traits können Default-Implementierungen von Methoden bereit stellen wie im folgenden Beispiel.
use std::ops::{Mul, Add, Sub}; trait Calculate: Mul<Output=Self> + Add<Output=Self> + Sub<Output=Self> + Copy {} impl<T: Mul<Output=Self> + Add<Output=Self> + Sub<Output=Self> + Copy> Calculate for T {} trait Sqrt { fn sqrt(self) -> Self; } impl Sqrt for f64 { fn sqrt(self) -> f64 { self.sqrt() } } impl Sqrt for f32 { fn sqrt(self) -> f32 { self.sqrt() } } trait MeasureDistanceTo0<T: Calculate + Sqrt> { fn squared_dist_to_0(&self) -> T; fn dist_to_0(&self) -> T { self.squared_dist_to_0().sqrt() } } #[derive(Copy, Clone)] struct Point<T> where T: Calculate { x: T, y: T, } impl<T> MeasureDistanceTo0<T> for Point<T> where T: Calculate + Sqrt { fn squared_dist_to_0(&self) -> T { self.x * self.x + self.y * self.y } } fn main() { let p = Point{ x: 1.0, y: 1.0 }; let squared_dist = p.squared_dist_to_0(); let dist = p.dist_to_0(); println!("{squared_dist}, {dist}"); }
Default-Implementierungen können über self
nur auf andere Methoden des Traits zugreifen, aber nicht auf
Methoden oder Felder der implementierenden Strukturtypen.
Typen können auch eigene Implementierungen bereitstellen und die Default-Implementierung überschreiben.
Die Syntax ist unabhängig von der Existenz einer Default-Implementierung.
1: Das ist übrigens die einzige Art Vererbung, die in Rust existiert. Es können nur Traits voneinander erben. Strukturtypen können das glücklicherweise nicht, wie man es von bedauernswerten objektorientierten Programmiersprachen kennt.
Closures
Closures sind anonyme Funktionen, die Variablen aus ihrer Umgebung erfassen können.
Das Erfassen von Variablen aus der Umgebung durch Closures nennt sich auch Currying.
Beispielsweise gibt f
den Wert von x
zurück und hat selbst keine Argumente.
#![allow(unused)] fn main() { let x = vec![0]; let f = || x; assert_eq!(f()[0], 0); }
Wenn wir f
aber ein 2tes Mal aufrufen, bekommen wir einen Fehler.
#![allow(unused)] fn main() { // kompiliert nicht let x = vec![0]; let f = || x; f(); assert_eq!(f()[0], 0); }
Die Rückgabe von x ist ein Move. Den können wir nur einmal ausführen. Daher können wir
f
auch nur einmal aufrufen. Closures, die mindestens einmal aufgerufen werden können, implementieren
den Trait FnOnce
.
fn sort<F>(f: F) -> Vec<i32> where F: FnOnce() -> Vec<i32> { let mut v = f(); v.sort(); v } fn main() { let x = vec![1, 0, 2]; let f = || x; let sorted = sort(f); assert_eq!(sorted, vec![0, 1, 2]); }
Wir klonen den Rückgabewert von f
, damit wir f
mehrfach aufrufen können. Der Trait für beliebig oft
aufrufbare Closures heißt Fn
.
fn sort<F>(f: F) -> Vec<i32> where F: Fn() -> Vec<i32> { f(); let mut v = f(); v.sort(); v } fn main() { let x = vec![1, 0, 2]; let f = || x.clone(); let sorted = sort(f); assert_eq!(sorted, vec![0, 1, 2]); f(); }
Bei der Übergabe an sort
findet ein Move oder eine Kopie von f
und aller erfassten
Variablen von f
statt. Die Variable x
aus der Umgebung wird in diesem Fall per Referenz erfasst.
Da Referenzen kopierbar sind, kann man f
nach Übergabe an sort
erneut ausführen.
Durch Verwendung des Schlüsselworts move
werden die Variablen nicht mehr per Referenz erfasst, sondern
verschoben oder kopiert abhängig vom Vorhandensein einer Copy
-Implementierung.
Mit move
ist ein Aufrufen f
s nach Übergabe an sort
nicht möglich.
// kompiliert nicht fn sort<F>(f: F) -> Vec<i32> where F: Fn() -> Vec<i32> { let mut v = f(); v.sort(); v } fn main() { let x = vec![1, 0, 2]; let f = move || x.clone(); let sorted = sort(f); f(); }
Man kann f
natürlich per Referenz an sort
übergeben.
fn sort<F>(f: &F) -> Vec<i32> where F: Fn() -> Vec<i32> { let mut v = f(); v.sort(); v } fn main() { let x = vec![1, 0, 2]; let f = move || x.clone(); let sorted = sort(&f); assert_eq!(sorted, vec![0, 1, 2]); f(); }
Es gibt auch Closures, die Umgebungsvariablen manipulieren. Diese implementieren FnMut
.
fn sort<F>(f: &mut F) -> Vec<i32> where F: FnMut() -> Vec<i32> { let mut v = f(); v.sort(); v } fn main() { let mut x = vec![1, 0, 2]; let mut f = move || { x[0] += 5; x.clone() }; let sorted = sort(&mut f); assert_eq!(sorted, vec![0, 2, 6]); assert_eq!(f(), vec![11, 0, 2]); }
Quiz
Warum ist das erste Element des Vektors im letzten Assert
11
?Warum würde im obigen Beispiel
fn sort(f: fn() -> Vec<i32>) -> Vec<i32>
nicht funktionieren?
Eine Closure, die Fn
implementiert, implementiert auch FnMut
. Eine Closure die FnMut
implementiert,
implementiert auch FnOnce
.
Optionale Werte und Fehlerbehandlung
In diesem Abschnitt lernen wir zwei generische Aufzählungstypen kennen, die eine große Bedeutung in Rust haben und von Rust bereit gestellt werden.
Optionale Werte
In anderen Sprachen wie C++, Java oder Python muss man zuweilen zur Laufzeit überprüfen,
ob der Wert einer Variable vorhanden ist oder ob ihr Wert Null ist. In C++ entspricht das
nullptr
, in Java null
oder in Python None
. Manchmal wird eine derartige
Überprüfung vergessen. Dann wird auf den nullartigen Wert in einer Art zugegriffen die vom
Nullwert nicht unterstützt wird. Das führt zu hässlichen Fehlern.
Der Erfinder der Laufzeit-Null nennt ihre Einführung ein "Billion-Dollar Mistakte". In
Rust gibt es zwar einen Wert None
. Der Zugriff wird jedoch bereits zur Kompilierzeit überprüft.
Es ist dementsprechend nicht ohne Weiteres möglich, Laufzeitfehler wegen eines Zugriffs auf
None
zu produzieren.
Zur Implementierung stellt Rust den generischen Aufzählungstyp Option<T>
bereit. Option<T>
hat
die beiden Varianten Some(T)
und None
und ist folgendermaßen in der
Standardbibliothek definiert.
#![allow(unused)] fn main() { enum Option<T> { Some(T), None, } }
Im folgenden Beispiel werden wir Option
so verwenden, wie wir das von Aufzählungstypen kennen.
fn add(a: Option<i32>, b: Option<i32>) -> Option<i32> { match a { Some(a) => match b { Some(b) => Some(a + b), _ => None } _ => None, } } fn main() { assert_eq!(add(Some(1), Some(2)), Some(3)); assert_eq!(add(Some(1), None), None); }
Da Option
integraler Bestandteil der Sprache ist, brauchen wir weder Option
importieren, noch
Option::
vor die Varianten Some
und None
schreiben. Der obere Schnipsel lässt sich vereinfachen,
da man match
auch auf Tupel anwenden kann.
fn add(a: Option<i32>, b: Option<i32>) -> Option<i32> { match (a, b) { (Some(a), Some(b)) => Some(a + b), _ => None } } fn main() { assert_eq!(add(Some(1), Some(2)), Some(3)); assert_eq!(add(Some(1), None), None); }
Es geht auch ohne match
. Der Aufzählungstyp Option
implementiert die Methoden
map
und
and_then
.
Die Methode map
bekommt als Argument eine FnOnce
-Closure,
die T
auf U
abbildet. Falls die Option einen einen Wert beinhaltet, ist das Ergebnis ein Some(U)
ansonten None
.
Auch and_then
hat nur eine FnOnce
-Closure als Argument. Diese gibt allerdings direkt den resultierenden optionalen Wert
vom Typ Option<U>
zurück.
fn add(a: Option<i32>, b: Option<i32>) -> Option<i32> { a.and_then(|a| b.map(|b| a+b)) } fn main() { assert_eq!(add(Some(1), Some(2)), Some(3)); assert_eq!(add(Some(1), None), None); }
Weitere hilfreiche Methoden sind in der Dokumentation zu finden.
Beispielsweise kann man mit unwrap_or
einen Default-Wert im None
-Fall festlegen.
Wenn man sich ganz sicher ist, dass der Wert einer Variable die Variante Some
beinhaltet und nicht None
, kann man
mit expect
direkt den Wert auspacken. Hier ist sehr große Vorsicht geboten! Falls die Variable wider Erwarten doch
None
war, bricht das Programm kontrolliert ab und gibt eine Fehlermeldung aus. Die Methode unwrap
verhält sich
wie expect
. Man kann unwrap
jedoch keine konkrete Fehlermeldung übergeben. Die Methoden unwrap
und expect
finden
beispielsweise in Tests Verwendung.
Fehlerbehandlung
In Rust gibt es 2 Arten von Fehlern.
- Panik! Das Programm bricht ab.
- Fehler die im Kontrollfluss des Programms behandelt werden, sind in den Aufzählungstyp
Result<T, E>
verpackt.
Es gibt keine Exceptions wie man sie aus anderen Sprachen wie Java
, Python
oder C++
kennt.
Fehler führen entweder zum Abbruch des Programms oder können dem Rückgabewert einer Funktion
entnommen werden. Allerdings bringt Rust ein paar Werkzeuge mit, um die Fehlerbehandlung per Rückgabewert
angenehmer zu gestalten wie den ?
-Operator.
Panik
Um ein Programm in Rust kontrolliert mit einer Fehlermeldung zu beenden, kann man das Makro panic!
verwenden.
#![allow(unused)] fn main() { panic!("There was an unrecoverable error."); }
Das ist sinnvoll, wenn der Fehler so gravierend ist, dass das Programm nicht sinnvoll weiter ausgeführt
werden kann. Beispiele sind ein Zugriff auf einen Array außerhalb des verwalteten Speichers oder
ein unwrap
auf ein None
.
Fehler im Kontrollfluss
Oft können Programme auf Fehler reagieren und weiter funktionieren. Wie man dieses Verhalten in Rust abbildet,
schauen wir uns anhand eines Beispiels an.
Dazu betrachten wir unseren Punkt-Typen. Wir implementieren Div
als Division durch einen Skalar.
Bei Division durch 0 werden wir einen Fehler zurückgeben. Dazu verwenden wir den Aufzählungstyp Result
, den
Rust für uns bereit stellt. Seine Definition sieht im Wesentlichen folgendermaßen aus.
#![allow(unused)] fn main() { enum Result<T, E> { Ok(T), Err(E) } }
Analog zu Option
s Varianten sind auch Ok
und Err
direkt verfügbar. Die Verwendung von
Result::Ok
oder Result::Err
ist unnötig.
Der erste generische Parameter T
ist der Typ des Rückgabenwerts, für den wir uns im Erfolgsfall interessieren.
Der zweite generische Parameter E
ist der Typ des Fehlers, der uns im bei Fehlschlagen um die Ohren fliegt.
Da in Rust 1.0/0.0
den Wert f64::INFINITY
und 0.0/0.0
den Wert f64::NAN
ergibt, ist das Teilen durch 0 für
primitive Skalare kein Fehler. Es gibt dementsprechend auch keinen DivisionByZero
-Fehlertypen, den wir einfach verwenden
könnten. Daher legen wir einen Fehlertypen für das Teilen durch 0 an. Dieser implementiert den Fehler-Trait
std::error::Error
und die Anzeige-Traits
std::fmt::Display
und std::fmt::Debug
.
#![allow(unused)] fn main() { use std::{error::Error, fmt::{self, Display, Debug, Formatter}}; #[derive(Debug)] struct DivisionByZero; impl Error for DivisionByZero{} impl Display for DivisionByZero { fn fmt(&self, f: &mut Formatter) -> fmt::Result { write!(f, "Division by zero error") } } }
Die Methode fmt
des Display-Traits definiert wie die String-Repräsentation des Strukturtypen DivisionByZero
aussieht.
Der Rückgabetyp std::fmt::Result
von fmt
ist eine Typdefinition und sieht im Wesentlichen wie folgt aus.
#![allow(unused)] fn main() { type Result = Result<(), std::fmt::Error>; }
Das heißt fmt
gibt im Erfolgsfall das leere Tupel zurück und im Fehlerfall Fehlerfall den Fehlertyp std::fmt::Error
Gerade bei generischen Typen, die man oft mit den gleichen Typparametern verwenden möchte, erspart type
einem einiges an
Schreib- und Lesearbeit.
Quiz
Es sei
e
eine Instanz vonDivisionByZero
. Was gibtprintln!("{e}");
auf dem Bildschirm aus?
Fehlermeldungen können also durch println!("{e}");
für eine Instanz e
eines Fehlertyps angezeigt werden.
Mit dem Fehlertypen DivisionByZero
implementieren wir jetzt Division durch Skalare für Point
.
use std::{ops::Div, convert::From}; use std::{error::Error, fmt::{self, Display, Debug, Formatter}}; #[derive(Debug)] struct DivisionByZero; impl Error for DivisionByZero{} impl Display for DivisionByZero { fn fmt(&self, f: &mut Formatter) -> fmt::Result { write!(f, "division by zero error") } } #[derive(Copy, Clone)] struct Point<T> where T: Div<Output=T> + Copy { x: T, y: T, } impl<T> Div<T> for Point<T> where T: Div<Output=T> + Copy + PartialEq + From<i32> { // Output ist nicht Self im Gegensatz zu vorherigen Beispielen type Output = Result<Self, DivisionByZero>; fn div(self, rhs: T) -> Self::Output { if rhs == T::from(0) { Err(DivisionByZero{}) } else { let p = Point{ x: self.x / rhs, y: self.y / rhs, }; Ok(p) } } } fn main() { let p = Point::<f64>{ x: 1.0, y: 1.0 }; let q = p / 2.0; if let Ok(q) = q { assert!((q.x - 0.5).abs() < 1e-12); assert!((q.y - 0.5).abs() < 1e-12); } else { panic!("Error not expected"); } let q = p / 0.0; match q { Ok(_x) => panic!("I expect an error!"), Err(e) => println!("The expected error is '{e}'"), }; }
Der Trait std::convert::From<i32>
erfordert von implementierenden Typen, dass sie aus einem i32
erstellt werden können.
Für f64
ist From<i32>
implementiert. An dieser Stelle verwenden wir From
, um eine generische 0 zu erzeugen,
mit der wir den Nenner vergleichen können. Da wir den Typ T
allgemein halten, kennen wir das konkrete 0-Literal nicht.
Ein Resultat immerzu aus
dem Aufzählungstypen Result
auspacken zu müssen kann lästig werden. Zum Glück gibt es den ?
-Operator in Rust.
Wenn eine Funktion eine Instanz von Result<T, E>
zurückgibt, kann sie Instanzen von Result<U, E>
mit ?
entpacken.
Ein Fehler führt zum vorzeitigen Abbruch der Funktion und zur Rückgabe des Fehlers e
eingepack in die Variante Err(e)
.
use std::{ops::Div, convert::From}; use std::{error::Error, fmt::{self, Display, Debug, Formatter}}; #[derive(Debug)] struct DivisionByZero; impl Error for DivisionByZero{} impl Display for DivisionByZero { fn fmt(&self, f: &mut Formatter) -> fmt::Result { write!(f, "division by zero error") } } #[derive(Copy, Clone)] struct Point<T> where T: Div<Output=T> + Copy { x: T, y: T, } impl<T> Div<T> for Point<T> where T: Div<Output=T> + Copy + PartialEq + From<i32> { type Output = Result<Self, DivisionByZero>; fn div(self, rhs: T) -> Self::Output { if rhs == T::from(0) { Err(DivisionByZero{}) } else { let p = Point{ x: self.x / rhs, y: self.y / rhs, }; Ok(p) } } } fn print_division(p: Point<f64>, s: f64) -> Result<Point<f64>, DivisionByZero> { let p = (p / s)?; println!("{}, {}", p.x, p.y); Ok(p) } fn main() -> Result<(), DivisionByZero> { let p = Point::<f64>{ x: 1.0, y: 1.0 }; let q = print_division(p, 2.0)?; assert!((q.x - 0.5).abs() < 1e-12); assert!((q.y - 0.5).abs() < 1e-12); let q = print_division(p, 0.0); if let Err(e) = q { println!("as expected the result is not printed and we have a '{e}'"); } else { panic!("we expected an error but didn't get one"); } Ok(()) }
Genauer gesagt entspricht
#![allow(unused)] fn main() { let p = (p / s)?; }
dem folgenden Schnipsel
#![allow(unused)] fn main() { p = match p / x { Ok(res) => res, Err(e) => { return Err(e); } }; }
Iteratoren
Um eine Folge von Entitäten zu verarbeiten, werden in Rust Iteratoren verwendet. Iteratoren
implementieren den Trait std::iter::Iterator
. Der Iterator
Trait bringt einige
Methoden mit Standardimplementierungen mit.
Standardimplementierungen stehen implementierenden
Typen direkt zur Verfügung und müssen nicht explizit implementiert werden. Die Kernfunktionalität
wird aber durch die Methode next
bestimmt, für die es keine Standardimplementierung gibt.
Das nächste Element
Ohne Methoden mit Standardimplementierung sieht der Iterator
Trait wie folgt aus.
#![allow(unused)] fn main() { trait Iterator { type Item; fn next(&mut self) -> Option<Self::Item>; } }
Iteratortypen, die Iterator
implementieren, müssen den assozierten Typ Item
festlegen und
die Methode next
bereitstellen. Die Methode next
wird immer aufgerufen, wenn das nächste
Element des Iterators angefragt wird. Sobald es kein nächstes Element mehr gibt, wird von next
ein None
erwartet.
Dementsprechend werden die nächsten existierenden Elemente in der Variante
Some
verpackt. Des Weiteren erhält next
eine veränderliche Referenz auf sich selbst.
Bei jedem Aufruf auf next
ändert sich der Iterator bis zur vollständigen Konsumierung.
Wenn man einen Iterator \( n \) mal durchlaufen möchte, benötigt man \( n \) Instanzen des Iterators.
Als Benutzer eines Iterators möchte man next
oft nicht explizit aufrufen.
Beispielsweise terminiert eine for
-Schleife sobald next
den Wert None
zurückgibt.
Die Elemente in der Schleife sind bereits ausgepackt und hängen nicht mehr im Some
-Arm.
#![allow(unused)] fn main() { let arr = [1, 2, 3]; for elt in arr { println!("{elt}"); } }
entspricht
#![allow(unused)] fn main() { let arr = [1, 2, 3]; let mut arr_iter = arr.into_iter(); while let Some(elt) = arr_iter.next() { println!("{elt}"); } }
Der Arraytyp implementiert nicht direkt Iterator
, sondern std::iter::IntoIterator
. Typen, die
IntoIterator
implementieren, können direkt in for
-Schleifen verwendet werden. Der Trait
IntoIterator
erfordert die Implementierung einer Methode into_iter
die einen Typen zurückgibt, der
Iterator
implementiert. Auch Vec
und andere Container die wir noch kennenlernen werden implementieren
IntoIterator
. Wenn die Elemente im Container nicht den Copy
Trait implementieren, werden sie
durch into_iter
konsumiert.
#![allow(unused)] fn main() { // kompiliert nicht let v = vec![1.to_string(), 2.to_string(), 3.to_string()]; for elt in v { println!("{elt}"); } println!("{}", v[0]); }
Es existieren verschiedene Lösungsmöglichkeiten:
- Wenn man über eine Referenz iteriert, gibt
into_iter
eine Referenz zurück und nicht den Wert. Das liegt daran, dassIntoIterator
nicht nur fürVec
implementiert ist, sondern auch für&Vec
und&mut Vec
. Vec<T>
und andere Container verfügen über die Methodeniter
unditer_mut
die immer&T
bzw.&mut T
zurückliefern.
#![allow(unused)] fn main() { let v = vec![1.to_string(), 2.to_string(), 3.to_string()]; for elt in &v { println!("{elt}"); } for elt in v.iter() { println!("{elt}"); } println!("{}", v[0]); }
Iteratoren können auch unabhängig von Containern existieren. Beispielsweise erlaubt
Range
über Zahlen zu iterieren, ohne dass diese vorher allokiert werden.
Ranges können mit der speziellen Syntax ..
angelegt werden.
Beispielsweise entspricht
#![allow(unused)] fn main() { for i in 0..5 { println!("{i}"); } }
#![allow(unused)] fn main() { let mut range_iter = 0..5; while let Some(i) = range_iter.next() { println!("{i}"); } }
Nützliche Methoden mit Standardimplementierung
Wir listen einige oft verwendeten Methoden auf und verweisen für die übrigen auf die Dokumentation.
collect
Die Methode collect<T>
sammelt alle Elemente des Iterators in einen Container vom
Typ T
.
#![allow(unused)] fn main() { let v = (0..3).collect::<Vec<i32>>(); assert_eq!(v, vec![0, 1, 2]); }
map
Die Methode nimmt eine Closure und gibt den Iterator std::iter::Map
über die Rückgabewerte der Closure zurück.
#![allow(unused)] fn main() { let v = (0..3).map(|i| i * i).collect::<Vec<i32>>(); assert_eq!(v, vec![0, 1, 4]); }
Wenn die Closure fehlschlagen kann und entsprechend ein Result<T, E>
zurückgibt, ist es möglich,
die Elemente in einen Result<Vec<T>, E>
zu sammeln.
use std::num::ParseIntError; fn main() -> Result<(), ParseIntError> { let arr = ["0", "1", "2"]; type CollectedType = Result<Vec<i32>, ParseIntError>; let v = arr .iter() .map(|i| Ok(i.parse::<i32>()?.pow(2))) .collect::<CollectedType>()?; assert_eq!(v, vec![0, 1, 4]); Ok(()) }
filter
Anhand einer Bedingung werden Elemente zugelassen oder ignoriert und ein entsprechender
Iterator vom Typ std::iter::Filter
wird zurückgegeben.
#![allow(unused)] fn main() { let v = (0..3) .filter(|i| i % 2 == 0) .map(|i| i * i) .collect::<Vec<i32>>(); assert_eq!(v, vec![0, 4]); }
reduce
Aus den Elementen des Iterators wird eine Zahl errechnet. Der initiale Wert ist der erste Wert des Iterators.
#![allow(unused)] fn main() { let square_sum = (0..5).reduce(|a, b| a + b * b); assert_eq!(square_sum, Some(1 + 4 + 9 + 16)); }
Mit fold
existiert eine Verallgemeinerung von reduce
.
enumerate
Ergänzt die Elemente des Eingangsiterators mit einer Zählvariable und gibt einen Iterator vom Typ
std::iter::Enumerate
zurück, dessen
Item
ein Tupel ist.
#![allow(unused)] fn main() { let arr = ["a", "b", "c"]; let v = arr .iter() .enumerate() .filter(|(i, _)| i % 2 == 0) .map(|(_, s)| format!("{s}_")) .collect::<Vec<_>>(); assert_eq!(v, vec!["a_", "c_"]); }
Wir haben mehrfach _
verwendet. Wenn wir das anstelle einer Variable verwenden, wollen wir die Variable
im folgenden Geltungsbereich nicht verwenden. Wir verwenden aber auch Vec<_>
. Damit teilen wir dem Compiler
mit, dass es einen generischen Parameter, den er bitte selbst herleiten möchte.
Quiz
Warum können wir die Methoden
iter
,enumerate
,filter
,map
undcollect
per.
aneinander hängen?
chain
Mit chain
können mehrere Iteratoren verkettet werden.
#![allow(unused)] fn main() { let iter1 = [3, 4, 5].iter().map(|i| *i); let iter2 = (0..3); assert_eq!(iter2.chain(iter1).collect::<Vec<_>>(), vec![0, 1, 2, 3, 4, 5]); }
take
Wenn die ersten \( n \) Elemente von Interesse sind, ist take
hilfreich.
#![allow(unused)] fn main() { let my_iter = 0..10; let first4 = my_iter.take(4); assert_eq!(first4.collect::<Vec<_>>(), vec![0, 1, 2, 3]); }
Iteratoren der Standardbibliothek
Die Rust Standardbibliothek stellt Iteratoren bereit. Zwei Beispiele folgen.
Once
Dieser Iterator verwandelt eine Variable in einen 1-Elementigen Iterator. Zum Konstruieren
gibt es die Funktion iter::once
.
#![allow(unused)] fn main() { use std::iter; let it = iter::once(4); assert_eq!(it.collect::<Vec<_>>(), vec![4]); }
Repeat
Wie Once
verwandelt Repeat
eine Variable in einen Iterator. Allerdings wird das Element ewig
wiederholt. Man kann in Kombination mit take
\( n \) Wiederholungen eines Elements erzeugen.
#![allow(unused)] fn main() { use std::iter; let it = iter::repeat(4).take(3); assert_eq!(it.collect::<Vec<_>>(), vec![4, 4, 4]); }
Container
Unter Containern verstehen wir Typen, die mehrere Elemente beinhalten und Iteration über diese erlauben. Viele Container sind
generische Typen, da sie für verschiedene Elementtypen funktionieren. Wir kennen bereits die Container String
, Vec<T>
und
[T; n]
. Es gibt in Rust noch weitere Container, die nicht Gegenstand dieses Skripts sind. Wir beschränken uns hier auf
die Container, die 99% aller Use-Cases im Programmiererleben des Autors abdecken.
Quiz
Worin unterscheiden sich Arrays und Vektoren?
Container mit zusammenhängendem Speicher
Arrays und Vektoren legen ihre Elemente in zusammenhängenden Speicherbereichen ab. Das hat einige Vorteile.
- Die CPU liest Elemente aus dem Speicher in Blöcken gewisser Größe ein. Diese Blöcke heißen auch Cache-Lines. Bei der Iteration über Container mit zusammenhängendem Speicher ist die Wahrscheinlichkeit höher, dass auch Elemente die als nächstes gebraucht werden, bereits mit der aktuellen Cache-Line eingelesen wurden.
- Die Allokation von Speicher am Stück ist oft effizienter.
- Oft erhöht das die Interoperabilität zwischen System oder Sprachen. Ein Beispiel ist die Verarbeitung von in Python allokierten NumPy-Arrays mit Rust aus Effizienzgründen.
Neben den Containern bieten auch ihre Slices wie [T]
die Möglichkeit über die beinhalteten Elemente zu iterieren.
Quiz
Wie heißen Slices von Strings?
Welchen Nachteil haben Container zusammenhängenden Speichers zumindest in der Theorie?
Um beispielsweise einen Vec<i32>
mit den Werten 1
, 3
, 5
und 7
zu erstellen gibt es verschiedene Möglichkeiten.
- Das Makro
vec!
ist uns schon begegnet.#![allow(unused)] fn main() { let v = vec![1, 3, 5, 7]; }
- Iteratoren sind eine weitere Möglichkeit.
#![allow(unused)] fn main() { let v = (1..=7).filter(|i| i % 2 == 1).collect::<Vec<_>>(); assert_eq!(v, vec![1, 3, 5, 7]); }
- Wir können uns der Veränderlichkeit via
mut
bedienen, einen leerenVec
anlegen und die gewünschten Werte hinzufügen.#![allow(unused)] fn main() { let mut v = Vec::new(); v.push(1); v.push(3); v.push(5); v.push(7); assert_eq!(v, vec![1, 3, 5, 7]); }
Wir haben bereits die Methoden sort
und sort_by_key
kennen gelernt. Wir wollen nun einen Vektor aus Gleitkommazahlen
der Größe nach sortieren.
#![allow(unused)] fn main() { let mut v = vec![2.3, 1.2, 4.3, 9.6]; v.sort(); println!("{v:?}", v); }
Die Fehlermedlung, die wir bei der Ausführung bekommen, besagt, Gleitkommazahlen implementierten den Trait Ord
nicht.
Typen die Ord
implementieren sind total geordert. Das gilt aber für Gleitkommazahlen nicht, da
#![allow(unused)] fn main() { let x = 0.0/0.0; }
den Wert f64::NAN
annimmt. Die Abkürzung NAN
steht für not a number und es gilt
#![allow(unused)] fn main() { assert!(!(f64::NAN == f64::NAN)); assert!(f64::NAN != f64::NAN); assert!(!(f64::NAN >= f64::NAN)); assert!(!(f64::NAN <= f64::NAN)); }
Gleitkommazahlen können miteinander verglichen werden und bei den meisten Werten kommt sogar etwas sinnvolles heraus. Sie
implementieren den Trait PartialOrd
. Wegen PartialOrd
implementiert
f64
die Methode
#![allow(unused)] fn main() { fn partial_cmp(&self, other: &Rhs) -> Option<Ordering>; }
Der Rückgabetyp
Ordering
ist ein Aufzählungstyp mit den Varianten Less
, Equal
und Greater
. Wenn self
größer ist als other
erwarten
wir dementsprechend den Wert Some(Ordering::Greater)
. Wenn die Werte nicht vergleichbar sind, gibt partial_cmp
den
Wert None
zurück.
Des Weiteren gibt es neben sort
und sort_by_key
es die Methode sort_by
, die wie
sort_by_key
eine Funktion als Parameter bekommt. Während sort_by_key
die Elemente des Vektors in etwas
Vergleichbares1
transformiert, erwartet sort_by
eine Vergleichsfunktion vom Typ FnMut(&T, &T) -> Ordering
.
Wir können also einen Vec<f64>
sortieren.
#![allow(unused)] fn main() { use std::cmp::Ordering; let mut v: Vec<f64> = vec![2.3, 1.2, f64::NAN, 4.3, 9.6]; v.sort_by(|a, b| match a.partial_cmp(b) { Some(o) => o, None => { if a.is_nan() { Ordering::Less } else { Ordering::Greater } } } ); println!("{v:?}"); }
Falls f64::NAN
auftritt, entscheiden wir in diesem Fall, dass diese Werte an den Anfang kommen, also die Kleinsten sind.
Sowohl Arrays vom Typ [T; n]
als auch Slices lassen sich ebenfalls sortieren.
Um Elemente aus einem Vec<T>
zu entfernen, stellt Rust pop
und remove
bereit. Ersteres entfernt das letzte Element, letzteres
ein Beliebiges. Bei der Verwendung von remove
werden alle Werte mit größerem Index als das Entfernte verschoben,
damit sie nach wie vor zusammenhängend im Speicher liegen.
Elemente entfernen mit remove
Es sei v
ein Vec<i32>
mit folgenden Inhalt
Wert |5|7|2|4|5|6|
Index |0|1|2|3|4|5|
Um das Element mit Index 1
zu entfernen, verwenden wir v.remove(1)
und bekommen 7
zurück. v
wird zu
Elemente verschoben
< < < <
Wert |5|2|4|5|6|
Index |0|2|3|4|5|
|
Element wurde entfernt
Falls der Index-Parameter zu groß ist im Vergleich zur Länge des Vec
s, bricht das Programm ab.
Elemente entfernen mit pop
Es sei v
ein Vec<i32>
mit folgenden Inhalt
Wert |5|7|2|4|5|6|
Index |0|1|2|3|4|5|
Das letzte Element entfernen wir mit v.pop()
und bekommen Some(5)
zurück. v
wird zu
Wert |5|7|2|4|5|
Index |0|1|2|3|4|
|
Element wurde entfernt
Falls v
leer ist, gibt pop
den Wert None
zurück.
HashMap
s
Eine HashMap<K, V>
speichert Key-Value-Paare2.
Auf die Values des Typs V
kann per Key des Typs K
zugegriffen werden. Die Keys
können beispielsweise Zahlen, String
s oder &str
s sein.
Beim Zugriff auf eine HashMap
wird der Key durch eine Hashfunktion \( h: S \rightarrow I \) auf einen Index in einem dynamischen Array abgebildet.
Die Menge der hashbaren Keys \( S \) ist üblicherweise eine Menge deutlich größerer Kardinalität als die Menge der Möglichen Indizes
\( I\subseteq \{0, \dots, n\} \). Die Hashfunktion impliziert eine Grenze für die Typen, die als Keys verwendet werden können.
Der entsprechende Trait heißt std::hash::Hash
. Ein weiterer Trait
den Keys implementieren müssen ist std::cmp::Eq
.
Eq
stellt sicher, dass alle Werte x
,
die ein Typ annehmen kann, per ==
verglichen werden können und dass (x == x) == true
für alle x
gilt.
Quiz
Welcher allgegenwärtige Typ kann nicht als Key verwendet werden und warum nicht?
In seltenen Fällen kann es passieren, dass die Hashfunktion zwei Keys auf den selben Index abbildet. In diesem Fall
spricht man von einer Kollision. Es gibt verschiedene Strategien mit Kollisionen umzugehen.
Für unsere Zwecke reicht es zu verstehen, dass Kollisionen
Zeit kosten. Theoretisch kann man einen Wert in konstanter Zeit aus einer HashMap
lesen und in ablegen, wenn es keine Kollision gibt.
Konstante Zeit bedeutet unabhängig von der Anzahl der Elemente im Container. Da die Berechnung der Hashes Zeit kostet, sind HashMap
s in
der Praxis nicht immer die effizienteste Lösung, selbst wenn es zu keiner Kollision kommen sollte.
Es folgen einige Beispiele zur Initialisierung von HashMap
s. Die dunkle Seite der Veränderlichkeit steht mit einer einfachen
Lösung Gewehr bei Fuß.
#![allow(unused)] fn main() { use std::collections::HashMap; let mut h = HashMap::new(); h.insert("a", 1); h.insert("b", 10); h.insert("c", 100); h.insert("d", 1000); println!("{h:?}"); }
Um ohne mut
auszukommen, existiert die Implementierung des From
-Traits. Die entsprechende Methode from
erwartet
einen Array von Key-Value-Tupeln.
#![allow(unused)] fn main() { use std::collections::HashMap; let h = HashMap::from([("a", 1), ("b", 10), ("c", 100), ("d", 1000)]); println!("{h:?}"); }
Iteratoren über Paare vom Typ (T, U)
können in einer HashMap
gesammelt werden.
#![allow(unused)] fn main() { use std::collections::HashMap; let h = ["a", "b", "c", "d"] .iter() .enumerate() .map(|(i, c)| (c, 10i32.pow(i as u32))) .collect::<HashMap<_, _>>(); println!("{h:?}"); }
Um einzelne Werte auszulesen, können wir Dank der Index
-Trait-Implementierung eckige Klammern verwenden und bekommen direkt den Wert zurück.
#![allow(unused)] fn main() { use std::collections::HashMap; let h = HashMap::from([("a", 1), ("b", 10), ("c", 100), ("d", 1000)]); println!("{}", h["b"]); }
Vorsicht! Falls wir mit
[]
nach einem Key fragen, der nicht vorhanden ist, bricht das Programm ab.
Wir können die Methode get
verwenden, wenn wir herausfinden wollen, ob der Key existiert. Diese gibt
einen Wert vom Typ Option<&V>
zurück.
#![allow(unused)] fn main() { use std::collections::HashMap; let h = HashMap::from([("a", 1), ("b", 10), ("c", 100), ("d", 1000)]); assert_eq!(Some(&10), h.get("b")); assert_eq!(None, h.get("e")); }
Die Methode insert
mit der wir oben neue Werte hinzugefügt haben, überschreibt bestehende Werte.
#![allow(unused)] fn main() { use std::collections::HashMap; let mut h = HashMap::from([("a", 1), ("b", 10), ("c", 100), ("d", 1000)]); assert_eq!(10, h["b"]); h.insert("b", 20); assert_eq!(20, h["b"]); }
Da IndexMut
nicht implementiert ist, können die in Python populäre Syntax
h["b"] = 20;
nicht verwenden. Anstatt dessen gibt uns get_mut
eine Option auf eine veränderliche Referenz.
Wenn man einen Wert nur hinzufügen will, falls er noch nicht existiert, ist die entry
-Methode von Interesse.
#![allow(unused)] fn main() { use std::collections::HashMap; let mut h = HashMap::from([("a", 1), ("b", 10), ("c", 100), ("d", 1000)]); h.entry("b").or_insert(50); h.entry("e").or_insert(50); assert_eq!(10, h["b"]); assert_eq!(50, h["e"]); }
Die entry
Methode gibt eine Instanz des Entry
-Typen zurück,
der noch mehr Methoden zur Manipulation eines Eintrags bereit stellt. Zum Entfernen gibt es die Methode remove
.
Iteratoren über Container
Neben iter
gibt es noch die Methoden into_iter
, iter_mut
um über Vec
s zu iterieren. Das Argument von iter
ist &self
und das Argument von iter_mut
ist &mut self
. Die Methode iter
gibt uns in jeder Iteration ein &T
und entsprechend bekommen wir von iter_mut
in jeder Iteration ein &mut T
. Der Trait IntoIter
der die
Methode into_iter
mitbringt, ist implementiert
für &Vec<T>
, &mut Vec<T>
und Vec<T>
. Auf einem &Vec<T>
verhält sich into_iter
wie iter
und auf einem
&mut Vec<T>
wie iter_mut
. Auf einer nicht-Referenz führt into_iter
einen Move des Vec
s durch. into_iter
wird
auch bei einer for
-Schleife verwendet.
Der folgende Schnipsel kompiliert nicht, da nach der for
-Schleife v
verschoben wurde.
#![allow(unused)] fn main() { let v = vec![1, 2, 3]; for i in v { println!("{i}"); } println!("{v:?}"); }
Wir können aber &Vec<i32>::into_iter
verwenden, indem wir über eine Referenz iterieren.
#![allow(unused)] fn main() { let v = vec![1, 2, 3]; for i in &v { println!("{i}"); } assert_eq!(v, vec![1, 2, 3]); }
Die Methoden sind ebenfalls für [T; n]
implementiert und verhalten sich vergleichbar. Nur falls T
den Trait Copy
implementiert,
wird die Instanz von [T; n]
nicht verschoben sondern kopiert. Für Slices vom Typ [T; n]
sind diese Methoden ebenfalls vorhanden.
Die Ausnahme ist [T]::into_iter
. Erstens ergibt es nicht viel Sinn, Slices zu konsumieren, da sie Fenster in Container sind. Zweitens
können Slices nur als Referenzen verwendet werden. Iterator-Methoden über HashMap
s sind
analog zu denen über Vec
s vorhanden. Nur erzeugen sie Iteratoren über Tupel bestehend aus Key und Value.
Zusätzlich gibt es die Möglichkeit über Keys oder Values separat zu iterieren.
#![allow(unused)] fn main() { use std::collections::HashMap; let mut h = HashMap::from([("a", 1), ("b", 10), ("c", 100), ("d", 1000)]); for (k, v) in &h { println!("{k}: {v}"); } for k in h.keys() { println!("{k}"); } for v in h.values() { println!("{v}"); } }
Referenz
In der Rust Dokumentation findet sich eine vollständige Beschreibung der Container und ihrer Methoden, die wir von der Rust Standardbibliothek zur Verfügung gestellt bekommen.
1: Etwas, das verglichen werden kann.
2: Wir verwenden hier des öfteren die englischen Wörter Key und Value anstatt der
deutschen Wörter Schlüssel und Wert, da diese Begriffe in der Programmierwildnis insbesondere nahe HashMap
s allgegenwärtig sind.
Quiz
Fragen
- Was ist der Zweck generischer Typen?
- Was ist ein Trait?
- Welche Traits der Rust Standardbibliothek kennst du? Liste 3 von ihnen auf und beschreibe ihren Zweck.
- Beschreibe ein Beispiel wie sich mit Hilfe generischer Typen und Traits modularer Code schreiben lässt.
- Welches sehr simple alternative modulare Programmiermittel haben wir bereits im Grundlagenkapitel kennen gelernt?
- Was ist der Unterschied zwischen dem ersten Parameter von
und#![allow(unused)] fn main() { fn some_higher_order_fn<T>(f: fn(T) -> T); }
#![allow(unused)] fn main() { fn some_higher_order_fn<T, F: Fn(T) -> T>(f: F); }
- Welche 3 Closure-Traits gibt es und was unterscheidet sie?
- Was wird mit dem Billion-Dollar-Mistake bezeichnet und wie wird es in Rust verhindert?
- Welche Arten der Fehlerbehandlung gibt es in Rust?
- Was ist ein Iterator?
- Nenne 3 Methoden die einen Iterator in einen anderen Iterator transformieren und beschreibe ihren Zweck.
- Welchen nicht generischen Containertypen kennst du?
- Welche der folgenden Typen kann man als Key einer HashMap verwenden?
String
&str
[usize; 3]
&[usize]
Vec<usize>
&[f32]
Vec<f32>
Aufgabe
Schreibe einen Containertypen VecMap
, der ähnlich wie eine HashMap
die Methoden
get
,get_mut
,insert
undremove
und die Traits
Index
undIntoIter
implementiert.
IndexMut
kann gerne zusätzlich implementiert werden. Intern besteht der Container aus einem Vec<(K, V)>
. Die Suche
nach einem Key ist eine lineare Suche über den Vec
. Vergleiche Zeiten verschiedene Zugriffs-, Lese-,
und Schreibesituationen mit denen einer HashMap
. Führe die Vergleiche für unterschiedlich viele Elemente durch.
Eine einfache Möglichkeit dazu bietet std::time::Instant
.
Benchmarks
Üblicherweise ist es einfacher, ein korrektes Programm zu verschnellern, als ein effizientes Programm zu korrigieren. Daher ist es normalerweise sinnvoll, mit möglichst simplem und lesbarem Code zu starten. Sobald die Effizienz für den gegebenen Anwendungsfall ein Problem wird, ergibt die Detektion von Flaschenhälsen des Programms Sinn, bevor Maßnahmen zur Effizienzsteigerung ergriffen werden. Ansonsten ist die Gefahr groß, dass Programme unter großem Zeiteinsatz an Stellen optimiert werden, die keinen Einfluss auf den Anwendungsfall haben. Zeiten werden immer in Release-Builds gemessen. Debug-Builds sind üblicherweise signifikant langsamer und haben keine Relevanz für das in der Praxis eingesetzte Programm. Dabei muss man aufpassen, dass Optimierungen im Release-Build nicht den Code entfernen, dessen Ausführungszeit zu untersuchen ist, falls dieser aus dem Kontext separiert untersucht wird.
Profiling
Zum Entdecken von Flaschenhälsen bietet sich Profiling als Werkzeug an. Mit Profiling lassen
sich für einen Programmdurchlauf die Programmabschnitte bestimmen, in denen am meisten Zeit verbaucht
wurde. Unglücklicherweise ist mir für Rust
kein betriebssystemunabhängiges, minimalinvasives Tool bekannt, mit dem man Programme
einfach profilen könnte, z.B. im Gegensatz zu Python's sehr praktischem line_profiler
.
Eine plattformunabhängie aber sehr invasive Variante ist die Verwendung von
Counts. Um Counts zu verwenden, muss man im Code an interessanten
Stellen Zeitmessungen unterbringen und per eprint!
ausgeben. Counts kann dann verwendet werden
um die Zeiten zu aggregieren.
Benchmarks
Wenn man Flaschenhälse identifiziert hat, befindet man sich vornehmlich in der Situation die Laufzeit \( t \) eines bestimmten Teilprogramms abschätzen und für verschiedene Veränderungen vergleichen zu wollen. Eine direkte Messung ist nicht möglich, da ein Programm immer im Kontext eines Betriebssystems läuft, in dem weitere Hintergrundprozesse am Werkeln sind. Was wir also messen können ist eine Zeit \( t_{\text{M}} \) die durch die Summe \[ t_{\text{M}} = t + t_{\text{N}} + t_{\text{O}} \] gegeben ist, wobei wir \( t_{\text{N}} \ge 0 \) als Rauschen (engl. noise) wahrnehmen, das durch Hintergundprozesse oder ähnliches verursacht wird. Benutzer von Hardware, die von einer zentralen IT-Abteilung verwaltet wird, können hier oft ein trauriges Lied von singen. Der letzte Term \( t_{\text{O}} \ge 0 \) ist der Overhead, den das Testen verursacht. Das heißt, die Dauer der Messung \( t_{\text{M}} \) ist immer größer als die oder gleich der eigentlich interessanten Zeit \( t \).
In Rust gibt es die externe Bibliothek
Criterion. Externe Bibliotheken werden in Rust auch Crates genannt und sind
das Thema eines späteren Kapitels. Um Criterion zu verwenden, fügen wir in unserer Cargo.toml
die Zeilen
[dev-dependencies]
criterion = { version = "0.4", features = ["html_reports"] }
[[bench]]
name = "my_benchmark"
harness = false
ein. Mit dem Key features
lässt sich Funktionalität von Crates ein oder ausschalten. Ob ein Crate über Features verfügt
obliegt den Crate-Entwicklern und unterscheidet sich üblicherweise von Crate zu Crate. Das Criterion-Feature html_reports
sorgt dafür, dass
wir Plots unserer Benchmarks im target
-Ordner finden. Als nächsten Schritt brauchen wir im Ordner benches
eine Datei namens
my_benchmarks.rs
. Der Name der Datei
muss dem Namen unter [[bench]]
entsprechen.
Beispielhaften Inhalt der Datei my_benchmarks.rs
betrachten wir im Folgenden.
#![allow(unused)] fn main() { use criterion::{black_box, criterion_group, criterion_main, Criterion}; use std::time::Duration; use std::thread; fn sleepy_func(n: u64) -> u64 { thread::sleep(Duration::from_millis(n)); n } fn benchmark_sleepy(c: &mut Criterion) { c.bench_function("sleep", |b| b.iter(|| sleepy_func(black_box(20)))); } criterion_group!(benches, benchmark_sleepy); criterion_main!(benches); }
Die Funktion criterion::black_box
verhindert, dass der Compiler unsere Funktionsaufrufe wegoptimiert.
Die Makros am Ende werden verwendet, um zu benchmarkende Funktionen zu registrieren, damit sie per
cargo bench
ausgeführt werden können. Criterion führt mehrere Läufe durch und erstellt Statistiken, die Ausreißer beachten. Die Philosophie hinter Criterion ist, dass zu benchmarkende Programme fast nie deterministisch seien. Determinismus impliziert, dass man das Minimum über mehrere Läufe oder wenigstens Statisitiken heranziehen sollte, die robust gegenüber Ausreißern sind, denn Abweichungen sind nur additives Rauschen. Man kann nicht durch zufällige Ereignisse auf dem Rechner ein verschnellertes Programm erwarten. Ob die Zufallselemente einer Ausführung wirklich relevant sind, oder ob ein Programm aus praktischer Sicht als deterministisch betrachtet werden sollte, hängt von der Anwendung ab. Beispielsweise hängt die Zeit einer Vektoralloktation von der aktuellen Konfiguration des Speichers ab oder die Zugriffszeit auf eine Hashmap vom Vorhanden sein einer Kollision ab. Diese Zeitunterschiede sind oft so gering sein, dass sie für die Anwendung völlig irrelevant sind. Ein im Hintergrund vom Betriebssystem gestartetes Programm beispielsweise auf einem von einer zentralen IT gesteuerten Windows PC kann jedoch bei Benchmarks durchaus signifikant dazwischen funken. Leider bietet Criterion nicht die Option, das Minimum zu verwenden. Dennoch ist Criterion ein komfortables Crate mit hilfreichen Funktionen zum Erstellen von Benchmarks. Beispielsweise merkt sich Criterion für uns den vorherigen Durchlauf als Baseline und und vergleicht den aktuellen Durchlauf damit. Man kann auch manuell Baselines definieren.
Im vorherigen Kapitel haben wir eine Aufgabe zum Erstellen einer VecMap
gesehen.
Um ihre Zugriffszeiten bewerten zu können, ist die HashMap
eine sehr gut geeignete Baseline. Des Weiteren können wir mit Criterion
unterschiedliche Implementierungen einer Funktion miteinander vergleichen. Dazu verwenden wir eine Gruppe.
#![allow(unused)] fn main() { use std::time::Duration; use std::thread; use criterion::{black_box, criterion_group, criterion_main, Criterion, BenchmarkId}; fn sleepy_func(n: u64) -> u64 { thread::sleep(Duration::from_millis(n)); n } fn very_sleepy_func(n: u64) -> u64 { thread::sleep(Duration::from_millis(n * 2)); 2 * n } fn benchmark_sleepy(c: &mut Criterion) { let mut group = c.benchmark_group("sleepy_group"); for n in [1, 5, 10] { group.bench_with_input(BenchmarkId::new("sleepy", n), &n, |b, n| { b.iter(|| sleepy_func(black_box(n))) }); group.bench_with_input(BenchmarkId::new("very_sleepy", n), &n, |b, n| { b.iter(|| very_sleepy_func(black_box(n))) }); } } }
Wenn wir nun cargo bench
ausführen, finden wir unter target/criterion/sleepy_group/report
eine index.html
, in der
für verschiedene Werte von n
auf der x-Achse entsprechende Zeiten auf der y-Achse zu finden sind.
Aufgabe
Erstelle Benchmarks mit Criterion für Lese- und Schreibzugriffe auf die
VecMap
im Vergleich zurHashMap
. Vergleiche verschieden große Maps für 5, 10, 30 und 100 Elemente.
Module, Crates, Sichtbarkeit und Tests
In diesem Abschnitt werden wir Wege zur Organisation wachsender Codeprojekte behandeln. Wir behandeln diese Themen nicht in aller Ausführlichkeit und verweisen auf das Rust-Book für weitere Informationen.
Crates
Die Codemenge, die der Compiler zu einer Zeit übersetzt, nennt sich Crate. Ein Crate kann eine einzelne Datei sein.
Ein Crate
kann auch ein Projekt mit vielen Dateien und Abhängigkeiten zu anderen Crates sein. Ein Crate ist entweder
ein ausführbares Programm (engl. binary crate) mit einer main
-Funktion oder eine Bibliothek
(engl. library crate), die von anderen Crates verwendet werden kann. Bibliotheks-Crates haben keine
main
-Funktion.
Der Begriff Crate wird von Rustaceans1 üblicherweise als Synonym für
Bibliotheks-Crate verwendet. Jedes Crate hat eine Datei, die als Startpunkt verwendet wird. Diese Datei
heißt Crate Root.
Bei Verwendung von cargo new
bekommen wir nicht nur ein Crate sondern auch ein Paket (engl. package),
das aus einem oder mehreren Crates bestehen kann und manuell zu versionieren ist.
Der Befehl
cargo new my_package
erstellt ein ausführbares Crate. Der Ordner beinhaltet neben der Cargo.toml
einen .git
-Ordner, eine
.gitignore
-Datei2 und
eine Datei src/main.rs
. Letztgenannte Datei ist die Crate Root des einzigen ausführbaren Crates unseres Pakets mit dem Namen
my_package
. Die .gitignore
-Datei beinhaltet lediglich den Ordner target
, in dem die Kompilate abgelegt werden.
Wenn wir einen Bibliotheks-Crate erstellen wollen, ist die Option --lib
hilfreich. Die Crate Root heißt in diesem Fall
src/lib.rs
und beinhaltet keine main
-Funktion. Daneben gibt es noch einen Unterschied. Die .gitignore
-Datei beinhaltet
neben dem Ordner target
auch eine Datei mit dem Namen Cargo.lock
.
Die Dateien Cargo.toml
und Cargo.lock
beinhalten teilweise ähnliche Informationen, haben aber unterschiedliche Verwendungszwecke.
Cargo.toml
wird von der Rustacean mit Abhängigkeiten ihres Pakets und weiterer Konfiguration wie der Version des Pakets befüllt.Cargo.lock
wird von Cargo automatisch mit den exakten Abhängigkeiten und Abhängigkeiten der Abhängigkeiten, die für den letzten erfolgreichen Build verwendet wurden, befüllt.Cargo.lock
sollte nicht manuell editiert werden.
Mit der Datei Cargo.lock
kann man also den letzten Build exakt reproduzieren. Das ist hilfreich für Anwendungen um
Versionskonflikte in den Abhängigkeiten auszuschließen, da alle Abhängigkeiten dieser Anwendung in einer für den letzten Build
funktionierenden Konfiguration verwendet werden. Verwender des Bibliotheks-Crates haben aber üblicherweise weitere Bibliotheken und
Abhängigkeiten. Daher ist es für Bibliotheksnutzer gut einen minimalen Satz an Abhängigkeiten definiert zu haben, so dass die
Wahrscheinlichkeit für Versionskonflikte minimiert wird.
Wenn ein Paket sowohl eine Datei src/main.rs
und src/lib.rs
beinhaltet, dann gibt es im Paket 2 Crates mit gleichem Namen.
Ein Paket kann mehrere ausführbare Crates haben. Dazu werden weitere .rs
-Dateien mit eigener main
-Funktion
unter src/bin
abgelegt. Die Anzahl der Bibliotheks-Crates pro Paket ist auf 1 beschränkt.
Um Crates zu verwenden, die andere zur Verfügung gestellt haben, ist Crates.io die erste Anlaufstelle. Wenn wir beispielsweise den Crate Exmex in unserem Crate verwenden wollen, geben wir
cargo add exmex
ein. In der Cargo.toml
taucht exmex
nun als Abhängigkeit auf.
[dependencies]
exmex = "0.17.3"
Jeder kann Crates auf Crates.io zur Verfügung stellen.
Module und Sichtbarkeit
Crates sind in Module organisiert. Immer. Die Create Root ist ein Modul. Weitere Module können innerhalb der Crate Root definiert werden. Per
mod my_submodule { fn g() { println!("g"); } } fn main() { g(); }
wird ein Submodul in einer Datei definiert. Module verhindern den direkten Zugriff auf ihre Elemente aus anderen Modulen.
Dementsprechend funktioniert der obere Schnipsel nicht, da g
in main
aufgerufen wird, aber g
in einem anderen Modul
lebt. Wir müssen entweder den kompletten Pfad zu g
angeben oder g
per use
importieren.
mod my_submodule { fn g() { println!("g"); } } fn main() { my_submodule::g(); }
Auch dieser Schnipsel funktioniert nicht, denn Funktionen und Typen in Modulen sind erstmal private Elemente des Moduls.
Wenn sie von außerhalb des Moduls verwendet werden sollen, müssen sie mit dem Keyword pub
annotiert werden.
mod my_submodule { pub fn g() { println!("g"); } } fn main() { my_submodule::g(); }
Umgekehrt können wir aus Submodulen auf andere Module zugreifen, wenn sie über den richtigen Pfad angesprochen oder importiert werden.
mod my_submodule { use f; pub fn g() { f(); println!("g"); } } fn f() { println!("f"); } fn main() { my_submodule::g(); }
Bemerke, dass f
nicht pub
ist. Submodule sind nicht außerhalb ihrer Module und können daher direkt auf
private Funktionen und Typen zugreifen.
Des Weiteren sind andere .rs
-Dateien Module, wenn sie per mod
Erwähnung im Crate Root finden.
Nehmen wir beispielsweise folgende Struktur unseres Pakets an.
my_package
├── Cargo.lock
├── Cargo.toml
└── src
├── module_2
│ └── mod.rs
│ └── module_2_a.rs
│ └── module_2_b.rs
├── module_1.rs
└── main.rs
Die Crate Root main.rs
bindet die Module module_1
und module_2
per mod
ein.
#![allow(unused)] fn main() { // main.rs mod module_1; mod module_2; }
Das Modul module_2
besteht nicht aus einer einzelnen Datei, sondern aus einem Ordner.
Die Datei mod.rs
definiert die Sichtbarkeit dieses Moduls nach außen.
Bei folgender Konfiguration ist module_2_a
von main
erreichbar und module_2_b
nicht.
#![allow(unused)] fn main() { // mod.rs pub mod module_2_a; mod module_2_b; }
Module externer Crates können ebenfalls über Pfade erreicht werden, nachdem wir sie der Cargo.toml
hinzugefügt haben.
Wir können in unserem Crate Exmex-Funktionalität über Pfade beginnend mit exmex::
verwenden,
nachdem wir Exmex manuell oder cargo add exmex
der Cargo.toml
hinzugefügt haben.
Auch der Zugriff auf Methoden und Felder von Struktur- und
Aufzählungstypen außerhalb des Moduls lässt sich per pub
steuern. Varianten von Aufzählungstypen sind
dagegen immer pub
.
Das heißt die Felder a
und b
von
#![allow(unused)] fn main() { struct X { a: u8, b: u8, } }
können überall innerhalb des Moduls, in dem X
definiert wurde, direkt verwendet werden.
struct X { a: u8, b: u8, } fn main() { let x = X{ a: 2, b: 1 }; println!("{}", x.a); }
Über Modulgrenzen hinweg lassen sich nur pub
-Felder eines pub
-Typen verwenden.
// kompiliert nicht mod detail { struct X { a: u8, b: u8, } } use detail::X; fn main() { let x = X{ a: 2, b: 1 }; println!("{}", x.a); }
Der Compiler weist uns auf die private Sichtbarkeit von X
und ihrer Felder a
und b
hin.
Wir können durch das Schlüsselwort pub
Zugriff ermöglichen.
mod detail { pub struct X { pub a: u8, pub b: u8, } } use detail::X; fn main() { let x = X{ a: 2, b: 1 }; println!("{}", x.a); }
Das use
-Statement haben wir schon oft in Aktion gesehene. Mit
#![allow(unused)] fn main() { use crate_name; }
binden wir das Crate-Root ein. Dann lassen sich die Elemente des Crates z.B. per
#![allow(unused)] fn main() { use crate_name::ein_crate_modul:EinCrateTyp; }
einbinden und wir können EinCrateTyp
verwenden. Wenn wir
nicht den ganzen Pfad einbinden können wir den Typen
#![allow(unused)] fn main() { let x = crate_name::ein_crate_modul::EinCrateTyp::new(); }
instanziieren, falls er die Methode new
mitbringt.
Um ein Supermodul einzubinden können wir entweder
den Pfad vom Crate-Root ausgehend verwenden oder
use super;
einsetzen.
Tests
Wenn wir einen Blick in die Datei src/lib.rs
werfen, die durch
cargo new --lib
erstellt wurde, finden wir folgenden Inhalt.
#![allow(unused)] fn main() { pub fn add(left: usize, right: usize) -> usize { left + right } #[cfg(test)] mod tests { use super::*; #[test] fn it_works() { let result = add(2, 2); assert_eq!(result, 4); } } }
Mit dem Attribut #[cfg(test)]
annotieren wir Code, der nur für Tests relevant ist. Das
Attribut #[test]
annotiert den eigentlichen Test. cargo run
ignoriert alle entsprechend
annotierte Bereiche. Zum Ausführen der Tests können wir einfach cargo test
eingeben.
Cargo hat die Tests in einem Modul separiert, womit die eigentliche Funktion add
getestet wird.
Innerhalb einer Testfunktion wird die Richtigkeit üblicherweise mit assert
s geprüft. Das Makro assert_eq!
,
überprüft die Gleichheit zweier Audrücke, die als Argumente übergeben werden. Vorsicht ist bei den Typen
f32
und f64
geboten. Daneben
gibt es assert!
, dass fehlschlägt, wenn der eine Ausdruck in den folgenden Klammern false
zurück gibt, und
assert_ne!
, das auf Ungleichheit prüft. Es ist möglich, assert
als weiteres Argument Nachrichten
zu übergeben, die bei Fehlschlagen ausgegeben werden. Diese folgen dem Format, das wir bei println!
bereits kennen gelernt haben.
Testen durch das public Interface
Der Test aus der vorherigen Sektion hat Zugriff auf die Interna des Moduls. Um Tests zu schreiben, die ein Crate nur durch
sein public Interface testen können, bietet Cargo an, den separaten Ordner tests
zu
durchforsten.
my_package
├── Cargo.lock
├── Cargo.toml
└── src
└── tests
Darin können wir auf unseren Crate zugreifen, als sei es ein externer Crate. Damit die Tests durch cargo test
ausgeführt werden, verwenden wir weiterhin die #[test]
oder #[cfg(test)]
Attribute.
Der Ordner tests
wird allerdings nur bei Bibliotheks-Crates verwendet. Daher werden oft Rust Anwendungen in einen Bibliotheks-Crate
und eine minimale main.rs
aufgeteilt.
Doc-Tests und Dokumentation
Die Beschreibung von Dokumentation, die automatisch aus Kommentaren extrahiert wird und auf docs.rs beim
Upload des eigenen Crates veröffentlicht wird, findet sich im
Rustdoc Book beschrieben. Dort findet man auch einen Abschnitt zu
Doc-Tests. Damit kann man Code
Beispiele aus der Dokumentation
direkt als Tests verwenden, was äußerst praktisch ist.
Wenn beispielsweise einer Funktion ein Kommentar mit drei Slashes zu Beginn jeder Zeile voransteht, wird dieser
von Rustdoc extrahiert. Mit cargo doc
lässt sich die Dokumentation extrahieren. Es können sich auch Code-Beispiele
darin befinden.
#![allow(unused)] fn main() { /// Adds two numbers /// /// Example /// ```rust /// use my_package::add; /// let sum = add(2, 4); /// assert_eq!(sum, 6); /// ``` pub fn add(left: usize, right: usize) -> usize { left + right } }
Die Zeichenketten ```rust
und ```
beginnen bzw. beenden einen Block der als Rust-Code formattiert wird.
Zusätzlich führt cargo test
jetzt einen Doc-Test aus, der diesen genau diesen Code ausführt. Dabei muss das public Interface
des Crates verwendet werden, was uns dazu zwingt use my_package::add
hinzuzufügen. Wenn wir den Import nicht in der
finalen Dokumentation sehen möchten, da er beispielsweise eh kloar ist, können wir eine Raute #
davor schreiben. Der
Doc-Test funktioniert weiterhin, die extrahierte Dokumentation verfügt dann über einen Nebekriegsschauplatz weniger.
#![allow(unused)] fn main() { /// Adds two numbers /// /// Example /// ```rust /// # use my_package::add; /// let sum = add(2, 4); /// assert_eq!(sum, 6); /// ``` pub fn add(left: usize, right: usize) -> usize { left + right } }
Quiz
Fragen
- Was ist ein Crate streng genommen und was wird oft unter einem Crate verstanden?
- Wie viele Module hat ein Crate mindestens?
- Wie viele Module befinden sich mindestens in einer Quelltextdatei?
- Welchen Nachteil haben Tests, die im gleichen Modul leben, wie der zu testende Code?
- Unter welchen Umständen kann ich auf das Feld
x
instruct MyStruct { x: i32 }
zugreifen? - Unter welchen Umständen kann ich auf das Feld
x
instruct MyStruct { pub x: i32 }
zugreifen?
Aufgabe
Dokumentiere den Typen VecMap
, der im Quiz von Abschnitt 3 erstellt wurde und füge sinnvolle Doc-Tests hinzu,
die sowohl die Funktionalität erklären als auch testen.
1: gemeine Rust Programmierer
2: .gitignore
gibt an welche Dateien von Git ignoriert werden.
Nebenläufige Programme
In vielen Betriebssystemen wird Programmcode in Prozessen ausgeführt. In Programmen können verschiedene Programmteile unabhängig existieren. Diese lassen sich in separaten leichtgewichtigen Prozessen ausführen, sogenannte Threads. Beispielsweise könnte ein Webserver aus mehreren Threads bestehen um Anfragen zeitgleich abarbeiten zu können. Unter Umständen ist ein Austausch von Daten zwischen Threads erwünscht.
Threads
Threads können die Effizienz von Programmen erhöhen. Ihr Einsatz erhöht definitiv die Komplexität des Programms. Das Erstellen und Starten von Threads kostet definitiv auch immer CPU-Ressourcen. Das impliziert, dass der Einsatz von Threads immer die erhöhte Komplexität rechtfertigen sollte. Die Reihenfolge, in der Threads abgearbeitet werden obliegt dem Betriebssystem. Insbesondere können folgende Probleme beim Einsatz von Threads auftreten:
- Es kann zu Race Conditions kommen. Dabei findet der Zugriff auf Daten, die von mehreren Threads benötigt werden, auf inkonsistente Art und Weise statt. Beispielsweise könnte ein Thread an einer Speicheradresse etwas schreiben, während ein anderer etwas liest.
- Mit Deadlocks bezeichnet man Situationen in denen beispielsweise zwei Threads auf gegenseitige Beendigung warten, bevor sie weiterlaufen können.
- Es kann zu schwer nachvollziehbaren Bugs kommen, die nur unter bestimmten Situationen z.B. abhängig von der Reihenfolge der ausgeführten Threads auftreten.
Rust verfolgt das Ziel das Aufkommen derartiger Probleme zu minimieren. Trotzdem ist auch in Rust die Programmierung mit mehreren Threads komplexer und fehleranfälliger.
Threads erstellen
In Rust kann ein Thread mit der Funktion std::thread::spawn
erstellt werden. Als Argument bekommt spawn
eine Closure ohne Parameter, die ausgeführt wird.
use std::thread; use std::time::Duration; fn main() { // Closure, die vom Thread ausgeführt wird let print_i = || { for i in 0..10 { println!("{i} spawned thread"); thread::sleep(Duration::from_micros(20)); } }; // Separater Thread wird asynchron gestartet thread::spawn(print_i); // Hauptthread geht weiter for i in 0..4 { println!("{i} main thread"); thread::sleep(Duration::from_micros(20)); } }
Sobald der Hauptthread fertig ist, werden alle separat gestarteten Threads beendet. Die Ausgabe des obigen Schnipsels ist nicht deterministisch sondern von abhängig von den Launen des Betriebssystems, könnte aber folgendermaßen aussehen.
0 main thread
0 spawned thread
1 main thread
1 spawned thread
2 main thread
2 spawned thread
3 main thread
3 spawned thread
4
Die letzte Zeile zeigt nur eine 4. Der separate Thread wurde also mitten in der Ausgabe beendet.
Auf Threads warten
Die Funktion thread::spawn
gibt eine Instanz des Typs JoinHandle
zurück. Ein JoinHandle
hat eine Methode join
, die auf das Beenden des gestartetn Threads wartet.
use std::thread; use std::time::Duration; fn main() { // Closure, die vom Thread ausgeführt wird let print_i = || { for i in 0..10 { println!("{i} spawned thread"); thread::sleep(Duration::from_micros(20)); } 73 }; // Separater Thread wird asynchron gestartet let handle = thread::spawn(print_i); // Hauptthread geht weiter for i in 0..4 { println!("{i} main thread"); thread::sleep(Duration::from_micros(20)); } match handle.join() { Ok(x) => println!("the thread returned {x}"), Err(e) => println!("thread panicked due to {e:?}"), } }
Die Methode join
wartet nicht nur auf Beendigung des Threads, sie auch gibt den Rückgabewert
des Closures im Thread zurück oder einen Fehler, falls der separate Thread eine Panikattacke
erlitten hat. Dementsprechend kann die nicht-deterministische Ausgabe folgendermaßen aussehen.
0 main thread
0 spawned thread
1 spawned thread
1 main thread
2 main thread
2 spawned thread
3 main thread
3 spawned thread
4 spawned thread
5 spawned thread
6 spawned thread
7 spawned thread
8 spawned thread
9 spawned thread
the thread returned 73
Die letzte Zeile ist jedoch deterministisch, falls das Betriebssystem das Starten des Threads zugelassen hat.
Ownership von erfassten Variablen in Threads
Wenn unser separater Threads eine Closure ausführen soll, die Variablen aus dem umgebenden Geltungsbereich
erfasst, ist es am einfachsten, Ownership der Closure über die Variable zu erfordern. Closures leiten von
der Verwendung der Variablen ab, ob die Variablen aus der Umgebung per Referenz erfasst werden, oder ob die
Closure Ownership übernimmt. Um die Verwendung per Referenz zu verhindern, gibt es das Schlüsselwort move
,
das Ownership der Closure erzwingt.
Quiz
Wovon hängt ab, ob per
move
eine Variable in eine Closure verschoben oder kopiert wird?
Im folgenden Schnipsel sehen wir, wie eine Closure eine Variable per Referenz erfassen möchte, was nicht funktioniert.
// kompiliert nicht use std::thread; fn main() { let n = 5; let print_n = || { println!("{n}"); }; thread::spawn(print_n); }
Der Borrow-Checker erlaubt zur Kompilierzeit nicht, dass eine Referenz in einem separaten Thread verwendet wird,
da er nicht wissen kann, wie lange der Thread lebt.
Um das Problem zu beheben, erzwingen durch Verwendung von move
wir eine Kopie von n
.
use std::thread; fn main() { let n = 5; let print_n = move || { println!("{n}"); }; thread::spawn(print_n).join(); }
Eine weitere in Rust recht neue Methode ist die Verwendung von thread::scope
. Die Funktion scope
erhält eine Closure als Argument.
Diese Closure bekommt als Argument wiederum eine
Scope
-Instanz. Der Typ Scope
hat eine spawn
-Methode, die verwendet werden kann, um Threads
zu starten. Am Ende des Geltungsbereichs der Scope
-Instanz wird durch die entsprechende Drop
-Implementierung auf
die Beendigung aller durch die Scope
-Instanz gestarteten Threads gewartet. Dadurch kann der
Borrow-Checker sicher sein, dass alle Threads und die entsprechenden Referenzen nicht mehr vorhanden sind.
use std::thread; fn main() { let n = 5; thread::scope(|s|{ let print_n = || { println!("{n}"); }; s.spawn(print_n); }); }
Message Passing
Um Daten zwischen Threads zu teilen gibt es verschiedene Möglichkeiten. Das mehr und mehr populäre Message Passing
ist der einzige Weg, den wir in diesem Kurs besprechen werden. Dabei werden nicht Zugriffsschutzmechanismen
auf geteilten Speicherbereich verwendet, sondern Daten werden zwischen Threads verschickt.
Rust verwendet zu diesem Zweck Einweg-Kanäle, die nur das Verschicken von Daten von einem oder mehreren Versendern zu einem Empfänger erlauben. Dementsprechend lebt die Funktion zum erzeugen von Kanälen channel
im Modul
std::sync::mpsc
, denn mpsc
steht für multiple producer, single consumer. Die Funktion channel
gibt einen Versender
vom Typ std::sync::mpsc::Sender<T>
und
einen Empfänger vom Typ std::sync::mpsc::Receiver<T>
zurück. Der Empfänger kann geklont werden, der Empfänger kann nur verschoben werden.
Der generische Typ T
ist der Typ der Instanz, die versendet wird.
use std::thread; use std::sync::{mpsc::{self, SendError, RecvError}}; fn main() -> Result<(), SendError<i32>> { let n = 5; let (send, recv) = mpsc::channel(); tx.send(n)?; let print_n = move || -> Result<(), RecvError> { let n = recv.recv()?; println!("{n}"); Ok(()) }; thread::spawn(print_n).join().unwrap(); Ok(()) }
Im obigen Schnipsel haben wir eine Nachricht an den separat gestarteten Thread geschickt, der auf die
Nachricht wartet.
Die Methode recv
blockiert den aktuellen Thread, bis eine Nachricht angekommen ist. Wenn man überprüfen
möchte, ob eine Nachricht da ist, ohne den aktuellen Thread zu blockieren, bietet sich die Verwendung von
try_recv
an.
Die send
- und recv
-Calls schlagen fehl und geben Result::Err
zurück, wenn das entpsrechende
Gegenstück nicht mehr verfügbar ist.
Andersrum ist es ebenfalls möglich, aus einem oder mehreren Threads Nachrichten an den Hauptthread zu schicken.
Dazu iterieren wir über den Empfängernachrichten mit recv.iter()
. Das ist ein blockierender Aufruf, der
über alle gesendeten Nachrichtenpakete iteriert und auf neue Nachrichten wartet, solange noch ein Sender
existiert, der in Zukunft Pakete senden könnte. Daher verwenden wir im folgenden Schnipsel die Funktion
drop
, die nichts tut und ihre Parameter per Move bekommt. Dadurch gibt es keine aktiven Sender mehr, wenn
wir über Receiver<T>::iter()
iterieren. Ansonsten würden wir eine Endlosschleife produzieren.
use std::thread; use std::sync::{mpsc::{self, SendError, RecvError}}; fn main() -> Result<(), SendError<i32>> { let (send, recv) = mpsc::channel(); for i in 0..3 { let send_i = send.clone(); // clone for thread i let print_i = move || -> Result<(), SendError<i32>> { send_i.send(i)?; Ok(()) }; thread::spawn(print_i); } drop(send); for x in recv.iter() { println!("{x}"); } Ok(()) }
Analog zu try_recv
gibt es auch die nicht-blockierende Variante Receiver<T>::iter()
namens
Receiver<T>::try_iter
.
Quiz
Fragen
- Wie definiert man den Programmcode, der in einem separaten Thread ausgeführt wird?
- Welche Wege gibt es, den Borrow-Checker davon zu überzeugen, dass Variablen in Threads zu keinen baumelnden Referenzen führen?
- Wie kann man verhindern, dass das Hauptprogramm beendet wird, bevor alle separaten Threads fertig sind?
- Unter welchen Umständen ist das Ergebnis eines
thread::spawn
-Calls einResult::Err
? - Wie kann man Daten aus dem Hauptthread in einem separaten Thread verwenden?
- Wie kann ich in Daten, die in einem separaten Thread erstellt wurden, im Hauptthread verwenden?
- Wie kann ich Daten aus einem separaten Thread in einem anderen separaten Thread verwenden?
Aufgabe
Schreibe ein Programm, dass als interaktiven User-Input via stdin().read_line
einen Pfad zu einer Textdatei enthalten soll. Nach Eingabe des Pfades soll ein separater Thread gestartet werden, der die durch Leerzeichen getrennten Wörter in dieser Textdatei zählt. Das Hauptprogramm soll nach dem Starten des Threads sofort wieder bereit stehen, um die nächste Benutzereingabe für einen neuen Pfad entgegenzunehmen, auch wenn der erste Zähl-Thread noch nicht fertig ist. Sobald eine Benutzereingabe keinen Pfad zu einer Textdatei ist, wird die Liste bisher verarbeiteten Pfaden und Wortanzahlen ausgegeben und das Programm beendet.
Polymorphie
Polymorphie kommt aus dem Griechischen und bedeutet Vielgestaltigkeit. Im Software Engineering ist damit
gemeint, dass eine Instanz verschiedene konkrete Gestalten annehmen kann, die alle von einem identischen
Teilprogramm prozessiert werden können. Wir haben bereits Beispiele kennengelernt. Unsere Funktion
longest_dist_to_0
aus Kapitel 3 konnte auf verschieden gestaltige Eingaben angewendet werden.
Punkt ・
Linie ___ -> fn longest_dist_to_0 -> f64
Kreis ⬤
Wir haben Punkte, Linien und Kreise bzgl. ihres Abstandes zum Ursprung miteinander vergleichen können, da wir den Typen als generischen Parameter verwendet haben. Zur Auffrischung folgt nochmal die Implementierung.
#![allow(unused)] fn main() { use std::ops::{Mul, Add, Sub}; trait Calculate: Mul<Output=Self> + Add<Output=Self> + Sub<Output=Self> + Copy {} impl<T: Mul<Output=Self> + Add<Output=Self> + Sub<Output=Self> + Copy> Calculate for T {} trait MeasureDistanceTo0<T: Calculate> { fn squared_dist_to_0(&self) -> T; } fn longest_dist_to_0<T, M1, M2>(p1: M1, p2: M2) -> T where T: Calculate + PartialOrd, M1: MeasureDistanceTo0<T>, M2: MeasureDistanceTo0<T> { let d1 = p1.squared_dist_to_0(); let d2 = p2.squared_dist_to_0(); if d1 > p2.squared_dist_to_0() { d1 } else { d2 } } }
Hierbei handelt es sich um statische Polymorphie. Wir reden von statischer Polymorphie, wenn alle
Gestalten zur Kompilierzeit feststehen. Der Compiler erstellt für jede Kombination an Eingabetypen, die
wir im Programm verwenden eine Kopie der Funktion und führt diese dann aus. Wenn wir beispielsweise
Nur Punkte mit Punkten und Linien mit Punkten vergleichen, erstellt der Compiler zwei Versionen
von longest_dist_to_0<T, Point<T>, Point<T>>
und longest_dist_to_0<T, Point<T>, Line<T>>
für uns. Wenn wir
noch einen dritten Aufruf hinzufügen indem wir erst die Linie und dann den Punkt als Argument verwenden,
erstellt der Compiler für uns eine weitere Version longest_dist_to_0<T, Line<T>, Point<T>>
.
Ein weiterer prominenter Anwendungsfall polymorpher Typen ist die Sammlung seiner Instanzen in einem
Container. Beispielsweise könnte unsere Funktion longest_dist_to_0
anstatt zweier Argumente den längsten
Abstand über beliebig viele Formen möglicherweise unterschiedlicher Gestalt suchen. Wenn wir die
Signatur
fn longest_dist_to_0<T, M1, M2>(p1: M1, p2: M2) -> T
nochmal betrachten, fällt auf, dass jeder Parameter einen separaten Typ-Parameter bekommt. Bei einem Vec<T>
kann aber nicht jedes Element einen anderen Typen haben. Alle Typen sind T
. Wir kennen jedoch einen Weg, unterschiedliche Gestalten in einem Vec<T>
unterzubringen, der uns im folgenden zum Begriff der dynamischen Polymorphie führt.
Quiz
An dieser Stelle sei der Leser gebeten kurz innezuhalten und nachzudenken. Was könnte das sein?
Ergänzend zur statischen Polymorphie gibt es die dynamische Polymorphie, bei der erst zur Laufzeit die konkrete Gestalt der Eingabe bestimmt und verwendet wird. Die Variante dynamischer Polymorphie, die wir bereits kennengelernt haben, ist der Aufzählungstyp. Wir packen die unterschiedlichen Gestalten in die verschiedenen Varianten eines Aufzählungstypen. Für unser Beispiel könnte man dynamische Polymorphie mit einem Container anstelle zweier Parameter folgendermaßen umsetzen.
#![allow(unused)] fn main() { use std::ops::{Mul, Add, Sub}; use std::cmp::Ordering; trait Sqrt { fn sqrt(self) -> Self; } impl Sqrt for f64 { fn sqrt(self) -> f64 { self.sqrt() } } impl Sqrt for f32 { fn sqrt(self) -> f32 { self.sqrt() } } trait Calculate: Mul<Output=Self> + Add<Output=Self> + Sub<Output=Self> + Sqrt + Copy {} impl<T: Mul<Output=Self> + Add<Output=Self> + Sub<Output=Self> + Sqrt + Copy> Calculate for T {} struct Point<T> where T: Calculate { x: T, y: T, } impl<T: Calculate> Point<T> { fn squared_dist_2_0(&self) -> T { self.x * self.x + self.y * self.y } } struct Circle<T: Calculate> { center: Point<T>, r: T } impl<T: Calculate> Circle<T> { fn squared_dist_2_0(&self) -> T { self.center.squared_dist_2_0().sqrt() - self.r } } enum Measurable<T: Calculate> { Point(Point<T>), Circle(Circle<T>), } impl<T: Calculate> Measurable<T> { fn squared_dist_2_0 (&self) -> T{ match self { Measurable::Point(p) => p.squared_dist_2_0(), Measurable::Circle(c) => c.squared_dist_2_0() } } fn dist_to_0(&self) -> T { self.squared_dist_2_0().sqrt() } } fn longest_dist_to_0<T>(p: &[Measurable<T>]) -> Option<T> where T: Calculate + PartialOrd, { p.iter() .map(|pi| pi.squared_dist_2_0()) .max_by(|a, b| match a.partial_cmp(b) { Some(o) => o, None => Ordering::Equal, }) } }
Ein offensichtlicher Nachteil der enum
-Lösung ist, dass sie sich nicht von außerhalb des Moduls erweitern lässt.
Um weitere Gestalten hinzufügen zu können, müssen wir den Aufzählungstypen anpassen.
Wir kennen also bereits zwei Wege, Polymorphie in Rust umzusetzen. Beide haben Vor- und Nachteile, die
wir im folgenden zusammenfassen.
Statische Polymorphie durch generische Typen...
- ...ist üblicherweise effizienter zur Laufzeit als dynamische Polymorphie, da die Entscheidungspfade einkompiliert sind und der Compiler noch mehr Optimierungsmöglichkeiten hat.
- ...kann im Gegensatz zu dynamischer Polymorphie mit
enum
s auch von außerhalb des Moduls erweitert werden. Man kann z.B. polymorphe Funktionalität in einer Bibliothek definieren und Benutzer dieser Bibliothek können neue Gestalten erfinden und die Funktionalität der Bibliothek auf diese anwenden. - ...führt zu größeren Kompilaten, da für jede Kombination generischer Typen ein neuer Typ oder eine Funktion vom Compiler erzeugt wird.
- ...führt dazu, dass der Übersetzungsvorgang selbst länger dauert.
- ...kann nur verwendet werden, wenn alle relevanten Entscheidungen zur Kompilierzeit getroffen werden können. Beispielsweise Benutzereingaben von Programmnutzern sind zur Kompilierzeit noch nicht bekannt.
- ...kann nicht verwendet werden, um in einem Container unterschiedliche Gestalten abzulegen. Man kann zur Kompilierzeit festlegen, welche Gestalt in den Container gehört und dann ausschließlich diesen konkreten Typen zur Laufzeit verwenden. Einem Container Instanzen eines Aufzählungstypen mit unterschiedlichen Varianten hinzuzufügen ist dagegen kein Problem.
- ...ist manchmal nicht trivial. Das ist insbesondere dann der Fall, wenn man generischen Code über ein Verhalten schreiben will, für das es noch keinen passenden Trait gibt. Die Verwendung von Aufzählungstypen ist oft simpler.
Aufgabe
Um den letzten Punkt zu verdeutlichen, sei angenommen, dass wir eine Funktion schreiben, die ein assoziatives Array verwendet. Es soll dem Benutzer aber überlassen werden, ob er eine
HashMap
verwendet oder z.B. dieVecMap
, die wir in Kapitel 3 selbst entwickelt haben. Unsere Funktion soll den Typ des Containers als generischen Typen erhalten und eine Liste an Key-Value-Paaren bekommen, die je nach Benutzereingabe entweder nach den Key oder nach den Values sortiert wurden.
Es gibt noch eine weitere Möglichkeit dynamischer Polymorphie in Rust. Die sogenannten Trait-Objekte sind das Thema der folgenden Abschnitte und entsprechen dem Konzept der Polymophie in Objekt-orientierter Programmierung. Damit ist es möglich dynamisch polymorphe Typen, die außerhalb des Moduls definiert wurden, zu verwenden. Aufzählungstypen, die Daten beinhalten, gibt es in vielen objekt-orientierten Sprachen gar nicht. Die Mengen der Probleme, die sich durch den Aufzählungstypen und durch Trait-Objekte lösen lassen, überlappen sich also, wie wir im nächsten Abschnitt sehen werden.
Objektorientierte Programmierung
In objektorientierter Programmierung werden Daten und Methoden gemeinsam in Typen
gebündelt. Die Methoden können auf den Daten operieren. Beispielsweise beinhalten in
Rust struct
s und enum
s sowohl Daten als auch Methoden, wobei die Methoden
per self
auf die Daten zugreifen können. Folgende Aspekte werden häufig mit
objektorientierter Programmierung in Verbindung gesetzt.
Kapselung
Wenn nun nur die Methoden auf interne Daten zugreifen, wird der unnötige Pfusch
des gemeinen Benutzers mit Implementierungsdetails verhindert. Wenn die Typen jedoch
zu groß werden, verbirgt sich hinter self
sehr oft eine unnötig große Vielfalt an
Feldern, so dass die meisten Methoden nur Zugriff auf einen Bruchteil der zur Verfügung
stehenden Daten benötigen.
Kapselung im Sinne objektorientierter Programmierung ist dementsprechend nur effektiv,
solange die Typen übersichtlich bleiben.
In des Autors Programmiererleben haben Tendenzen ewigen Typwachstums bedauerlicherweise
eine signifikante Rolle gespielt.
Vererbung
In vielen objektorientierten Sprachen können Typen direkt voneinander erben. Dabei
ist es dem Erbenden über self
-artige Konstrukte möglich, nicht nur auf Methoden sondern auch auf Felder sämtlicher Vorfahren
zuzugreifen. Zuträglich ist das vor allem der Mannigfaltigkeit des Datenspektrums, dass
sich hinter self
verbirgt. Übersichtlicher werden Programme dadurch eher nicht. Glücklicherweise
ist das in Rust nicht möglich. In Rust können nur Traits von Traits erben wie im Kapitel über Traits
dargestellt.
Eine Motivation für die Verwendung von Vererbung ist die Vermeidung von Code-Duplikation. In Rust erinnern wir uns neben der Möglichkeit einfach freie Funktionen zu verwenden, an Standardimplementierungen von Trait-Methoden. Die Verwendung von Vererbung in objektorientierten Sprachen ausschließlich zur Verringerung von Code-Duplikation ist mindestens kontrovers. Aggregation von Typen in Feldern wird oft der Vererbung vorgezogen.
Ein weiterer deutlich besserer Grund zur Verwendung von Vererbung ist dynamische Polymorphie. Wir möchten also Funktionalität für verschiedene Gestalten eines Konzepts wiederverwenden. In diesem Fall möchten wir also Funktionalität für alle Erben zugänglich machen.
Trait Objekte
In Rust können wir Funktionalität für alle Implementierer eines Traits bereitstellen. Neben statischer Polymorphie mit generischen Datentypen können wir auch konkrete Typen und Trait Objekte verwenden, deren Gestalt erst zur Laufzeit fest steht. Dazu betrachten wir erneut unser Punkt-Kreis-Linien-Beispiel.
#![allow(unused)] fn main() { use std::ops::{Mul, Add, Sub}; trait Calculate: Mul<Output=Self> + Add<Output=Self> + Sub<Output=Self> + Copy {} impl<T: Mul<Output=Self> + Add<Output=Self> + Sub<Output=Self> + Copy> Calculate for T {} trait MeasureDistanceTo0<T: Calculate> { fn squared_dist_to_0(&self) -> T; } fn longest_dist_to_0<T>( p1: &dyn MeasureDistanceTo0<T>, p2: &dyn MeasureDistanceTo0<T> ) -> T where T: Calculate + PartialOrd { let d1 = p1.squared_dist_to_0(); let d2 = p2.squared_dist_to_0(); if d1 > p2.squared_dist_to_0() { d1 } else { d2 } } }
Die generischen Parameter reduzieren sich zu T
, das oft f32
oder f64
annimmt.
Unser Trait MeasureDistanceTo0
ändert sich nicht unabhängig von seiner Nutzung in statischer
oder dynamischer Polymorphie. Die Parameter der Funktion longest_dist_to_0
sind nun Referenzen
auf dyn MeasureDistanceTo0<T>
. Das Schlüsselwort dyn
dient nur der Markierung von Trait-Objekten
und war in alten Versionen von Rust optional. Es ist jedoch nicht Möglich
dyn MeasureDistanceTo0<T>
ohne Referenz als Argument zu verwenden, denn die Größe der Typen muss
zur Kompilierzeit feststehen, wie wir im Kapitel über Heap und Stack gelernt haben.
Wir haben nun eine weitere Alternative zur Erstellung von verschiedengestaltigen Containern.
use std::{cmp::Ordering, ops::{Mul, Add, Sub}}; trait Calculate: Mul<Output=Self> + Add<Output=Self> + Sub<Output=Self> + Copy + From<i32> {} impl<T: Mul<Output=Self> + Add<Output=Self> + Sub<Output=Self> + Copy + From<i32>> Calculate for T {} #[derive(Copy, Clone)] struct Point<T> where T: Calculate { x: T, y: T, } impl<T> MeasureDistanceTo0<T> for Point<T> where T: Calculate { fn squared_dist_to_0(&self) -> T { self.x * self.x + self.y * self.y } } struct Circle<T: Calculate> { center: Point<T>, r: T } trait Sqrt { fn sqrt(self) -> Self; } impl Sqrt for f64 { fn sqrt(self) -> f64 { self.sqrt() } } impl Sqrt for f32 { fn sqrt(self) -> f32 { self.sqrt() } } impl<T> MeasureDistanceTo0<T> for Circle<T> where T: Calculate + Sqrt { fn squared_dist_to_0(&self) -> T { self.center.squared_dist_to_0().sqrt() - self.r } } trait MeasureDistanceTo0<T: Calculate> { fn squared_dist_to_0(&self) -> T; } fn longest_dist_to_0<T>(ps: &[&dyn MeasureDistanceTo0<T>]) -> T where T: Calculate + PartialOrd { ps.iter().map(|p| p.squared_dist_to_0()).max_by(|a, b|{ match a.partial_cmp(b) { Some(o) => o, None => Ordering::Equal, } }).unwrap_or(T::from(0)) } fn main() { let point = Point{ x: 0.1, y:0.2 }; let point = Point{ x: 0.1, y:0.2 }; let circle = Circle{ center: point, r: 0.3 }; let v: Vec<&dyn MeasureDistanceTo0<f64>> = vec![ &point, &point, &circle ]; let ld = longest_dist_to_0(&v); println!("{ld}"); }
Der innere Typ des Vektors v
muss explizit als &dyn MeasureDistanceTo0
angegeben werden, da ansonsten
der Compiler sich weigert, die Typen &Point
und &Circle
in einem Vektor unterzubringen.
Angenommen, wir möchten ein Trait-Objekt nicht per Referenz übergeben, sondern wir wollen Ownership
abgeben. In diesem Fall können wir unser Trait-Objekt in einen Zeiger einwickeln. In Rust gibt es
verschiedene Zeigertypen. Der wichtigste Zeiger heißt Box<T>
. Ein Box<T>
-Zeiger legt einen Wert von Typ T
auf den Heap und behält auf dem Stack nur die Adresse. Des weiteren hat Box
Ownership über den Wert
und entsprechend eine drop
-Implementierung, die den Wert aufräumt. Beispielsweise
landet der in einem Box
-Zeiger verpacktem Array
#![allow(unused)] fn main() { let x = Box::new([10, 20, 30]); }
inklusive Metadaten auf dem Heap
Stack Heap
| address | name | value | | address | value |
| ------- | ---- | -------- | | ------- | ----- |
| | | | | 0x2040 | ? |
| | | | | 0x2036 | 30 |
| | | | | 0x2032 | 20 |
| | | | | 0x2028 | 10 |
| 0x1064 | x | 0x2024 | ---> | 0x2024 | len |
| | | | | 0x2020 | ? |
während der Array [10, 20, 30]
ohne Box
-Verpackung komplett auf dem Stack landen würde.
Wir können also anstatt Referenzen einen Box
-Zeiger verwenden, der unabhängig von der Gestalt
identischen Platz auf dem Stack einnimmt und die Gestalt besitzt anstatt sie nur zu referenzieren.
use std::{cmp::Ordering, ops::{Mul, Add, Sub}}; trait Calculate: Mul<Output=Self> + Add<Output=Self> + Sub<Output=Self> + Copy + From<i32> {} impl<T: Mul<Output=Self> + Add<Output=Self> + Sub<Output=Self> + Copy + From<i32>> Calculate for T {} #[derive(Copy, Clone)] struct Point<T> where T: Calculate { x: T, y: T, } impl<T> MeasureDistanceTo0<T> for Point<T> where T: Calculate { fn squared_dist_to_0(&self) -> T { self.x * self.x + self.y * self.y } } struct Circle<T: Calculate> { center: Point<T>, r: T } trait Sqrt { fn sqrt(self) -> Self; } impl Sqrt for f64 { fn sqrt(self) -> f64 { self.sqrt() } } impl Sqrt for f32 { fn sqrt(self) -> f32 { self.sqrt() } } impl<T> MeasureDistanceTo0<T> for Circle<T> where T: Calculate + Sqrt { fn squared_dist_to_0(&self) -> T { self.center.squared_dist_to_0().sqrt() - self.r } } trait MeasureDistanceTo0<T: Calculate> { fn squared_dist_to_0(&self) -> T; } fn longest_dist_to_0<T>(ps: &[Box<dyn MeasureDistanceTo0<T>>]) -> T where T: Calculate + PartialOrd { ps.iter().map(|p| p.squared_dist_to_0()).max_by(|a, b|{ match a.partial_cmp(b) { Some(o) => o, None => Ordering::Equal, } }).unwrap_or(T::from(0)) } fn main() { let point = Point{ x: 0.1, y:0.2 }; let point = Point{ x: 0.1, y:0.2 }; let circle = Circle{ center: point, r: 0.3 }; let v: Vec<Box<dyn MeasureDistanceTo0<f64>>> = vec![ Box::new(point), Box::new(point), Box::new(circle) ]; let ld = longest_dist_to_0(&v); println!("{ld}"); }
Im Vergleich zu enum
s haben Trait-Objekte den Vorteil, dass nicht alle Gestalten feststehen müssen und
z.B. auch Nutzer einer Bibliothek Funktionalität mit eigenen Gestalten verwenden können. Im Vergleich zu
statischer Polymorphie mit generischen Typen bleibt die geringere Laufzeiteffizienz gegenüber kürzeren
Kompilierzeiten, kleineren Kompilaten und der Möglichkeit vielgestaltiger Container.
Wir können auch Closures als Trait-Objekte auffassen mit äquivalenten Vor- und Nachteilen. Wir betrachten erneut ein Beispiel aus dem Grundlagenkapitel. Wir wollen einen element-weise definierten Operator auf alle Elemente eines Arrays anwenden.
#![allow(unused)] fn main() { /// Unary array operator fn unary_arr_op( arr: [f64; 3], f: fn(f64) -> f64 ) -> [f64; 3] { let mut arr_res = [0.0; 3]; arr_res[0] = f(arr[0]); arr_res[1] = f(arr[1]); arr_res[2] = f(arr[2]); arr_res } fn increase_by_10(arr: [f64; 3]) -> [f64; 3]{ // kompiliert nicht, da die Variable `ten` außerhalb des Geltungsbereichs // der anonymen Funktion incr ist // und incr somit keine einfache Funktion mehr ist let ten = 10.0; let incr = |x| x + ten; unary_arr_op(arr, incr) } }
Anstatt einer einfachen Funktion, die keine Variablen aus der Umgebung erfassen kann, können wir Closures mit statischer Polymorphie
#![allow(unused)] fn main() { /// Unary array operator fn unary_arr_op<F>( arr: [f64; 3], f: F ) -> [f64; 3] where F: Fn(f64) -> f64 { let mut arr_res = [0.0; 3]; arr_res[0] = f(arr[0]); arr_res[1] = f(arr[1]); arr_res[2] = f(arr[2]); arr_res } fn increase_by_10(arr: [f64; 3]) -> [f64; 3]{ let ten = 10.0; let incr = |x| x + ten; unary_arr_op(arr, incr) } }
und Closures mit dynamischer Polymophie
#![allow(unused)] fn main() { /// Unary array operator fn unary_arr_op( arr: [f64; 3], f: &dyn Fn(f64) -> f64 ) -> [f64; 3] { let mut arr_res = [0.0; 3]; arr_res[0] = f(arr[0]); arr_res[1] = f(arr[1]); arr_res[2] = f(arr[2]); arr_res } fn increase_by_10(arr: [f64; 3]) -> [f64; 3]{ let ten = 10.0; let incr = |x| x + ten; unary_arr_op(arr, &incr) } }
verwenden.
Quiz
Fragen
- Was ist Polymorphie?
- Was unterscheidet statische von dynamischer Polymorphie?
- Wie implementiert man statische Polymorphie in Rust?
- Wie implementiert man dynamische Polymorphie in Rust?
- Was ist der entscheidende Vorteil statischer Polymorphie?
- Welche Nachteile hat statische Polymorphie?
- Was ist ein Trait-Objekt?
- Warum kann man Trait-Objekte nur per Zeiger oder Referenz übergeben?
Aufgabe
Erstelle einen einfachen Threadpool, dem der Benutzer eine Anzahl an Threads übergibt, an den der Benutzer Jobs senden kann und dessen Threads beendet werden, sobald die Threadpool-Instanz ihren Geltungsbereich verlässt. Die Jobs sollen Closures beinhalten, die von den jeweiligen Threads ausgeführt werden.
Deklarative Makros
Unicode Strings
Index
binary crate, 1
Cache-Lines, 1
Crate Root, 1
library crate, 1
statische Polymorphie, 1
annotieren, 1
Array, 1
Arrays dynamischer Größe, 1
assoziierter Typ, 1
Bibliotheks-Crate, 1
Borrow-Checker, 1
Borrowing, 1
Box
, 1
Closure, 1
Copy
, 1
Currying, 1
Data Race, 1
Default-Implementierung, 1
Fn
, 1
FnMut
, 1
FnOnce
, 1
Funktionspointer, 1
Geltungsbereich, 1
Git, 1
HashMap<K, V>
, 1
if
-Ausdrücke, 1
Installation, 1
Instanz, 1
IntoIterator
, 1
JoinHandle
, 1
Lambda-Funktion, 1
Lebenszeit, 1
Lifetime Elision, 1
lifetimes, 1
modular, 1
Move, 1
mutable reference, 1
None
, 1
Ordering
, 1
Overflow, 1
Paket, 1
PartialOrd
, 1
Pattern Matching, 1
Race Condition, 1
Range
, 1
Referenz, 1
Referenz, baumelnde, 1
Rekursion, 1
Schleifen, 1
Seiteneffekte, 1
Slice, 1
Some(T)
, 1
sort
, 1
sort_by_key
, 1
Speicherverwaltung, 1
Standardbibliothek, 1
Standardimplementierung, 1
Thread, 1
Trait, 1
Tupel, 1
Vec
, 1
Vererbung, 1
Visual Studio Code, 1
überschatten, 1