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

DateipfadBedeutung
.gitGit Ordner, da Cargo auch ein Git Repository angelegt hat.
.gitignorekompilierte Dateien sollen von der Versionsverwaltung ignoriert werden
Cargo.tomlAbhängigkeiten zu anderen Paketen
src/main.rsQuellcode 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.

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

  1. Wahrheitswerte (engl. booleans), die nur true oder false sein können, und
  2. Zeichen (engl. characters).
  3. ganze Zahlen (engl. integers),
  4. 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.

TypMinMax
u80255
u16065 535
u3204 294 967 295
u640264-1
u12802128-1
i8-128127
i16-32 76832 767
i32-2 147 483 6482 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.

  1. Der Compiler entdeckt den Overflow und verweigert die Übersetzung, was aber auch ausgeschaltet werden kann.
  2. Bei einem Debug-Build, der zusätzliche Überprüfungen beinhaltet, wird das Programm mit einem Laufzeitfehler abgebrochen.
  3. 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 Wert 0.

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.

BeschreibungBeispiel
Funktionsaufrufe ohne expliziten Rückgabewertprint_hello_world()
Funktionsaufrufe mit explizitem Rückgabewertadd(1.0, 1.1)
Macroaufrufeprintln!("Hallo Welt.")
arithmetische Operationen5.0 + 7.5
Literale4
Variablenx

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 nach let 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 enums 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 in let 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.

ProgrammeingabeErwartetes 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 x, und y befinden sich auf dem Stack.

fn add(a: f64, b: f64) 
-> f64 {
    a + b
}
fn main() {
    let x = 1;
    let y = 2;
    // <<<<<<<<<
    let z = add(x, y);
}
| address | name | value |
| ------- | ---- | ----- |
| 0x2010  |      |       |
| 0x2008  |      |       |
| ---------------------- |
| 0x2000  | z    | ?     |
| 0x1008  | y    | 2     | stack frame
| 0x1000  | x    | 1     | von main
| ---------------------- |

Die Rücksprungadresse der Funktion add und ihre Parameter a und b kommen oben auf den Stapel.

fn add(a: f64, b: f64) 
-> f64 {
    a + b
    // <<<<<<<<<
}
fn main() {
    let x = 1;
    let y = 2;
    let z = add(x, y);
}
| address | name      | value  |
| ---------------------------- |
| 0x2018  | b         | 2      |
| 0x2010  | a         | 1      | stack frame
| 0x2008  | jump addr | 0x2000 | von add
| ---------------------------- |
| 0x2000  | z         | ?      |
| 0x1008  | y         | 2      | stack frame
| 0x1000  | x         | 1      | von main
| ---------------------------- |

Wir sind wieder zurück in der ursprünglichen Funktion. Die Parameter a und b sowie die Rücksprungadresse ist nicht mehr im Stack. Dafür haben wir jetzt eine neue Variable z, die das Ergebnis der Addition beinhaltet.

fn add(a: f64, b: f64) 
-> f64 {
    a + b
}
fn main() {
    let x = 1;
    let y = 2;
    let z = add(x, y);
    // <<<<<<<<<
}
| address | name | value |
| ---------------------- |
| 0x2010  |      |       |
| 0x2008  |      |       |
| ---------------------- |
| 0x2000  | z    | 3     |
| 0x1008  | y    | 2     | stack frame
| 0x1000  | x    | 1     | von main
| ---------------------- |

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 Vecs 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 Vecs 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. Strings legen neben einem Verwaltungsoverhead wie Vecs ihre Daten hauptsächlich auf dem Heap an. Und ebenfalls wird der Speicher eines Strings 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

Strings und auch Vecs 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 Vectoren 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 Strings 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 Strings 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 Strings. 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:

  1. Das Programm wird nicht übersetzt, da eine Referenz auf eine lokale Variable der Funktion zurückgegeben wird.
  2. 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:

  1. Jedes Argument dessen Typ eine Referenz ist, bekommt einen eigenen Lifetime-Parameter. Das heißt f(n: &i32) entspricht f<'a>(n: &'a i32), f(n: &i32, m: &i32) entspricht f<'a, 'b>(n: &'a i32, m: &'b i32), ... .
  2. 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).
  3. 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; , wenn a Owner von Stack-Speicher ist?
  • Was passiert bei der Zuweisung let b = a; , wenn a 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 Struktutypen struct 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.

  1. Es treten neben den Traits Mul und Add der Trait Copy auf. Typen, die den Trait Copy implementieren, verhalten sich kopierbar. Das heißt self.x * self.x multipliziert zwei Kopien von self.x. Es findet kein Move statt.

    Quiz

    Was würde passieren, wenn T nicht Copy implementierte?

  2. Mehrere Traits, die durch ein Plus separiert werden wie Mul<Output=T> + Add<Output=T> + Copy, müssen allesamt implementiert worden sein.
  3. Die Traits Mul und Add haben ein generisches Argument Output = T. Das ist ein assoziierter Typ, der den Rückgabewert der Addition festlegt. Oft verwendet man hier Self. 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 traits 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ür Line den Trait MeasureDistanceTo0 und verwende Line in der Funktion longest_dist_to_0. Wenn Line 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 fs 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.

  1. Panik! Das Programm bricht ab.
  2. 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 Options 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 von DivisionByZero. Was gibt println!("{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:

  1. Wenn man über eine Referenz iteriert, gibt into_iter eine Referenz zurück und nicht den Wert. Das liegt daran, dass IntoIterator nicht nur für Vec implementiert ist, sondern auch für &Vec und &mut Vec.
  2. Vec<T> und andere Container verfügen über die Methoden iter und iter_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 und collect 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.

  1. 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.
  2. Die Allokation von Speicher am Stück ist oft effizienter.
  3. 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.

  1. Das Makro vec! ist uns schon begegnet.
    #![allow(unused)]
    fn main() {
    let v = vec![1, 3, 5, 7];
    }
  2. 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]);
    }
  3. Wir können uns der Veränderlichkeit via mut bedienen, einen leeren Vec 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 Vecs, 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.

HashMaps

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, Strings oder &strs 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 HashMaps in der Praxis nicht immer die effizienteste Lösung, selbst wenn es zu keiner Kollision kommen sollte. Es folgen einige Beispiele zur Initialisierung von HashMaps. 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 Vecs 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 Vecs 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 HashMaps sind analog zu denen über Vecs 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 HashMaps 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
    #![allow(unused)]
    fn main() {
    fn some_higher_order_fn<T>(f: fn(T) -> T);
    }
    und
    #![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?
    1. String
    2. &str
    3. [usize; 3]
    4. &[usize]
    5. Vec<usize>
    6. &[f32]
    7. Vec<f32>

Aufgabe

Schreibe einen Containertypen VecMap, der ähnlich wie eine HashMap die Methoden

  • get,
  • get_mut,
  • insert und
  • remove

und die Traits

  • Index und
  • IntoIter

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 zur HashMap. 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 asserts 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 in struct MyStruct { x: i32 } zugreifen?
  • Unter welchen Umständen kann ich auf das Feld x in struct 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 ein Result::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 enums 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. die VecMap, 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 structs und enums 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 enums 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