Impressum
Verfasser und Herausgeber
© 2020 Malte Luttermann
Martin-Behaim-Straße 70
23879 Mölln
books@malte-luttermann.de
1. Auflage

Cover erstellt von
germancreative (https://www.fiverr.com/germancreative)

Programmierung sicherer Systeme mit Rust – Eine Einführung

Inhaltsverzeichnis

Inhaltsverzeichnis

Bevor es losgeht

Was ist eine Programmiersprache?

Warum ausgerechnet Rust?

Zusammenfassung dieses Kapitels

Die Installation von Rust

Rust herunterladen

Rust Programme ausführen

Ein »Hallo, Welt!« Programm in Rust

Kommentare

Zusammenfassung dieses Kapitels

Aufgabe 1

Variablen

Änderbarkeit (Mutability) von Variablen

Benennung von Variablen

Schlüsselwörter

Shadowing

Konstanten

Zusammenfassung dieses Kapitels

Aufgabe 2

Datentypen

Primitive Datentypen

Zusammengesetzte Datentypen

Arrays

Tupel

Typumwandlung

Zusammenfassung dieses Kapitels

Aufgabe 3

Funktionen

Statements und Expressions

Zusammenfassung dieses Kapitels

Aufgabe 4

Kontrollstrukturen

Bedingungen

Schleifen

loop

while

for

Zusammenfassung dieses Kapitels

Aufgabe 5

Das Ownership Konzept

Stack und Heap

Der Stack

Der Heap

Ownership Regeln

Geltungsbereiche (Scopes)

Ownership Beispiele

Move

Ownership und Funktionen

Referenzen und Borrowing

Zusammenfassung dieses Kapitels

Zeichenketten

Der Datentyp Slice

Der Datentyp String

Zeichenketten formatieren

Zusammenfassung dieses Kapitels

Aufgabe 6

Benutzerdefinierte Typen

Structs (Strukturen)

Ausgabe von Structs

Methoden

Enums (Aufzählungen)

Das Enum Option

Pattern Matching

Zusammenfassung dieses Kapitels

Aufgabe 7

Der Projektmanager Cargo

Zusammenfassung dieses Kapitels

Projektmanagement

Packages und Crates

Module und Paths

Das Schlüsselwort use

Das Schlüsselwort as

Das Schlüsselwort pub use

Externe Pakete einbinden

Module in mehrere Dateien aufteilen

Automatische Generierung einer Dokumentation

Cargo Workspaces

Zusammenfassung dieses Kapitels

Aufgabe 8

Collections

Vektoren

HashMaps

HashSets

LinkedLists

Zusammenfassung dieses Kapitels

Aufgabe 9

Fehlerbehandlung

Nicht wiederherstellbare Fehler

Wiederherstellbare Fehler

Zusammenfassung dieses Kapitels

Aufgabe 10

Generics, Traits, Lifetimes

Generische Typen

Traits

Einen Trait definieren

Einen Trait implementieren

Standardimplementierungen

Traits als Parameter

Traits als Rückgabetyp

Methoden abhängig von Traits definieren

Lifetimes

Lifetimes in Structs

Die Lifetime static

Zusammenfassung dieses Kapitels

Tests

Die Ausführung von Tests konfigurieren

Tests sequentiell ausführen

Ausgaben von Funktionen anzeigen

Eine bestimmte Menge an Testfällen ausführen

Tests organisieren

Unit Tests

Integration Tests

Zusammenfassung dieses Kapitels

Aufgabe 11

Closures und Iteratoren

Closures

Iteratoren

Eigene Iteratoren definieren

Zusammenfassung dieses Kapitels

Aufgabe 12

Pointer

Der Smart Pointer Box<T>

Der Trait Deref

Der Trait Drop

Zusammenfassung dieses Kapitels

Nebenläufigkeit

Threads

Erzeugen von Threads

Kommunikation zwischen Threads mittels Nachrichten

Kommunikation zwischen Threads über geteilten Speicher

Zusammenfassung dieses Kapitels

Weitere Features

Unsafe Rust

Makros

Schlusswort

Lösungsvorschläge zu den Aufgaben

Lösungsvorschlag zu Aufgabe 1

Lösungsvorschlag zu Aufgabe 2

Lösungsvorschlag zu Aufgabe 3

Lösungsvorschlag zu Aufgabe 4

Lösungsvorschlag zu Aufgabe 5

Lösungsvorschlag zu Aufgabe 6

Lösungsvorschlag zu Aufgabe 7

Lösungsvorschlag zu Aufgabe 8

Lösungsvorschlag zu Aufgabe 9

Lösungsvorschlag zu Aufgabe 10

Lösungsvorschlag zu Aufgabe 11

Lösungsvorschlag zu Aufgabe 12

Referenzen

Weitere Bücher des Autors

Bevor es losgeht

Dieses Buch soll seinen Lesern einen möglichst einfachen Einstieg in die Programmiersprache Rust ermöglichen. Dabei ist es durchaus empfehlenswert, wenn man bereits einige Vorkenntnisse zum Thema Programmierung mitbringt.

Dennoch ist dieses Buch so aufgebaut, dass die Inhalte auch für Anfänger ohne jegliche Programmiererfahrung verständlich sein sollten, sofern die Reihenfolge der Kapitel eingehalten wird.

Mein oberstes Ziel ist es, die Themen so einfach wie möglich anhand von leicht verständlichen Beispielen zu erklären. Aufgrund dessen, dass man Programmieren nur lernt, wenn man selbst Programme schreibt, gibt es am Ende vieler Kapitel einige Übungsaufgaben. Am Ende des Buches befinden sich Lösungsvorschläge zu jeder dieser Aufgaben. Wichtig dabei ist, dass es in der Regel immer mehrere richtige Lösungen gibt. Die hier vorgestellten Lösungen sind also nicht die einzige Möglichkeit, die Aufgaben korrekt zu lösen.

Darüber hinaus gibt es zusätzlich zu diesem Buch alle lauffähigen Programmbeispiele zum Download, damit Sie keinen Programmcode abtippen müssen. Sie können sich die Programmcodes direkt auf der folgenden Internetseite herunterladen (keine Anmeldung oder Ähnliches erforderlich):

https://github.com/book-resources/rustlernen/releases/tag/v1.0

Was ist eine Programmiersprache?

Einige Leser stellen sich nun möglicherweise die Frage, was denn überhaupt eine Programmiersprache ist.

Computer (Prozessoren) können nur mit dem binären Alphabet arbeiten. Das binäre Alphabet ist ein Alphabet, welches nur die beiden Zeichen »0« und »1« beinhaltet. Das liegt an der Bauweise von Computern: Die Transistoren können entweder Strom leiten (»1«) oder keinen Strom leiten (»0«). Weitere Zustände haben wir hier nicht zur Verfügung.

Programmtext 1: Darstellung von Informationen in Binärcode

01100110 01101110 00100000 01101101 01100001 01101001 01101110

00101000 00101001 00100000 01111011 01110000 01110010 01101001

01101110 01110100 01101100 01101110 00100001 00101000 00100010

01001000 01100001 01101100 01101100 01101111 00101100 00100000

01010111 01100101 01101100 01110100 00100001 00100010 00101001

00111011 01111101    

Da lange Anweisungen in dieser Darstellung für uns Menschen jedoch sehr unleserlich sind, gibt es Programmiersprachen. Diese sind übersichtlicher für den Menschen, können aber nicht vom Computer (Prozessor) verstanden werden. Damit dieser unsere Anweisungen dennoch verstehen kann, gibt es sogenannte Compiler. Einen Compiler kann man sich wie einen Übersetzer vorstellen: Anweisungen einer Programmiersprache werden durch einen Compiler in Anweisungen des binären Alphabets übersetzt, damit der Computer die Befehle versteht und ausführen kann.

Es gibt unzählige Programmiersprachen, wobei jede Programmiersprache gewisse Vor- und Nachteile mit sich bringt. Dementsprechend gibt es zu jeder Programmiersprache den passenden Compiler (häufig gibt es sogar mehrere Compiler für dieselbe Sprache).

Da es in diesem Buch nun speziell um die Programmiersprache Rust geht, wollen wir uns zunächst einmal ansehen, was diese Programmiersprache auszeichnet.

Warum ausgerechnet Rust?

Was macht Rust nun besonders?

Um diese Frage zu beantworten, schauen wir uns kurz die Entwicklung von Prozessoren etwas genauer an. In den letzten Jahrzehnten sind Prozessoren immer leistungsfähiger geworden. Damals konnte man dies noch erreichen, indem immer mehr Transistoren auf den Chips angesiedelt wurden.

In den letzten Jahren hat sich das Vorgehen etwas geändert, da nun ein Schwellenwert erreicht wurde, was bedeutet, dass weitere Transistoren zu erheblichen Problemen bei der Kühlung der Prozessoren führen würden. Um trotzdem schnellere Prozessoren zu entwickeln, gibt es seit einigen Jahren den Trend, sogenannte Multicore-Prozessoren zu bauen. Diese Prozessoren besitzen mehrere, unabhängige Rechenkerne.

Dies hat zur Folge, dass wir jetzt nicht mehr genau eine Recheneinheit in unserem Rechner verbaut haben, sondern meistens mehrere. Damit alle Rechenkerne genutzt werden können, muss die Software auch dementsprechend programmiert werden. Dabei spricht man von nebenläufiger Programmierung.

Der Programmierer hat jetzt also die Aufgabe, Anweisungen an mehrere Rechenkerne zu verteilen. Dabei können verschiedene Probleme auftreten, wie wir später noch genauer feststellen werden. Was passiert zum Beispiel, wenn zwei Rechenkerne gleichzeitig einen Wert an derselben Speicherstelle verändern wollen? Dafür müssen sich die unterschiedlichen Rechenkerne untereinander absprechen.

An diesem Punkt kommt nun Rust ins Spiel. Rust wurde in erster Linie für die sichere Programmierung nebenläufiger Software entwickelt. Viele Fehler, die sich bei der Programmierung ergeben können, sollen schon vor dem Start des Programms entdeckt werden. Deshalb auch der Begriff »sichere Programmierung«: Wir meinen damit nicht die Sicherheit gegenüber Angriffen oder unerwünschten Zugriffen, sondern Sicherheit bezogen auf die Funktionalität unseres Programmes. Man könnte also sagen, dass unser Programm sicher ist, wenn es genau das tut, was es tun soll.

Nun fragt man sich vielleicht, wie genau Rust diese Sicherheit ermöglicht. Der Rust Compiler stellt bereits bei dem Übersetzen unseres Programmes fest, ob bestimmte Fehler vorhanden sind. Dazu wird ein starkes Typsystem verwendet. Ein Typsystem dient lediglich dazu, zu überprüfen, ob alle verwendeten Variablen, Funktionen, usw. korrekt genutzt werden.

Dadurch, dass viele Fehler bereits festgestellt werden, bevor das Programm ausgeführt wird, verringert sich die Anzahl von Laufzeitfehlern (das sind Fehler, die auftreten, während das Programm ausgeführt wird). Ein interessantes Konzept in Rust, welches zur Verhinderung von Programmierfehlern genutzt wird, nennt sich Ownership. Wir werden in einem eigenen Kapitel noch sehr ausführlich auf dieses Konzept eingehen.

An dieser Stelle sei erstmal gesagt, dass dieses Konzept dafür sorgt, dass jeder Speicherplatz einen Besitzer hat – dadurch werden beispielsweise Probleme wie oben beschrieben (»Was passiert, wenn zwei Rechenkerne gleichzeitig einen Wert an derselben Speicherstelle verändern wollen?«) verhindert. Wie das Ganze im Detail funktioniert, sehen wir dann im dazugehörigen Kapitel.

Fassen wir die Vorteile von Rust noch einmal zusammen: Rust ermöglicht verschiedene Programmierparadigmen (Programmierstile) und wahrt eine hohe Effizienz (in Bezug auf die Laufzeit). Dies wird dadurch erreicht, dass Rust Concurrency (Nebenläufigkeit) fördert, d.h. Rust ermöglicht eine effiziente, nebenläufige Programmierung und verhindert dabei häufige Fehler, die bei nebenläufigen Programmen auftreten können.

Zusammenfassung dieses Kapitels

Eine Programmiersprache ist für uns Menschen leichter verständlich als Binärcode. Computer »verstehen« jedoch nur Binärcode.

Ein Compiler übersetzt unseren Programmcode in Binärcode.

Rust ist eine moderne, nebenläufige und effiziente Programmiersprache.

Noch eine letzte Bemerkung, bevor es richtig losgeht: Im Zusammenhang mit Programmiersprachen werden häufig englische Wörter verwendet. Deshalb werden auch in diesem Buch oftmals zusätzlich zu der deutschen Übersetzung die englischen Begriffe angeführt. Dies geschieht dann in der Regel in Klammern direkt hinter dem deutschen Begriff.

Die Installation von Rust

In diesem Kapitel wird zunächst beschrieben, wie Rust installiert werden kann. Anschließend wird gezeigt, wie Rust Programme ausgeführt werden können.

Rust herunterladen

Bevor es losgehen kann, müssen wir Rust noch schnell installieren. Dazu gehen wir auf die offizielle Webseite der Programmiersprache Rust. Diese ist zum aktuellen Zeitpunkt unter folgender URL zu finden:

https://www.rust-lang.org

Ausgehend von der Startseite, klicken wir auf den Tab »Install«, um zu der Installationsseite zu gelangen. Momentan lautet der Link zur Installationsseite:

https://www.rust-lang.org/tools/install

Rust für Windows installieren:
Wir laden uns den Installer von der Website herunter. Nach erfolgreichem Download führen wir den Installer aus und folgen den Anweisungen. Dann dürfte Rust auch schon installiert sein.

Um Rust verwenden zu können, sind auch noch die »C++ Build Tools« für Visual Studio 2013 oder neuer von Microsoft erforderlich. Auch dazu gibt es einen Link auf der Downloadseite von Rust, momentan ist es dieser hier:

https://visualstudio.microsoft.com/de/downloads/

Dort kann dann nach »C++ Build Tools« gesucht werden und es erscheint der passende Download (aktuell handelt es sich dabei um die Buildtools für Visual Studio 2019).

Rust für macOS oder Linux installieren:
Wir öffnen das Terminal und geben den Befehl ein, der auf der Installationsseite von Rust bereitgestellt wird. Zum aktuellen Zeitpunkt handelt es sich dabei um folgenden Befehl:

curl https://sh.rustup.rs -sSf | sh

Jetzt folgen wir den Anweisungen im Terminal. Danach sollte Rust erfolgreich installiert worden sein.

Rust Programme ausführen

Um ein Rust Programm auszuführen, brauchen wir natürlich zunächst einmal ein Programm.

Dazu speichern wir eine Datei mit der Dateiendung .rs ab. In diese Datei schreiben wir dann unseren Programmcode.

Um das Programm nun auszuführen, öffnen wir die Eingabeaufforderung bzw. das Terminal in dem Ordner, in welchem sich unsere Programmdatei befindet. Nun geben wir zunächst den Befehl rustc Dateiname.rs in die Konsole ein. Dieser Befehl sorgt dafür, dass der Compiler unseren Programmcode in eine ausführbare Datei übersetzt.

Danach können wir die ausführbare Datei ausführen, indem wir ./Dateiname in die Konsole eingeben, wenn wir Linux oder macOS als Betriebssystem nutzen. Für Windows geben wir .\Dateiname.exe in die Konsole ein, um das Programm zu starten. Selbstverständlich steht an der Stelle »Dateiname« der tatsächliche Name der Datei.

In dem nächsten Kapitel schauen wir uns das Ganze anhand eines konkreten Beispiels an.

Ein »Hallo, Welt!« Programm in Rust

Möchte man eine neue Programmiersprache lernen, so stößt man in der Regel immer direkt am Anfang auf ein sogenanntes »Hallo, Welt!« Programm. Ein solches Programm schreibt die Worte »Hallo, Welt!« in die Konsole, wenn man es ausführt.

Auch wir wollen uns nun ein »Hallo, Welt!« Programm ansehen. Dazu erstellen wir uns zunächst eine Datei mit dem Namen hallo.rs. Dann bearbeiten wir diese Datei mit einem beliebigen Texteditor und fügen folgende Befehle ein:

Programmtext 2: Ein »Hallo, Welt!« Programm in Rust

fn main() {

println!("Hallo, Welt!");

}

Sobald dies erledigt ist, lassen wir den Compiler eine ausführbare Datei aus unserem Programmcode erzeugen. Dazu geben wir nun rustc hallo.rs in die Konsole ein. Nun können wir unser Programm auch schon starten, indem wir ./hallo (für Linux und macOS) bzw. .\hallo (für Windows) in die Konsole eingeben.

Jetzt sollte der Text »Hallo, Welt!« erschienen sein. Damit haben wir bereits ein erstes Programm erstellt und ausgeführt.

An dieser Stelle gehen wir nur kurz auf die einzelnen Bestandteile des Programms ein. Detaillierte Erläuterungen werden dann im weiteren Verlauf des Buches folgen.

Der Befehl println! gibt den Text auf der Konsole aus, der dahinter in Klammern steht. Dabei gilt es zu beachten, dass der Text in Anführungszeichen gesetzt wird. Beim Programmieren setzen wir Texte immer in Anführungszeichen. Wir befassen uns damit später noch sehr genau. Das Ausrufezeichen gibt an, dass es sich bei diesem Befehl nicht um eine normale Funktion, sondern um einen sogenannten Makro handelt. Was das bedeutet, werden wir auch noch sehen.

Unsere Anweisungen stehen in der sogenannten main Funktion. Sobald unser Programm gestartet wird, werden alle Befehle innerhalb dieser Funktion der Reihe nach ausgeführt. Die geschweiften Klammern geben den Bereich der Funktion an. Alle Befehle innerhalb des durch die Klammern spezifizierten Blockes gehören zu der Funktion. Zusätzlich wird jeder Befehl mit einem Semikolon beendet.

Auch diese Themen werden wir uns noch intensiv ansehen.

Kommentare

Ein Programm sollte immer gut kommentiert werden, damit leicht verständlich wird, was das Programm tut. Das ist besonders bei größeren Programmen enorm wichtig, damit man auch noch nach einer Woche weiß, was man dort genau gemacht hat. Jegliche Kommentare werden von dem Compiler ignoriert, das heißt, sie werden nicht übersetzt. Dementsprechend sind Kommentare nur für den Programmierer relevant und der Computer bekommt davon im Endeffekt nichts mit, weil der Compiler Kommentare nicht übersetzt.

Es gibt verschiedene Arten von Kommentaren:

Einzeilige Kommentare, d.h. Kommentare, die in einer einzigen Zeile stehen. Solche Kommentare werden folgendermaßen in das Programm aufgenommen:

Programmtext 3: Ein Beispiel für einen einzeiligen Kommentar

fn main() {

// Dies ist ein einzeiliger Kommentar.

println!("Hallo, Welt!");

}           

Ein einzeiliger Kommentar wird mit // eingeleitet und endet am Zeilenende der jeweiligen Zeile.

Darüber hinaus gibt es auch mehrzeilige Kommentare. Diese werden mittels /* eingeleitet und mit */ wieder beendet. Das Ganze könnte dann so aussehen:

Programmtext 4: Ein Beispiel für einen mehrzeiligen Kommentar

fn main() {

/*

* Dies ist ein mehrzeiliger Kommentar.

* Der Kommentar wird hier fortgesetzt.

*/

println!("Hallo, Welt!");

}           

Außerdem gibt es noch die Möglichkeit, Kommentare zu verfassen, aus denen später automatisch eine Dokumentation des Programmcodes generiert werden kann (sogenannte rustdoc Kommentare). Auf diese Art von Kommentaren wollen wir in einem gesonderten Kapitel noch genauer eingehen. Vorerst reicht es aus, wenn wir wissen, dass diese Kommentare mit /// eingeleitet werden und bis zum Ende der jeweiligen Zeile reichen.

Zusammenfassung dieses Kapitels

Der println!-Makro gibt Text auf der Konsole aus.

Einzeilige Kommentare werden mittels // eingeleitet.

Mehrzeilige Kommentare werden mittels /* eingeleitet und mit */ beendet.

rustdoc Kommentare werden mittels /// eingeleitet.

Wir wollen das Kapitel nun mit einer Aufgabe für den Leser abschließen.

Aufgabe 1

Schreiben Sie ein Programm, welches folgenden Text auf der Konsole ausgibt:

Hallo, Welt!
Dies ist mein erstes Programm in Rust!

Dabei soll der Zeilenumbruch hinter »Hallo, Welt!« ebenfalls berücksichtigt werden.

Lösungsvorschläge zu allen Aufgaben befinden sich im Anhang dieses Buches.

Variablen

Wie auch in anderen Programmiersprachen werden Variablen dazu benutzt, um Werte abzuspeichern. Dies ermöglicht es uns, berechnete Werte zu einem späteren Zeitpunkt wieder zu verwenden. Dennoch gibt es einige Unterschiede bei der Verwendung von Variablen in Rust im Vergleich zu der Verwendung von Variablen in anderen Programmiersprachen.

Änderbarkeit (Mutability) von Variablen

Um eine Variable zu erstellen, nutzen wir zunächst das Schlüsselwort let. Das Erstellen einer Variablen wird als »Deklaration« bezeichnet.

Programmtext 5: Ein Beispiel für die Deklaration einer Variablen

fn main() {

let a;

}   

Jetzt haben wir zwar eine Variable mit dem Namen »a« angelegt, aber noch keinen Wert darin gespeichert. Möchten wir nun einen Wert in unserer Variablen abspeichern, so weisen wir der Variablen den gewünschten Wert zu.

Programmtext 6: Ein Beispiel für die Wertzuweisung einer Variablen

fn main() {

let a;

a = 12;

}   

Hier erstellen wir nun eine Variable mit dem Namen »a«, in der wir anschließend den Wert 12 speichern. Wir können diese beiden Zeilen auch in einer Zeile zusammenfassen. Dieser Vorgang wird dann als »Initialisierung« bezeichnet. Grundsätzlich ist es immer empfehlenswert, Variablen bei der Erstellung direkt einen Wert zuzuweisen. Damit lassen sich unnötige Fehler vermeiden, wie zum Beispiel der Zugriff auf eine Variable, die noch gar keinen Wert zugewiesen bekommen hat.

Programmtext 7: Ein Beispiel für die Initialisierung einer Variablen

fn main() {

let a = 12;

}   

Wenn wir nun den Wert der Variablen auf der Konsole ausgeben lassen, sollte anstelle von »a« die Zahl 12 erscheinen. Den Wert einer Variablen lassen wir ausgeben, indem wir einen Platzhalter nutzen, an dessen Stelle dann der Wert der Variablen eingefügt wird. Das Ganze wird folgendermaßen umgesetzt:

Programmtext 8: Ausgabe einer Variablen auf der Konsole

fn main() {

let a = 12;

println!("a = {}", a);

}   

Führen wir dieses Programm nun aus, erscheint in der Tat der Wert 12 auf der Konsole:

a = 12

Wenn man bereits andere Programmiersprachen kennt, könnte man eventuell erwarten, dass man die Variable auch direkt ausgeben lassen kann, ohne einen Platzhalter zu verwenden, wie der folgende Codeausschnitt zeigt. Dies ist in Rust jedoch nicht möglich und verursacht einen Kompilierfehler.

Programmtext 9: Ein Beispiel für eine fehlerhafte Konsolenausgabe

fn main() {

let a = 12;

println!(a); // Fehler!

}   

Sobald wir versuchen, den Wert der Variablen im Verlauf unseres Programms zu ändern, stellen wir fest, dass auch dies nicht so leicht wie in anderen Programmiersprachen möglich ist.

Programmtext 10: Ein Beispiel für eine unveränderbare Variable

fn main() {

let a = 12;

println!("a = {}", a);

a = 33; // Fehler!

println!("a = {}", a);

}   

Beim Kompilieren dieses Programms erhalten wir eine Fehlermeldung. Das liegt daran, dass Variablen in Rust automatisch so definiert sind, dass sie nicht verändert werden können. Wenn wir beabsichtigen, dass eine Variable änderbar ist, müssen wir dies explizit angeben. Das machen wir wie folgt:

Programmtext 11: Ein Beispiel für eine veränderbare Variable

fn main() {

let mut a = 12;

println!("a = {}", a);

a = 33;

println!("a = {}", a);

}   

Nun kann das Programm fehlerfrei kompiliert werden und liefert die folgende Ausgabe:

a = 12
a = 33

Dieses Prinzip wird als »immutable by default« bezeichnet. Wir sehen, dass lediglich das Schlüsselwort mut bei der Variablendefinition hinzugekommen ist.

Dadurch behalten wir besser den Überblick darüber, wo Variablen überall geändert werden können. Ein solches Feature haben die meisten gängigen Programmiersprachen nicht.

Durch eine bessere Übersicht darüber, wo Variablen geändert werden dürfen und wo nicht, soll das Schreiben sicherer Programme vereinfacht werden.

Benennung von Variablen

Bei der Benennung von Variablen gibt es einige Regeln zu beachten:

Möglichkeit 1:
Das erste Zeichen ist ein Buchstabe und die restlichen Zeichen sind alphanumerische Zeichen (Buchstaben und Zahlen) oder der Unterstrich.

Möglichkeit 2:
Das erste Zeichen ist der Unterstrich. Dann müssen noch weitere Zeichen folgen, da der Unterstrich alleine kein Bezeichner ist. Die weiteren Zeichen bestehen dann aus Buchstaben und/oder Zahlen.

Darüber hinaus gibt es einige Prinzipien zur Benennung, die dafür sorgen sollen, dass der Programmcode übersichtlicher und somit leichter verständlich wird. Solche Prinzipien werden oftmals als Namensgebungskonvention (naming convention) bezeichnet. Diese Prinzipien müssen nicht zwingend eingehalten werden, aber es macht durchaus Sinn, sich trotzdem daran zu halten.

Die Namensgebungskonvention für Rust ist folgendermaßen aufgebaut (wir haben bisher nur Variablen kennengelernt, die anderen Dinge folgen noch):

Gegenstand | Konvention
Crates | snake_case
Module | snake_case
Typen | CamelCase
Traits | CamelCase
Enums | CamelCase
Funktionen | snake_case
Methoden | snake_case
Lokale Variablen | snake_case
Statische Variablen | SCREAMING_SNAKE_CASE
Konstanten | SCREAMING_SNAKE_CASE

Der snake_case nutzt nur Kleinbuchstaben sowie Unterstriche, um Wörter voneinander zu trennen (beim SCREAMING_SNAKE_CASE werden nur Großbuchstaben verwendet). Der CamelCase trennt Wörter durch einen Großbuchstaben. Dort sind also alle restlichen Buchstaben kleingeschrieben. Leerzeichen können in Bezeichnern nicht verwendet werden, da der Compiler sonst nicht entscheiden könnte, an welcher Stelle ein Bezeichner endet.

Schlüsselwörter

In Rust gibt es eine Auswahl von Schlüsselwörtern, welche nicht verwendet werden dürfen, wenn wir Dinge benennen. Die Bedeutung dieser Schlüsselwörter ist an dieser Stelle nicht wichtig. Es geht nur darum, dass Bezeichnungen keine Schlüsselwörter sein dürfen.

Eine Liste von Schlüsselwörtern in Rust:

as | break | const | continue | crate | else | enum | extern | false | fn
for | if | impl | in | let | loop | match | mod | move | mut
pub | ref | return | Self | self | static | struct | super | trait | true
type | unsafe | use | where | while |  |  |  |  |

Darüber hinaus sind auch noch folgende Schlüsselwörter reserviert (diese haben zum aktuellen Zeitpunkt jedoch noch keine Funktionalität):

abstract | alignof | become | box | do | final | macro | offsetof
override | priv | proc | pure | sizeof | typeof | unsized | virtual
yield |  |  |  |  |  |  |

Sollten wir unbedingt ein Schlüsselwort als Bezeichner wählen wollen, so können wir die Zeichen r# vor den Bezeichner setzen. Dies wird als »raw identifier« bezeichnet und ist nur in seltenen Fällen wirklich notwendig.

Shadowing

In Rust ist es möglich, eine Variable mit demselben Namen wie eine zuvor deklarierte Variable zu definieren. Dann wird die ältere Variable von der neueren »überdeckt«. Sehen wir uns nun dieses Vorgehen, welches auch als shadowing bezeichnet wird, anhand eines Beispiels an.

Programmtext 12: Ein Beispiel für shadowing

fn main() {

let a = 12;

println!("a = {}", a);

let a = 2 * a;

println!("a = {}", a);

}   

Wie wir sehen können, ist die Variable a nicht veränderbar. Überdecken können wir sie hingegen schon (durch die erneute Verwendung des Schlüsselwortes let). Dabei erhalten wir die folgende Ausgabe:

a = 12
a = 24

Die Variable hat nun den Wert der letzten Zuweisung. Der vorherige Wert existiert somit nicht mehr. Eine verdeckte Variable ist nicht dasselbe wie eine veränderbare Variable. Sollten wir in dem obigen Beispiel vergessen, das Schlüsselwort let erneut zu verwenden, so erhalten wir einen Kompilierfehler, da wir eine unveränderbare Variable nicht ändern dürfen.

Programmtext 13: Ein Beispiel für eine unveränderbare Variable

fn main() {

let a = 12;

println!("a = {}", a);

a = 2 * a; // Fehler!

println!("a = {}", a);

}   

Es gibt aber noch einen weiteren Unterschied zwischen einer veränderbaren Variablen und einer verdeckten Variablen. Schauen wir uns dazu folgendes Programm an:

Programmtext 14: Ein Fehler durch Änderung des Typs

fn main() {

let mut a = 12;       

a = 2.5; // Fehler!

}   

Dieses Programm lässt sich so nicht kompilieren. Warum? Die Variable a enthält zunächst den Wert 12, also geht Rust davon aus, dass diese Variable zur Speicherung ganzer Zahlen gedacht ist.

In der nächsten Zeile weisen wir der Variablen nun aber eine Kommazahl zu. Dafür ist ein anderer Datentyp erforderlich, wie wir in dem Kapitel „Datentypen“ sehen werden.

Nutzen wir stattdessen shadowing, so ist die Änderung des Typs kein Problem.

Programmtext 15: Änderung des Typs durch shadowing

fn main() {

let a = 12;       

let a = 2.5;

}   

Dieses Programm ist nun wieder fehlerfrei kompilierbar.

Konstanten

Nachdem wir nun festgestellt haben, dass Variablen von Natur aus unveränderbar sind, stellt man sich jetzt eventuell die Frage, was denn der Unterschied zwischen unveränderbaren Variablen und Konstanten ist. Schließlich speichern Konstanten ebenfalls Werte, die niemals verändert werden können.

Dennoch gibt es einige Unterschiede. Konstanten sind beispielsweise dazu gedacht, wirklich immer unveränderbar zu sein. Hier kann das Schlüsselwort mut also nicht verwendet werden.

Darüber hinaus haben wir bisher alle Variablen angelegt, ohne den Datentyp der Variablen anzugeben. Bei Konstanten muss der Datentyp immer angegeben werden. Der Datentyp gibt den Bereich der möglichen Werte, die abgespeichert werden können, an. Das nächste Kapitel wird sich ausgiebig mit Datentypen auseinandersetzen.

Zudem können Konstanten in verschiedenen Geltungsbereichen, sogenannten scopes, definiert werden – sie können also vor allem global verfügbar gemacht werden. Auch dieses Thema behandeln wir in Kürze noch detailliert.

Überdies müssen Konstanten immer einen festen Wert zugewiesen bekommen. Das bedeutet, dass wir einer Konstanten beispielsweise nicht das Ergebnis einer Berechnung zuweisen können.

Jetzt, wo wir die Unterscheide zwischen Konstanten und Variablen kennengelernt haben, schauen wir uns noch kurz ein Beispiel an, wie eine Konstante innerhalb eines Programms aussehen könnte:

Programmtext 16: Ein Beispiel für eine lokale Konstante

fn main() {

const PI: f32 = 3.141;

println!("PI = {}", PI);

}   

In diesem Beispiel wurde eine Konstante mit dem Namen PI innerhalb der Funktion main definiert. Sie ist also nur innerhalb dieser Funktion verfügbar. Das Programm liefert uns dann die Ausgabe:

PI = 3.141

Wie oben bereits beschrieben, können wir Konstanten auch global definieren. Dann kann die Konstante nicht nur innerhalb der Funktion main verwendet werden, sondern auch in anderen Blöcken (sofern solche vorhanden sind).

Programmtext 17: Ein Beispiel für eine globale Konstante

const PI: f32 = 3.141;

fn main() {

println!("PI = {}", PI);

}   

An der Ausgabe des Programms ändert sich im Vergleich zu dem vorherigen Programm nichts.

Auf den Unterschied zwischen lokalen und globalen Variablen werden wir in dem Kapitel „Das Ownership Konzept“ in aller Ausführlichkeit eingehen.

Zusammenfassung dieses Kapitels

Variablen werden mit Hilfe von let definiert und sind von Natur aus unveränderbar.

Variablen können mit Hilfe von mut veränderbar gemacht werden.

Konstanten werden mit Hilfe von const definiert.

Aufgabe 2

Schreiben Sie ein Programm, in welchem Sie eine beliebige Variable definieren. Geben Sie den Wert der Variablen aus, ändern Sie den Wert anschließend und geben Sie den geänderten Wert erneut aus.

Definieren Sie eine Variable, die das Ergebnis der Rechnung 39 * speichert und geben Sie das Ergebnis auf der Konsole aus.

Sei x das Ergebnis aus Aufgabenteil 2. Erweitern Sie Ihr Programm aus Aufgabenteil 2 nun derart, sodass zusätzlich noch 10 * berechnet und ausgegeben wird.

Datentypen

Jede Variable besitzt einen eigenen Datentyp. Ein Datentyp gibt den Wertebereich der gültigen Werte an, die von der Variablen angenommen werden können. Wir haben im letzten Kapitel gesehen, wie eine Variable eine ganze Zahl abspeichern kann. Anstelle von ganzen Zahlen können wir auch Dezimalzahlen (Kommazahlen), Wahrheitswerte (wahr oder falsch), einzelne Zeichen oder noch komplexere Dinge abspeichern.

Rust ist eine statisch getypte Programmiersprache. Das bedeutet, dass der Typ jeder Variablen vor der Ausführung unseres Programmes – also bereits zum Zeitpunkt des Kompilierens – feststehen muss.

Bisher haben wir keinen Datentyp angegeben, wenn wir eine Variable definiert haben. Geben wir keinen Datentyp an, so ermittelt Rust den Datentyp der Variablen anhand des Wertes, welcher der Variablen zugewiesen wurde. Wir müssen also nicht immer einen Datentyp angeben, aber es kann durchaus sinnvoll sein, dies in einigen Fällen trotzdem zu tun.

Durch Angabe des Datentyps sieht der Programmierer leichter, welche Werte eine Variable annehmen kann. Falls wir beispielsweise eine Variable haben, welche ausschließlich positive ganze Zahlen speichern soll, dann können wir Rust durch den Datentyp mitteilen, dass diese Variable niemals negative Werte speichern darf.

Es gibt aber auch Situationen, in denen der Rust Compiler den Datentyp nicht automatisch herausfinden kann, zum Beispiel wenn es mehrere mögliche Typen gibt. In diesem Fall müssen wir tatsächlich angeben, um welchen Datentyp es sich handeln soll. Der Compiler informiert uns durch eine entsprechende Fehlermeldung, falls der Datentyp einer Variablen nicht automatisch erkannt werden kann.

In den folgenden Abschnitten werden wir uns nun ansehen, welche Datentypen überhaupt zur Auswahl stehen.

Primitive Datentypen

Zuerst sehen wir uns die sogenannten primitiven Datentypen an. Primitive Datentypen sind die einfachsten Datentypen, die wir vorfinden können. Die folgende Übersicht listet alle primitiven Datentypen von Rust auf:

Ganze Zahlen mit Vorzeichen (positive und negative ganze Zahlen):
i8, i16, i32, i64, i128, isize

Ganze Zahlen ohne Vorzeichen (positive ganze Zahlen):
u8, u16, u32, u64, u128, usize

Dezimalzahlen (Kommazahlen):
f32, f64

Unicode-Zeichen (der Unicode ist ein Standard, welcher jedem Zeichen einen Wert zuweist):
char

Wahrheitswerte (true für »wahr« und false für »falsch«):
bool

Der primitive Typ Unit: Dieser Typ hat genau einen Wert, nämlich das leere Tupel (). Dieser Wert wird dann benutzt, wenn kein anderer Wert zurückgegeben werden kann. In anderen Programmiersprachen würde man den Wert () als nil oder null wiederfinden.

Der Datentyp char wird für die Speicherung von einzelnen Zeichen genutzt. Zum Speichern von Wahrheitswerten wird der Typ bool eingesetzt. Die Abkürzungen char und bool stehen dabei für character und boolean.

Es gibt jedoch viele verschiedene Datentypen zur Speicherung von Zahlen, weshalb nicht unbedingt sofort klar ist, in welcher Situation ein bestimmter Typ zur Speicherung von Zahlen verwendet wird.

Beschäftigen wir uns zunächst mit vorzeichenbehafteten ganzen Zahlen. Die zur Verfügung stehenden Datentypen für vorzeichenbehaftete ganze Zahlen sind i8, i16, i32, i64, i128 und isize. Dabei steht der Buchstabe »i« für integer, was übersetzt »Ganzzahl« bedeutet. Die Zahl hinter dem »i« gibt die Anzahl an Bit an, die an Speicherplatz zur Verfügung stehen.

Ein Bit ist eine Speichereinheit, d.h. je mehr Bit wir nutzen, desto größer ist der mögliche Wertebereich und desto mehr Speicher verbrauchen wir. Geben wir für eine ganze Zahl keinen Datentyp an, so nimmt Rust an, dass wir i32 meinen. Dies ist der Standardtyp für ganze Zahlen. Das bedeutet, dass jede Variable, die eine ganze Zahl speichert und keinen Datentyp angegeben hat, den Typ i32 bekommt.

Der Typ isize umfasst so viele Bit, wie nötig sind, um eine Speicherstelle im Speicher anzusteuern. Diese Größe hängt vom jeweiligen Computer ab und kann daher auf unterschiedlichen Geräten verschieden ausfallen. Im Wesentlichen gibt es hier 32-Bit sowie 64-Bit Architekturen.

Bei ganzen Zahlen ohne Vorzeichen verhält es sich auf dieselbe Weise. Das »u« steht für unsigned (»vorzeichenlos«). Danach wird wieder die Anzahl an Bit angegeben.

Auch bei Kommazahlen ist das Prinzip dasselbe. Der Buchstabe »f« soll an dieser Stelle eine Abkürzung für float sein, was zu »Gleitkommazahl« übersetzt werden kann. Geben wir bei einer Kommazahl keinen Datentyp an, so verwendet Rust automatisch den Datentyp f64.

Wenn man zuvor noch nichts von Bit gehört hat, weiß man an dieser Stelle vermutlich noch nicht, welche Zahlen nun alle gespeichert werden können und welche nicht.

Wir schauen uns nun anhand eines Beispiels an, wie wir dies herausfinden können. Wir rufen in diesem Beispiel eine Methode auf. Auch wenn das Thema Methoden erst später genauer behandelt wird, sollte das Beispiel gut zu verstehen sein.

Programmtext 18: Den Wertebereich von i8 herausfinden

fn main() {

println!("Wertebereich von i8:");

println!("{} bis {}", i8::min_value(), i8::max_value());

}   

Die Methode i8::min_value() verrät uns den kleinstmöglichen Wert, welcher in einer Variablen vom Datentyp i8 gespeichert werden kann. Analog liefert die Methode i8::max_value() den größtmöglichen Wert für den Typ i8. Die Ausgabe dieses Programms verrät uns nun den gesamten Wertebereich von dem Datentyp i8:

Wertebereich von i8:
-128 bis 127

Wir sehen, dass wir mit dem Datentyp i8 Zahlen in dem Bereich von -128 bis 127 abspeichern können. Zum Speichern von Zahlen außerhalb dieses Bereiches brauchen wir also einen anderen Datentyp mit einem größeren Wertebereich.

Dasselbe Vorgehen können wir auch bei den anderen Datentypen für ganze Zahlen anwenden. Wir sehen uns dies anhand von zwei weiteren Beispielen noch einmal an.

Programmtext 19: Den Wertebereich von u8 herausfinden

fn main() {

println!("Wertebereich von u8:");

println!("{} bis {}", u8::min_value(), u8::max_value());

}   

Die Ausgabe dieses Programms lautet nun:

Wertebereich von u8:
0 bis 255

Wir stellen fest, dass sich an der Anzahl der möglichen Werte zunächst nichts geändert hat. Es gibt 256 verschiedene Werte, die dieser Datentyp speichern kann. Das macht natürlich auch Sinn, da wir genauso viel Speicherplatz wie zuvor zur Verfügung haben, nämlich 8 Bit.

Zusätzlich stellen wir fest, dass nur positive ganze Zahlen gespeichert werden können. Dieser Datentyp ist also dann nützlich, wenn wir bereits wissen, dass eine Variable nur positive Werte annehmen soll und diese Werte nie größer als 255 werden.

Das ist aber nicht immer der Fall. Manchmal weiß man nicht im Voraus, welche Werte alles auftreten können. Dann belässt man es am besten bei dem Standardtyp für ganze Zahlen: i32. Das folgende Beispiel präsentiert uns den Wertebereich von i32.

Programmtext 20: Den Wertebereich von i32 herausfinden

fn main() {

println!("Wertebereich von i32:");

println!("{} bis {}", i32::min_value(), i32::max_value());

}   

Bei Ausführung dieses Programms erhalten wir die folgende Ausgabe:

Wertebereich von i32:
-2147483648 bis 2147483647

Das sind eine Menge Zahlen. Vermutlich viel mehr, als wir in den meisten Fällen tatsächlich benötigen werden. Sollte dieser Bereich dennoch für einen bestimmten Zweck mal nicht ausreichen, gibt es immer noch die Möglichkeit, auf i64 zu erhöhen.

Der Wertebereich einer Kommazahl kann leider nicht auf die gleiche Art und Weise herausgefunden werden. Das liegt daran, dass Computer nicht so leicht mit Kommazahlen umgehen können und der zur Verfügung stehende Speicherplatz immer möglichst gut ausgenutzt werden soll.

Wenn wir eine Kommazahl mit einem kleinen Wert haben, dann können wir den Speicherplatz dazu nutzen, mehr Nachkommastellen zu speichern, sodass das Ergebnis möglichst genau wird. Haben wir jedoch eine sehr große Zahl, dann ist die Genauigkeit etwas geringer, da weniger Nachkommastellen gespeichert werden können (schließlich wird mehr Speicherplatz benötigt, um die Zahl vor dem Komma darzustellen).

Wir merken uns an dieser Stelle, dass Kommazahlen in der Regel den Datentyp f64 benutzen und dass es bei Rechnungen mit Kommazahlen zu Ungenauigkeiten kommen kann. Der Grund dafür ist die interne Darstellungsweise von Kommazahlen im Computer.

Jetzt, wo wir die primitiven Datentypen kennengelernt haben, sehen wir uns an, wie wir den Datentyp einer Variablen explizit im Programm angeben können. Dazu schreiben wir den Datentyp direkt hinter den Namen der Variablen, wobei wir einen Doppelpunkt zur Trennung von Bezeichner und Datentyp nutzen.

Programmtext 21: Ein Beispiel für die explizite Angabe von Datentypen

fn main() {

let a: i8 = 20;

let b: bool = false;

let c: char = 'a';

let d: f32 = 4.4321;

println!("a = {}", a);

println!("b = {}", b);

println!("c = {}", c);

println!("d = {}", d);

}   

Die Ausgabe dieses Programms entspricht nun den folgenden Zeilen:

a = 20
b = false
c = a
d = 4.4321

Bei Zahlen ist es ebenfalls möglich, den Datentyp als Suffix anzugeben. Das bedeutet lediglich, dass wir den Datentyp an das Ende der Zahl anhängen. Aussehen tut das Ganze dann so:

Programmtext 22: Die Suffixdarstellung von Datentypen bei Zahlen

fn main() {

let a = 3.0f32;

let b = 42i8;

}   

Bisher haben wir Zahlen immer im Dezimalsystem angegeben. Rust erlaubt es auch, andere Zahlensysteme zur Darstellung von Zahlen zu verwenden. Darunter fallen beispielsweise das Hexadezimalsystem (Präfix 0x), das Oktalsystem (Präfix 0o) oder das Binärsystem (Präfix 0b). Bei dem Datentyp u8, welcher aus genau einem Byte (8 Bit) besteht, können wir das Präfix b nutzen, um dann dahinter in einfachen Anführungszeichen den Wert, welchen wir als Byte kodieren wollen, anzugeben.

Programmtext 23: Beispiele für unterschiedliche Zahlensysteme

fn main() {

let a: i32 = 0xff;

let b: i32 = 0o56;

let c: i32 = 0b00001111;

let d: u8 = b'a';

}   

Wir werden in allen weiteren Beispielen immer das Dezimalsystem benutzen. An dieser Stelle sollte lediglich darauf hingewiesen werden, dass weitere Darstellungen möglich sind.

Was uns jedoch behilflich sein könnte, ist die Verwendung eines Unterstriches in Zahlen für eine bessere Lesbarkeit.

Programmtext 24: Verwendung eines Unterstriches zur besseren Lesbarkeit

fn main() {

let a: i32 = 320_000;

}   

Hier wird der Wert 320 000 abgespeichert. Besonders für sehr große Zahlen ist diese Darstellung hilfreich. Die Verwendung des Unterstriches ist auch nicht auf das Dezimalsystem beschränkt, d.h. wir können ihn beispielsweise auch nutzen, um Binärzahlen lesbarer darzustellen.

Auf eine trickreiche Sache soll an dieser Stelle noch hingewiesen werden: Da jeder Datentyp eine beschränkte Anzahl an Speicherplatz zur Verfügung hat, kann es passieren, dass sogenannte Overflows (»Speicherüberläufe«) entstehen. Ein Overflow tritt genau dann auf, wenn einem Datentyp ein Wert zugewiesen wird, der nicht in dem Bereich dieses Typs liegt. Das nachfolgende Programm zeigt, was in einem solchen Fall passiert.

Programmtext 25: Ein Beispiel für einen Overflow

fn main() {

let a: i8 = 256;

println!("a = {}", a);

}   

Bereits beim Kompilieren fällt uns auf, dass eine Warnung erscheint, die uns vor einem Overflow warnt. Ignorieren wir diese Warnung nun und führen das Programm aus, so erhalten wir folgendes Resultat:

a = 0

Ein Overflow sorgt also dafür, dass wir wieder bei der Zahl 0 beginnen. Würden wir also let a: i8 = 300; schreiben, so erhielten wir das Ergebnis a = 44. Overflows können zu unerwarteten Ergebnissen führen und sollten daher vermieden werden.

Zusammengesetzte Datentypen

Arrays

Zusätzlich gibt es auch noch zusammengesetzte Datentypen. Nehmen wir mal an, dass wir ein Programm haben, welches 100 verschiedene Zahlen abspeichern soll. Jetzt wäre es natürlich sehr umständlich, wenn wir für jede dieser 100 Zahlen eine extra Variable anlegen müssten. Abgesehen davon, dass uns irgendwann keine passenden Namen mehr einfallen, wird der Programmtext sehr lang und unübersichtlich. In einer solchen Situation helfen uns Arrays weiter.

Arrays können mehrere Werte auf einmal abspeichern. Dabei gilt es zu beachten, dass alle Elemente eines Arrays den gleichen Datentyp haben müssen. Außerdem muss die Größe eines Arrays fest sein, d.h. wenn wir ein Array anlegen, müssen wir dessen Größe (direkt oder indirekt) angeben. Um ein Array anzulegen, nutzen wir eckige Klammern.

Programmtext 26: Ein Beispiel für ein Array

fn main() {

let a = [5, 7, 12, 3241];

println!("a = {:?}", a);

}       

In diesem Beispiel wird die Größe des Arrays automatisch anhand der Anzahl der darin platzierten Elemente ermittelt. Auch der Datentyp der Elemente wird automatisch herausgefunden.

Darüber hinaus sehen wir, dass Arrays nicht mit dem üblichen Platzhalter {} auf der Konsole ausgegeben werden können. Wir benötigen an dieser Stelle einen speziellen Platzhalter {:?}, wie in dem Beispiel zu sehen ist. Die Ausgabe sieht nun folgendermaßen aus:

a = [5, 7, 12, 3241]

Es gibt noch einen weiteren Platzhalter {:#?}, der die Ausgabe etwas übersichtlicher formatiert. Dieser ist besonders bei sehr großen Arrays hilfreich.

Programmtext 27: Ein Beispiel für die formatierte Ausgabe eines Arrays

fn main() {

let a = [5, 7, 12, 3241];

println!("a = {:#?}", a);

}       

Nun wird die Ausgabe auf folgende Art und Weise formatiert:

a = [
    5,
    7,
    12,
    3241
]

Um noch einmal zum Datentyp eines Arrays zurückzukommen: Der Datentyp eines Arrays ist folgendermaßen aufgebaut: [Datentyp; Größe]. Der erste Wert gibt den Datentyp der Elemente des Arrays an und der zweite Wert bestimmt, wie groß das Array sein soll.

Geben wir den Datentyp explizit im Programm an, so sieht das Ganze folgendermaßen aus:

Programmtext 28: Ein Beispiel für ein Array mit explizitem Datentyp

fn main() {

let a: [i32; 4] = [5, 7, 12, 3241];

}       

Um nun auf einzelne Elemente eines Arrays zugreifen zu können, nutzen wir sogenannte Indizes. Ein Index beschreibt im Prinzip nur die Position eines Elementes. Dabei muss man jedoch darauf achten, dass die Positionen mit dem Zählen bei 0 beginnen. Das vorderste Element befindet sich also an Position 0, das zweite Element an Position 1, und so weiter.

Programmtext 29: Ein Beispiel zur Verwendung von Indizes

fn main() {

let a = [5, 7, 12, 3241];

println!("a[0] = {}", a[0]);

println!("a[3] = {}", a[3]);

}       

Die Ausgabe dazu lautet dann:

a[0] = 5
a[3] = 3241

Hier sehen wir, dass nun wieder der einfache Platzhalter verwendet wird. Das ist der Fall, weil wir jetzt einzelne Zahlen ausgeben lassen (also Elemente, die einen primitiven Datentypen besitzen).

Ein häufiger Fehler im Zusammenhang mit Arrays ist es, auf einen Index zugreifen zu wollen, den es nicht gibt. Wenn dies passiert, dann tritt ein Fehler auf und das Programm beendet sich sofort.

Diese Art von Fehler nennen wir im Allgemeinen Laufzeitfehler, da sich der Fehler während der Ausführung des Programmes – also zur Laufzeit – ereignet. In Rust sagt man bei einem solchen Vorfall, dass unser Programm »panicked« (was so viel wie in Panik geraten bedeutet).

Wenn wir daran interessiert sind, die Länge eines Arrays herauszufinden, dann nutzen wir len().

Programmtext 30: Die Länge eines Arrays ermitteln

fn main() {

let a = [5, 7, 12, 3241];

println!("Laenge von a: {}", a.len());

}       

Ausgabe:

Laenge von a: 4

Ebenfalls möglich ist es, Arrays zu zerlegen. Bei diesem Vorgang können wir zusätzliche Variablen einführen, um die Werte in dem Array einfacher zu adressieren.

Programmtext 31: Ein Array zerlegen

fn main() {

let a = [5, 7, 12];

let [eins, zwei, drei] = a;

println!("[{}, {}, {}]", eins, zwei, drei);

}       

Führen wir dieses Programm nun aus, so erhalten wir die folgende Ausgabe:

[5, 7, 12]

Dabei ist die Bezeichnung der Variablen natürlich beliebig wählbar.

Wie auch in anderen Programmiersprachen können außerdem mehrdimensionale Arrays angelegt werden. Ein zweidimensionales Array kann man sich beispielsweise wie eine Tabelle vorstellen. Ein solches Array lässt sich in Rust auf folgende Art und Weise definieren:

Programmtext 32: Ein Beispiel für ein zweidimensionales Array

fn main() {

let arr = [[1,2,3], [4,5,6]];

println!("{:?}", arr);

}       

Ausgabe:

[[1, 2, 3], [4, 5, 6]]

Im Prinzip legen wir dabei Arrays in ein Array hinein, sodass wir ein Array an Arrays erhalten. Das Ganze funktioniert analog für höhere Dimensionen. Möchte man für das oben definierte Array den Datentyp explizit angeben, so kann man das natürlich tun. Programmtext 33 zeigt, wie sich dies umsetzen lässt.

Programmtext 33: Den Datentyp eines zweidimensionalen Arrays angeben

fn main() {

let arr: [[i32; 3]; 2] = [[1,2,3], [4,5,6]];

println!("{:?}", arr);

}       

An der Ausgabe ändert sich im Vergleich zu dem vorherigen Beispiel nichts.

Arrays eignen sich sehr gut dafür, mehrere Werte eines gleichen Datentyps zu speichern. Die Größe eines Arrays ist immer fest und kann nicht verändert werden (wir können also nicht im Verlauf des Programms Elemente hinzufügen oder entfernen).

Tupel

Es gibt noch einen weiteren zusammengesetzten Datentyp, den wir uns an dieser Stelle anschauen wollen. Dabei handelt es sich um Tupel. Tupel sind ähnlich zu Arrays, da wir auch hier viele Elemente gleichzeitig speichern können. Anstelle von eckigen Klammern verwenden wir nun runde Klammern und Indizes werden nun mit einem Punkt adressiert.

Programmtext 34: Ein Beispiel für ein Tupel

fn main() {

let a = (5, 7, 12, 3241);

println!("a = {:?}", a);

println!("a.2 = {}", a.2);

}       

Die Ausgabe sieht dann so aus:

a = (5, 7, 12, 3241)
a.2 = 12

Arrays können nur Elemente vom gleichen Datentyp beinhalten. Tupel hingegen können auch Elemente mit verschiedenen Datentypen enthalten.

Programmtext 35: Ein Tupel mit unterschiedlichen Datentypen

fn main() {

let a = (5, false, "Hallo", 3241);

println!("a = {:?}", a);

println!("a.2 = {}", a.2);

}       

Ausgabe:

a = (5, false, "Hallo", 3241)
a.2 = Hallo

Es ist ebenfalls möglich, Tupel ineinander zu verschachteln, d.h. Tupel innerhalb von Tupeln zu erstellen.

Programmtext 36: Verschachtelung von Tupeln

fn main() {

let a = (((5, false), "Hallo"), 3241);

}       

Wir können Tupel beliebig kompliziert ineinander verschachteln. Man sollte dabei aber jederzeit hinterfragen, wie sinnvoll eine sehr komplexe Verschachtelung ist.

Genau wie Arrays können auch Tupel zerlegt werden, indem wir neue Bezeichner einführen.

Programmtext 37: Zerlegung eines Tupels

fn main() {

let a = (5, false, "Hallo");

let (eins, zwei, drei) = a;

println!("[{}, {}, {}]", eins, zwei, drei);

}       

Die entsprechende Ausgabe lautet hier:

[5, false, Hallo]

Wir merken uns also: Ein Tupel ist eine Sammlung von Werten unterschiedlicher Typen. Darüber hinaus können Tupel benutzt werden, um mehrere Werte in einer Funktion zurückzugeben. Dazu kommen wir aber später.

Neben den hier vorgestellten Datentypen gibt es auch noch die Möglichkeit, Zeichenketten -- sogenannte Strings – zu speichern. Dazu werden doppelte Anführungszeichen verwendet, wie wir es in dem Kapitel „Ein »Hallo, Welt!« Programm in Rust“ bereits beim Ausgeben der Zeichenkette »Hallo, Welt!« gesehen haben.

Da dieser Datentyp etwas komplizierter ist, werden wir uns damit noch eingehend in einem gesonderten Kapitel beschäftigen.

Typumwandlung

Das Schlüsselwort as ermöglicht es uns, eine Typumwandlung durchzuführen. Dies kann in einigen Situationen erforderlich sein. Das folgende Programm ist ein Beispiel für eine solche Situation.

Programmtext 38: Addition von zwei Zahlen

fn main() {

let a: u8 = 15;

let b: u16 = 20;

println!("a + b = {}", a + b); // Fehler!

}   

Das Problem ist, dass wir versuchen, einen Wert vom Typ u8 auf einen Wert vom Typ u16 zu addieren. Die Addition ist aber nur für Werte des gleichen Typs erlaubt. Deshalb benötigen wir an dieser Stelle eine Typumwandlung, um die Addition ausführen zu können.

Programmtext 39: Ein Beispiel für eine Typumwandlung

fn main() {

let a: u8 = 15;

let b: u16 = 20;

println!("a + b = {}", (a as u16) + b);

}   

Mit Hilfe einer Typumwandlung gibt es nun keine Fehlermeldung mehr und wir erhalten die folgende Ausgabe:

a + b = 35

In diesem Beispiel wurde ein Wert des Typs u8 in den Typ u16 konvertiert. Dabei gibt es keine Probleme, da der Wertebereich von u16 alle Werte des Typs u8 einschließt.

Jedoch ist auch eine Konvertierung von u16 nach u8 möglich. In diesem Fall kann eine Konvertierung aber zu Problemen führen, da der Wertebereich von u8 weniger Werte umfasst als der Wertebereich von u16.

Programmtext 40: Ein weiteres Beispiel für eine Typumwandlung

fn main() {

let a: u8 = 15;

let b: u16 = 256;

println!("a + b = {}", a + (b as u8));

}   

Im ersten Moment würde man sicherlich erwarten, dass wir das Ergebnis 15 + 256 = 271 erhalten. Dies ist aber nicht der Fall. Stattdessen bekommen wir als Ausgabe:

a + b = 15

Der Grund dafür ist, dass der Wertebereich von u8 lediglich von 0 bis 255 reicht. Die Variable b speichert aber den Wert 256. Dies ist zunächst noch kein Problem, weil die Variable b den Typ u16 hat und dessen Wertebereich die Zahl 256 beinhaltet.

Sobald wir nun aber eine Konvertierung nach u8 durchführen, passt der Wert nicht mehr in den Wertebereich und deshalb wird der Wert zu 0 (Overflow). Hätte b beispielsweise den Wert 260 gehabt, dann wäre das Ergebnis 15 + 4 = 19, weil wir nach einem Overflow mit dem Zählen erneut bei 0 beginnen.

Zusammenfassung dieses Kapitels

Primitive Datentypen:

Datentypen für ganze Zahlen (mit Vorzeichen): i8, i16, i32, i64, i128, isize.

Datentypen für ganze Zahlen (ohne Vorzeichen): u8, u16, u32, u64, u128, usize.

Datentypen für Kommazahlen: f32, f64.

Datentyp für einzelne Zeichen: char.

Datentyp für Wahrheitswerte: bool.

Datentyp für »null«: ().

Zusammengesetze Datentypen:

Ein Array ist eine Sammlung von Werten gleichen Typs und besitzt eine feste Größe.

Ein Tupel ist eine Sammlung von Werten unterschiedlicher Typen und besitzt eine feste Größe.

Aufgabe 3

Schreiben Sie ein Programm, welches ein Array der Größe 4 definiert. Dieses Array soll vier beliebige Unicode-Zeichen enthalten. Geben Sie anschließend sowohl das gesamte Array als auch den ersten darin gespeicherten Wert auf der Konsole aus.

Schreiben Sie ein Programm, welches ein Tupel der Größe 4 definiert. Dieses Tupel kann beliebige Werte enthalten. Geben Sie anschließend den ersten Wert in diesem Tupel auf der Konsole aus.

Funktionen

Funktionen eignen sich besonders gut dazu, bestimmte Befehle wiederholt verwenden zu können. Somit müssen wir Befehlsfolgen nicht mehrfach aufschreiben. Stattdessen schreiben wir eine Befehlsfolge einmalig auf und können dann beliebig oft darauf zugreifen.

Eine spezielle Funktion ist uns auch bereits begegnet, nämlich die main Funktion. Diese Funktion ist der Einstiegspunkt unseres Programmes. Die main Funktion wird immer als Erstes ausgeführt, sobald wir das Programm starten.

Wenn wir nun selbst weitere Funktionen definieren möchten, dann benutzen wir dazu das Schlüsselwort fn. Geschweifte Klammern geben dabei an, wo die Funktion beginnt und wo sie endet.

Programmtext 41: Eine neue Funktion definieren und aufrufen

fn main() {

neue_funktion();

}

fn neue_funktion() {

println!("Hallo, Welt!");

}

Jede Funktion besitzt einen eindeutigen Namen, über den wir die Funktion dann aufrufen können. Wenn wir eine Funktion aufrufen, werden alle darin enthaltenen Befehle der Reihe nach ausgeführt. Unser Beispiel würde also die folgende Ausgabe produzieren:

Hallo, Welt!

Die Reihenfolge der Funktionsdefinitionen ist dabei irrelevant. Das bedeutet, dass wir die neue Funktion auch vor der Funktion main definieren könnten.

Darüber hinaus ist es möglich, einer Funktion Daten – sogenannte Parameter – zu übergeben. Diese werden dann zwischen den runden Klammern der Funktionsdefinition platziert. Dabei müssen wir für jeden Parameter seinen Datentyp angeben. Mehrere Parameter werden durch Kommata getrennt.

Programmtext 42: Eine Funktion mit Parametern definieren

fn neue_funktion(a: i32, b: i32) {

println!("a = {}, b = {}", a, b);

}

Bei der Definition einer Funktion müssen wir die Datentypen der Parameter immer angeben. Hier können wir sie nicht einfach weglassen. Wenn wir diese Funktion nun aufrufen möchten, dann müssen wir zwei Werte mit dem Datentyp i32 mitgeben. Dies geschieht in den runden Klammern beim Aufruf der Funktion.

Programmtext 43: Eine Funktion mit Parametern aufrufen

fn main() {

let x: i32 = 10;

let y: i32 = 20;

neue_funktion(x, y);

}

fn neue_funktion(a: i32, b: i32) {

println!("a = {}, b = {}", a, b);

}

Nun erhalten wir als Ergebnis:

a = 10, b = 20

Wir sehen, dass der Parameter a nun den ersten angegebenen Wert übernimmt. Dieser Wert ist in diesem Beispiel gleich dem Wert der Variablen x, also 10. Das gleiche gilt für den Parameter b.

Wir können die Werte auch direkt angeben, ohne Variablen zu benutzen. Dazu schreiben wir konkrete Werte in die runden Klammern beim Funktionsaufruf.

Programmtext 44: Eine Funktion mit Parametern aufrufen

fn main() {

neue_funktion(10, 20);

}

fn neue_funktion(a: i32, b: i32) {

println!("a = {}, b = {}", a, b);

}

Die Ausgabe ändert sich dabei natürlich nicht. Funktionen können beliebig viele Parameter haben. Man sollte sich aber Gedanken darüber machen, ob es noch sinnvoll ist, eine Funktion zu haben, wenn die Anzahl der Parameter immer größer wird.

Oftmals ist es dann empfehlenswert, die Funktion in mehrere Funktionen zu unterteilen. Die Idee einer Funktion ist schließlich, eine bestimmte Funktionalität zur Verfügung zu stellen. Eine Funktion ist nicht dazu gedacht, mehrere verschiedene Aufgaben zu erledigen.

Statements und Expressions

Bisher haben wir nur Anweisungen (»Statements«) innerhalb von Funktionen gesehen. Es ist auch möglich, sogenannte Ausdrücke (»Expressions«) zu verwenden. Anweisungen sind Befehle, die eine Aktion ausführen und dabei keinen Wert zurückliefern.

Ausdrücke hingegen werden ausgewertet und der Ergebniswert wird dann zurückgegeben.

Ein simples Beispiel für eine Anweisung ist die Definition einer Variablen.

Programmtext 45: Ein Beispiel für eine Anweisung (Statement)

fn main() {

let a = 5;

}   

Auch Funktionsdefinitionen sind Anweisungen.

Ein Beispiel für einen Ausdruck wäre eine einfache mathematische Addition: 5+5 ist ein Ausdruck, der zu dem Wert 10 ausgewertet wird. Der Aufruf einer Funktion ist ebenfalls ein Ausdruck.

Auch ein Block, welcher mittels geschweifter Klammern ({}) definiert wird, ist ein Ausdruck.

Programmtext 46: Ein Beispiel für einen Ausdruck (Expression)

fn main() {

let a = {

let b = 5;

b + b

};

println!("a = {}", a);

}   

Wir weisen der Variablen a in diesem Beispiel den Wert des Ausdrucks innerhalb der geschweiften Klammern zu. Inmitten dieses Blockes wird der Ausdruck b + b zu 10 ausgewertet, weshalb die Variable a den Wert 10 zugewiesen bekommt:

a = 10

Bei genauem Hinsehen stellen wir fest, dass die letzte Zeile innerhalb des Blockes kein Semikolon am Zeilenende besitzt. Dies ist der Fall, weil es sich um einen Ausdruck handelt. Anweisungen haben immer ein Semikolon am Zeilenende, Ausdrücke jedoch nicht.

Durch die Nutzung eines Semikolons würden wir aus dem Ausdruck eine Anweisung machen. Das würde für unseren Block – welcher ein Ausdruck ist – bedeuten, dass darin kein Wert zurückgeliefert wird.

In diesem Fall würde der Block den Wert () zurückliefern. Das liegt daran, dass der Block ein Ausdruck ist und somit immer einen Wert zurückgibt. Geben wir darin keinen passenden Wert zurück, dann wird automatisch der Wert () zurückgegeben.

Jetzt, wo wir den Unterschied zwischen Anweisungen und Ausdrücken verstanden haben, kommen wir wieder auf Funktionen zurück.

Wie oben bereits erwähnt, sind Funktionsaufrufe ebenfalls Ausdrücke. Wir erinnern uns: Ausdrücke geben immer einen Wert zurück. Falls wir dies nicht explizit tun, so wird der Wert () zurückgegeben.

Das bedeutet für uns, dass wir innerhalb von Funktionen einen Wert zurückliefern können. Bisher haben wir dies nicht getan, weshalb immer automatisch der Wert () zurückgegeben wurde.

Demnach würden die folgenden beiden Ausschnitte genau dasselbe bedeuten, nur dass die erste Variante etwas kürzer aufgeschrieben wurde:

Programmtext 47: Einfache Funktionsdefinition ohne Angabe des Rückgabetyps

fn test() {

println!("Test.");

}   

Programmtext 48: Einfache Funktionsdefinition mit Angabe des Rückgabetyps

fn test() -> () {

println!("Test.");

}   

Aufgrund dessen, dass beide Beispiele genau dasselbe bedeuten, nutzen wir in der Regel die erste Variante, weil diese etwas kürzer ist.

Anhand des zweiten Codeausschnittes können wir nun bereits erahnen, wie wir allgemein den Typ eines zurückgegebenen Wertes angeben können. Hierzu verwenden wir den Pfeil ->, welcher auf den Funktionsnamen folgt. Hinter diesem Pfeil wird dann der Rückgabetyp der Funktion angegeben.

Das nachfolgende Beispiel demonstriert, wie ein Wert ungleich () in einer Funktion zurückgeben werden kann.

Programmtext 49: Ein Beispiel für eine Funktion mit Rückgabewert

fn main() {

let a = some_function();

println!("a = {}", a);

}

fn some_function() -> i32 {

10

}   

Wenn wir dieses Programm nun ausführen, dann erscheint der Wert 10 auf der Konsole.

a = 10

Der Rückgabetyp der Funktion some_function ist an dieser Stelle i32, da der Rückgabewert der Funktion 10 ist. Eine solche Funktion ist selbstverständlich unnötig, weil wir stattdessen auch einfach let a = 10; schreiben könnten.

Spannender wird es, wenn wir zusätzlich Parameter verwenden, welche dann zur Berechnung des Rückgabewertes gebraucht werden.

Programmtext 50: Eine Funktion mit Rückgabewert und Parameter

fn main() {

let a = squared(10);

println!("a = {}", a);

}

fn squared(a: i32) -> i32 {

a * a

}   

Diese Funktion berechnet nun das Quadrat des übergebenen Parameters, also . Die Ausgabe lautet dementsprechend:

a = 100

Aus anderen Programmiersprachen kennt man möglicherweise das Schlüsselwort return. Auch in Rust können wir return benutzen. Dabei wird die Funktion verlassen und der dahinter angegebene Wert zurückgegeben.

Programmtext 51: Das Schlüsselwort return

fn main() {

let a = some_function(10);

println!("a = {}", a);

}

fn some_function(a: i32) -> i32 {

let b = a + 5;

return a;

b

}   

Wenn wir hier die Warnungen des Compilers ignorieren und unser Programm ausführen, dann entsteht diese Ausgabe:

a = 10

Wir sehen hier, dass die Funktion durch das return verlassen wird und somit nicht der Wert b, sondern der Wert a zurückgegeben wird. Das frühzeitige Verlassen von Funktionen ist besonders bei Verwendung von bedingten Anweisungen sinnvoll. Diese wollen wir uns nun im nächsten Abschnitt etwas genauer ansehen.

Zusammenfassung dieses Kapitels

Funktionen werden mit Hilfe des Schlüsselwortes fn definiert.

Funktionen können beliebig viele Parameter haben.

Anweisungen (Statements) führen eine Aktion aus, ohne einen Wert zurückzugeben.

Ausdrücke (Expressions) werden zu einem Wert ausgewertet und geben das Ergebnis zurück.

Funktionen sind Ausdrücke und geben daher immer einen Wert zurück. Wenn wir nicht explizit einen Wert zurückgeben, dann wird das leere Tupel () zurückgegeben.

Funktionen können mit Hilfe von return frühzeitig verlassen werden.

Aufgabe 4

Definieren Sie eine Funktion, die zwei Zahlen als Parameter nimmt und das Ergebnis der Addition dieser beiden Zahlen zurückgibt.

Definieren Sie eine Funktion, die zwei Zahlen als Parameter bekommt und true zurückgibt, falls die erste Zahl größer als die zweite Zahl ist. Anderenfalls soll die Funktion false zurückgeben. Tipp: Zwei Zahlen können mit Hilfe des Operators > verglichen werden, um zu überprüfen, ob die Zahl vor dem Operator größer als die Zahl hinter dem Operator ist.

Kontrollstrukturen

Rust stellt uns – wie andere Programmiersprachen auch – einige Kontrollstrukturen zur Verfügung. Diese erlauben es uns beispielsweise, bestimmte Anweisungen nur dann auszuführen, wenn eine Bedingung erfüllt ist oder einen Block von Anweisungen solange zu wiederholen, wie eine bestimmte Bedingung gilt.

Im Folgenden werden wir uns mit bedingten Anweisungen und Schleifen befassen.

Bedingungen

Bedingte Anweisungen erlauben es uns, bestimmte Codeabschnitte nur dann auszuführen, wenn eine angegebene Bedingung erfüllt ist. Ebenfalls bieten sie uns die Möglichkeit, einen alternativen Block genau dann auszuführen, wenn die Bedingung nicht erfüllt ist.

Wir können mit Hilfe von bedingten Anweisungen also Folgendes ausdrücken: »Wenn die Bedingung erfüllt ist, dann führe Abschnitt A aus. Ansonsten führe Abschnitt B aus«. In Rust werden dazu die Schlüsselwörter if und else benutzt.

Ist die vorgegebene Bedingung erfüllt, so werden alle Anweisungen in dem Block hinter dem Schlüsselwort if der Reihe nach ausgeführt. Anderenfalls werden alle Anweisungen in dem Block hinter dem Schlüsselwort else der Reihe nach ausgeführt.

Programmtext 52: Ein Beispiel für einen if-else-Ausdruck

fn main() {

let a = 10;

if a == 10 {

println!("a = 10");

}

else {

println!("a != 10");

}

}   

In Rust benötigen wir keine Klammern um die Bedingung, auch wenn dies in anderen Programmiersprachen oftmals üblich ist. Als Ausgabe erhalten wir dann:

a = 10

In diesem Beispiel vergleichen wir den Wert der Variablen a mit dem Wert 10. Ändern wir den Wert der Variablen a, dann stellen wir fest, dass sich die Ausgabe auch verändert.

Anstelle eines Vergleiches mit == können wir auch prüfen, ob a ungleich (!=), größer (>), größer gleich (>=), kleiner (<) oder kleiner gleich (<=) als ein Vergleichswert ist.

Eine Bedingung hinter dem Schlüsselwort if muss immer den Datentyp bool haben. Andere Programmiersprachen erlauben auch Zahlen, die dann automatisch in einen Wahrheitswert umgewandelt werden, aber in Rust ist das nicht erlaubt. Der Datentyp einer Bedingung ist ausnahmslos immer bool.

Der else-Teil ist optional und kann daher problemlos weggelassen werden, wie das nachfolgende Beispiel zeigt.

Programmtext 53: Ein Beispiel für einen einfachen if-Ausdruck

fn main() {

let a = 10;

if a == 10 {

println!("a = 10");

}

}   

Jetzt wird der Code in dem Block nach dem Schlüsselwort if nach wie vor ausgeführt, wenn die Bedingung erfüllt ist. Ist die Bedingung jedoch nicht erfüllt, dann passiert gar nichts.

Nach einer bedingten Anweisung werden die Befehle wieder normal der Reihe nach abgearbeitet. Das Programm wird dementsprechend bei der nächsten Anweisung fortgesetzt.

Nun kann es natürlich vorkommen, dass wir mehrere Fälle unterscheiden möchten. Das Schlüsselwort else if erlaubt es uns, weitere Bedingungen zu überprüfen.

Programmtext 54: Ein Beispiel für einen else if-Ausdruck

fn main() {

let a = 8;

if a == 10 {

println!("a = 10");

}

else if a > 10 {

println!("a > 10");

}

else {

println!("a < 10");

}

}   

Ausgabe:

a < 10

In diesem Beispiel testen wir zuerst, ob die Variable a den Wert 10 besitzt. Ist dies nicht der Fall, so schauen wir, ob a größer als 10 ist. Wenn auch dies nicht wahr ist, dann muss a also kleiner als 10 sein.

Hierbei ist es wichtig, zu beachten, dass der Block der ersten Bedingung, die zu true ausgewertet wird, ausgeführt wird. Gibt es also mehrere Bedingungen, die erfüllt sind, wird trotzdem immer nur der Block mit der ersten erfüllten Bedingung ausgeführt. Folgendes Beispiel stellt dieses Szenario dar:

Programmtext 55: Ein Beispiel für mehrere erfüllte Bedingungen

fn main() {

let a = 8;

if a < 20 {

println!("a < 20");

}

else if a < 30 {

println!("a < 30");

}

else {

println!("a >= 30");

}

}   

Wir erhalten dabei die folgende Ausgabe:

a < 20

Auch wenn der Wert von a zusätzlich kleiner als 30 ist, wird dieser Fall nicht mehr ausgeführt, da bereits eine Bedingung davor erfolgreich war.

Wir können beliebig viele else if Fälle verwenden und if-else Konstrukte beliebig tief ineinander verschachteln. Dennoch sollte man auf tiefe Verschachtelungen verzichten, weil der Code dadurch immer unübersichtlicher und somit fehleranfälliger wird. Unser Ziel ist es immer, gut lesbaren Programmcode zu schreiben, der leicht nachvollzogen werden kann, um die Anfälligkeit für Fehler zu reduzieren.

Vielleicht ist dem ein oder anderen Leser eben aufgefallen, dass wir Blöcke mit geschweiften Klammern verwenden, wenn wir if-else Konstrukte nutzen. Wer sich an den letzten Abschnitt erinnert, weiß somit, dass diese Konstrukte Expressions – also Ausdrücke – sind. Dementsprechend ist es möglich, einer Variablen den Wert eines if-else Ausdrucks zuzuweisen.

Programmtext 56: if-else auf der rechten Seite einer Variablenzuweisung

fn main() {

let a = 10;

let b = if a == 10 {

1

}

else {

2

};

println!("a = {}, b = {}", a, b);

}   

Als Ergebnis erhalten wir nun:

a = 10, b = 1

Falls wir einen if-else Ausdruck auf der rechten Seite einer Variablenzuweisung benutzen wollen, so ist es wichtig, dass der zurückgelieferte Ausdruck im if Block denselben Datentyp hat wie der zurückgelieferte Ausdruck im else Block.

Schließlich besteht die Möglichkeit, dass der Variablen irgendeiner dieser beiden Werte zugewiesen wird. Damit der Typ der Variablen eindeutig feststeht, ist es also erforderlich, dass beide Ausdrücke denselben Typ besitzen.

Innerhalb eines if-else Ausdrucks können beliebige Bedingungen abgefragt werden. Außerdem ist es möglich, zu fordern, dass mehrere Bedingungen gleichzeitig gelten müssen, indem Bedingungen mit dem Operator && verknüpft werden.

Programmtext 57: Verknüpfung mehrerer Bedingungen mittels &&

fn main() {

let a = 8;

let b = 12;

if a < 10 && b < 20 {

println!("a < 10 und b < 20");

}

}   

In diesem Beispiel fordern wir, dass a kleiner als 10 sein muss und b kleiner als 20 sein muss, damit die Bedingung zu true auswertet. Die Ausgabe dazu sieht dann wie folgt aus:

a < 10 und b < 20

Werden stattdessen mehrere Bedingungen mit dem Operator || verknüpft, so ist es ausreichend, wenn mindestens eine der Bedingungen erfüllt ist.

Programmtext 58: Verknüpfung mehrerer Bedingungen mittels ||

fn main() {

let a = 8;

let b = 22;

if a < 10 || b < 20 {

println!("a < 10 oder b < 20");

}

}   

Ausgabe:

a < 10 oder b < 20

Schleifen

Für den Fall, dass wir bestimmte Anweisungen mehrfach wiederholen möchten, gibt es Schleifen. Schleifen ermöglichen es uns, einen Block von Anweisungen solange zu wiederholen, wie eine bestimmte Bedingung erfüllt ist. In Rust gibt es im Wesentlichen drei verschiedene Arten von Schleifen: loop, while und for.

loop

Mit Hilfe des Schlüsselwortes loop erzeugen wir eine Endlosschleife. Das bedeutet, dass alle Befehle in einem loop-Block immer wieder wiederholt werden. Das nächste Beispiel demonstriert dies.

Programmtext 59: Eine Schleife mit loop

fn main() {

loop {

println!("Hallo, Welt!");

}

}       

Sobald wir dieses Programm ausführen, stellen wir fest, dass der Befehl unendlich oft wiederholt wird.

Hallo, Welt!
Hallo, Welt!
Hallo, Welt!
...

Wir können die Ausführung des Programms beenden, indem wir STRG+C drücken. Es ist ebenfalls möglich, direkt im Programm festzulegen, wann die Schleife beendet werden soll. Dazu verwenden wir das Schlüsselwort break.

Programmtext 60: Eine Schleife mit break vorzeitig beenden

fn main() {

let mut counter = 0;

loop {

if counter == 3 {

break;

}

else {

println!("Hallo, Welt!");

}

counter = counter + 1;

}

}       

In diesem Beispiel beenden wir die Schleife, sobald der Zähler den Wert 3 erreicht hat. Dies ist zu Beginn des vierten Durchgangs (weil wir bei 0 begonnen haben) der Fall. Dadurch wird nun insgesamt drei Mal "Hallo, Welt!" ausgegeben.

Hallo, Welt!
Hallo, Welt!
Hallo, Welt!

Es ist auch möglich, dass eine Schleife einen Wert zurückgibt. So kann das Ergebnis der Berechnung beispielsweise in einer Variablen gespeichert werden. Wenn eine Schleife einen Wert zurückgeben soll, so schreiben wir diesen Wert hinter das break Schlüsselwort. Das nächste Beispiel zeigt, wie wir einer Variablen den Wert einer Schleife zuweisen können.

Programmtext 61: Einen Wert aus einer Schleife zurückgeben

fn main() {

let mut counter = 0;

let loop_value = loop {

if counter == 3 {

break counter;

}

else {

println!("Hallo, Welt!");

}

counter = counter + 1;

};

println!("loop_value = {}", loop_value);

}       

Die Programmausgabe sieht dann wie folgt aus:

Hallo, Welt!
Hallo, Welt!
Hallo, Welt!
loop_value = 3

while

Wir haben eben gesehen, wie eine Schleife beendet werden kann, sobald eine gewisse Bedingung erfüllt ist. Einfacher geht dies, wenn wir eine while Schleife benutzen. Eine while Schleife wird so lange wiederholt, wie eine gegebene Bedingung erfüllt ist. Das kommende Beispiel zeigt, wie wir das Programm von eben mit Hilfe von einer while Schleife darstellen können.

Programmtext 62: Eine Schleife mit while

fn main() {

let mut counter = 0;

while counter < 3 {

println!("Hallo, Welt!");

counter = counter + 1;

}

}       

Der Block einer while Schleife wird dabei so lange wiederholt, bis die angegebene Bedingung nicht mehr erfüllt ist. In diesem Beispiel wird die Schleife also abgebrochen, sobald die Variable counter den Wert 3 erreicht hat. Die Ausgabe enthält dementsprechend erneut drei Mal "Hallo, Welt!".

Hallo, Welt!
Hallo, Welt!
Hallo, Welt!

for

Eine for Schleife ermöglicht es uns, über eine Ansammlung von Werten zu iterieren. Beispielsweise können wir mit Hilfe einer for Schleife einen Block an Anweisungen für jedes Element eines Arrays wiederholen. Dies wird in dem nachfolgenden Beispiel gezeigt.

Programmtext 63: Eine Schleife mit for

fn main() {

let array = [1, 3, 5, 7, 9];

for element in array.iter() {

println!("value: {}", element);

}

}       

Als Ausgabe erhalten wir nun:

value: 1
value: 3
value: 5
value: 7
value: 9

Wir könnten natürlich auch eine while Schleife benutzen, um denselben Effekt zu erzielen. Auch eine Schleife mit loop wäre möglich. Jede dieser drei Schleifen kann durch die jeweils zwei anderen Schleifen ersetzt werden.

Es kommt immer auf das Anwendungsgebiet an, wenn es darum geht, welche Schleife sich am besten eignet. Beispielsweise ist die for Schleife besser geeignet, um ein Array zu durchlaufen, als vergleichsweise die while Schleife.

Das nächste Beispiel stellt dasselbe Programm wie eben dar, jedoch dieses Mal unter Verwendung einer while Schleife.

Programmtext 64: Durchlauf eines Arrays mit Hilfe einer while Schleife

fn main() {

let array = [1, 3, 5, 7, 9];

let mut index = 0;

while index < array.len() {

println!("value: {}", array[index]);

index = index + 1;

}

}       

Die Ausgabe bleibt dabei gleich, aber die Gefahr, dass wir einen Fehler machen, ist höher als bei einer for Schleife. Würden wir beispielsweise die Zeile index = index + 1; vergessen, dann würden wir in eine endlose Schleife geraten. Zudem ist der Code einige Zeilen länger und somit weniger präzise.

Die for Schleife ist vermutlich die am häufigsten verwendete Schleife, weil sie weniger Fehler seitens des Programmierers zulässt. Wir können die for Schleife auch für eine feste Anzahl an Durchläufen nutzen. Ein Beispiel dafür könnte so aussehen:

Programmtext 65: Schleife mit for und einer festen Anzahl an Durchläufen

fn main() {

for number in 1..5 {

println!("number = {}", number);

}

}       

Dabei werden die Zahlen der Sequenz 1, 2, 3, 4 durchlaufen. Es ist also wichtig zu wissen, dass der Beginn der Sequenz 1..5 mit inbegriffen ist, das Ende jedoch nicht. Das Resultat sieht dann folgendermaßen aus:

number = 1
number = 2
number = 3
number = 4

Wenn auch das Ende der zu durchlaufenden Sequenz inklusive sein soll, dann können wir die folgende Syntax verwenden:

Programmtext 66: Schleife mit for und einer festen Anzahl an Durchläufen

fn main() {

for number in 1..=5 {

println!("number = {}", number);

}

}       

Jetzt werden alle Zahlen der Sequenz 1, 2, 3, 4, 5 ausgegeben.

number = 1
number = 2
number = 3
number = 4
number = 5

Neben dem zuvor vorgestellten Schlüsselwort break gibt es auch noch das Schlüsselwort continue. Damit kann der aktuelle Schleifendurchlauf übersprungen und direkt zum nächsten Durchlauf gesprungen werden. Sowohl break als auch continue können in allen Arten von Schleifen verwendet werden. Das untenstehende Beispiel zeigt exemplarisch, wie das Schlüsselwort continue eingesetzt werden kann.

Programmtext 67: Einen Schleifendurchlauf mit continue überspringen

fn main() {

for number in 1..5 {

if number == 3 {

continue;

}

println!("number = {}", number);

}

}       

In diesem Beispiel wird der Durchgang mit number = 3 übersprungen. Deshalb erhalten wir die nachfolgende Ausgabe:

number = 1
number = 2
number = 4

Würden wir den allerletzten Durchgang überspringen, dann würde die Schleife danach zu Ende sein, weil kein weiterer Durchgang mehr folgt.

Zusammenfassung dieses Kapitels

Bedingte Anweisungen können mit if-else Ausdrücken realisiert werden.

Anweisungen können mit Hilfe von loop, while oder for wiederholt werden.

Schleifen können mit break frühzeitig abgebrochen werden.

Ein Schleifendurchlauf kann mit continue übersprungen werden.

Aufgabe 5

Hinweis: Der untenstehende Programmtext 68 zeigt, wie wir ein Array als Parameter übergeben können. Das nächste Kapitel wird dann erklären, warum wir diese spezielle Schreibweise brauchen.

Schreiben Sie ein Programm, welches die Summe aller Zahlen des Arrays [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] berechnet und ausgibt.

Schreiben Sie ein Programm, welches die Summe der Quadratzahlen der Elemente des Arrays [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] berechnet und ausgibt.

Schreiben Sie eine Funktion, die überprüft, ob eine Zahl gerade oder ungerade ist. Die Funktion soll true zurückgeben, wenn die Zahl gerade ist. Ansonsten soll false zurückgegeben werden. Tipp: Eine Zahl ist gerade, wenn sie durch 2 teilbar ist. Der Operator % berechnet den Rest einer Division.

Programmtext 68: Ein Array als Parameter übergeben

fn main() {

let array = [1, 2, 3, 4, 5];

some_function(&array);

}

fn some_function(arr: &[i32]) {

println!("arr = {:?}", arr);

}   

arr = [1, 2, 3, 4, 5]

Wir fügen sowohl bei der Übergabe des Parameters als auch bei der Angabe des Datentyps in der Funktionsdefinition das Zeichen & vor dem Array ein. Dies ist notwendig, damit das Programm erfolgreich kompiliert wird. Das folgende Kapitel wird darstellen, warum wir das tun müssen.

Das Ownership Konzept

Ein Großteil der grundlegenden Sprachkonstrukte ist uns nun bekannt. Alle bisher vorgestellten Konzepte sind in den meisten gängigen Programmiersprachen wiederzufinden. Wir fahren nun mit einem Konzept fort, welches eine Besonderheit von Rust ist und deshalb in vielen anderen Programmiersprachen nicht wiederzufinden ist.

Bei der nebenläufigen Programmierung ist ein häufig auftretendes Problem, dass es verschiedene Rechenkerne gibt, die auf dieselbe Speicherstelle zugreifen wollen und sich dabei »in die Quere kommen«.

Sehen wir uns dazu das folgende Szenario an. Wir haben zwei Prozesse, welche beide auf den Wert von z zugreifen wollen. Jeder dieser beiden Prozesse möchte den Wert inkrementieren. Wir gehen dabei davon aus, dass z zu Beginn mit dem Wert 5 belegt ist.

Die folgende Tabelle stellt den gewünschten zeitlichen Ablauf der einzelnen Operationen dar.

Zeitschritt | Prozess 1 | Prozess 2 | Bemerkung
1 | a = z |  | Wert von z lesen (a = 5)
2 | a = a + 1 |  | Wert verändern (a = 6)
3 | z = a |  | Wert von z schreiben (z = 6)
4 |  | b = z | Wert von z lesen (b = 6)
5 |  | b = b + 1 | Wert verändern (b = 7)
6 |  | z = b | Wert von z schreiben (z = 7)

Nachdem beide Prozesse ihre Operationen durchgeführt haben, hat z im Optimalfall also den Wert 7. Sehen wir uns nun eine Folge an, bei dem ein falsches Ergebnis durch einen ungünstigen zeitlichen Verlauf entsteht.

Zeitschritt | Prozess 1 | Prozess 2 | Bemerkung
1 | a = z |  | Wert von z lesen (a = 5)
2 |  | b = z | Wert von z lesen (b = 5)
3 | a = a + 1 |  | Wert verändern (a = 6)
4 |  | b = b + 1 | Wert verändern (b = 6)
5 | z = a |  | Wert von z schreiben (z = 6)
6 |  | z = b | Wert von z schreiben (z = 6)

Hier erhalten wir das Ergebnis z = 6, welches nicht korrekt ist. Daraus können wir schließen, dass eine Synchronisation mehrerer Prozesse notwendig ist, um eine solche Situation zu vermeiden.

Es gibt verschiedenste Ansätze ein derartiges Problem, welches übrigens als race condition bezeichnet wird, zu verhindern. In vielen Programmiersprachen sind dafür gewisse Synchronisationsmethoden vorgesehen. Dadurch werden race conditions zwar verhindert, aber unser Programm wird in der Regel langsamer und wir können die Nebenläufigkeit nicht wirklich ausnutzen.

Ein weiterer Nachteil dieses Vorgehens ist, dass ein solcher Fehler sehr schwer auffindbar ist. Sollte der Programmierer also vergessen, einen entsprechenden Synchronisationsmechanismus zu verwenden, dann ist es schwierig, den entstandenen Laufzeitfehler zu finden. Bei der Suche nach dem Fehler kann es passieren, dass der Fehler zeitweise verschwindet.

Schließlich kann die zeitliche Folge der Operationen bei jeder Ausführung des Programms variieren und somit in einigen Fällen das richtige Ergebnis hervorrufen. Das liegt daran, dass die Prozesse nicht immer exakt gleich schnell arbeiten und die Reihenfolge der Operationen somit nicht gleichbleibend ist. Daher kann manchmal eine Folge entstehen, welche das korrekte Ergebnis produziert, wie es in der ersten Tabelle dargestellt wurde. Es kann aber eben auch eine Folge auftauchen, die ein falsches Ergebnis verursacht, wie es in der unteren Tabelle der Fall ist.

Rust besitzt das sogenannte Ownership Konzept. Dieses soll unter anderem race conditions verhindern, ohne dabei an Effizienz zu verlieren. Die Effizienz bleibt erhalten, weil bereits vor der Ausführung des Programmes vom Compiler festgestellt wird, ob race conditions möglicherweise auftreten können. Falls dem so ist, erhalten wir einen Kompilierfehler und sehen sofort, dass unser Programm nicht sicher ist.

Wie genau dieses Ownership Konzept funktioniert, wollen wir uns jetzt im Detail ansehen.

Stack und Heap

Grundsätzlich unterscheiden wir zwischen zwei Arten von Speicher, die unser Programm nutzen kann. Dabei handelt es sich um den Stack und den Heap. Diese beiden Arten von Speicher können beide verwendet werden, um Daten zu speichern. Sie sind jedoch unterschiedlich aufgebaut und somit für verschiedene Zwecke bestimmt.

Der Stack

Den Stack kann man sich wie einen Stapel vorstellen: Die Elemente darin werden quasi »aufeinander gestapelt«. Das hat den Effekt, dass Elemente, die zuerst ankommen, ganz unten liegen. Elemente, die zuletzt hinzugefügt wurden, befinden sich dementsprechend ganz oben.

Das bedeutet im Umkehrschluss, dass Elemente, die zuletzt hinzugefügt wurden, als erstes wieder entnommen werden. Dieses Prinzip wird als »Last-In-First-Out« (LIFO) bezeichnet.

Immer, wenn wir Daten auf dem Stack ablegen möchten, muss die Größe der Daten bekannt sein. Alle Daten, deren Größe unbekannt ist oder deren Größe sich noch ändern kann, müssen stattdessen auf dem Heap gespeichert werden.

Das Ablegen von Daten auf dem Stack wird als push und das Herausnehmen von Daten als pop bezeichnet.

Der Heap

Auf dem Heap werden alle Daten gespeichert, die keine feste Größe haben. Im Gegensatz zu dem Stack ist der Heap nicht organisiert. Wenn Speicherplatz benötigt wird, dann wird nach einem freien Platz gesucht und die Daten werden dort abgelegt.

Damit die Daten später wiedergefunden werden können, gibt es einen sogenannten Pointer (Zeiger), welcher angibt, an welcher Speicheradresse sich die Daten befinden. Der belegte Speicherbereich wird dann als besetzt markiert.

Das Reservieren von Speicher auf dem Heap ist aufwendiger, als Daten auf dem Stack abzulegen. Das liegt daran, dass das Betriebssystem zunächst einen ausreichend großen freien Bereich suchen und finden muss, bevor die Daten gespeichert werden können.

Glücklicherweise müssen wir beim Programmieren nicht dauerhaft über Stack und Heap nachdenken. Dennoch brauchen wir diese Konzepte, um zu verstehen, wie und warum das Ownership Prinzip funktioniert.

Ownership Regeln

Es gibt Ownership Regeln, die immer eingehalten werden müssen. Sobald eine oder mehrere dieser Regeln verletzt werden, erhalten wir einen Kompilierfehler. Dabei handelt es sich um folgende Regeln:

Jeder Wert in Rust gehört zu einer Variablen. Diese Variable wird als Owner (Besitzer) betitelt.

Zu jedem Zeitpunkt darf es nur einen Owner geben.

Wenn der Owner seinen Geltungsbereich verlässt, dann wird der Wert verworfen. Das bedeutet, dass der Wert außerhalb des Geltungsbereiches nicht mehr existiert.

Wir sehen uns zunächst kurz an, was Geltungsbereiche sind und wie sie funktionieren. Danach gehen wir detailliert auf die Ownership Regeln ein.

Geltungsbereiche (Scopes)

Wir haben bereits häufig gesehen, dass geschweifte Klammern verwendet werden, um Blöcke von Anweisungen zu kennzeichnen. Diese Blöcke, welche durch geschweifte Klammern markiert werden, sind eigene Scopes (Geltungsbereiche).

Wenn wir eine Variable definieren, dann ist sie nur innerhalb ihres Scopes definiert.

Programmtext 69: Der Geltungsbereich einer Variablen

fn main() {

{ // s existiert hier nicht

let s = "Hallo, Welt!"; // ab hier ist s definiert

// Hier kann s verwendet werden

} // Scope zuende: s existiert ab hier nicht mehr

}   

Eine Variable ist immer nur innerhalb des Scopes gültig, in dem sie definiert wurde. Sobald wir diesen Scope verlassen, ist die Variable nicht mehr existent.

Ownership Beispiele

Das Ownership Konzept ist ausschließlich für Datentypen relevant, welche auf dem Heap gespeichert werden. Primitive Datentypen werden auf dem Stack gespeichert, weil ihre Größe immer dieselbe und somit zur Kompilierzeit bekannt ist.

Deshalb betrachten wir den Datentyp String. Dieser Datentyp wird dazu verwendet, um Zeichenketten zu speichern. Zeichenketten werden auf dem Heap gespeichert, weil der benötigte Speicherplatz von der Anzahl der enthaltenen Zeichen abhängt.

In diesem Kapitel konzentrieren wir uns lediglich auf die Aspekte des Datentyps String, welche nötig sind, um das Ownership Prinzip zu verstehen. Anschließend werden wir Zeichenketten ausführlich in dem Kapitel „Zeichenketten“ behandeln.

Bisher haben wir nur String Literale gesehen. Dabei handelt es sich um eine fest gewählte Zeichenkette, die einmal gewählt wird und dann nicht mehr verändert werden kann. Achtung: Jene Variable, die ein String Literal speichert, kann selbst trotzdem änderbar sein, indem ihr ein neues Literal zugewiesen wird.

Anders sieht es bei dem Datentyp String aus. Dieser erlaubt es, Zeichenketten beliebig zu ändern. Das folgende Beispiel zeigt, wie wir eine änderbare Zeichenkette erstellen und erweitern können.

Programmtext 70: Einen String mit Hilfe von String::from() erzeugen

fn main() {

let mut s = String::from("Hallo");

s.push_str(", Welt!");

println!("{}", s);

}

Da wir die Zeichenkette ausgeben lassen, nachdem wir sie erweitert haben, erhalten wir folgende Ausgabe:

Hallo, Welt!

Wir benutzen hier einen neuen Operator, nämlich den :: Operator. Dieser erlaubt es uns, eine Funktion aus dem Namensbereich des Typs String zu verwenden.

Die Funktion push_str() lässt uns ein String Literal an das Ende einer Zeichenkette anfügen. Literale selbst können nicht erweitert werden. Der Grund dafür liegt in der Art und Weise, wie Literale und Zeichenketten vom Typ String im Speicher gehandhabt werden.

Bei einem Literal steht der Inhalt der Zeichenkette bereits sicher fest und kann somit direkt in die ausführbare Datei hineingeschrieben werden. Deshalb sind Literale auch effizienter und schneller als dynamisch änderbare Zeichenketten.

Umgekehrt bedeutet dies, dass Werte vom Typ String auf dem Heap gespeichert werden müssen, da die benötigte Speichergröße nicht fest ist. Der Speicherplatz muss also beim Betriebssystem angefragt werden (weil das Betriebssystem den Heap verwaltet). Sobald der Speicherplatz nicht mehr benötigt wird (beispielsweise, weil eine String Variable ihren Scope verlässt), muss dies dem Betriebssystem mitgeteilt werden.

Wir fragen Speicherplatz beim Betriebssystem an, indem wir die Funktion String::from() aufrufen. In Rust wird der Speicherplatz automatisch wieder freigegeben, sobald der Owner des Wertes seinen Scope verlässt. Das folgende Beispiel stellt diese Situation dar.

Programmtext 71: Der Geltungsbereich einer Variablen

fn main() {

{

let s = String::from("Hallo"); // Ab hier ist s gueltig

// Hier kann s verwendet werden

} // Scope zuende: s ist nicht mehr gueltig

}   

In einigen anderen Programmiersprachen muss sich der Programmierer selbst darum kümmern, den Speicherplatz wieder freizugeben. Dies erfordert einen zusätzlichen Funktionsaufruf. Dabei besteht die Gefahr, dass der Programmierer den Aufruf zu spät tätigt, wodurch Speicherplatz verschwendet wird. Es ist auch möglich, dass der Aufruf versehentlich zu früh erfolgt. In diesem Fall ist die Variable dann ungültig und Zugriffe darauf können zu Fehlern führen.

Wiederum andere Programmiersprachen benutzen einen sogenannten Garbage Collector, welcher den Speicherplatz von nicht mehr genutzten Variablen automatisch freigibt. Um diesen Garbage Collector auszuführen, muss das Programm immer kurz unterbrochen werden, weshalb diese Variante eine schlechtere Performanz zur Folge hat.

Der Ansatz von Rust bewahrt somit eine hohe Geschwindigkeit, da kein Garbage Collector notwendig ist. Gleichzeitig werden einige häufige Programmierfehler ausgeschlossen.

Bis hierhin waren die vorgestellten Situationen noch wenig komplex. Wir wollen uns nun komplexere Situationen ansehen, in denen das Ownership Konzept ebenfalls eine wichtige Rolle spielt.

Move

Variablen können auf identischen Daten arbeiten. Dabei macht es einen Unterschied, ob die Daten auf dem Stack oder auf dem Heap gespeichert sind. Nehmen wir an, dass wir folgendes Programm haben:

Programmtext 72: Einer Variablen den Wert einer anderen Variablen zuweisen

fn main() {

let a = 10;

let b = a;

}       

In diesem Programm wird der Variablen b der Wert der Variablen a zugewiesen. Nun halten beide Variablen den gleichen Wert und können unabhängig voneinander benutzt werden. Hierbei treten auch keine Probleme auf, weil beide Variablen eine ganze Zahl abspeichern und dafür ein primitiver Datentyp verwendet wird. Die Größe dieses Datentyps ist immer fest und somit werden die Werte auf dem Stack gespeichert.

Für Werte, die auf dem Stack liegen, wird eine Kopie erzeugt. Das heißt, dass es sowohl einen Wert für die Variable a als auch einen Wert für die Variable b auf dem Stack gibt. Wenn nun der Wert einer Variablen geändert wird, dann bleibt der Wert der jeweils anderen Variablen davon unberührt.

Das Ganze wird nun etwas komplizierter, wenn wir stattdessen den Datentyp String verwenden, weil Daten dieses Typs auf dem Heap gespeichert werden. Wir betrachten nun das folgende Programm:

Programmtext 73: Einer Variablen den Wert einer anderen Variablen zuweisen

fn main() {

let a = String::from("Hallo, Welt!");

let b = a;

}       

Dieses Programm ist dem vorherigen Programm sehr ähnlich. Dennoch gibt es nun einen wichtigen Unterschied: Da der Datentyp String auf dem Heap gespeichert wird, wird nun keine Kopie des Wertes erzeugt. Eine Kopie würde viele Ressourcen benötigen. Stattdessen zeigt b nun auf dieselbe Stelle im Heap wie a. Das hat zur Folge, dass beide Variablen mit demselben Speicher arbeiten.

Dabei können Probleme auftreten: Wenn der Wert der Variablen b geändert wird, so ändert sich ebenfalls der Wert der Variablen a auf den neuen Wert, auch wenn das vielleicht nicht beabsichtigt ist. In einigen Programmiersprachen passiert genau das.

Zudem könnte es passieren, dass der reservierte Speicherplatz doppelt freigegeben wird, wenn a und b beide ihren Scope verlassen. Dies führt zu einem Fehler, da der Speicher nur einmal vorhanden ist und somit nur einmalig freigegeben werden kann.

Rust verwendet das move Prinzip, welches dieses Problem verhindert. Ab dem Augenblick, wo let b = a; ausgeführt wird, ist die Variable a nicht mehr gültig. Der Wert wurde nach b bewegt (move) und fortan kann mit b weitergearbeitet werden. Das folgende Beispiel demonstriert dies.

Programmtext 74: Ein Beispiel für move

fn main() {

let a = String::from("Hallo, Welt!");

let b = a;

println!("{}", a); // Fehler!

}       

Aufgrund dessen, dass a nach b bewegt wurde, können wir a nicht mehr verwenden. Ein Zugriff führt dementsprechend zu einer Fehlermeldung. Wir können die Variable b jedoch ganz normal nutzen.

Falls man tatsächlich eine Kopie einer Zeichenkette anlegen möchte, dann bietet sich die Funktion clone() an. Unter Verwendung von clone() können weiterhin beide Variablen unabhängig voneinander genutzt werden.

Programmtext 75: Ein Beispiel für clone()

fn main() {

let a = String::from("Hallo, Welt!");

let b = a.clone();

println!("a = {}, b = {}", a, b);

}       

Wir erhalten dabei die folgende Ausgabe:

a = Hallo, Welt!, b = Hallo, Welt!

Dennoch sollte man bei jeder Verwendung von clone() daran denken, dass dieser Aufruf viele Ressourcen (Laufzeit und Speicherplatz) beansprucht. Wenn man auch ohne eine Kopie auskommt, dann empfiehlt es sich, clone() wegzulassen.

Bei primitiven Datentypen, wie beispielsweise ganzen Zahlen, müssen wir über diese Problematik nicht weiter nachdenken. Dort wird immer eine vollständige Kopie angelegt, wie wir bereits zuvor gesehen haben. Auch Arrays und Tupel, die nur primitive Datentypen enthalten, erzeugen immer eine vollständige Kopie.

Ownership und Funktionen

Wenn wir einer Funktion Werte als Parameter übergeben, dann ist das Verhalten des Programmes sehr ähnlich zu einer Variablenzuweisung. Wieder ist es der Fall, dass bei primitiven Datentypen nichts Besonderes passiert, weil eine Kopie an die Funktion übergeben wird.

Programmtext 76: Funktionsaufruf mit einem Integer Parameter

fn main() {

let a = 15;

some_function(a);

// a ist auch nach der Funktion gueltig, weil nur eine Kopie uebergeben wurde

println!("a = {}", a);

}

fn some_function(some_int: i32) {

println!("some_int = {}", some_int);

}       

Das Programm funktioniert wie erwartet und gibt folgende Zeilen aus:

some_int = 15
a = 15

Nun sieht das Ganze jedoch wieder etwas anders aus, wenn wir einen String als Parameter übergeben. Dann wird der Wert nämlich in die Funktion bewegt und ist somit nach dem Funktionsaufruf nicht mehr gültig. Im nachfolgenden Beispiel können wir genau dies beobachten.

Programmtext 77: Funktionsaufruf mit einem String Parameter

fn main() {

let s = String::from("Hallo, Welt!");

some_function(s); // s wird in some_function bewegt

println!("s = {}", s); // Fehler! s ist hier nicht mehr gueltig

}

fn some_function(some_str: String) { // hier beginnt der Scope von some_str

println!("some_str = {}", some_str);

} // hier endet der Scope von some_str       

Wird ein Wert auf dem Heap als Parameter an eine Funktion übergeben, dann wird der Wert in die Funktion bewegt (move) und ist danach nicht mehr gültig.

Wie wir bereits wissen, können Funktionen auch Werte zurückgeben. Bei primitiven Datentypen gibt es nichts Spezielles zu beachten. Bei Daten auf dem Heap jedoch findet wieder ein move statt.

Programmtext 78: Funktion mit einem String als Rückgabewert

fn main() {

let s = some_function(); // Der String wird aus der Funktion nach s bewegt

println!("s = {}", s); // s ist hier gueltig und kann verwendet werden

}

fn some_function() -> String {

let new_str = String::from("Hallo, Welt!");

println!("new_str = {}", new_str);

new_str // new_str wird in die aufrufende Funktion bewegt

}       

Zuvor haben wir gesehen, wie ein Wert in eine Funktion hinein bewegt werden kann. Nun können wir beobachten, wie ein Wert in die aufrufende Funktion heraus bewegt werden kann. Als Ausgabe erhalten wir dann:

new_str = Hallo, Welt!
s = Hallo, Welt!

Nun ist es natürlich auch möglich, dass eine Funktion einen Wert als Parameter enthält und ebenfalls einen Wert zurückgibt.

Programmtext 79: Funktion mit Parameter und Rückgabewert

fn main() {

let s1 = String::from("Hallo, Welt!");

let s2 = some_function(s1); // s1 wird nach some_function bewegt

println!("s2 = {}", s2); // s1 ist nun nicht mehr gueltig, s2 aber schon

}

fn some_function(some_str: String) -> String { // some_str wird in some_function bewegt

println!("some_str = {}", some_str);

some_str // some_str wird in die aufrufende Funktion bewegt

}       

Dieses Mal nimmt die Funktion einen Wert entgegen und gibt einen Wert zurück. Ab der Zeile, in der die Funktion aufgerufen wird, wird s1 in die Funktion bewegt und ist somit danach innerhalb von main() nicht mehr gültig. s2 hingegen wird von der Funktion zurückgegeben und ist somit ab genau dieser Zeile gültig und kann danach beliebig verwendet werden. Daher lautet die Ausgabe dieses Beispiels:

some_str = Hallo, Welt!
s2 = Hallo, Welt!

Jetzt ist es natürlich sehr unpraktisch, wenn wir einen Wert nicht mehr verwenden können, nachdem er an eine Funktion übergeben wurde.

Um den Wert weiterhin nutzen zu können, könnten wir den Wert immer wieder aus der Funktion zurückgeben. Falls die Funktion selbst auch einen berechneten Wert zurückgeben soll, könnten wir ein Tupel verwenden.

Programmtext 80: Funktion mit einem Tupel als Rückgabewert

fn main() {

let a = String::from("Hallo, Welt!");

let (b, length) = get_str_length(a);

println!("length of '{}' = {}", b, length);

}

fn get_str_length(some_str: String) -> (String, usize) {

let length = some_str.len();

(some_str, length)

}       

length of 'Hallo, Welt!' = 12

Wir sehen, dass es zwar möglich ist, den verwendeten Wert wieder zurückzugeben, jedoch ist dies etwas umständlich. Um den Wert in der Funktion main() weiterhin verwenden zu können, müssen wir nun statt a die Variable b nutzen.

Referenzen lösen dieses Problem deutlich eleganter, wie wir im Folgenden sehen werden.

Referenzen und Borrowing

Damit eine Funktion nicht den Besitz eines Wertes übernimmt, können wir stattdessen eine Referenz des Wertes übergeben. Dazu benutzen wir den Operator &, wie in folgendem Beispiel zu sehen ist:

Programmtext 81: Eine Referenz an eine Funktion übergeben

fn main() {

let a = String::from("Hallo, Welt!");

let length = get_str_length(&a);

println!("length of '{}' = {}", a, length);

}

fn get_str_length(some_str: &String) -> usize {

some_str.len()

}       

Wir nutzen den Operator sowohl bei dem Aufruf der Funktion als auch bei der Definition der Funktion. Die Ausgabe des Programms bleibt dabei unverändert, aber der Programmtext ist deutlich eleganter als bei der Lösung mit Hilfe von Tupeln. Wenn wir eine Referenz an eine Funktion übergeben, dann bezeichnen wir dies als borrowing (ausleihen). Die Funktion leiht sich den Wert aus und gibt ihn danach automatisch wieder zurück, sodass er weiterhin verwendet werden kann.

Referenzen sind standardmäßig nicht änderbar, genau wie normale Variablen auch. Auch hier kann das Schlüsselwort mut Abhilfe schaffen, um änderbare Referenzen zu erhalten.

Programmtext 82: Änderbare Referenzen

fn main() {

let mut s = String::from("Hallo");

append_str(&mut s);

println!("s = {}", s);

}

fn append_str(some_str: &mut String) {

some_str.push_str(", Welt!");

}       

Die Variable s muss zunächst selbst änderbar sein, damit auch Referenzen davon änderbar gemacht werden können. Wenn wir nun eine änderbare Referenz dieser Variablen an eine Funktion übergeben wollten, dann nutzen wir dazu &mut. Auch bei der Definition der Funktion wird der Parameter entsprechend gekennzeichnet.

Änderbare Referenzen haben jedoch eine Einschränkung: Es kann immer nur eine änderbare Referenz einer Variablen in demselben Scope geben. Das bedeutet, dass folgender Programmcode zu einem Fehler führen würde:

Programmtext 83: Mehrere änderbare Referenzen im gleichen Scope

fn main() {

let mut s = String::from("Hallo");

let s1 = &mut s;

let s2 = &mut s; // Fehler!

println!("s1 = {}, s2 = {}", s1, s2);

}       

Diese Einschränkung sorgt dafür, dass sogenannte race conditions (siehe Anfang dieses Kapitels) nicht auftreten können. Daten können somit nicht zur gleichen Zeit an mehreren Stellen manipuliert werden. Dadurch werden einige Fehler bereits durch den Compiler verhindert.

Es ist hingegen völlig in Ordnung, wenn es mehrere änderbare Referenzen in unterschiedlichen Scopes gibt, solange es niemals mehr als eine änderbare Referenz zur gleichen Zeit geben kann.

Programmtext 84: Mehrere änderbare Referenzen in unterschiedlichen Scopes

fn main() {

let mut s = String::from("Hallo");

{

let s1 = &mut s;

} // s1 ist ab hier nicht mehr gueltig

let s2 = &mut s;

}       

Da s1 den Scope verlässt, bevor s2 eingeführt wird, ist dieses Programm nun wieder fehlerfrei. Zu dem Zeitpunkt, bei dem s2 definiert wird, hat s1 nämlich seinen Scope bereits verlassen und ist somit nicht mehr vorhanden.

Eine ähnliche Einschränkung gibt es auch bei unveränderlichen Referenzen. Es dürfen zwar beliebig viele unveränderliche Referenzen einer Variablen existieren, aber es darf keine weitere Referenz existieren, sobald eine änderbare Referenz vorhanden ist (auch keine unveränderbare Referenz).

Programmtext 85: Änderbare und unveränderbare Referenzen im gleichen Scope

fn main() {

let mut s = String::from("Hallo");

let s1 = &s; // OK

let s2 = &s; // OK

let s3 = &mut s; // Fehler!

}       

Beliebig viele unveränderliche Referenzen stellen kein Problem dar, weil ein gleichzeitiges Ändern der Daten ausgeschlossen ist. Sofern die Daten immer nur gelesen werden, ist alles in bester Ordnung.

Sobald es aber auch nur eine änderbare Referenz gibt, können die Daten theoretisch zu jedem beliebigen Zeitpunkt geändert werden – auch wenn gleichzeitig eine unveränderliche Referenz gelesen wird. Dies führt zu Problemen, weshalb eine solche Einschränkung sinnvoll ist.

In Programmiersprachen, wo Pointer verwendet werden, besteht oftmals die Gefahr, dass Pointer auf eine Speicherstelle zeigen, die eigentlich bereits freigegeben wurde. Dies wird als »dangling pointer« bezeichnet. In Rust sorgt der Compiler dafür, dass ein solches Szenario niemals eintreten kann. Das folgende Beispiel demonstriert dies (der Programmcode ist fehlerhaft und kompiliert somit nicht).

Programmtext 86: »Dangling References«

fn main() {

let reference = some_function();

}

fn some_function() -> &String {

let s = String::from("Hallo, Welt!");

&s // Wir geben eine Referenz auf s zurueck

} // Hier verlaesst s seinen Scope und wird somit ungueltig       

Dieser Programmcode führt zu einem Kompilierfehler. Somit wird ein möglicher Fehler bereits verhindert, bevor das Programm überhaupt gestartet wird. Das Problem liegt darin, dass die Variable s ungültig wird, sobald die Funktion some_function() verlassen wird. Wenn wir nun eine Referenz auf diese Variable aus der Funktion zurückgeben würden, dann würde sie auf einen Speicherplatz verweisen, der bereits freigegeben wurde.

Der Rust Compiler verhindert dies, damit keine unerwarteten Fehler eintreten können. Wenn wir das obige Programm funktionsfähig machen wollen, dann könnten wir direkt den String zurückgeben.

Programmtext 87: »Dangling References« beheben

fn main() {

let new_string = some_function();

}

fn some_function() -> String {

let s = String::from("Hallo, Welt!");

s

}       

Zusammenfassung dieses Kapitels

Ownership betrifft nur Daten, die auf dem Heap gespeichert werden.

Es gibt immer genau einen Besitzer von solchen Daten.

Werden Daten einer Variablen zu einem anderen Besitzer bewegt, so ist die Variable im alten Scope nicht mehr gültig. Gleiches gilt, wenn eine Variable ihren Scope verlässt.

Zu jedem beliebigen Zeitpunkt darf es entweder eine änderbare Referenz oder beliebig viele unveränderbare Referenzen geben.

Rust stellt sicher, dass Referenzen immer gültig sind und keine »dangling pointer« existieren können.

Zeichenketten

Der Datentyp Slice

Auch der Datentyp Slice bleibt vom Ownership Konzept unberührt. Dieser Typ erlaubt es beispielsweise, Sequenzen von Zeichenketten zu extrahieren. Wir können Referenzen auf einzelne Teile einer Zeichenkette folgendermaßen erhalten:

Programmtext 88: Teile einer Zeichenkette extrahieren

fn main() {

let s = String::from("Hallo, Welt!");

let hallo = &s[0..5];

let welt = &s[7..11];

println!("s = {}, {}!", hallo, welt);

}   

s = Hallo, Welt!

Anstelle einer Referenz auf den kompletten String haben wir nun lediglich eine Referenz auf einen Teil davon. In den eckigen Klammern können wir angeben, ab welcher Position der Teil beginnen soll und an welcher Position er enden soll. Dabei gilt es zu beachten, dass das Zeichen an der Endposition nicht mit inbegriffen ist.

Möchte man bei der ersten Position (Index 0) beginnen, kann man die vorangestellte Null auch weglassen:

Programmtext 89: Weglassen der Startposition

fn main() {

let s = String::from("Hallo, Welt!");

let hallo = &s[..5]; // Identisch zu let hallo = &s[0..5];

}   

Dasselbe gilt für das Ende: Wenn man wünscht, dass die Sequenz bis zum Ende der Zeichenkette geht, kann die hintere Zahl weggelassen werden:

Programmtext 90: Weglassen der Endposition

fn main() {

let s = String::from("Hallo, Welt!");

let end = &s[7..]; // Identisch zu let end = &s[7..s.len()];

}   

Werden also Startposition und Endposition weggelassen, dann extrahieren wir die gesamte Zeichenkette, wie das folgende Beispiel zeigt.

Programmtext 91: Weglassen von Startposition und Endposition

fn main() {

let s = String::from("Hallo, Welt!");

let all = &s[..];

println!("all = {}", all);

}   

all = Hallo, Welt!

Der Datentyp Slice wird in Rust mit &str notiert.

Programmtext 92: Notation des Typs Slice

fn main() {

let s = String::from("Hallo, Welt!");

let all: &str = &s[..];

}   

Wenn wir nun versuchen, die ursprüngliche Zeichenkette zu ändern, erhalten wir einen Fehler. Dies wird in dem folgenden Beispiel demonstriert.

Programmtext 93: Änderung der ursprünglichen Zeichenkette

fn main() {

let mut s = String::from("Hallo");

let all: &str = &s[..];

s.push_str(", Welt!"); // Fehler

println!("s = {}", s);

}   

Eine Änderung der Zeichenkette s ist nun nicht mehr möglich. Das liegt daran, dass wir bereits eine unveränderbare Referenz von s besitzen (nämlich all). Um die Funktion push_str() aufzurufen, benötigt diese aber eine änderbare Referenz von s (schließlich soll s geändert werden). Dann würden jedoch gleichzeitig eine nicht änderbare und eine änderbare Referenz existieren – und das ist verboten, wie wir zuvor gesehen haben.

Dadurch ist immer sichergestellt, dass die Referenz nicht auf einmal an einer anderen Stelle geändert werden kann. Die Referenz zeigt auf die entsprechende Stelle im Speicher, wo sich der Teil der extrahierten Teilfolge befindet. Würde sich die ursprüngliche Zeichenkette nun ändern, dann würde die Referenz möglicherweise auf andere Daten zeigen. Der Rust Compiler sorgt dafür, dass solche Arten von Fehlern ausgeschlossen sind.

Jetzt, wo wir Slices kennengelernt haben, können wir auch String Literale besser verstehen. Denn solche Literale sind ebenfalls vom Typ &str. Da es sich bei dem Typ &str um eine unveränderliche Referenz handelt, sind Literale auch nicht änderbar.

Wenn es darum geht, eine Zeichenkette als Parameter an eine Funktion zu übergeben, dann bieten sich Slices meistens besser als Werte vom Datentyp String an. Der Grund dafür ist, dass wir die Funktion somit sowohl für Werte vom Typ String als auch für Werte vom Typ &str benutzen können.

Dann können Werte vom Typ &str immer direkt übergeben werden und Werte vom Typ String können als Slice übergeben werden, wie das nachfolgende Beispiel zeigt.

Programmtext 94: Funktionsparameter vom Typ &str

fn main() {

let s1 = String::from("Hallo");

let s2 = "Welt";

use_str(&s1[..]);

use_str(s2);

}

fn use_str(some_str: &str) {

println!("some_str = {}", some_str);

}   

some_str = Hallo
some_str = Welt

Weil das Literal s2 bereits vom Typ &str ist, können wir es direkt an die Funktion übergeben, ohne die Slice Notation zu verwenden. Mit Slice Notation würde es aber genauso gut funktionieren.

Bisher haben wir nur Slices im Zusammenhang mit Zeichenketten gesehen. Es ist ebenfalls möglich, Teile von Arrays zu extrahieren, indem wir Slices verwenden. Das Ganze könnte beispielsweise so aussehen:

Programmtext 95: Slices von Arrays

fn main() {

let arr = [1,2,3,4,5];

let slice = &arr[2..];

println!("slice = {:?}", slice);

}   

Die Ausgabe dieses Programmes sieht dann so aus:

slice = [3, 4, 5]

Die Funktionsweise ist im Prinzip dieselbe wie bei Zeichenketten auch. Der extrahierte Teil hat nun aber nicht mehr den Datentyp &str, sondern den Typ &[i32], da es sich um ein Array von i32 Zahlen handelt. Slices können auf alle Arten von Collections (Sammlungen von Werten) angewandt werden. Auch zu Collections wird es noch ein gesondertes Kapitel geben.

Der Datentyp String

Wir haben schon öfter Zeichenketten gesehen und auch bereits einige Male den Datentyp String verwendet. Alle Zeichenketten vom Typ String sind UTF-8 kodiert. Das bedeutet, dass zum Beispiel auch arabische Schriftzeichen verwendet werden können.

Wir wollen uns nun einmal etwas genauer mit dem Typ String beschäftigen.

Zunächst können wir leere Zeichenketten erzeugen, indem wir die Funktion String::new() verwenden. Diese Funktion erzeugt eine leere Zeichenkette vom Typ String. Die Funktion String::from() hingegen erzeugt eine neue Zeichenkette, die bereits einige Zeichen enthält.

Programmtext 96: Erzeugen eines neuen Strings

fn main() {

let s1 = String::new();

let s2 = String::from("Hallo, Welt!");

println!("s1 = {}, s2 = {}", s1, s2);

}   

s1 = , s2 = Hallo, Welt!

Wir können ein String Literal auch direkt in den Typ String umwandeln, indem wir die to_string() Methode verwenden.

Programmtext 97: Verwendung von to_string() auf ein String Literal

fn main() {

let s1 = String::from("Hallo, Welt!");

let s2 = "Hallo, Welt!".to_string();

println!("s1 = {}, s2 = {}", s1, s2);

}   

s1 = Hallo, Welt!, s2 = Hallo, Welt!

Wir können hier sehen, dass beide Verfahren das gleiche Ergebnis hervorbringen. Es ist also völlig gleich, welches dieser Verfahren verwendet wird, um einen neuen String mit Inhalt anzulegen.

Auch die Methode push_str, welche neuen Inhalt an einen String anfügen kann, haben wir bereits kennengelernt.

Programmtext 98: Einen String mit der Methode push_str erweitern

fn main() {

let mut s1 = String::from("Hallo");

s1.push_str(", Welt!");

println!("s1 = {}", s1);

}   

s1 = Hallo, Welt!

Dabei erwartet die Methode push_str einen Parameter vom Typ &str. Sie übernimmt also nicht den Besitz des Parameters, sodass dieser danach weiterhin verwendet werden kann.

Zusätzlich gibt es noch die Methode push, die ein einzelnes Zeichen an einen String anfügt:

Programmtext 99: Einen String mit der Methode push erweitern

fn main() {

let mut s1 = String::from("Hallo");

s1.push('!');

println!("s1 = {}", s1);

}   

s1 = Hallo!

Um zwei Zeichenketten zu konkatenieren, können wir den Operator + verwenden. Alternativ könnten wir auch den Makro format! nutzen, welchen wir uns im nächsten Abschnitt ansehen werden.

Programmtext 100: Zwei Zeichenketten konkatenieren

fn main() {

let s1 = String::from("Hallo");

let s2 = String::from(", Welt!");

println!("s1 + s2 = {}", s1 + &s2);

}   

s1 + s2 = Hallo, Welt!

Nun sehen wir bereits, dass wir keine Referenz von s1 verwenden. Das hat zur Folge, dass s1 nach einer solchen Konkatenation nicht mehr gültig ist und somit nicht mehr verwendet werden kann. Der Teil, der angefügt wird (hier also s2), wird hingegen als Referenz übergeben.

Der hintere Teil des Operators + hat den Typ &str. Wenn man etwas genauer hinsieht, stellt man fest, dass das in unserem Beispiel aber nicht der Fall ist: &s2 hat eigentlich den Typ &String. Dennoch funktioniert die Operation, weil der Compiler dafür sorgt, dass &s2 zu &s2[..] gemacht wird. Dadurch können wir uns etwas Schreibarbeit sparen und lediglich &s2 statt &s2[..] schreiben.

Natürlich können wir auch mehr als zwei Zeichenketten miteinander verbinden.

Programmtext 101: Mehr als zwei Zeichenketten konkatenieren

fn main() {

let s1 = String::from("Hallo");

let s2 = String::from(", ");

let s3 = String::from("Welt");

let conc = s1 + &s2 + &s3 + "!";

println!("{}", conc);

}   

Hallo, Welt!

Bei Zeichenketten vom Typ String ist es nicht möglich, über die Indexnotation auf einzelne Zeichen zuzugreifen. Das ist bei einigen anderen Programmiersprachen anders. Der Grund dafür ist Folgender: Wie zu Beginn erwähnt, können Zeichenketten vom Datentyp String auch internationale Schriftzeichen enthalten.

Das Problem dabei ist, dass nicht jedes Zeichen exakt die gleiche Anzahl an Speicherplatz in Bytes verbraucht. Dies soll durch das nächste Beispiel verdeutlicht werden.

Programmtext 102: Die Länge einer Zeichenkette vom Typ String ermitteln

fn main() {

let s1 = String::from("Hallo Welt");

let s2 = String::from("ПРИВϵТ МИР");

println!("s1.len = {}, s2.len = {}", s1.len(), s2.len());

}

Wenn wir die Länge dieser beiden Strings nun ausgeben lassen, dann erhalten wir das folgende Ergebnis:

s1.len = 10, s2.len = 19

Wir sehen dabei, dass s2 deutlich weniger Zeichen enthält, als durch die Länge angegeben wird. Das liegt daran, dass jedes Zeichen in s2 (außer dem Leerzeichen) zwei Bytes an Speicher benötigt.

Da solche internationalen Zeichen viele Probleme machen können, gibt es in Rust nicht die Möglichkeit, über einen Index auf ein bestimmtes Zeichen zuzugreifen. Es ist nicht klar, ob ein Indexzugriff nun einen Byte-Wert, ein Zeichen (char), ein Slice (&str) oder was auch immer zurückgeben soll. Der Typ ist nicht klar bestimmt.

Slices hingegen können selbstverständlich auf jegliche Art von Zeichenketten vom Typ String angewandt werden. Schließlich ist der Typ von Slices immer wohldefiniert.

Programmtext 103: Erzeugen eines Slices

fn main() {

let s = String::from("ПРИВϵТ МИР");

let slice = &s[0..12];

println!("slice = {}", slice);

}   

slice = ПРИВϵТ

Die angegebene Reichweite [0..12] bezieht sich dabei auf die Bytes. In diesem Beispiel enthält die Variable slice somit die ersten 12 Bytes der Zeichenkette s.

Achtung: Da jedes Zeichen zwei Byte Speicherplatz benötigt, würde das Programm sofort abstürzen, wenn wir versuchen würden, auf ein einziges Byte zuzugreifen.

Programmtext 104: Erzeugen eines Slices

fn main() {

let s = String::from("ПРИВϵТ МИР");

let slice = &s[0..1];

println!("slice = {}", slice);

}   

Dieses Programm bricht sofort ab, weil wir lediglich ein Byte der Zeichenkette extrahieren. Das ist aber nicht möglich. Daraus können wir lernen, dass man immer gut aufpassen sollte, wenn man Slices verwendet. Ein kleiner Fehler kann dazu führen, dass unser Programm unerwartet abstürzt.

Wir können Elemente einer Zeichenkette vom Typ String auch erreichen, indem wir über die Zeichenkette iterieren. Beispielsweise können wir über alle Zeichen einer Zeichenkette iterieren.

Programmtext 105: Iterieren über alle Zeichen einer Zeichenkette

fn main() {

let s = String::from("Hallo, Welt!");

for c in s.chars() {

print!("{}", c);

}

}   

Hallo, Welt!

Alternativ können wir sogar über die einzelnen Bytes der Zeichenkette iterieren.

Programmtext 106: Iterieren über alle Bytes einer Zeichenkette

fn main() {

let s = String::from("Hallo, Welt!");

for b in s.bytes() {

print!("{} ", b);

}

}   

72 97 108 108 111 44 32 87 101 108 116 33

Jetzt bekommen wir die einzelnen Bytes ausgegeben, aus denen die Zeichenkette besteht.

Zeichenketten formatieren

Es gibt bereits einige vordefinierte Makros, die uns bei der Formatierung von Ausgaben behilflich sind. Hier ist ein kleiner Überblick über diese Makros:

format!: Gibt eine formatierte Zeichenkette vom Typ String zurück.

print!: Gibt Text auf der Konsole (io::stdout) aus.

println!: Gibt Text auf der Konsole (io::stdout) aus und fügt einen Zeilenumbruch hinzu.

eprint!: Gibt Text auf der Fehlerkonsole (io::stderr) aus.

eprintln!: Gibt Text auf der Fehlerkonsole (io::stderr) aus und fügt einen Zeilenumbruch hinzu.

Zur Konkatenation von Zeichenketten hatten wir zuvor folgendes Beispiel gesehen:

Programmtext 107: Mehr als zwei Zeichenketten konkatenieren

fn main() {

let s1 = String::from("Hallo");

let s2 = String::from(", ");

let s3 = String::from("Welt");

let conc = s1 + &s2 + &s3 + "!";

println!("{}", conc);

}   

Einfacher lassen sich verschiedene Zeichenketten verbinden, indem wir den Makro format! benutzen. Das Ganze könnte dann so aussehen:

Programmtext 108: Ein Beispiel für den Makro format!

fn main() {

let s1 = String::from("Hallo");

let s2 = String::from(", ");

let s3 = String::from("Welt");

let conc = format!("{}{}{}!", s1, &s2, &s3);

println!("{}", conc);

}   

Hallo, Welt!

Der Makro format! liefert eine formatierte Zeichenkette zurück, die wir dann weiterverwenden können. Im Grunde genommen funktioniert format! analog zu println!, wobei der Unterschied darin liegt, dass format! die formatierte Zeichenkette zurückgibt und println! die formatierte Zeichenkette auf der Konsole ausgibt.

Die folgenden Beispiele nutzen nun wieder den Makro println!, um die Ergebnisse direkt auf der Konsole darzustellen. Die darin vorgestellten Konzepte können aber selbstverständlich auch für die anderen Makros benutzt werden.

Bisher haben wir immer Platzhalter ohne Namen verwendet. Ebenfalls möglich ist es, eine Bezeichnung für einen Platzhalter zu wählen.

Programmtext 109: Eine Bezeichnung für einen Platzhalter festlegen

fn main() {

println!("{y} {x}", x = "Hallo", y = "Welt!");

}   

Hier würde die Ausgabe nun wie folgt aussehen:

Welt! Hallo

Die Ausgabe besitzt eine andere Reihenfolge, weil der Platzhalter y vor dem Platzhalter x platziert wurde. Dieses Beispiel sollte vor allem zeigen, dass die Reihenfolge der Platzhalter beliebig angepasst werden kann, wenn man Bezeichnungen dafür vergibt.

Zuletzt folgt noch eine weitere Möglichkeit zur Formatierung, die ähnlich zu benannten Platzhaltern ist. Wir können die Platzhalter auch über ihre Position auswählen. Dabei ist es wichtig zu wissen, dass Positionen immer bei der Zahl 0 beginnen. Ein Beispiel:

Programmtext 110: Nummerierte Platzhalter verwenden

fn main() {

println!("{1} {2} {0}", "a", "b", "c");

}   

Die Ausgabe würde hier nun also lauten:

b c a

Zur Erklärung: Der erste Platzhalter wählt Position 1, welche das »b« beinhaltet (da die Positionen bei 0 starten). Der zweite Platzhalter wählt Position 2 mit dem »c«. Und der letzte Platzhalter wählt die vorderste Position, nämlich Position 0, welche das »a« enthält.

Jede Position kann auch mehrfach vorkommen, muss aber mindestens einmal verwendet werden. Das bedeutet konkret, dass folgendes Beispiel einen Fehler produziert, weil Position 2 nicht benutzt wird:

Programmtext 111: Eine fehlerhafte Anwendung von nummerierten Platzhaltern

fn main() {

println!("{1} {1} {0}", "a", "b", "c"); // Fehler!

}   

Lässt man den Wert »c« hingegen weg, ist alles in Ordnung, da Positionen mehrfach verwendet werden dürfen.

Programmtext 112: Eine weitere Anwendung von nummerierten Platzhaltern

fn main() {

println!("{1} {1} {0}", "a", "b");

}   

Die Ausgabe dazu wäre nun:

b b a

Die soeben vorgestellten Konzepte zur Formatierung können für alle zu Beginn dieses Abschnittes genannten Makros verwendet werden (format!, println!, print!, eprintln!, eprint!).

Übrigens war dies hier nur eine kleine Auswahl an möglichen Tricks, um Zeichenketten zu formatieren. Es gibt noch unzählige weitere Möglichkeiten, auf die wir an dieser Stelle jedoch nicht näher eingehen werden.

Zusammenfassung dieses Kapitels

Literale sind unveränderlich und haben den Typ &str, während Werte vom Typ String verändert werden können.

Der Typ String ist trickreicher, als man vielleicht denken mag.

Wir können sowohl über die Zeichen als auch über die Bytes einer Zeichenkette iterieren.

Der Typ &str eignet sich als Parameter einer Funktion besser als der Typ String, damit die Funktion vielseitiger einsetzbar ist.

Aufgabe 6

Schreiben Sie eine Funktion, welche eine Zeichenkette als Eingabe bekommt, sie umdreht und das Ergebnis zurückgibt. Beispiel: Die Eingabe "Hallo" soll "ollaH" als Ergebnis liefern.

Schreiben Sie eine Funktion, welche überprüft, ob eine gegebene Zeichenkette ein Palindrom ist. Ein Palindrom ist ein Wort, welches vorwärts und rückwärts gelesen identisch ist, wie zum Beispiel "anna". Wir nehmen dabei an, dass alle Eingaben nur aus Kleinbuchstaben bestehen.

Schreiben Sie eine Funktion, welche eine Zeichenkette und ein einzelnes Zeichen als Parameter bekommt und dann überprüft, ob das Zeichen in der Zeichenkette enthalten ist. Beispiel: Die Eingabe ("Hallo", 'a') soll true zurückliefern.

Schreiben Sie eine Funktion, welche eine Zeichenkette und eine positive ganze Zahl x als Parameter bekommt und die Zeichenkette dann x-mal wiederholt zurückgibt. Beispiel: Die Eingabe ("Hallo", 3) soll "HalloHalloHallo" als Ergebnis liefern.

Benutzerdefinierte Typen

Structs (Strukturen)

Ein Struct ist ein weiterer Datentyp in Rust. Dieser wird verwendet, um verschiedene Werte gruppieren zu können. Structs können neben Enums dazu verwendet werden, neue Typen zu erschaffen. Structs sind zunächst sehr ähnlich zu Tupeln, weil auch sie eine Ansammlung von Werten speichern. Jedoch werden bei Structs alle Werte mit einem Bezeichner versehen, sodass sie direkt über diesen Bezeichner erreicht werden können. Somit müssen wir nicht auf eine Reihenfolge achten, sondern können jeden Wert auf direktem Wege adressieren.

Um ein Struct zu definieren, verwenden wir das Schlüsselwort struct. Danach folgt die Bezeichnung für das Struct. Nun können innerhalb von geschweiften Klammern die einzelnen Werte mit ihren Bezeichnern angegeben werden. Die enthaltenen Daten werden auch als Felder bezeichnet. Ein Struct, welches ein Buch beschreiben soll, könnte beispielsweise so aussehen:

Programmtext 113: Anlegen eines Structs

struct Book {

author: String,

isbn: String,

price: f32,

title: String

}   

Nun haben wir einen eigenen Datentyp Book definiert. Wir können diesen jetzt verwenden, indem wir eine Instanz davon erzeugen. Das bedeutet, dass wir konkrete Werte für jedes der enthaltenen Felder spezifizieren. Dabei müssen die Felder nicht unbedingt dieselbe Reihenfolge aufweisen wie bei der Definition des Structs.

Programmtext 114: Benutzen eines Structs

struct Book {

author: String,

isbn: String,

price: f32,

title: String

}

fn main() {

let buch1 = Book {

author: String::from("Max Mustermann"),

isbn: String::from("123-4-5678-9123-4"),

price: 9.99,

title: String::from("Mustertitel")

};

}   

Um nun auf einen bestimmten Wert des Structs zugreifen zu können, benutzen wir die Punktschreibweise. Sollte unsere Instanz änderbar sein, können wir Felder auf dieselbe Art und Weise ändern. Das folgende Beispiel demonstriert dies.

Programmtext 115: Zugriff auf Felder eines Structs

struct Book {

author: String,

isbn: String,

price: f32,

title: String

}

fn main() {

let mut buch1 = Book {

author: String::from("Max Mustermann"),

isbn: String::from("123-4-5678-9123-4"),

price: 9.99,

title: String::from("Mustertitel")

};

println!("Preis vorher: {}", buch1.price);

buch1.price = 8.99;

println!("Preis nachher: {}", buch1.price);

}   

Als Ausgabe erhalten wir dabei:

Preis vorher: 9.99
Preis nachher: 8.99

Dabei gilt es zu beachten, dass die gesamte Instanz änderbar sein muss, auch wenn wir nur einzelne Felder davon ändern möchten. Es ist nicht möglich, einzelne Felder als änderbar zu markieren.

Wir können unseren neu definierten Datentyp nun auch innerhalb von Funktionen verwenden. Das heißt, wir können einer Funktion Parameter vom Typ Book übergeben und Funktionen können ebenso Werte vom Typ Book zurückgeben.

Programmtext 116: Werte vom Typ eines Structs aus einer Funktion zurückgeben

struct Book {

author: String,

isbn: String,

price: f32,

title: String

}

fn main() {

let buch1 = create_book(

String::from("Max Mustermann"),

String::from("123-4-5678-9123-4"),

9.99,

String::from("Mustertitel")

);

}

fn create_book(author: String, isbn: String, price: f32, title: String) -> Book {

Book {

author: author,

isbn: isbn,

price: price,

title: title

}

}   

Immer, wenn Variablen und Felder denselben Namen haben, reicht es aus, wenn wir den Namen aufschreiben. Unsere Funktion create_book() kann somit wie folgt abgekürzt werden:

Programmtext 117: Kurzschreibweise für das Erzeugen von Instanzen

fn create_book(author: String, isbn: String, price: f32, title: String) -> Book {

Book {

author,

isbn,

price,

title

}

}   

Statt zum Beispiel author: author schreiben zu müssen, können wir nun einfach nur author schreiben.

Darüber hinaus gibt es eine spezielle Syntax, die es erlaubt, Werte aus einer bereits erstellten Instanz zu übernehmen, wenn wir eine neue Instanz erzeugen möchten. Angenommen, wir möchten ein weiteres Buch vom gleichen Autor hinzufügen. Dann können wir alle Felder (auch das Feld author) erneut angeben, wie im nächsten Beispiel dargestellt.

Programmtext 118: Eine weitere Book Instanz erzeugen

struct Book {

author: String,

isbn: String,

price: f32,

title: String

}

fn main() {

let buch1 = Book {

author: String::from("Max Mustermann"),

isbn: String::from("123-4-5678-9123-4"),

price: 9.99,

title: String::from("Mustertitel")

};

let buch2 = Book {

author: String::from("Max Mustermann"),

isbn: String::from("987-6-5432-2198-7"),

price: 9.99,

title: String::from("Mustertitel Teil 2")

};

}   

Alternativ können wir folgende Syntax verwenden, um den gleichen Effekt mit weniger Code zu erreichen:

Programmtext 119: Eine weitere Book Instanz erzeugen (Kurzschreibweise)

struct Book {

author: String,

isbn: String,

price: f32,

title: String

}

fn main() {

let buch1 = Book {

author: String::from("Max Mustermann"),

isbn: String::from("123-4-5678-9123-4"),

price: 9.99,

title: String::from("Mustertitel")

};

let buch2 = Book {

isbn: String::from("987-6-5432-2198-7"),

title: String::from("Mustertitel Teil 2"),

..buch1

};

println!("Autor von buch2: {}", buch2.author);

}   

Durch die Zeile ..buch1 geben wir an, dass alle Felder, die noch nicht explizit definiert wurden (hier also author und price), dieselben Werte wie die Instanz buch1 erhalten sollen. Somit erhalten wir die folgende Ausgabe:

Autor von buch2: Max Mustermann

Neben den bisher behandelten Structs gibt es auch noch weitere Arten von Structs. Dazu gehören Tupel Structs. Dabei handelt es sich im Prinzip um benannte Tupel.

Programmtext 120: Tupel Struct

struct Point(i32, i32);

fn main() {

let p = Point(0, 1);

println!("({}, {})", p.0, p.1);

}   

In diesem Programmabschnitt können wir auch sehen, dass die Felder eines Tupel Structs nicht benannt sind. Möchten wir auf ein Feld zugreifen, so benutzen wir dieselbe Schreibweise wie bei normalen Tupeln. Als Ausgabe erhalten wir in diesem Fall dann:

(0, 1)

Zuletzt gibt es noch ein Struct ohne Felder, welches auch als Unit Struct bezeichnet wird. Ein solches Struct ist ähnlich zu dem Typ (), dem Unit Typ. Der folgende Codeausschnitt zeigt, wie ein Unit Struct definiert werden kann.

Programmtext 121: Unit Struct

struct Empty;   

Oftmals sind Unit Structs nicht besonders nützlich. Manchmal kann ein Unit Struct sinnvoll sein, um einen Typ für einen Marker zu haben, welcher selbst keine Daten enthalten muss.

Ausgabe von Structs

Vielleicht ist einigen Lesern schon aufgefallen, dass wir ein Struct nicht mit Hilfe von println! ausgeben lassen können. Das folgende Beispiel zeigt, wie wir dieses Problem umgehen können.

Programmtext 122: Ausgabe von Structs

#[derive(Debug)]

struct Point {

x: i32,

y: i32

}

fn main() {

let p = Point {x: 0, y: 1};

println!("p = {:?}", p);

}       

Mit Hilfe der Annotation #[derive(Debug)] können wir das Struct ausgeben lassen. Die Ausgabe sieht dann wie folgt aus:

p = Point { x: 0, y: 1 }

Wir werden in dem Kapitel „Generics, Traits, Lifetimes“ noch detailliert auf Traits eingehen. Dort werden wir dann auch verstehen, warum ebendiese Annotation notwendig ist, um ein Struct ausgeben lassen zu können.

Methoden

Methoden sind ähnlich zu Funktionen. Methoden werden in dem Kontext eines Structs (oder eines Enums oder Traits) definiert und sind somit nur für Instanzen dieses Structs (bzw. Enums oder Traits) verfügbar. Der erste Parameter einer Methode ist immer self.

Der Parameter self repräsentiert die Instanz des Structs, auf welcher die Methode aufgerufen wird. Das folgende Programmbeispiel zeigt, wie eine Methode für ein Struct implementiert werden kann.

Programmtext 123: Methode eines Structs

#[derive(Debug)]

struct Rechteck {

breite: u32,

hoehe: u32

}

impl Rechteck {

fn flaecheninhalt(&self) -> u32 {

self.breite * self.hoehe

}

}

fn main() {

let r = Rechteck {breite: 10, hoehe: 15};

println!("Flaecheninhalt von r: {}", r.flaecheninhalt());

}       

Um eine Methode für ein Struct zu implementieren, verwenden wir das Schlüsselwort impl. Dieses startet einen Block, der dazu genutzt werden kann, um Methoden für unser Struct zu definieren. Wenn wir nun eine Methode aufrufen wollen, dann können wir die Punktschreibweise auf die Instanz des Structs anwenden. Die Ausgabe sieht dann so aus:

Flaecheninhalt von r: 150

Wie bereits erwähnt, haben Methoden immer self als ersten Parameter. In unserem Beispiel handelt es sich um eine unveränderliche Referenz. Das muss aber nicht immer der Fall sein. self kann auch in Methoden hinein bewegt werden oder als veränderbare Referenz übergeben werden. Ein move von self in eine Methode ist in den meisten Fällen aber nicht empfehlenswert und kommt eher selten vor.

Selbstverständlich können Methoden auch mehr als nur einen Parameter haben. Die einzige Voraussetzung bleibt, dass es sich bei dem ersten Parameter um self handeln muss. Folgendes Beispiel zeigt, wie wir eine Methode mit mehreren Parametern hinzufügen können.

Programmtext 124: Methode eines Structs mit mehreren Parametern

#[derive(Debug)]

struct Rechteck {

breite: u32,

hoehe: u32

}

impl Rechteck {

fn umschliesst(&self, other: &Rechteck) -> bool {

self.breite > other.breite && self.hoehe > other.hoehe

}

}

fn main() {

let r1 = Rechteck {breite: 10, hoehe: 15};

let r2 = Rechteck {breite: 5, hoehe: 10};

println!("Passt r2 in r1? {}", r1.umschliesst(&r2));

println!("Passt r1 in r2? {}", r2.umschliesst(&r1));

}       

Passt r2 in r1? true
Passt r1 in r2? false

Auch für den Parameter other wollen wir eine Referenz verwenden, damit die Variable r2 nach einem Methodenaufruf weiterhin in main() verwendet werden kann.

Weiter ist es möglich, innerhalb von impl Blöcken Funktionen zu definieren. Diese besitzen keinen Parameter self. Methoden werden immer auf die aufrufende Instanz angewendet, Funktionen hingegen nicht. Um eine solche Funktion aufrufen zu können, nutzen wir den Operator ::, welcher uns beispielsweise schon bei String::from() begegnet ist.

Programmtext 125: Funktion eines Structs

#[derive(Debug)]

struct Rechteck {

breite: u32,

hoehe: u32

}

impl Rechteck {

fn create(breite: u32, hoehe: u32) -> Rechteck {

Rechteck {breite, hoehe}

}

}

fn main() {

let r1 = Rechteck::create(10, 15);

println!("r1 = {:?}", r1);

}       

r1 = Rechteck { breite: 10, hoehe: 15 }

Darüber hinaus ist es erlaubt, beliebig viele impl Blöcke zu definieren.

Enums (Aufzählungen)

Enums können ebenfalls verwendet werden, um neue Datentypen zu erschaffen. Dabei werden alle möglichen Werte für diesen neuen Typ aufgezählt, woher auch der Name kommt. Eine Instanz kann nun genau einen der aufgelisteten Werte annehmen.

Programmtext 126: Anlegen eines Enums

enum Tag {

Montag,

Dienstag,

Mittwoch,

Donnerstag,

Freitag,

Samstag,

Sonntag

}   

Wir verwenden das Schlüsselwort enum, um ein Enum anzulegen. In dem zugehörigen Block listen wir alle möglichen Werte für diesen Typ auf. Wenn wir nun eine Instanz dieses Typs erzeugen möchten, dann können wir das folgendermaßen tun:

Programmtext 127: Erzeugen einer Instanz eines Enums

enum Tag {

Montag,

Dienstag,

Mittwoch,

Donnerstag,

Freitag,

Samstag,

Sonntag

}

fn main() {

let montag = Tag::Montag;

let freitag = Tag::Freitag;

}   

Enums können auch innerhalb von Structs verwendet werden. Beispielsweise könnte ein Tagebuch als Struct dargestellt werden, welches ein Feld tag enthält. Dieses Feld gibt den Wochentag des Eintrages an.

Programmtext 128: Beispiel Tagebuch

enum Tag {

Montag,

Dienstag,

Mittwoch,

Donnerstag,

Freitag,

Samstag,

Sonntag

}

struct Tagebuch {

datum: String,

tag: Tag,

text: String

}

fn main() {

let eintrag = Tagebuch {

datum: String::from("09.10.2019"),

tag: Tag::Mittwoch,

text: String::from("Heute war ein toller Tag!")

};

}   

Außerdem ist es möglich, Daten direkt in einem Enum abzulegen. Somit ersparen wir uns das zusätzliche Struct. Dazu passen wir die Definition des Enums ein wenig an:

Programmtext 129: Beispiel Tagebuch (ohne ein zusätzliches Struct)

enum Tagebuch {

Montag(String, String),

Dienstag(String, String),

Mittwoch(String, String),

Donnerstag(String, String),

Freitag(String, String),

Samstag(String, String),

Sonntag(String, String)

}

fn main() {

let eintrag = Tagebuch::Mittwoch(

String::from("09.10.2019"),

String::from("Heute war ein toller Tag!")

);

}   

Hier übergeben wir jedem Tagebucheintrag zwei Werte: Das Datum sowie den eigentlichen Textinhalt. Es müssen dabei nicht alle Felder des Enums dieselbe Struktur aufweisen. Es wäre auch möglich, dass die Felder unterschiedliche Typen speichern. Auch die Anzahl an gespeicherten Werten kann variieren. Der Typ selbst kann ebenfalls beliebig sein (der Typ darf auch ein struct oder ein enum sein).

Dieses Beispiel sollte lediglich zeigen, dass ein Enum auch Werte übergeben bekommen kann. Ein zusätzliches Struct, wie in dem vorherigen Beispiel, ist für die Repräsentation eines Tagebuches möglicherweise trotzdem besser geeignet, weil wir dort über den Namen der Felder auf einzelne Einträge zugreifen können.

Das Enum Option

Es gibt manchmal Situationen, in denen ein Wert fehlen oder vorhanden sein kann. In vielen anderen Programmiersprachen gibt es dafür einen Wert null. Ein solcher Wert kann aber zu vielen Fehlern führen. Beispielsweise stürzt ein Programm ab, sobald versucht wird, eine Funktion auf einen null Wert anzuwenden.

In Rust gibt es keinen Wert null. Stattdessen gibt es ein Enum Option. Dieses ist bereits vordefiniert und kann jederzeit verwendet werden. Die Definition dieses Enums sieht so aus:

Programmtext 130: Das Enum Option

enum Option<T> {

Some(T),

None,

}       

Es handelt sich hierbei um ein normales Enum, wie wir es bereits kennen. Die Syntax <T> steht für einen generischen Parameter (ein Platzhalter für den Datentyp). Das heißt, es können Werte eines beliebigen Typs verwendet werden, so wie in diesem Beispiel:

Programmtext 131: Beispiele für das Enum Option

fn main() {

let a = Some("Hallo, Welt!");

let b = Some(55);

let c: Option<u8> = None;

}       

Generischen Parametern (Generics) ist später noch ein extra Kapitel gewidmet.

Was wir hier aber schon sehen können, ist, dass wir den Typ der Variablen c explizit angeben müssen, weil Rust sonst nicht wissen kann, um welchen Typ es sich handeln soll. Schließlich kann der Wert None für beliebige Typen verwendet werden.

Der Wert None ist im Prinzip so ähnlich wie der Wert null in anderen Programmiersprachen. Wenn wir jetzt versuchen würden, den Wert einer Option direkt zu verwenden, dann erhalten wir einen Kompilierfehler. Das ist sehr nützlich, da somit direkt auf den Fehler aufmerksam gemacht wird, bevor das Programm startet. Dadurch werden viele Programmabstürze durch eine falsche Nutzung von null Werten verhindert. Folgendes Beispiel würde direkt beim Kompilieren einen Fehler erzeugen:

Programmtext 132: Falscher Zugriff auf den Wert einer Option

fn main() {

let a: u8 = 10;

let b: Option<u8> = Some(10);

println!("a + b = {}", a + b); // Fehler!

}       

Aufgrund der Tatsache, dass u8 und Option<u8> unterschiedliche Typen sind, können sie nicht addiert werden. Da es theoretisch möglich wäre, dass der Typ Option<u8> den Wert None annimmt, müssen wir erst sicherstellen, dass dieses Szenario ausgeschlossen ist. Erst wenn wir wissen, dass tatsächlich ein Wert vorhanden ist, können wir diesen verwenden.

Anders formuliert: Wir müssen den Typ Option<T> zunächst in den Typ T umwandeln, um Operationen des Typs T anwenden zu können.

Das bedeutet, dass wir immer annehmen können, dass ein Typ nicht den Wert null hat, wenn es sich nicht um eine Option handelt. Das ist nicht in allen Programmiersprachen der Fall und vereinfacht viele Situationen.

Nun stellt sich natürlich die Frage: Wie können wir den Wert einer Option vom Typ Option<T> in den Typ T umwandeln, damit wir ihn verwenden können? Das wollen wir uns jetzt anschauen.

Pattern Matching

Der Operator match erlaubt es uns, einen Wert auf verschiedene Patterns (Muster) zu testen. Beispielsweise können wir damit herausfinden, ob eine Option None ist oder nicht. Eine match Anweisung listet eine Reihe von möglichen Fällen auf, die überprüft werden sollen. Der erste passende Fall wird dann ausgeführt. Jeder Fall besteht zunächst aus einem Pattern. Dahinter folgt dann der Operator =>, um anzugeben, dass im Falle einer Übereinstimmung dieses Patterns der Code hinter dem Operator => ausgeführt werden soll.

Schauen wir uns das Ganze doch mal anhand eines Beispiels an. Der folgende Programmcode nimmt zwei Werte vom Typ Option<u8> und addiert diese.

Programmtext 133: Addieren zweier Werte vom Typ Option<u8>

fn main() {

let a: Option<u8> = Some(10);

let b: Option<u8> = Some(10);

println!("{:?}", add_options(a, b));

}

fn add_options(a: Option<u8>, b: Option<u8>) -> Option<u8> {

match a {

None => None,

Some(i) => match b {

None => None,

Some(j) => Some(i + j)

}

}

}   

Some(20)

Die Funktion add_options nimmt zwei Werte vom Typ Option<u8> als Parameter. Dann wird zunächst der Parameter a mit Hilfe von match verglichen: Sollte a den Wert None haben, so wird auch None als Ergebnis der Addition zurückgegeben. Anderenfalls können wir den enthaltenen Wert Some(i) zuordnen.

Dabei kann i ein beliebiger Bezeichner sein, der für den in a vorhandenen Wert vom Typ u8 steht. Sollte a also nicht None sein, so prüfen wir als nächstes auf dem gleichen Wege den Parameter b. Wenn beide Parameter Werte enthalten, dann geben wir eine neue Option mit der Summe der Werte von a und b als Wert zurück.

Pattern Matching kann aber auch sehr gut im Zusammenhang mit Enums eingesetzt werden. Genau wie Structs können auch Enums eigene Methoden implementieren. Dazu wird ebenfalls das Schlüsselwort impl verwendet und auch dabei ist der erste Parameter immer self. Nun können wir Pattern Matching verwenden, um die einzelnen Werte des Enums abzugleichen.

Programmtext 134: Pattern Matching bei Enums

enum Event {

KeyPress(char),

Click {x: i32, y: i32}

}

impl Event {

fn some_method(&self) {

match self {

Event::KeyPress(c) => println!("Taste {} gedrueckt", c),

Event::Click {x, y} => println!("Klick bei ({}, {})", x, y)

}

}

}

fn main() {

let a = Event::Click {x: 120, y: 100};

a.some_method();

let b = Event::KeyPress('b');

b.some_method();

}   

Klick bei (120, 100)
Taste b gedrueckt

Natürlich muss das Pattern Matching nicht zwingend in einer Methode vorkommen, sondern kann auch innerhalb einer normalen Funktion eingesetzt werden.

Wenn bei Übereinstimmung eines Patterns mehrere Zeilen Code ausgeführt werden sollen, dann können wir geschweifte Klammern nutzen, um einen ganzen Block zu eröffnen. Darin können dann beliebig viele Anweisungen folgen.

Wichtig ist aber, dass immer alle möglichen Fälle abgedeckt werden. Für ein Enum bedeutet dies, dass alle möglichen Felder des Enums vorkommen. Im obigen Beispiel dürfte also weder Event::KeyPress noch Event::Click fehlen. Für eine Option müssen dementsprechend immer None und Some abgedeckt sein.

Programmtext 135: Fehlerhaftes Pattern Matching

fn match_option(opt: Option<u8>) {

match opt {

None => println!("None")

// Fehler, weil kein Fall fuer "Some" existiert

}

}   

Angenommen, wir möchten jetzt Pattern Matching für eine Zahl anwenden. Dabei können wir schlecht alle möglichen Zahlen auflisten, um alle Fälle abzudecken. Für solche Situationen gibt es einen Platzhalter, dargestellt als Unterstrich, welcher jeden beliebigen Wert abdeckt.

Programmtext 136: Der Platzhalter _

fn main() {

let a = 5;

match a {

1 => println!("1"),

2 => println!("2"),

_ => println!("Weder 1 noch 2")

}

}   

Weder 1 noch 2

Somit müssen wir nicht alle möglichen Fälle auflisten: Wenn einer der ersten beiden Fälle nicht eintrifft, dann landen wir automatisch in dem Fall mit dem Unterstrich, weil dieser Platzhalter auf jeden beliebigen Wert zutrifft. Mit Hilfe dieses Platzhalters können wir nun auch einen match Block definieren, der in genau einem Fall Code ausführen soll. Dazu nutzen wir den Platzhalter in Kombination mit dem Wert ().

Programmtext 137: Pattern Matching für genau einen bestimmten Fall

fn main() {

let a = 5;

match a {

5 => println!("a = 5"),

_ => ()

}

}   

a = 5

Auf diese Art und Weise können wir ausdrücken, dass nur dann etwas passieren soll, wenn a den Wert 5 hat. Für solche Fälle können wir auch alternativ if let verwenden, um das Ganze ein wenig abzukürzen.

Programmtext 138: Ein Beispiel für if let

fn main() {

let a = 5;

if let 5 = a {

println!("a = 5");

}

}   

a = 5

Dabei ist es auch möglich, ein else hinzuzufügen. Das kann man sich dann wie den Platzhalter _ bei einem match Block vorstellen. Dementsprechend wird in allen anderen Fällen der else Block ausgeführt.

Programmtext 139: Ein Beispiel für if let mit einem else

fn main() {

let a = 2;

if let 5 = a {

println!("a = 5");

} else {

println!("a != 5");

}

}   

a != 5

Dieses Beispiel wäre deshalb identisch zu folgendem Programm:

Programmtext 140: Darstellung als match Block

fn main() {

let a = 2;

match a {

5 => println!("a = 5"),

_ => println!("a != 5")

}

}   

Neben if let ist auch die Kombination von while und let möglich. So können wir eine Schleife wiederholen, solange das angegebene Pattern übereinstimmt.

Programmtext 141: Eine Schleife mit while let

fn main() {

let mut counter = 1;

while let Some(e) = some_function(counter) {

println!("e = {}", e);

counter += 1;

}

}

fn some_function(a: i32) -> Option<i32> {

if a <= 5 {

Some(a)

} else {

None

}

}   

e = 1
e = 2
e = 3
e = 4
e = 5

Zum Abschluss dieses Kapitels schauen wir uns noch einige weitere nützliche Schreibweisen im Zusammenhang mit Pattern Matching an. Beispielsweise können wir mit Hilfe des Operators | auf mehrere Werte in demselben Fall prüfen:

Programmtext 142: Pattern Matching mit dem Operator |

fn main() {

let var = 1;

match var {

1 | 5 | 10 => println!("1 oder 5 oder 10"),

_ => println!("Irgendwas anderes")

}

}   

1 oder 5 oder 10

Der erste Fall prüft nun gleich auf drei verschiedene Werte, nämlich auf 1, 5 und 10. Sollte var mit irgendeinem dieser Werte übereinstimmen, so wird der zugehörige println! Makro ausgeführt.

Ähnlich können wir auch eine Reichweite an Werten spezifizieren. Dazu verwenden wir den Operator ..=, wie in dem folgenden Beispiel zu sehen ist:

Programmtext 143: Pattern Matching mit dem Operator ..=

fn main() {

let var = 9;

match var {

1..=10 => println!("match!"),

_ => println!("no match!")

}

}   

match!

Dabei ist die Reichweite inklusiv, das heißt, dass auch Start und Ende mit inbegriffen sind. Es werden also alle Werte von 1 bis 10 betrachtet. Würde var den Wert 1 oder 10 haben, so hätten wir weiterhin eine Übereinstimmung. Aktuell ist es nicht möglich, eine Reichweite anzugeben, bei der das Ende nicht inklusive ist, so wie es bei for Schleifen mit Hilfe von .. möglich ist.

In einer match Anweisung können wir auch auf bestimmte Werte für Structs prüfen:

Programmtext 144: Pattern Matching für Structs

struct Rechteck {

breite: u32,

hoehe: u32

}

fn main() {

let r = Rechteck {breite: 10, hoehe: 15};

match r {

Rechteck {breite: _, hoehe: 10} => println!("breite egal, hoehe = 10"),

Rechteck {breite: 10, hoehe: _} => println!("breite = 10, hoehe egal"),

Rechteck {breite: _, hoehe: _} => println!("breite und hoehe egal")

}

}   

Hier prüfen wir zunächst, ob das Rechteck eine Höhe von 10 hat. Dies ist jedoch nicht der Fall. Anschließend testen wir, ob das Rechteck eine Breite von 10 hat. Da dies offenbar stimmt, erhalten wir die folgende Ausgabe:

breite = 10, hoehe egal

Der letzte Fall ist notwendig, damit die match Anweisung alle möglichen Fälle abdeckt. Aktuell setzen wir den Unterstrich ein, um Werte zu ignorieren. Wenn wir nun aber ein Struct haben, das viele Felder enthält und wir uns nur für eines dieser Felder interessieren, dann ist es sehr aufwendig, jedes Feld innerhalb der match Anweisung zu spezifizieren. Einfacher geht es mit dem Operator .., welcher nicht angegebene Felder automatisch ignoriert.

Programmtext 145: Weitere Felder eines Struct mit .. ignorieren

struct Struct {

a: i32,

b: i32,

c: i32,

d: i32

}

fn main() {

let s = Struct {a: 1, b: 2, c: 3, d: 4};

match s {

Struct {a: 1, ..} => println!("a = 1"),

Struct {..} => println!("a != 1")

}

}   

a = 1

Auf diese Art und Weise ist es deutlich leichter, mehrere Felder zu ignorieren, als für jedes Feld einzeln einen Unterstrich zu nutzen. Wir können diese Schreibweise vielseitig benutzen, jedoch muss dabei immer ein eindeutiges Muster zustande kommen.

Weiter besteht die Möglichkeit, einem Fall in einer match Anweisung eine weitere Filterung mit if hinzuzufügen. Dies sieht dann wie folgt aus:

Programmtext 146: Zusätzliche Bedingungen in match Anweisungen

fn main() {

let a = Some(10);

match a {

Some(b) if b % 2 == 0 => println!("a ist gerade"),

Some(b) => println!("a ist ungerade"),

None => ()

}

}   

Hier prüfen wir im ersten Fall nicht nur, ob die Variable a ungleich None ist, sondern auch, ob sie eine gerade Zahl ist. Nur wenn die Bedingung zusätzlich erfüllt ist, wird dieser Fall ausgeführt. Die Ausgabe sieht dann folgendermaßen aus:

a ist gerade

Zusammenfassung dieses Kapitels

Structs und Enums erlauben es, neue Datentypen zu definieren.

Mit impl können eigene Methoden für Structs und Enums definiert werden.

match erlaubt es, eine Variable auf verschiedene Fälle zu prüfen, wobei der Unterstrich als Platzhalter dient.

Aufgabe 7

Definieren Sie ein Struct Mensch, welches die Felder name, alter und geschlecht enthält.

Schreiben Sie für das Struct Mensch eine Funktion, welche drei Parameter (name, alter und geschlecht) bekommt und dann eine neue Instanz des Structs Mensch zurückgibt.

Schreiben Sie für das Struct Mensch eine Methode, welche überprüft, ob eine Instanz volljährig (mindestens 18 Jahre alt) ist.

Der Projektmanager Cargo

Rust besitzt einen eingebauten Projektmanager, der Cargo genannt wird. Dieser erleichtert die Verwaltung des eigenen Projekts teilweise enorm, da Cargo unser Programm automatisch kompiliert und alle benötigten Abhängigkeiten (dependencies) lädt. Abhängigkeiten sind bereits existierende Programme, die wir in unser Programm einbinden können und werden meistens als libraries (Bibliotheken) bezeichnet. Wir werden auch noch sehen, wie man bereits bestehende Bibliotheken für sein eigenes Programm nutzen kann.

Um ein neues Projekt mit Cargo anzulegen, öffnen wir zunächst die Konsole in dem Ordner, wo das Projekt angelegt werden soll. Nun geben wir cargo new hallo_cargo in die Konsole ein. Dann wird ein Projekt mit dem Namen hallo_cargo erstellt.

In diesem Ordner finden wir nun ein Verzeichnis src, welches unseren Programmcode beinhaltet. Außerdem wurde eine Datei mit dem Namen Cargo.toml hinzugefügt. Wenn wir diese Datei nun in einem Texteditor öffnen, stellen wir fest, dass sie folgendermaßen aufgebaut ist:

Programmtext 147: Der Inhalt der Datei Cargo.toml

[package]

name = "hallo_cargo"

version = "0.1.0"

authors = ["Name <email>"]

[dependencies]

Unter dem Abschnitt [package] finden wir alle Informationen über das Projekt. Sollte Ihr Name an dieser Stelle falsch angegeben sein, dann korrigieren Sie dies und speichern die Datei ab. Unter [dependencies] werden alle benötigten Bibliotheken aufgelistet. Momentan benutzen wir noch keine Bibliotheken, weswegen dieser Abschnitt zunächst leer ist.

Wenn wir nun noch etwas genauer hinschauen, dann werden wir feststellen, dass in dem Ordner src bereits eine Datei main.rs liegt. Wenn wir in diese Datei hineinschauen, sehen wir, dass Cargo ein »Hallo, Welt!« Programm für uns erstellt hat. Dieses Programm tut im Grunde genommen genau dasselbe, wie unser Programm aus dem Kapitel „Ein »Hallo, Welt!« Programm in Rust“ – es ist dieses Mal lediglich auf Englisch.

Möchten wir unser Programm nun mit Cargo kompilieren, so geben wir cargo build in die Konsole ein. Wir finden die ausführbare Datei nun in dem Verzeichnis target/debug/hallo_cargo wieder. Um sie auszuführen, geben wir ./target/debug/hallo_cargo (macOS und Linux) bzw. .\target\debug\hallo_cargo.exe (Windows) in die Konsole ein.

Wir können unseren Code auch direkt mit Cargo ausführen, indem wir cargo run eingeben. Dann kompiliert Cargo zuerst unser Programm und führt es danach aus.

Ein weiteres Feature von Cargo ist der Befehl cargo check. Mit diesem Befehl können wir ganz leicht testen, ob unser Code kompiliert, oder ob Kompilierfehler aufgetreten sind. Der einzige Unterschied zu cargo build ist, dass keine ausführbare Datei erzeugt wird und cargo check somit etwas schneller ausgeführt werden kann. Dieser Befehl eignet sich besonders dafür, um zwischendurch zu überprüfen, ob der Programmcode Kompilierfehler enthält.

Ist unser Programm eines Tages so weit, dass wir es veröffentlichen wollen, so nutzen wir cargo build --release. Damit wird unser Projekt kompiliert und optimiert, sodass es etwas schneller läuft. Die Kompilierung dafür dauert jedoch etwas länger, weshalb wir diesen Befehl erst dann ausführen wollen, wenn unser Programm tatsächlich fertiggestellt wurde.

Zusammenfassung dieses Kapitels

Neues Projekt erstellen: cargo new projektname.

Projekt auf Kompilierfehler testen: cargo check.

Projekt kompilieren: cargo build.

Projekt kompilieren und ausführen: cargo run.

Projekt veröffentlichen: cargo build --release.

Projektmanagement

Bisher haben wir immer nur sehr kleine Programmausschnitte gesehen, sodass keine besondere Struktur nötig war. Ab einem gewissen Zeitpunkt verliert man aber den Überblick, wenn man auf diese Weise vorgeht. Je größer das eigene Projekt wird, desto wichtiger ist es, eine gute Struktur zu haben. Anderenfalls wird es nicht möglich sein, den Programmcode übersichtlich und verständlich zu halten.

Um auch bei sehr großen Projekten den Überblick behalten zu können, gibt es einige Features in Rust. Dazu gehören Packages (Pakete), Crates, Modules (Module) und Paths (Pfade).

Packages und Crates

Ein Crate ist entweder eine Binärdatei oder eine Programmbibliothek (library). Eine Binärdatei ist eine ausführbare Datei, welche auf gewohnte Art und Weise gestartet werden kann. Eine Programmbibliothek hingegen kann nicht ohne Weiteres ausgeführt werden, sondern ist dazu gedacht, von anderen Programmen eingebunden zu werden.

Ein Package besteht aus einem oder mehreren Crates und enthält eine Datei Cargo.toml, die angibt, wie ein Programm aus diesen Crates erstellt werden soll.

Jedes Package muss insgesamt mindestens einen Crate enthalten (Binärdatei oder Programmbibliothek) und darf beliebig viele Binärdatei-Crates, aber maximal einen Programmbibliothek-Crate beinhalten.

Mit dem Befehl cargo new können wir ein neues Package anlegen. Für einen Binärdatei-Crate wird die Datei src/main.rs automatisch als Crate-Root interpretiert, d.h. der Rust Compiler beginnt den Erstellungsprozess einer ausführbaren Datei ausgehend von src/main.rs.

Sollte es eine Datei src/lib.rs geben, dann enthält das Package einen Programmbibliothek-Crate, wobei ebendiese Datei src/lib.rs als Crate-Root angesehen wird.

Gibt es sowohl die Datei src/main.rs als auch die Datei src/lib.rs, dann enthält das Package einen Binärdatei-Crate und einen Programmbibliothek-Crate.

Crates sind dazu gedacht, zusammengehörige Funktionalitäten zu bündeln, um diese Funktionalitäten dann in beliebigen Projekten nutzen zu können.

Module und Paths

Module erlauben es, Programmcode innerhalb eines Crates zu organisieren. Dadurch wird der Code übersichtlicher und kann einfacher wiederverwendet werden. Außerdem kann festgelegt werden, welche Teile von außerhalb benutzt werden dürfen (public) und welche nicht (private).

Im Folgenden wollen wir nun eine eigene Programmbibliothek entwickeln. Darin wollen wir dann Module nutzen. Mit dem Befehl cargo new --lib example können wir zunächst ein neues Projekt mit dem Namen example erstellen. Nun können wir in der Datei src/lib.rs neue Module anlegen, indem wir das Schlüsselwort mod verwenden.

Programmtext 148: Definition eines Moduls mit mod

mod outer_module {

mod inner_module1 {

fn some_function1() {}

fn some_function2() {}

}

mod inner_module2 {

fn some_function3() {}

}

}   

Dabei kann jedes Modul auch noch weitere Module beinhalten. Zusätzlich können in jedem Modul beliebig viele Funktionen, Structs, Enums, Konstanten oder Traits enthalten sein. Dadurch können zusammengehörige Funktionalitäten in Gruppen eingeteilt werden.

Wenn ein Programmierer nun eine bestimmte Definition sucht, kann er oder sie diese Definition deutlich schneller finden, falls das Programm in sinnvolle Module eingeteilt ist. Außerdem findet sich schnell der richtige Ort, wenn neuer Code hinzugefügt werden soll. Dafür ist es natürlich wichtig, dass die Module sinnvoll benannt werden – was in dem obigen Beispiel selbstverständlich nicht der Fall ist. Dort sind die Bezeichnungen wenig aussagekräftig.

Wenn wir nun einen Blick auf den Modulbaum unseres Projektes werfen, dann sehen wir eine gewisse Ähnlichkeit zu einem Verzeichnisbaum des Betriebssystems.

crate

outer_module

inner_module1

some_function1

some_function2

inner_module2

some_function3

Das Betriebssystem nutzt Ordner, um Dateien zu organisieren. Analog werden Module in Rust dazu verwendet, um den Programmcode zu organisieren.

Für die Navigation in diesem Modulbaum verwenden wir Paths (Pfade). Wenn wir eine bestimmte Funktion aufrufen wollen, müssen wir Rust sagen, wo sich diese Funktion befindet. Dabei gibt es zwei Formen von Pfaden:

Einen absoluten Pfad: Ein absoluter Pfad beginnt bei einem Crate-Root und wird durch den Namen des Crates oder das Literal crate angegeben.

Einen relativen Pfad: Ein relativer Pfad beginnt bei dem aktuell verwendeten Modul und benutzt Schlüsselwörter wie self oder super oder einen Bezeichner aus dem aktuellen Modul.

Besser verstehen kann man das Ganze anhand eines Beispiels. Dazu wollen wir die Funktion some_function1 in dem Crate-Root aufrufen. Damit das möglich ist, muss die Funktion von außerhalb zugreifbar sein. Das ist standardmäßig aber nicht der Fall. Ein Modul sowie alle seine Inhalte sind immer privat, wenn wir nicht explizit etwas anderes angeben.

Das bedeutet, dass weder das Modul noch seine Inhalte von außerhalb verwendet werden können. Dementsprechend können wir Definitionen (Structs, Funktionen, usw.) auch jederzeit geheim halten, indem wir sie in ein Modul verpacken.

Damit ein Modul von außerhalb verwendet werden kann, benutzen wir das Schlüsselwort pub (Abkürzung für public). Dieses erlaubt es uns, ein Modul als öffentlich zu kennzeichnen. Somit kann das Modul von überall aus verwendet werden.

Ein öffentliches Modul ist aber immer noch nicht ausreichend, um eine darin enthaltene Funktion von überall aus aufrufen zu können, weil alle Inhalte des Moduls weiterhin privat bleiben. Erst wenn der Inhalt (in unserem Beispiel eine Funktion) ebenfalls explizit als öffentlich gekennzeichnet wird, kann der Inhalt von außen verwendet werden. Das nächste Beispiel demonstriert genau dies.

Programmtext 149: Beispiel für einen absoluten Pfad

mod outer_module {

pub mod inner_module1 {

pub fn some_function1() {}

fn some_function2() {}

}

mod inner_module2 {

fn some_function3() {}

}

}

fn call_some_function() {

// Aufruf mittels absolutem Pfad

crate::outer_module::inner_module1::some_function1();

}   

Aufgrund dessen, dass die Funktion some_function1 in dem Crate definiert ist, wo auch der Funktionsaufruf stattfindet, können wir das Schlüsselwort crate am Anfang des Pfades verwenden. Dahinter geben wir dann nacheinander die einzelnen Module an, bis wir bei der Funktion angekommen sind. Wenn wir uns an den Modulbaum zurückerinnern, dann startet ein absoluter Pfad immer auf der obersten Ebene.

Vielleicht ist bereits aufgefallen, dass das äußerste Modul, outer_module nicht als public gekennzeichnet wurde. Dennoch gibt es keine Probleme bei diesem Programmcode. Das liegt daran, dass wir nicht von außerhalb auf dieses Modul zugreifen, weil sich die aufrufende Funktion call_some_function im gleichen Modul wie outer_module befindet.

Wenn wir nun einen relativen Pfad verwenden würden, dann beginnen wir in dem Modulbaum auf dem gleichen Level wie die aufrufende Funktion. Wollen wir also wieder die Funktion some_function1 in call_some_function aufrufen, dann können wir den Pfad direkt bei outer_module beginnen:

Programmtext 150: Beispiel für einen relativen Pfad

mod outer_module {

pub mod inner_module1 {

pub fn some_function1() {}

fn some_function2() {}

}

mod inner_module2 {

fn some_function3() {}

}

}

fn call_some_function() {

// Aufruf mittels relativem Pfad

outer_module::inner_module1::some_function1();

}   

Sollte sich die Funktion call_some_function stattdessen innerhalb von outer_module befinden, so können wir auch diesen Teil des Pfades weglassen. Bei einem absoluten Pfad hingegen würde sich nichts ändern.

Programmtext 151: Ein weiteres Beispiel für einen relativen Pfad

mod outer_module {

pub mod inner_module1 {

pub fn some_function1() {}

fn some_function2() {}

}

mod inner_module2 {

fn some_function3() {}

}

fn call_some_function() {

// Aufruf mittels relativem Pfad

inner_module1::some_function1();

}

}   

Welche Art von Pfad zu bevorzugen ist, hängt von der Situation ab. Würden wir nun den gesamten Code in ein neues Modul bewegen, dann würde der absolute Pfad nicht mehr korrekt sein, der relative Pfad hingegen schon. Sollten wir aber beispielsweise nur die Funktion verschieben und das Modul outer_module an derselben Stelle behalten, dann würde der absolute Pfad weiterhin korrekt bleiben, aber der relative Pfad nicht. Hier sollte man sich daher überlegen, welches Szenario man als wahrscheinlicher erachtet.

Aktuell haben wir jedoch noch ein Problem, wenn wir eine Funktion aufrufen möchten, die in dem Modulbaum eine Ebene höher angesiedelt ist und wir dafür einen relativen Pfad verwenden möchten. Bei Dateipfaden des Betriebssystems wird dafür die Syntax .. verwendet. In Rust nutzen wir an dieser Stelle das Schlüsselwort super. Folgendes Beispiel demonstriert seine Verwendung:

Programmtext 152: Relative Pfade mit super

mod some_module {

fn call_some_function() {

// Aufruf mittels relativem Pfad

super::some_function();

}

}

fn some_function() {}   

Sollten wir einen relativen Pfad haben, wo das Ziel mehr als eine Ebene höher im Modulbaum liegt, dann können wir das Schlüsselwort super mehrfach verwenden.

Programmtext 153: Relative Pfade mit super (mehrfach)

mod some_module {

mod some_inner_module {

fn call_some_function() {

// Aufruf mittels relativem Pfad

super::super::some_function();

}

}

}

fn some_function() {}   

Im Laufe dieses Kapitels wurde erwähnt, dass auch Structs mit Hilfe von pub öffentlich verfügbar gemacht werden können. Dabei gilt es ebenfalls zu beachten, dass die darin enthaltenen Felder weiterhin privat bleiben, solange sie nicht explizit als öffentlich gekennzeichnet werden.

Programmtext 154: Ein öffentliches Struct mit öffentlichen Feldern

mod geometrie {

pub struct Rechteck {

pub breite: u32,

pub hoehe: u32

}

}

pub fn some_function() {

let r = geometrie::Rechteck {breite: 10, hoehe: 15};

}   

Es kann aber genauso gut vorkommen, dass nicht alle Felder öffentlich sind. Beispielsweise kann eine öffentliche Funktion verwendet werden, um private Felder mit Werten zu belegen. Das folgende Beispiel ist zwar nicht unbedingt sinnvoll, aber es demonstriert dieses Szenario.

Programmtext 155: Ein öffentliches Struct mit einer öffentlichen Funktion

mod geometrie {

pub struct Quadrat {

pub breite: u32,

hoehe: u32

}

impl Quadrat {

pub fn create(laenge: u32) -> Quadrat {

Quadrat {breite: laenge, hoehe: laenge}

}

}

}

pub fn some_function() {

let r = geometrie::Quadrat::create(10);

println!("{}", r.hoehe); // Fehler!

}   

Wir können auf das Feld breite zugreifen, weil dieses öffentlich ist. Das Feld hoehe hingegen ist privat und kann somit nicht adressiert werden. Würde die Funktion create nicht existieren, so könnten wir außerhalb des Moduls geometrie keine Instanzen von dem Struct Quadrat erzeugen, weil es nicht möglich wäre, alle Felder anzugeben.

Bei Enums hingegen sieht das Ganze wiederum etwas anders aus. Hier ist es ausreichend, wenn wir das Enum selbst als öffentlich kennzeichnen, um alle darin enthaltenen Felder öffentlich nutzbar zu machen.

Programmtext 156: Ein öffentliches Enum

mod some_module {

pub enum SomeEnum {

A,

B

}

}

pub fn some_function() {

let a = some_module::SomeEnum::A;

}   

Das Schlüsselwort use

Wir haben eben gelernt, wie wir auf Inhalte unterschiedlichster Module zugreifen können, indem wir Pfade verwenden. Dabei können nicht nur absolute, sondern auch relative Pfade sehr lang werden. Falls wir dann öfter auf dasselbe Element zugreifen wollen, müssten wir immer wieder den gesamten Pfad abtippen, was auf Dauer sehr unpraktisch ist.

Dieses Problem lässt sich mit Hilfe des Schlüsselwortes use vermeiden. use erlaubt es uns, einen Pfad in den aktuellen Scope zu laden, sodass wir Inhalte dieses Pfades wie lokale Inhalte behandeln können. Das folgende Beispiel stellt diesen Vorgang dar.

Programmtext 157: Einen Pfad zu einem Modul mit use laden

mod outer_module {

pub mod inner_module {

pub fn some_function() {}

}

}

use crate::outer_module::inner_module;

fn call_some_function() {

inner_module::some_function();

}       

Auch bei relativen Pfaden können wir use einsetzen. Dabei muss der Pfad aber mit dem Schlüsselwort self und nicht mit dem Namen des aktuellen Scopes beginnen.

Programmtext 158: Einen relativen Pfad mit use laden

mod outer_module {

pub mod inner_module {

pub fn some_function() {}

}

}

use self::outer_module::inner_module;

fn call_some_function() {

inner_module::some_function();

}       

Sollten wir tatsächlich häufig immer dieselbe Funktion aufrufen wollen, so können wir auch die Funktion anstelle des gesamten Moduls laden (hier wieder mit einem absolutem Pfad):

Programmtext 159: Einen Pfad zu einer Funktion mit use laden

mod outer_module {

pub mod inner_module {

pub fn some_function() {}

}

}

use crate::outer_module::inner_module::some_function;

fn call_some_function() {

some_function();

}       

Üblicherweise wählt man jedoch die erste Variante, wo lediglich das übergeordnete Paket geladen wird. Dadurch müssen wir bei jedem Funktionsaufruf das übergeordnete Modul angeben.

Damit ersparen wir uns, jedes Mal den kompletten Pfad angeben zu müssen und gleichzeitig bleibt klar, dass die Funktion nicht lokal definiert wurde, sondern aus einem anderen Modul stammt. Wenn wir die Funktion direkt aufrufen können (wie in dem letzten Beispiel), dann ist nicht mehr auf Anhieb klar, wo some_function definiert wurde.

Für andere Inhalte wie Structs oder Enums ist es hingegen üblich, den gesamten Pfad hinter dem use anzugeben.

Programmtext 160: Den Pfad eines Enums laden

mod some_module {

pub enum Event {

KeyPress(char),

Click {x: i32, y: i32}

}

}

use crate::some_module::Event;

fn some_function() {

let c = Event::Click {x: 100, y: 150};

}       

Es gilt immer zu beachten, dass man nicht zwei Inhalte mit demselben Namen in den gleichen Scope laden kann, da diese Inhalte dann nicht mehr eindeutig adressiert werden können.

Programmtext 161: Ein Namenskonflikt

mod some_module {

pub enum Event {

Click {x: i32, y: i32}

}

}

mod other_module {

pub enum Event {

KeyPress(char)

}

}

use crate::some_module::Event;

use crate::other_module::Event; // Fehler!

fn some_function() {

let c = Event::Click {x: 100, y: 150};

}       

In solchen Fällen kann dann natürlich nicht der gesamte Pfad geladen werden. Dennoch kann derselbe Name in unterschiedlichen Modulen verwendet werden, ohne dass ein Konflikt entsteht.

Programmtext 162: Auflösung des Namenskonfliktes

mod some_module {

pub enum Event {

Click {x: i32, y: i32}

}

}

mod other_module {

pub enum Event {

KeyPress(char)

}

}

fn some_function() {

let c = some_module::Event::Click {x: 100, y: 150};

let k = other_module::Event::KeyPress('c');

}       

In diesem Beispiel gibt es nun kein use mehr, weil sich die beiden Module some_module und other_module auf demselben Level des Modulbaums befinden wie some_function. Das übergeordnete Modul bestimmt nun eindeutig, welches Enum gemeint ist.

Das Schlüsselwort as

Ein Namenskonflikt, so wie wir ihn eben gesehen haben, kann alternativ auf die folgende Art und Weise aufgelöst werden. Wir können das Schlüsselwort as hinter einem mittels use geladenen Pfad schreiben, um einen neuen lokalen Namen zu vergeben. Das nächste Beispiel demonstriert, wie wir as verwenden können, um den Namenskonflikt aufzulösen.

Programmtext 163: Auflösung des Namenskonfliktes mit as

mod some_module {

pub enum Event {

Click {x: i32, y: i32}

}

}

mod other_module {

pub enum Event {

KeyPress(char)

}

}

use crate::some_module::Event as ClickEvent;

use crate::other_module::Event as KeyEvent;

fn some_function() {

let c = ClickEvent::Click {x: 100, y: 150};

let k = KeyEvent::KeyPress('c');

}       

Hinter dem Schlüsselwort as folgt der Name, den wir für diesen Pfad vergeben möchten. Diesen Namen können wir selbst auswählen.

Das Schlüsselwort pub use

Immer, wenn wir einen Pfad mittels use laden, dann ist dieser Pfad privat. Falls wir aber wünschen, dass anderer Code, der unseren Code aufruft, den geladenen Pfad ebenfalls verwenden kann, dann können wir use durch pub use ersetzen.

Programmtext 164: Ein Beispiel für pub use

mod outer_module {

pub mod inner_module {

pub fn some_function() {}

}

}

pub use crate::outer_module::inner_module;

fn call_some_function() {

inner_module::some_function();

}       

Externer Code kann nun ebenfalls die Syntax inner_module::some_function() verwenden, um die Funktion some_function aufzurufen. Wir benötigen dieses Schlüsselwort somit nur, wenn wir eine Programmbibliothek entwickeln, die von anderen Programmcodes eingebunden werden kann.

Das kommende Beispiel soll den Nutzen noch einmal etwas besser veranschaulichen. Nehmen wir an, wir haben ein Modul mit der folgenden Struktur entwickelt:

Programmtext 165: Ein Beispiel für eine Modulstruktur

pub mod geometrie {

pub struct Rechteck {

breite: u32,

hoehe: u32

}

pub struct Quadrat {

laenge: u32

}

}       

Damit externer Code nun auf die in dem Modul enthaltenen Structs zugreifen kann, müsste er sie explizit in seinen Scope laden, indem das Schlüsselwort use verwendet wird. Wenn wir stattdessen eine kleine Anpassung vornehmen, ist dies nicht mehr notwendig:

Programmtext 166: Ein Beispiel für eine Modulstruktur mit pub use

pub use self::geometrie::Rechteck;

pub use self::geometrie::Quadrat;

pub mod geometrie {

pub struct Rechteck {

breite: u32,

hoehe: u32

}

pub struct Quadrat {

laenge: u32

}

}       

Jetzt kann externer Code direkt auf die Structs zugreifen, wenn er diese Programmbibliothek geladen hat.

Externe Pakete einbinden

Es gibt bereits eine Vielzahl an Programmbibliotheken, die wir in unser Programm einbinden können. Genauere Informationen über verfügbare Bibliotheken findet man unter https://crates.io/.

Um externe Pakete einzubinden, müssen wir diese in der Datei Cargo.toml als Abhängigkeit angeben. Dies geschieht unter dem Abschnitt [dependencies]. Dabei geben wir den Namen des Paketes sowie die gewünschte Version an. Beispielsweise können zufällige Zahlen mit Hilfe des Paketes rand erzeugt werden.

Programmtext 167: rand als Abhängigkeit hinzufügen (in Cargo.toml)

[dependencies]

rand = "0.5"       

Jetzt können wir Inhalte des Paketes rand mittels use verwenden.

Programmtext 168: Verwendung von rand

use rand::Rng;

fn main() {

let r = rand::thread_rng().gen_range(1, 11);

println!("r = {}", r);

}       

Die Ausgabe liefert dann eine zufällige ganze Zahl zwischen 1 (inklusive) und 11 (exklusive). Das Vorgehen, um ein externes Paket zu laden, ist für alle Pakete identisch. Lediglich bei der Standardbibliothek std gibt es einen kleinen Unterschied.

Die Standardbibliothek std muss nicht in der Datei Cargo.toml angegeben werden, falls wir sie laden möchten. Stattdessen können wir sie direkt in unser Projekt mit Hilfe von use einbinden.

Außerdem kann die Liste an use Anweisungen sehr lang werden. Falls wir mehrere Inhalte desselben Paketes laden möchten, können wir dies deshalb in einer Zeile tun. Nehmen wir also an, wir möchten folgende Pakete laden:

Programmtext 169: Mehrere use Anweisungen

use rand::Rng;

use rand::distributions::Uniform;       

Diese Anweisungen können nun folgendermaßen zusammengefasst werden:

Programmtext 170: Mehrere use Anweisungen zusammenfassen

use rand::{Rng, distributions::Uniform};       

Dazu ist es lediglich notwendig, dass die Pfade einen gemeinsamen Teilpfad aufweisen. Angenommen, wir haben stattdessen folgendes Szenario:

Programmtext 171: Mehrere use Anweisungen

use std::io;

use std::io::Write;       

Nun können wir mit Hilfe von self auch diese Anweisungen zusammenfassen:

Programmtext 172: Mehrere use Anweisungen zusammenfassen

use std::io::{self, Write};       

Für den Fall, dass wir alle Inhalte eines Pfades laden möchten, können wir den Operator * verwenden:

Programmtext 173: Alle Inhalte eines Pfades laden

use std::io::*;       

Diese use Anweisung lädt nun alle Inhalte innerhalb von std::io.

Ebenfalls möglich ist es, Felder eines Enums oder Structs zu laden, sodass wir ohne den Operator :: darauf zugreifen können:

Programmtext 174: Felder eines Enums laden

enum Event {

KeyPress(char),

Click {x: i32, y: i32}

}

fn main() {

use crate::Event::{KeyPress, Click};

let e = KeyPress('c');

match e {

KeyPress(c) => println!("KeyPress"),

Click {x, y} => println!("Click")

}

}       

KeyPress

Falls wir alle Felder eines Enums oder Structs laden möchten, dann ist der Operator * oftmals hilfreich.

Programmtext 175: Felder eines Enums laden mit dem Operator *

enum Event {

KeyPress(char),

Click {x: i32, y: i32}

}

fn main() {

use crate::Event::*;

let e = KeyPress('c');

match e {

KeyPress(c) => println!("KeyPress"),

Click {x, y} => println!("Click")

}

}       

Die Ausgabe bleibt dabei natürlich unverändert.

Module in mehrere Dateien aufteilen

Bisher haben wir immer nur eine einzige Datei verwendet, in der unser Programmcode enthalten war. Irgendwann wird es jedoch sehr unübersichtlich, wenn tausende Zeilen von Code in derselben Datei stehen. Deshalb kann es sich lohnen, Module auf mehrere Dateien aufzuteilen.

Möchten wir den Inhalt einer anderen Datei laden, dann benutzen wir dafür ebenfalls das Schlüsselwort mod. Mittels mod <Dateiname>; können wir den Inhalt eines Moduls aus der Datei mit dem angegebenen Namen laden.

Programmtext 176: Inhalt der Datei src/lib.rs

mod other_file;

pub use crate::other_file::other_module;

pub fn some_function() {

other_module::other_function();

}   

Programmtext 177: Inhalt der Datei src/other_file.rs

pub mod other_module {

pub fn other_function() {}

}   

In der Datei src/lib.rs lädt die erste Zeile das Modul aus der zweiten Datei. Somit können wir nun auf dieses Modul zugreifen. Durch use erleichtern wir uns zunächst den Zugriff und können das Modul other_module aus der zweiten Datei dann direkt adressieren.

Automatische Generierung einer Dokumentation

Wie zu Beginn kurz erwähnt, bietet Rust die Möglichkeit, Kommentare zu verfassen, aus denen dann automatisch eine Dokumentation unseres Codes generiert werden kann. Diese Kommentare werden durch /// eingeleitet. Solche Kommentare dienen dazu, zu beschreiben, wie der Code verwendet werden soll. Es geht nicht darum, zu beschreiben, wie die Implementierung im Detail aussieht.

Für die folgenden Beispiele erstellen wir zunächst mit dem Befehl cargo new beispiel --lib ein neues Projekt. Dann können wir den Kommentar direkt über den Code schreiben, welcher dokumentiert werden soll. Unsere Datei lib.rs sieht nun wie folgt aus:

Programmtext 178: Kommentare mit ///

/// Computes x + y.

///

/// # Examples

///

/// ```

/// let a = 5;

/// let b = 10;

///

/// assert_eq!(15, beispiel::add(a, b));

/// ```

pub fn add(x: i32, y: i32) -> i32 {

x + y

}   

Wir können nun automatisch eine HTML Dokumentation generieren lassen. Diese unterstützt auch Markdown Notation (beispielsweise haben wir durch # Examples eine Überschrift mit dem Namen Examples festgelegt). Der Abschnitt »Examples« enthält nun Beispiele, die zeigen, wie der dokumentierte Code verwendet werden kann. Dabei prüft der Makro assert_eq!, ob zwei übergebene Werte identisch sind. Falls dies nicht der Fall sein sollte, sorgt er dafür, dass das Programm sofort beendet wird.

Um jetzt tatsächlich eine Dokumentation zu erzeugen, benutzen wir den Befehl cargo doc. Danach befindet sich die Dokumentation in dem Verzeichnis target/doc. Wenn wir zusätzlich die Option open angeben, dann wird die erzeugte Dokumentation auch direkt im Webbrowser geöffnet.

Alternativ können wir auch die Datei index.html in dem Verzeichnis target/doc/<Name> manuell öffnen (<Name> bezeichnet dabei den Namen des mit Cargo erstellten Projektes).

cargo doc --open liefert uns für das vorherige Programmbeispiel die folgende Ansicht:

Hinter der Funktionsdefinition folgt die von uns gewählte Beschreibung, welche in diesem Beispiel auf Englisch ist und »Computes x + y.« lautet. Darunter befinden sich dann Beispiele, welche zeigen, wie die Funktion eingesetzt werden kann.

Neben dem Abschnitt # Examples sind zudem folgende Abschnitte interessant:

# Panics: Falls eine Funktion ein panic! auslöst, dann sollte in diesem Abschnitt dokumentiert werden, in welchem Szenario dies passiert.

# Errors: Falls eine Funktion die Option Result zurückgibt, dann sollte in diesem Abschnitt dokumentiert werden, welche Arten von Fehlern auftreten können und welche Situationen dazu führen, dass ein Fehler entsteht.

# Safety: Falls eine Funktion unsafe ist, dann sollte in diesem Abschnitt dokumentiert werden, warum das der Fall ist und welche Anforderungen die Funktion an seine Aufrufer stellt.

Jedes dieser drei Themen wird in den weiteren Kapiteln noch ausführlich aufgegriffen.

Der Abschnitt # Examples hat übrigens noch einen weiteren Vorteil: Wenn wir cargo test ausführen, dann werden die Beispiele als zusätzliche Testfälle ausgeführt. Somit ist zu jeder Zeit sichergestellt, dass die Beispiele mit dem Programmcode übereinstimmen. Auch dem Thema »Tests« ist im weiteren Verlauf noch ein eigenes Kapitel gewidmet.

Neben Kommentaren mit /// gibt es auch noch Kommentare durch //!. Diese dokumentieren nicht die darauffolgenden Elemente, sondern das Element, welches diesen Kommentar beinhaltet. Sie werden zur Dokumentation von Crates und Modulen eingesetzt.

Programmtext 179: Kommentare mit //!

//! # beispiel

//!

//! 'beispiel' ist ein Crate zu Demonstrationszwecken.

/// Computes x + y.

///

/// # Examples

///

/// ```

/// let a = 5;

/// let b = 10;

///

/// assert_eq!(15, beispiel::add(a, b));

/// ```

pub fn add(x: i32, y: i32) -> i32 {

x + y

}   

Die automatisch generierte Dokumentation sieht nun so aus:

Cargo Workspaces

Ein Workspace ist eine Menge an Packages, welche dieselbe Cargo.lock Datei nutzen sowie dasselbe Zielverzeichnis haben. Im Folgenden betrachten wir ein Beispiel, bei dem ein Binärdatei-Crate sowie eine Programmbibliothek vorkommen. Diese Crates sollen sich einen Workspace teilen, um die Nutzung von Workspaces zu veranschaulichen.

Zunächst erstellen wir eine Datei Cargo.toml, die für die Konfiguration des Workspaces zuständig ist. Darin befindet sich dieses Mal kein Abschnitt [package], sondern ein Abschnitt [workspace]. Innerhalb dieses Abschnittes können wir dann festlegen, welche Crates alles Teil dieses Workspaces sind.

Programmtext 180: Inhalt der Datei workspace/Cargo.toml

[workspace]

members = [

"bin_crate"

]   

Nun können wir in dem Verzeichnis workspace mit Hilfe von cargo new bin_crate einen neuen Binärdatei-Crate erstellen. Danach ist es auch möglich, cargo build in dem Verzeichnis workspace auszuführen.

Wenn wir dies tun, sehen wir auch, dass der Workspace nun ein Verzeichnis target angelegt hat. Dieses Verzeichnis dient nun für kompilierte Dateien aller im Workspace enthaltenen Crates. Dabei macht es auch keinen Unterschied, von wo aus wir den Befehl cargo build aufrufen. Auch wenn wir diesen innerhalb des Verzeichnisses workspace/bin_crate ausführen, wird dasselbe Zielverzeichnis verwendet (workspace/target statt workspace/bin_crate/target).

Wir können jetzt auch noch einen weiteren Crate zu unserem Workspace hinzufügen. Dazu passen wir zunächst die Datei Cargo.toml an und führen dann den Befehl cargo new lib1 --lib aus, um eine Programmbibliothek mit dem Namen lib1 hinzuzufügen.

Programmtext 181: Inhalt der Datei workspace/Cargo.toml

[workspace]

members = [

"bin_crate",

"lib1"

]   

Zusätzlich definieren wir eine Funktion in workspace/lib1/src/lib.rs:

Programmtext 182: Inhalt der Datei workspace/lib1/src/lib.rs

pub fn some_function(a: String) {

println!("a = {}", a);

}   

Jetzt kann unser Binärdatei-Crate diese Programmbibliothek nutzen, indem wir diese Abhängigkeit in der Datei workspace/bin_crate/Cargo.toml angeben.

Programmtext 183: Inhalt der Datei workspace/bin_crate/Cargo.toml

[dependencies]

lib1 = { path = "../lib1" }   

Damit ist es fortan möglich, die Programmbibliothek in dem Binärdatei-Crate zu verwenden.

Programmtext 184: Inhalt der Datei workspace/bin_crate/src/main.rs

use lib1;

fn main() {

let s = String::from("Hallo, Welt!");

lib1::some_function(s);

}   

Möchten wir nun einen Binärdatei-Crate ausführen, so benutzen wir die Option -p gefolgt von dem Namen des Packages, welches ausgeführt werden soll. In unserem Beispiel würde der vollständige Befehl also cargo run -p bin_crate lauten. Dann erhalten wir auch eine Ausgabe:

a = Hallo, Welt!

Dadurch, dass genau eine Cargo.lock Datei für alle Crates eines Workspaces existiert, wird sichergestellt, dass alle Crates immer dieselbe Version von extern eingebundenen Programmbibliotheken verwenden. Damit sind Crates in einem Workspace immer zueinander kompatibel. Gleichzeitig wird dabei Speicherplatz eingespart, da auf diese Weise nicht mehrere Versionen derselben Programmbibliothek heruntergeladen werden müssen. Falls wir eine externe Programmbibliothek nun in mehreren Crates nutzen möchten, so müssen wir diese Abhängigkeit in jeder Cargo.toml Datei gesondert angeben. Würden wir also in unserem Beispiel sowohl in der Datei workspace/bin_crate/src/main.rs als auch in der Datei workspace/lib1/src/lib.rs dieselbe externe Bibliothek verwenden wollen, so müssten wir diese Abhängigkeit in beiden zugehörigen Cargo.toml Dateien angeben.

Zusammenfassung dieses Kapitels

Module erlauben es uns, Programmcode besser zu organisieren und werden mit Hilfe des Schlüsselwortes mod definiert.

Mit dem Schlüsselwort use können wir Module in den aktuellen Scope laden und optional mit Hilfe von as lokal benennen.

Das Schlüsselwort pub kann Inhalte öffentlich verfügbar machen.

Mit mod <Dateiname>; können wir ein Modul aus einer anderen Datei laden.

Wir können mittels cargo doc automatisch eine Dokumentation aus Kommentaren erzeugen. Diese Kommentare werden mit /// oder //! begonnen.

Workspaces ermöglichen es, größere Projekte einfacher zu verwalten.

Aufgabe 8

Schreiben Sie ein Programm, welches das Crate rand zur Generierung von zufälligen Zahlen einbindet.

Das Programm aus dem ersten Aufgabenteil soll nun um ein Enum erweitert werden, welches die Werte »Kopf« und »Zahl« annehmen kann, um einen Münzwurf zu simulieren. Dann soll unter Verwendung von rand mit jeweils 50% Wahrscheinlichkeit entweder »Kopf« oder »Zahl« auf der Konsole ausgegeben werden.

Erweitern Sie das Programm nun derart, dass der Nutzer vor dem Münzwurf tippen kann, ob die Münze »Kopf« oder »Zahl« zeigen wird. Das Programm soll dem Nutzer dann rückmelden, ob sein Tipp korrekt gewesen ist. Programmtext 185 zeigt exemplarisch, wie Eingaben von einem Nutzer entgegengenommen werden können. Tipp: Um Werte eines Enums miteinander vergleichen zu können, benötigt das Enum die Annotation #[derive(PartialEq)] (später dazu mehr).

Programmtext 185: Eingaben vom Nutzer einlesen

use std::io;

fn main() {

println!("Kopf (k) oder Zahl (z)?");

let mut user_input = String::new();

io::stdin()

.read_line(&mut user_input)

.expect("Fehler beim Lesen der Eingabe");

println!("Die Eingabe lautet: {}", user_input.trim());

}   

Collections

Wie auch in anderen Programmiersprachen gibt es in Rust einige nützliche Datenstrukturen, die wir in unserem Programm verwenden können. Wir sehen uns in diesem Abschnitt einige der sogenannten Collections an.

Vektoren

Ein Vektor ist ein Typ, der (ähnlich zu einem Array) mehrere Werte hintereinander abspeichert. Anders als ein Array hat ein Vektor aber keine feste Länge. Das bedeutet, dass wir beliebig Werte hinzufügen oder entfernen können und sich die Länge des Vektors dabei ändert. Ein Array hingegen behält immer dieselbe Länge.

Wenn wir beispielsweise eine Textdatei einlesen möchten und im Vorfeld nicht wissen, wie viele Zeilen darin enthalten sind, dann können wir kein Array nutzen, weil die benötigte Länge nicht bekannt ist. Ein Vektor würde sich jedoch dafür eignen.

Um einen neuen Vektor zu erzeugen, gibt es die Funktion Vec::new().

Programmtext 186: Erzeugen eines neuen Vektors mittels Vec::new()

fn main() {

let vector: Vec<u32> = Vec::new();

println!("{:?}", vector);

}   

Zu Beginn ist der Vektor noch leer, weshalb wir angeben müssen, welchen Datentyp die Elemente besitzen, die in diesem Vektor gespeichert werden sollen. Wenn wir einen leeren Vektor ausgeben lassen, dann erhalten wir die folgende Ausgabe:

[]

Der gesamte Speicherplatz eines Vektors wird freigegeben, sobald er seinen Scope verlässt. Alle darin enthaltenen Elemente sowie der Vektor selbst existieren dann nicht mehr.

Wir können aber auch einen Vektor erstellen, der bereits zu Beginn einige Elemente enthalten soll. Dann müssen wir den Datentyp der enthaltenen Elemente auch nicht explizit angeben, da Rust diesen selbst herausfinden kann.

Programmtext 187: Erzeugen eines neuen Vektors mittels vec!

fn main() {

let vector = vec![5, 10, 15];

println!("{:?}", vector);

}   

[5, 10, 15]

In diesem Beispiel erzeugen wir einen Vektor, der initial die Werte 5, 10 und 15 beinhaltet. Wie bereits zu Beginn erwähnt, können wir jederzeit neue Werte zu einem Vektor hinzufügen. Die dafür vorhandene Methode nennt sich push und kann folgendermaßen verwendet werden:

Programmtext 188: Neue Werte zu einem Vektor hinzufügen mittels push

fn main() {

let mut vector = vec![5, 10, 15];

vector.push(2);

vector.push(4);

println!("{:?}", vector);

}   

[5, 10, 15, 2, 4]

Auch hier dürfen wir nicht vergessen, dass der Vektor veränderbar sein muss, um weitere Elemente hinzufügen zu können.

Mit Hilfe der Methode pop können wir Elemente am Ende eines Vektors wieder entfernen.

Programmtext 189: Den letzten Wert eines Vektors entfernen mittels pop

fn main() {

let mut vector = vec![5, 10, 15];

vector.pop();

println!("{:?}", vector);

}   

[5, 10]

Falls wir den Inhalt eines Vektors lesen möchten, so können wir über den Index auf einzelne Elemente zugreifen.

Programmtext 190: Den Inhalt eines Vektors mit Hilfe des Index lesen

fn main() {

let vector = vec![5, 10, 15];

let element = &vector[0];

println!("Element an Position 0: {}", element);

}   

Element an Position 0: 5

Ebenfalls möglich ist es, die Methode get zu verwenden. Das Ganze sieht dann wie folgt aus:

Programmtext 191: Den Inhalt eines Vektors mit Hilfe der Methode get lesen

fn main() {

let vector = vec![5, 10, 15];

match vector.get(0) {

Some(e) => println!("Element an Position 0: {}", e),

None => println!("Es gibt kein Element an Position 0")

}

}   

Element an Position 0: 5

Wie wir sehen können, verwenden wir nun eine match Anweisung. Das liegt daran, dass die Methode get ein Element vom Typ Option<&T> zurückliefert, während ein Zugriff über den Index ein Element vom Typ &T erhält. In diesem konkreten Beispiel handelt es sich um den Typ Option<&i32> sowie um den Typ &i32.

Das bedeutet gleichzeitig, dass unser Programm abstürzt, falls wir versuchen sollten, einen nicht existenten Index zu erreichen, indem wir direkt über den Index auf ein Element zugreifen. Verwenden wir stattdessen die Methode get auf einen nicht vorhandenen Index, so erhalten wir lediglich den Wert None zurück, aber das Programm stürzt nicht ab.

Werfen wir einmal einen Blick auf das nächste Beispiel. Dieses Beispiel kompiliert nicht, da es fehlerhaft ist:

Programmtext 192: Ein fehlerhaftes Programm

fn main() {

let mut vector = vec![5, 10, 15];

let elem = &vector[0];

vector.push(2);

}   

Auf den ersten Blick ist es nicht unbedingt leicht, zu erkennen, wo der Fehler liegt. Sobald wir etwas genauer hinsehen, erkennen wir, dass wir mittels let elem = &vector[0]; zunächst eine unveränderbare Referenz erzeugen. Damit dem Vektor ein Element hinzugefügt werden kann, benötigt die Methode push aber eine änderbare Referenz. Wenn wir uns nun an das Ownership Konzept zurückerinnern, dann stellen wir fest, dass das nicht erlaubt ist (wir dürfen neben der änderbaren Referenz keine weitere Referenz haben).

Jetzt fragt sich vielleicht der ein oder andere Leser: Ist das hier sinnvoll? Wir fügen doch nur am Ende des Vektors neue Elemente ein, da kann eine Referenz auf ein Element davor doch nicht verkehrt sein – oder etwa doch?

Es können jederzeit neue Elemente zu einem Vektor hinzugefügt werden. Da sich alle Elemente hintereinander im Speicher befinden, kann es passieren, dass irgendwann nicht mehr ausreichend freier Speicher am Stück vorhanden ist. Dann müssen alle Elemente an eine neue Stelle, die ausreichend groß ist, kopiert werden.

Nun kann es also tatsächlich vorkommen, dass eine zuvor erstellte Referenz auf die alte Speicherstelle eines Elements verweist. Das Ownership Konzept bewahrt uns vor einer solchen Situation.

Außerdem ist es möglich, über Vektoren zu iterieren. Das folgende Beispiel zeigt, wie wir eine Referenz für jedes Element eines Vektors erhalten können.

Programmtext 193: Iterieren über einen Vektor

fn main() {

let vector = vec![5, 10, 15];

for e in &vector {

println!("{}", e);

}

}   

5
10
15

Sollte der Vektor veränderbar sein, so können wir auch änderbare Referenzen der Elemente in unserer Schleife verwenden.

Programmtext 194: Iterieren über einen Vektor

fn main() {

let mut vector = vec![5, 10, 15];

for e in &mut vector {

*e *= 2;

}

println!("{:?}", vector);

}   

[10, 20, 30]

Dabei können wir beobachten, dass wir den Dereferenzierungsoperator * benutzen müssen, um den Wert in e zu ändern. Das liegt daran, dass wir eine Referenz auf e haben. Die Referenz zeigt zunächst auf die Speicherstelle, aber wir sind an dem Wert an dieser Speicherstelle interessiert. Um an den Wert zu gelangen, benötigen wir den Dereferenzierungsoperator *. Das Kapitel „Pointer“ behandelt den Dereferenzierungsoperator * noch detailliert.

Vektoren enthalten nur Elemente des gleichen Typs. Mit Hilfe eines Enums können wir aber auch dafür sorgen, dass Zahlen, Zeichenketten und weitere Daten gleichzeitig enthalten sein können. Dazu definieren wir uns ein Enum, welches solche Daten enthält.

Programmtext 195: Daten unterschiedlicher Typen in einem Vektor speichern

#[derive(Debug)]

enum MixedType {

Integer(i32),

Text(String)

}

fn main() {

let vector = vec![

MixedType::Integer(5),

MixedType::Integer(10),

MixedType::Text(String::from("Hallo, Welt!"))

];

println!("{:?}", vector);

}   

[Integer(5), Integer(10), Text("Hallo, Welt!")]

Auf diese Art und Weise können wir nicht nur unterschiedliche Typen in einem Vektor verwenden, sondern auch festlegen, welche Typen erlaubt sind (und somit Typen ausschließen, die nicht erlaubt sind).

HashMaps

Eine sogenannte HashMap speichert Paare von Schlüsseln und Werten. Wollen wir auf ein bestimmtes Element zugreifen, dann nutzen wir den zugehörigen Schlüssel, um an den Wert zu gelangen. Wir können eine neue HashMap mit Hilfe der Funktion new anlegen und neue Elemente mittels insert einfügen.

Programmtext 196: Anlegen einer HashMap und Hinzufügen neuer Elemente

use std::collections::HashMap;

fn main() {

let mut map = HashMap::new();

map.insert(String::from("Max"), 10);

map.insert(String::from("Linda"), 20);

println!("{:?}", map);

}   

{"Linda": 20, "Max": 10}

Zunächst gilt es zu beachten, dass wir den Typ HashMap mittels use einbinden müssen, um ihn verwenden zu können. Eine HashMap kann zum Beispiel bei einem Spiel dafür verwendet werden, jedem Spieler einen Punktestand zuzuordnen, wie es in diesem Beispiel der Fall ist. Hierbei besitzt die HashMap Schlüssel vom Typ String und Werte vom Typ i32. Daher ist der Typ von map in diesem Fall HashMap<String, i32>.

Rust kann den Typ selbst herausfinden, da wir Werte hinzufügen. Würden wir lediglich eine neue HashMap erzeugen wollen, dann müssten wir den Typ explizit angeben.

Programmtext 197: Anlegen einer HashMap

use std::collections::HashMap;

fn main() {

let map: HashMap<String, i32> = HashMap::new();

}   

Wir können eine HashMap auch über die Methode collect erzeugen. Diese Methode kann auf einen Vektor an Tupeln angewendet werden, wobei jedes Tupel aus einem Schlüssel und einem Wert besteht.

Programmtext 198: Anlegen einer HashMap mit Hilfe der Methode collect

use std::collections::HashMap;

fn main() {

let keys = vec![String::from("Max"), String::from("Linda")];

let values = vec![10, 20];

let m: HashMap<_, _> = keys.iter().zip(values.iter()).collect();

println!("map = {:?}", m);

}   

map = { "Max": 10, "Linda": 20 }

Wir sehen schon, dass diese Variante deutlich komplizierter ist. Zunächst können wir die Methode zip verwenden, um einen Vektor an Tupeln zu erzeugen: Dabei werden immer die Elemente an derselben Position in keys und values miteinander verknüpft. Die Methode collect verwandelt diesen Vektor an Tupeln dann in eine HashMap. Wir geben dabei den Typ HashMap<_, _> an, damit Rust weiß, dass es sich um eine HashMap handeln soll. Den Typ für die Schlüssel und den Typ für die Werte kann Rust dann anhand der Werte aus keys und values selbst herausfinden, weshalb wir den Platzhalter _ verwenden.

Sollten wir Elemente in eine HashMap einfügen, die auf dem Heap gespeichert werden, dann werden diese in die HashMap bewegt und somit ist die HashMap der neue Owner dieser Elemente. Falls die Elemente in einer Variablen gespeichert sind, kann diese Variable dementsprechend nicht weiterverwendet werden.

Programmtext 199: Ownership bei einer HashMap

use std::collections::HashMap;

fn main() {

let key = String::from("Max");

let value = 10;

let mut map = HashMap::new();

map.insert(key, value); // key ist nun nicht mehr gueltig

}   

In diesem Beispiel wird die Variable key in die HashMap bewegt und ist danach somit nicht mehr gültig. Die Variable value ist auch danach weiterhin gültig, weil sie vom Typ i32 ist. Würden wir hingegen auch Werte vom Datentyp String einfügen, dann wäre die zugehörige Variable danach ebenfalls ungültig.

Wenn wir eine HashMap haben und auf darin enthaltene Elemente zugreifen möchten, dann benutzen wir die Methode get. Diese Methode gibt uns eine Option zurück, da es vorkommen kann, dass wir versuchen, auf einen nicht vorhandenen Schlüssel zuzugreifen. In einem solchen Fall würden wir None als Ergebnis erhalten.

Programmtext 200: Elemente einer HashMap mit der Methode get adressieren

use std::collections::HashMap;

fn main() {

let mut map = HashMap::new();

map.insert(String::from("Max"), 10);

map.insert(String::from("Linda"), 20);

let key = String::from("Max");

println!("{:?}", map.get(&key));

let key2 = String::from("Peter");

println!("{:?}", map.get(&key2));

}   

Some(10)
None

Wenn wir das Ergebnis also weiterverwenden wollen würden, dann sollten wir die Option sinnvoll behandeln, zum Beispiel mit einer match Anweisung.

Wir können alternativ auch über eine HashMap iterieren, genau wie bei Vektoren auch.

Programmtext 201: Iterieren über eine HashMap

use std::collections::HashMap;

fn main() {

let mut map = HashMap::new();

map.insert(String::from("Max"), 10);

map.insert(String::from("Linda"), 20);

for (key, value) in &map {

println!("{} hat {} Punkte.", key, value);

}

}   

Max hat 10 Punkte.
Linda hat 20 Punkte.

Wichtig: Die Reihenfolge der Ausgabe ist dabei nicht immer dieselbe. Es kann genauso gut vorkommen, dass »Linda« vor »Max« ausgegeben wird. Bei einer HashMap gibt es keine feste Reihenfolge, da wir über Schlüssel auf Elemente zugreifen.

In einer HashMap wird für jeden Schlüssel genau ein Wert gespeichert. Sollten wir denselben Schlüssel ein zweites Mal einfügen, so wird der alte Wert durch den neuen Wert überschrieben.

Programmtext 202: Elemente einer HashMap aktualisieren

use std::collections::HashMap;

fn main() {

let mut map = HashMap::new();

map.insert(String::from("Max"), 10);

map.insert(String::from("Max"), 20);

println!("{:?}", map);

}   

{"Max": 20}

Der alte Wert 10 wird nun durch den neuen Wert 20 ersetzt. Das kann aber manchmal nicht wünschenswert sein. Vielleicht möchten wir nur dann einen Wert einfügen, wenn der Schlüssel noch nicht in der HashMap enthalten ist. Dies können wir mit Hilfe der Methode or_insert erreichen.

Programmtext 203: Nur neue Schlüssel in eine HashMap hinzufügen

use std::collections::HashMap;

fn main() {

let mut map = HashMap::new();

map.insert(String::from("Max"), 10);

map.entry(String::from("Max")).or_insert(20);

map.entry(String::from("Linda")).or_insert(20);

println!("{:?}", map);

}   

{"Max": 10, "Linda": 20}

In diesem Beispiel fügen wir den neuen Wert nur dann ein, wenn der Schlüssel noch nicht enthalten ist. Dazu nutzen wir entry, um zu prüfen, ob ein Schlüssel bereits vorhanden ist. Sollte dies nicht der Fall sein, dann fügt or_insert den Schlüssel in die HashMap ein. Ansonsten passiert nichts.

Angenommen, wir möchten die Häufigkeit einzelner Zeichen in einer Zeichenkette zählen. Dazu müssen wir zunächst herausfinden, wie oft das aktuell betrachtete Zeichen bereits vorkam, dann 1 dazu addieren und zuletzt den neuen Wert wieder in der HashMap speichern. Das folgende Beispiel zeigt, wie dies umgesetzt werden könnte.

Programmtext 204: Elemente basierend auf dem aktuellen Wert aktualisieren

use std::collections::HashMap;

fn main() {

let s = String::from("Hallo");

let mut map = HashMap::new();

for c in s.chars() {

let count = map.entry(c).or_insert(0);

*count += 1;

}

println!("{:?}", map);

}   

{'a': 1, 'l': 2, 'o': 1, 'H': 1}

Hier testen wir für jedes Zeichen zunächst, ob es bereits einen Wert in der HashMap gibt. Falls nicht, fügen wir das Zeichen als Schlüssel zur HashMap hinzu und erhöhen den dazugehörigen Zähler von 0 auf 1. Sollte das Zeichen bereits enthalten sein, so erhöhen wir den vorhandenen Zähler um 1.

Die Methode or_insert gibt eine änderbare Referenz vom Typ &mut V zurück (wobei V der Typ des Wertes ist). Um auf den Wert an der durch die Referenz adressierten Speicherstelle zugreifen zu können, benötigen wir erneut den Dereferenzierungsoperator *. Da diese Referenz am Ende der Schleife ihren Scope verlässt, werden auch die Ownership Regeln eingehalten.

HashSets

Neben der HashMap gibt es auch noch das HashSet. Ein Set ist eine Menge an Elementen, worin jedes Element maximal einmal vorkommt. Die Verwendung ist sehr ähnlich zu der Verwendung einer HashMap. Wir können die Funktion new verwenden, um ein neues HashSet anzulegen. Neue Elemente können mit Hilfe der Methode insert hinzugefügt werden.

Programmtext 205: Ein neues HashSet anlegen und Elemente hinzufügen

use std::collections::HashSet;

fn main() {

let mut set = HashSet::new();

set.insert(String::from("Auto"));

set.insert(String::from("Reifen"));

set.insert(String::from("Auto"));

println!("set = {:?}", set);

}   

set = {"Reifen", "Auto"}

Wie bereits erwähnt, werden neue Elemente nur dann hinzugefügt, wenn sie nicht bereits enthalten sind. Ein Set ist also eine Menge an eindeutigen Elementen.

Ähnlich wie bei einer HashMap können wir auch hier die Methode collect verwenden, um ein HashSet aus einem Vektor zu erstellen.

Programmtext 206: Ein HashSet mit Hilfe der Methode collect erzeugen

use std::collections::HashSet;

fn main() {

let elements = vec!["Auto", "Reifen", "Auto"];

let set: HashSet<_> = elements.iter().collect();

println!("set = {:?}", set);

}   

set = {"Reifen", "Auto"}

Wir müssen hier angeben, dass es sich bei der Variablen set um ein HashSet handeln soll. Den Typ der enthaltenen Elemente hingegen kann Rust wieder selbst herausfinden.

Mit Hilfe der Methode contains können wir überprüfen, ob ein bestimmtes Element in einem HashSet enthalten ist. Die Methode remove kann Elemente aus einem HashSet entfernen.

Programmtext 207: Elemente aus einem HashSet entfernen

use std::collections::HashSet;

fn main() {

let mut set = HashSet::new();

set.insert(String::from("Auto"));

set.insert(String::from("Reifen"));

if set.contains("Auto") {

set.remove("Auto");

}

println!("set = {:?}", set);

}   

set = {"Reifen"}

Ebenfalls ist es möglich, über ein HashSet zu iterieren. Das Vorgehen ist praktisch identisch zu dem Vorgehen bei einer HashMap.

Programmtext 208: Iterieren über ein HashSet

use std::collections::HashSet;

fn main() {

let elements = vec!["Auto", "Reifen", "Auto"];

let set: HashSet<_> = elements.iter().collect();

for elem in &set {

println!("{}", elem);

}

}   

Auto
Reifen

Wir können an dieser Stelle schon feststellen, dass Collections in der Regel sehr ähnlich aufgebaut sind. Die zugehörigen Methoden und Funktionen arbeiten oft analog zueinander, sodass deren Nutzung intuitiv erfolgen kann.

LinkedLists

Zuletzt wollen wir noch einen Blick auf die sogenannte LinkedList werfen. Eine LinkedList enthält Elemente desselben Typs, welche jeweils mit ihrem Vorgänger und ihrem Nachfolger verbunden sind. Man kann sich eine LinkedList wie eine »Kette« an Elementen vorstellen. Das nächste Beispiel zeigt zunächst, wie wir eine neue LinkedList anlegen können.

Programmtext 209: Erzeugen einer neuen LinkedList

use std::collections::LinkedList;

fn main() {

let mut list = LinkedList::new();

list.push_front(7);

list.push_front(10);

println!("list = {:?}", list);

}   

list = [10, 7]

In diesem Beispiel werden neue Elemente an den Anfang der Liste eingefügt. Wir können außerdem die Methode push_back verwenden, um Elemente an das Ende der Liste anzufügen.

Programmtext 210: Erzeugen einer neuen LinkedList

use std::collections::LinkedList;

fn main() {

let mut list = LinkedList::new();

list.push_back(7);

list.push_back(10);

println!("list = {:?}", list);

}   

list = [7, 10]

Alternativ können wir auch hier wieder die Methode collect nutzen, um eine neue Liste mit den Elementen eines Vektors anzulegen.

Programmtext 211: Erzeugen einer neuen LinkedList mit Hilfe von collect

use std::collections::LinkedList;

fn main() {

let elements = vec![7, 10, 12, 20];

let list: LinkedList<_> = elements.iter().collect();

println!("list = {:?}", list);

}   

list = [7, 10, 12, 20]

Wir können jederzeit das erste bzw. letzte Element der Liste entfernen, indem wir die Methode pop_front bzw. pop_back verwenden.

Programmtext 212: Elemente aus einer LinkedList entfernen

use std::collections::LinkedList;

fn main() {

let elements = vec![7, 10, 12, 20];

let mut list: LinkedList<_> = elements.iter().collect();

let first = list.pop_front();

println!("{:?} entfernt", first);

let last = list.pop_back();

println!("{:?} entfernt", last);

println!("list = {:?}", list);

}   

Some(7) entfernt
Some(20) entfernt
list = [10, 12]

Auch hier sehen wir wieder, dass die Methoden pop_front und pop_back eine Option zurückliefern. Sollten wir diese Methoden auf eine leere Liste anwenden, so erhalten wir das Ergebnis None. Um die Option sinnvoll verwenden zu können, bietet sich erneut die Anweisung match an.

Zuletzt wollen wir uns noch ansehen, wie wir zwei Listen zusammenfügen können und wie wir eine Liste in zwei Listen aufteilen können. Das folgende Beispiel zeigt, wie wir mit Hilfe der Methode append zwei Listen zu einer Liste verschmelzen können.

Programmtext 213: Zwei Listen zu einer Liste verschmelzen

use std::collections::LinkedList;

fn main() {

let elements = vec![7, 10, 12, 20];

let mut list1: LinkedList<_> = elements.iter().collect();

let mut list2: LinkedList<_> = elements.iter().collect();

list1.append(&mut list2);

println!("list1 = {:?}", list1);

}   

list1 = [7, 10, 12, 20, 7, 10, 12, 20]

Hierbei gilt es zu beachten, dass beide Listen änderbar sein müssen. Nach der Operation append ist list2 eine leere Liste, weil alle Elemente nach list1 bewegt wurden.

Möchten wir umgekehrt eine Liste in zwei Listen teilen, so können wir dies mit Hilfe der Methode split_off tun. Dafür muss die Liste änderbar sein, weil Elemente daraus entfernt werden sollen.

Programmtext 214: Eine Liste in zwei Listen teilen

use std::collections::LinkedList;

fn main() {

let elements = vec![7, 10, 12, 20];

let mut list1: LinkedList<_> = elements.iter().collect();

let list2 = list1.split_off(2);

println!("list1 = {:?}, list2 = {:?}", list1, list2);

}   

In diesem Beispiel teilen wir die Liste an Index 2. Das bedeutet, dass alle Elemente ab Index 2 in eine neue Liste wandern. Die Elemente davor bleiben in list1 enthalten, sodass wir die folgende Ausgabe bekommen:

list1 = [7, 10], list2 = [12, 20]

Neben den hier vorgestellten Collections gibt es noch weitere Collections in Rust. Auch die vorgestellten Methoden und Funktionen waren längst nicht alle, die Rust zu bieten hat.

Zusammenfassung dieses Kapitels

Rust liefert uns eine Vielzahl an bereits vorhandenen Datenstrukturen, die in unterschiedlichsten Situationen nützlich sein können.

Vektoren eignen sich hervorragend, wenn wir mehrere Elemente eines Typs speichern wollen, aber die Anzahl der Elemente während der Laufzeit variieren kann.

Eine HashMap erlaubt es uns, beliebige Werte für einen zugehörigen Schlüssel zu speichern. Die Werte sind dann über diesen Schlüssel erreichbar.

Ein HashSet ist eine Menge von Elementen, wobei jedes Element höchstens einmal vorkommen darf.

Eine LinkedList bietet uns die Möglichkeit, eine Liste sowohl am Anfang als auch am Ende leicht manipulieren zu können.

Aufgabe 9

Schreiben Sie ein Programm, welches 50-mal einen Münzwurf simuliert und die Ergebnisse in einer HashMap speichert. Anschließend soll ausgegeben werden, wie oft jeweils »Kopf« und »Zahl« vorgekommen sind.

Erweitern Sie das Programm nun, sodass nicht mehr konstant 50 Münzwürfe stattfinden. Stattdessen soll der Nutzer zu Beginn eine Zahl x eingeben, sodass der Münzwurf exakt x-mal wiederholt wird. Tipp: Mittels user_input.trim().parse::<u32>().unwrap() kann die Eingabe des Nutzers in eine ganze Zahl umgewandelt werden.

Fehlerbehandlung

Rust unterteilt Fehler in zwei Kategorien:

Wiederherstellbare Fehler und

nicht wiederherstellbare Fehler.

Die zweite Kategorie umfasst Fehler, die dazu führen, dass das Programm sofort abstürzt, weil nicht klar ist, was als nächstes passieren soll. Wiederherstellbare Fehler hingegen führen nicht sofort zu einem Absturz, sondern können noch behoben werden, beispielsweise durch einen weiteren Versuch.

Für wiederherstellbare Fehler stellt Rust den Typ Result<T, E> zur Verfügung. Zudem gibt es den Makro panic!, um die Ausführung eines Programmes zu beenden. Dieser kann benutzt werden, wenn ein nicht wiederherstellbarer Fehler auftreten sollte.

Nicht wiederherstellbare Fehler

Manchmal treten Fälle ein, in denen wir vielleicht nicht wissen, wie wir mit einem bestimmten Fehler umgehen sollen. An solchen Stellen können wir den Makro panic! benutzen, um die Ausführung des Programmes zu beenden. Das folgende Beispiel zeigt, wie der Makro panic! verwendet werden kann.

Programmtext 215: Ein einfaches Beispiel für den Makro panic!

fn main() {

panic!("Es ist etwas Schlimmes passiert!");

}   

Dabei können wir dem Makro panic! eine Fehlermeldung als Parameter übergeben. Es ist auf jeden Fall sinnvoll, den Grund für einen Programmabsturz anzugeben, damit wir sehen können, was zu dem Absturz geführt hat. Das obige Beispiel produziert nun die folgende Ausgabe:

thread 'main' panicked at 'Es ist etwas Schlimmes passiert!', ./main.rs:2:2
note: Run with `RUST_BACKTRACE=1` for a backtrace.

Die Ausgabe beschreibt nun, in welcher Zeile unseres Programms der Absturz erzeugt wurde. Hier handelt es sich um die zweite Zeile an Position zwei (die erste 2 gibt die Zeilennummer an, die zweite 2 die Position innerhalb dieser Zeile). Wenn wir unser Beispiel anschauen, dann sehen wir, dass sich dort der Makro panic! befindet.

Angenommen, wir wollen ein bestimmtes Element eines Vektors erhalten und geben aus Versehen einen nicht vorhandenen Index an. Auch dann stürzt das Programm ab.

Programmtext 216: Zugriff auf einen nicht vorhandenen Index eines Vektors

fn main() {

let vector = vec![1,2,3,4,5];

println!("{}", vector[10]);

}   

Aufgrund dessen, dass unser Vektor lediglich fünf Elemente enthält, gibt es natürlich keinen Index 10. Wenn wir dieses Programm nun kompilieren und trotzdem ausführen, dann erhalten wir die folgende Fehlermeldung:

thread 'main' panicked at 'index out of bounds: the len is 5 but the index is 10', libcore\slice\mod.rs:2448:10
note: Run with `RUST_BACKTRACE=1` for a backtrace.

Wir sehen, dass der Fehler in der Datei libcore\slice\mod.rs stattgefunden hat. Dies ist der Ort, an dem Rust Slices implementiert hat. Sobald wir in unserem Programm die Notation vector[10] benutzen, wird dieser Code ausgeführt (wegen der eckigen Klammern [ ]).

Außerdem bekommen wir die zusätzliche Information, dass wir uns mit RUST_BACKTRACE=1 einen Backtrace anzeigen lassen können. Ein Backtrace ist eine Folge von Funktionen, die nacheinander aufgerufen wurden, bis es zu dem Fehler gekommen ist. Um die Umgebung RUST_BACKTRACE zu setzen, nutzen wir den passenden der folgenden Befehle:

Für Windows Eingabeaufforderung: set RUST_BACKTRACE=1

Für Windows Powershell: $Env:RUST_BACKTRACE=1

Für Linux und macOS Terminal: export RUST_BACKTRACE=1

Sobald die Umgebung entsprechend gesetzt wurde und wir das Programm erneut ausführen, erhalten wir eine sehr lange Fehlermeldung, die leider nicht auf eine Seite in diesem Buch passt. Wenn wir einen Fehler in unserem Programm suchen, dann sollten wir in der Regel ab der Zeile nachschauen, die als erstes auf eine von uns selbst geschriebene Datei hinweist.

Wiederherstellbare Fehler

Meistens ist es nicht notwendig, die Ausführung des Programms komplett zu stoppen, wenn ein Fehler auftritt. In der Regel kann man eine Reaktion finden, die den Fehler behebt, ohne das Programm abstürzen zu lassen. Beispielsweise wollen wir vielleicht nicht, dass ein Programm abstürzt, wenn eine Datei geöffnet werden soll, die nicht existiert. Stattdessen wäre es sinnvoll, die Datei zunächst zu erstellen.

In Rust gibt es den Typ Result, welcher folgendermaßen definiert ist:

Programmtext 217: Das Enum Result

enum Result<T, E> {

Ok(T),

Err(E),

}   

T und E sind generische Parameter. Wir werden generische Parameter in dem Kapitel „Generics, Traits, Lifetimes“ behandeln. Vorerst ist es ausreichend, zu wissen, dass T den Typ des Wertes für den erfolgreichen Fall Ok und E den Typ des Wertes im Fall Err beschreibt.

Der Typ Result wird zum Beispiel verwendet, wenn wir versuchen, eine Datei zu öffnen.

Programmtext 218: Eine Datei öffnen

use std::fs::File;

fn main() {

let f = File::open("a.txt");

}   

Anhand dieses Beispielcodes sieht man noch nicht, welchen Typ f nun hat. Wir können uns aber einen kleinen Trick zunutze machen: Wenn wir einen offensichtlich falschen Typ für f angeben, dann wird der Compiler einen Fehler produzieren. In der zugehörigen Fehlermeldung steht dann auch, wie der erwartete Typ eigentlich aussehen müsste.

Programmtext 219: Eine Datei öffnen

use std::fs::File;

fn main() {

let f: u8 = File::open("a.txt");

}   

Der Typ u8 ist selbstverständlich falsch. Daher erhalten wir eine Meldung vom Compiler, die besagt, dass der Typ u8 gefunden wurde, obwohl der Typ std::result::Result<std::fs::File, std::io::Error> erwartet wurde.

Das bedeutet für uns, dass der Versuch, die Datei zu öffnen, auch fehlschlagen kann. Dies kann passieren, wenn die Datei nicht existiert oder entsprechende Zugriffsrechte fehlen. Der Typ Result teilt uns mit, ob der Versuch, die Datei zu öffnen, erfolgreich war. Wir können eine match Anweisung nutzen, um das Ergebnis zu überprüfen.

Programmtext 220: Eine Datei öffnen und auf Fehler prüfen

use std::fs::File;

fn main() {

let f = File::open("a.txt");

let f = match f {

Ok(file) => file,

Err(err) => panic!("Error while opening: {:?}", err)

};

}   

Das Enum Result ist automatisch verfügbar, sodass wir nicht Result:: vor Ok und Err schreiben müssen, sondern direkt auf diese Werte zugreifen können.

Aktuell überprüfen wir noch nicht, was genau der Grund für einen Fehler ist, falls der Fall Err eintritt. Wir könnten beispielsweise prüfen, ob der Fehler aufgetreten ist, weil die Datei nicht existiert und die Datei dann erstellen. Sollte der Fehler aus irgendeinem anderen Grund aufgetreten sein, so können wir einen anderen Weg wählen, damit umzugehen (zum Beispiel weiterhin panic! verwenden).

Programmtext 221: Eine Datei öffnen und den Fehlertyp unterscheiden

use std::fs::File;

use std::io::ErrorKind;

fn main() {

let f = File::open("a.txt");

let f = match f {

Ok(file) => file,

Err(err) => match err.kind() {

ErrorKind::NotFound => match File::create("a.txt") {

Ok(created_file) => created_file,

Err(e) => panic!("Cannot create file: {:?}", e)

},

other => panic!("Cannot open file: {:?}", other)

}

};

}   

Nun ist das Programm etwas komplizierter geworden: Wir prüfen zunächst, ob wir die angegebene Datei öffnen können. Falls nicht, überprüfen wir, warum ein Fehler aufgetreten ist. Sollte ein Fehler aufgetreten sein, weil die Datei nicht existiert, so versuchen wir nun, die Datei zu erstellen. Sollte dies ebenfalls fehlschlagen, dann führen wir den Makro panic! aus. Wenn die Datei aus irgendeinem anderen Grund nicht geöffnet werden konnte, dann führen wir den Makro panic! ebenfalls aus.

An dieser Stelle können wir feststellen, dass wir viele verschachtelte match Anweisungen brauchen, nur um eine Datei zu öffnen (beziehungsweise sie zu erstellen, falls sie noch nicht existiert hat). Der Code wird dadurch ab einem gewissen Punkt sehr unübersichtlich. Es gibt einige Hilfsmittel, welche es erlauben, das gleiche Programm etwas übersichtlicher auszudrücken. Wir sehen uns nun einige Methoden an, die uns dabei helfen, unseren Programmcode präzise zu halten.

Da es relativ häufig vorkommt, dass man den Typ Result weiterverarbeiten möchte, gibt es bereits vordefinierte Methoden, die einem die Arbeit mit dem Typ Result erleichtern sollen. Betrachten wir erneut den folgenden Programmausschnitt:

Programmtext 222: Eine Datei öffnen und auf Fehler prüfen

use std::fs::File;

fn main() {

let f = File::open("a.txt");

let f = match f {

Ok(file) => file,

Err(err) => panic!("Error while opening: {:?}", err)

};

}   

Diesen Programmcode können wir auch präziser ausdrücken, indem wir die Methode unwrap einsetzen:

Programmtext 223: Die Methode unwrap

use std::fs::File;

fn main() {

let f = File::open("a.txt").unwrap();

}   

Zunächst sehen wir, dass der Programmcode deutlich kürzer und übersichtlicher geworden ist. Die Funktionalität bleibt dabei dieselbe: Sollte das Ergebnis ein Wert innerhalb von Ok sein, so gibt unwrap diesen Wert zurück. Wenn das Ergebnis hingegen von der Variante Err ist, wird der Makro panic! ausgeführt.

Neben unwrap gibt es auch noch die Methode expect, welche sehr ähnlich zu unwrap ist. Wir können der Methode expect aber noch eine Fehlermeldung als Parameter übergeben, die dann verwendet wird, falls der Makro panic! aufgerufen wird. Somit können wir eine eigene Fehlermeldung festlegen, statt eine Standardmeldung zu verwenden.

Programmtext 224: Die Methode expect

use std::fs::File;

fn main() {

let f = File::open("a.txt").expect("Cannot open a.txt");

}   

Auf diese Art und Weise ist es viel einfacher, einen Fehler zu finden, da wir eine spezifische Fehlermeldung angeben können.

Wenn wir eine Funktion haben, die einen Fehler hervorrufen kann (zum Beispiel eine Funktion, die den Inhalt einer Datei liest), dann kann diese Funktion den Fehler auch zurückgeben. Somit können wir an der Stelle, wo diese Funktion aufgerufen wurde, entscheiden, wie mit dem Fehler umgegangen werden soll. Wenn wir uns das folgende Beispiel ansehen, dann stellen wir fest, dass die Funktion read_file einen Fehler zurückgibt, wenn die Operation fehlschlagen sollte:

Programmtext 225: Fehler zurückgeben

use std::fs::File;

use std::io;

use std::io::Read;

fn main() {

let a = read_file("a.txt");

println!("a = {:?}", a);

}

fn read_file(file_name: &str) -> Result<String, io::Error> {

let f = File::open(file_name);

let mut f = match f {

Ok(file) => file,

Err(err) => return Err(err)

};

let mut s = String::new();

match f.read_to_string(&mut s) {

Ok(_) => Ok(s),

Err(err) => Err(err)

}

}   

Die Funktion read_file öffnet zunächst die angegebene Datei und prüft dann, ob dies erfolgreich war. Falls nicht, wird direkt der Wert Err vom Typ Result zurückgegeben. Anderenfalls speichert die Variable f nun die geöffnete Datei, um damit weiterarbeiten zu können.

Im nächsten Schritt versuchen wir, den Inhalt der Datei mit Hilfe der Methode read_to_string zu lesen. Wenn die Operation erfolgreich beendet werden kann, dann geben wir den Wert Ok mit dem gelesenen String zurück. Auch dieser Wert ist vom Typ Result. Ansonsten geben wir erneut den Wert Err zurück.

Aufgrund dessen, dass die beiden Fehler, die auftreten können, beide vom Typ io::Error sind, ist der Rückgabetyp der Funktion Result<String, io::Error>.

Sollte nun tatsächlich ein Fehler auftreten, so kann die aufrufende Funktion entscheiden, wie damit umgegangen werden soll. Beispielsweise kann der Makro panic! verwendet werden oder a als leerer String initialisiert werden.

Sollte die Datei existieren, so wird der Inhalt (verpackt in Ok) ausgegeben. Ist die angegebene Datei nicht vorhanden, dann erhalten wir die folgende Ausgabe:

a = Err(Os { code: 2, kind: NotFound, message: "Das System kann die angegebene Datei nicht finden." })

Der Vorteil bei diesem Ansatz ist, dass wir jedes Mal, wenn wir die Funktion read_file aufrufen möchten, festlegen können, wie wir mit einem möglichen Fehler umgehen. Würden wir direkt in der Funktion read_file den Makro panic! auslösen, so hätten wir diese Flexibilität nicht. Daher ist es oft sinnvoll, Fehler auf diese Art und Weise »weiterzugeben«.

Um Fehler einfacher weiterreichen zu können, gibt es in Rust den Operator ?. Wir können das obige Programm damit folgendermaßen ausdrücken:

Programmtext 226: Der Operator ?

use std::fs::File;

use std::io;

use std::io::Read;

fn main() {

let a = read_file("a.txt");

println!("a = {:?}", a);

}

fn read_file(file_name: &str) -> Result<String, io::Error> {

let mut f = File::open(file_name)?;

let mut s = String::new();

f.read_to_string(&mut s)?;

Ok(s)

}   

Der Programmcode ist deutlich übersichtlicher geworden. Der Operator ? sorgt nämlich dafür, dass wir auf die match Anweisungen verzichten können.

Läuft eine Operation erfolgreich und liefert somit Ok zurück, so wird der Wert innerhalb von Ok zurückgegeben. Falls die Operation fehlschlägt und Err liefert, so wird Err als Ergebnis der übergeordneten Funktion zurückgegeben. Das ist daher analog zu einer Nutzung des Schlüsselwortes return.

Es gibt aber einen kleinen Unterschied zwischen dem Operator ? und der match Anweisung zuvor: Der Operator ? versucht, den Typ des Fehlers, der aufgetreten ist, in den Typ des Fehlers des Rückgabewertes der Funktion umzuwandeln, sofern die Typen unterschiedlich sein sollten. Falls unterschiedliche Fehlertypen in einer Funktion möglich sind, dann können wir trotzdem einen Typ als Rückgabewert der übergeordneten Funktion festlegen und der Operator ? kümmert sich um den Rest.

Da wir nun wissen, dass der Operator ? den Wert innerhalb von Ok zurückgibt, wenn kein Fehler auftritt, können wir den Programmcode noch weiter vereinfachen. Wir können direkt auf dem zurückgegebenen Wert weiterarbeiten, da die Funktion vorher beendet werden würde, falls ein Fehler auftreten sollte. Das nächste Beispiel zeigt, wie wir unser Programm wiederum etwas abkürzen können.

Programmtext 227: Weitere Vereinfachungen durch den Operator ?

use std::fs::File;

use std::io;

use std::io::Read;

fn main() {

let a = read_file("a.txt");

println!("a = {:?}", a);

}

fn read_file(file_name: &str) -> Result<String, io::Error> {

let mut s = String::new();

File::open(file_name)?.read_to_string(&mut s)?;

Ok(s)

}   

Wir lassen die Variable f nun komplett weg und lesen den Inhalt der Datei direkt in die Variable s ein, indem wir die Funktionsaufrufe aneinanderreihen.

Der Operator ? darf nur in Funktionen eingesetzt werden, die ein Ergebnis vom Typ Result zurückgeben. Damit auch in der Funktion main mit dem Operator ? gearbeitet werden kann, ist es auch möglich, dass die Funktion main den Rückgabetyp Result erhält.

Programmtext 228: Der Operator ? in der Funktion main

use std::error::Error;

use std::fs::File;

fn main() -> Result<(), Box<dyn Error>> {

let f = File::open("a.txt")?;

Ok(())

}   

Sollte kein Fehler auftreten, so ist der in Ok enthaltene Wert das leere Tupel. Der dazu passende Datentyp ist der Typ Unit. Der Typ Box<dyn Error> kann als »beliebiger Fehler« gesehen werden. Wir werden darauf in Kürze noch zu sprechen kommen.

Bisher haben die Beispiele in diesem Kapitel immer von dem Lesen aus einer Textdatei gehandelt. Des Weiteren können wir in eine Textdatei schreiben. Dazu gibt es die Funktion write, welche folgendermaßen verwendet werden kann:

Programmtext 229: Schreiben in eine Textdatei mit Hilfe von write

use std::error::Error;

use std::fs;

fn main() -> Result<(), Box<dyn Error>> {

fs::write("hallo.txt", "Hallo, Welt!")?;

Ok(())

}   

Führen wir dieses Programm aus, so wird der Text »Hallo, Welt!« in eine Textdatei mit dem Namen hallo.txt geschrieben. Ist die Datei bereits vorhanden, so wird der Inhalt darin überschrieben. Falls die Datei noch nicht existieren sollte, wird sie zunächst erstellt.

Zusammenfassung dieses Kapitels

Die Ausführung eines Programmes kann mit dem Makro panic! beendet werden.

Der Typ Result<T, E> erlaubt es, Fehler geeignet zu behandeln.

Die Methoden unwrap und expect sowie der Operator ? erleichtern den Umgang mit Result<T, E>.

Aufgabe 10

Schreiben Sie ein Programm, welches zuerst eine Zahl x von dem Nutzer erwartet, dann x-mal einen Münzwurf ausführt und abschließend das Ergebnis in einer Textdatei speichert.

Erweitern Sie das Programm nun dahingehend, dass die Textdatei nicht überschrieben wird, falls sie bereits existiert. Stattdessen sollen die Werte darin gelesen werden und zu der aktuellen Runde addiert werden, sodass das Programm mehrfach hintereinander ausgeführt werden kann und die Ergebnisse vorheriger Durchläufe dabei nicht verloren gehen. Beispiel: Angenommen, der Inhalt der Textdatei sieht zu Beginn wie folgt aus:

25
25
50

Dieses Format ist folgendermaßen zu interpretieren: Die erste Zahl gibt an, wie häufig das Ergebnis »Kopf« bisher vorgekommen ist. Die zweite Zahl steht analog für die Häufigkeit von »Zahl« und die dritte Zahl beschreibt die bisherige Anzahl an Münzwürfen.

Wenn das Programm nun ausgeführt wird und 30-mal »Kopf« sowie 20-mal »Zahl« liefert, dann soll die Textdatei anschließend derart aussehen:

55
45
100

Es darf gerne auch ein anderes Format zur Darstellung der Ergebnisse verwendet werden. Tipp: Zeilenumbrüche können durch das Sonderzeichen \n dargestellt werden. Mit Hilfe von s.split("\n").collect() können wir den Inhalt der Textdatei in einen Vektor einlesen, sodass jede Zeile einen eigenen Eintrag bekommt. Für das obige Beispiel würden wir den Vektor [25, 25, 50] erhalten (beachten Sie, dass die Einträge des Vektors als Zeichenketten repräsentiert werden und noch in eine Zahl umgewandelt werden müssen).

Generics, Traits, Lifetimes

Wir haben bereits an einigen Stellen gesehen, wie generische Typen aussehen. Generische Typen erlauben es uns, eine Funktionalität nicht nur für genau einen Datentyp zur Verfügung zu stellen, sondern für beliebige Datentypen.

Beispielsweise können Optionen für beliebige Typen verwendet werden, da ein generischer Parameter eingesetzt wird. Wir werden in diesem Kapitel lernen, wie auch wir eigene Definitionen mit generischen Parametern versehen können. Ebenfalls wollen wir uns die Konzepte von Traits und Lifetimes ansehen.

Wir beginnen dabei zunächst mit generischen Typen.

Generische Typen

Um unseren Programmcode übersichtlich und leicht anpassbar zu gestalten, sind Funktionen ein wichtiger Bestandteil von Programmen. Wenn wir ein bestimmtes Problem mehrfach lösen möchten, dann können wir eine Funktion definieren und diese dann mehrfach aufrufen. Es wäre eine sehr schlechte Idee, die Problemlösung mehrfach zu kopieren, statt eine Funktion zu verwenden.

Nehmen wir einmal an, wir möchten das größte Element in einer Liste von Elementen finden. Wir könnten den folgenden Programmcode verwenden, um dieses Ziel zu erreichen:

Programmtext 230: Das größte Element in einer Liste von Elementen finden

fn main() {

let list = vec![1,2,3,4,5];

let mut max = list[0];

for &num in &list {

if max < num {

max = num;

}

}

println!("max = {}", max);

}   

Das Programm funktioniert wunderbar und wir erhalten die folgende Ausgabe:

max = 5

Wenn wir nun jeweils das größte Element in zwei verschiedenen Listen finden möchten, dann können wir den Code duplizieren. Eine mögliche Lösung wäre die Folgende, in welcher identischer Programmcode doppelt vorkommt:

Programmtext 231: Das jeweils größte Element in zwei Listen finden

fn main() {

let list1 = vec![1,2,3,4,5];

let mut max1 = list1[0];

for &num in &list1 {

if max1 < num {

max1 = num;

}

}

println!("max1 = {}", max1);

let list2 = vec![6,7,8];

let mut max2 = list2[0];

for &num in &list2 {

if max2 < num {

max2 = num;

}

}

println!("max2 = {}", max2);

}   

max1 = 5
max2 = 8

Das Ergebnis ist weiterhin vollkommen korrekt, aber dieses Vorgehen ist nicht sinnvoll. Unser Programm wird schnell sehr lang und unübersichtlich. Gleichzeitig müssten wir an vielen Stellen Änderungen vornehmen, wenn wir den gewählten Lösungsweg später anpassen wollen. Das kann zu unnötigen Fehlern führen. Besser ist es, eine Funktion zu definieren und diese dann mehrfach aufzurufen.

Programmtext 232: Eine Funktion zum Finden des Maximums einer Liste

fn main() {

let list1 = vec![1,2,3,4,5];

println!("max1 = {}", max(&list1));

let list2 = vec![6,7,8];

println!("max2 = {}", max(&list2));

}

fn max(list: &[i32]) -> i32 {

let mut max = list[0];

for &num in list {

if max < num {

max = num;

}

}

max

}   

max1 = 5
max2 = 8

Nun haben wir eine Funktion definiert, die wir verwenden können, um das größte Element einer Liste von Zahlen vom Typ i32 zu bestimmen.

Falls wir nun das größte Element einer Liste von Zahlen vom Typ f32 herausfinden möchten, dann können wir diese Funktion dazu leider nicht nutzen. Wir könnten eine weitere Funktion definieren, aber dann hätten wir wieder dasselbe Problem wie bereits zuvor: Wir hätten duplizierten Programmcode.

Generische Datentypen können uns dabei helfen, dieses Problem zu beheben. Dazu ändern wir unsere Funktion max ein wenig ab:

Programmtext 233: Eine Funktion mit generischem Parameter

fn main() {

let list1 = vec![1,2,3,4,5];

println!("max1 = {}", max(&list1));

let list2 = vec![6,7,8];

println!("max2 = {}", max(&list2));

}

fn max<T>(list: &[T]) -> T {

let mut max = list[0];

for &num in list {

if max < num {

max = num;

}

}

max

}   

Dieser Programmcode funktioniert so zwar noch nicht, aber es ist bereits zu sehen, wie ein generischer Parameter verwendet werden kann. Es ist üblich, generische Parameter als T zu bezeichnen. Der Grund dafür, dass dieses Programm noch fehlerhaft ist, liegt in der Zeile if max < num: Die Variable max ist vom Typ T, aber wir haben nirgendwo sichergestellt, dass Elemente des Typs T mit dem Operator < verglichen werden können.

Dazu benötigen wir aber Traits, welche wir als nächstes kennenlernen werden. Bevor wir jedoch mit Traits weitermachen, sehen wir uns zunächst noch einige weitere Anwendungsbeispiele für generische Typen an.

Generische Typen können nicht nur in Funktionsdefinitionen eingesetzt werden, sondern auch für Structs oder Enums. Ein Struct mit einem generischen Typ könnte beispielsweise wie folgt definiert werden:

Programmtext 234: Ein Struct mit generischem Typ

struct Number<T> {

value: T

}

fn main() {

let int_val = Number{ value: 5 };

let float_val = Number{ value: 5.5 };

}   

Die Verwendung von generischen Typen bei Structs ist sehr ähnlich zu der Verwendung bei Funktionen. Sollten wir mehrere Felder innerhalb eines Structs haben, die mit dem Typ T gekennzeichnet sind, so gilt zu bedenken, dass der Typ T immer derselbe Typ sein muss.

Programmtext 235: Ein weiteres Struct mit generischem Typ

struct Point<T> {

x: T,

y: T

}

fn main() {

let p1 = Point{ x: 5, y: 5.5 }; // Fehler!

}   

Dieses Programm verursacht einen Fehler. In dem Moment, wo wir x den Wert 5 zuweisen, steht fest, dass T ein Datentyp für eine ganze Zahl sein muss. Danach versuchen wir aber, y (auch vom Typ T) eine Kommazahl zuzuweisen. Wenn wir mehrere generische Typen verwenden möchten, dann können wir das folgendermaßen tun:

Programmtext 236: Ein Struct mit mehreren generischen Typen

struct Point<T, U> {

x: T,

y: U

}

fn main() {

let p1 = Point{ x: 5, y: 5.5 };

}   

Jetzt ist wieder alles in Ordnung, weil x und y nicht mehr zwingend denselben Typ haben müssen. Es ist auch möglich, noch mehr generische Typen zu nutzen. Jedoch sollte man hierbei unbedingt darauf achten, dass der Code nicht zu unübersichtlich wird und man es mit der Anzahl an generischen Typen nicht übertreibt.

Enums können ebenfalls generische Typen verwenden. Ein solches Enum kennen wir sogar bereits: Das Enum Option.

Programmtext 237: Das Enum Option

enum Option<T> {

Some(T),

None,

}   

Dieses Enum verwendet generische Typen. Schließlich möchten wir es für beliebige Datentypen verwenden können. Enums können ebenfalls mehrere generische Typen benutzen. Auch hier kennen wir bereits einen Vertreter: Das Enum Result.

Programmtext 238: Das Enum Result

enum Result<T, E> {

Ok(T),

Err(E),

}   

Hier haben wir einen Typ für erfolgreiche Operationen (T) und einen Typ für fehlerhafte Operationen (E). Wir haben bereits gesehen, wie wir diese Parameter nutzen können, um zum Beispiel eine Datei zu lesen.

Methoden, die wir für Structs oder Enums implementieren, können ebenfalls generische Typen verwenden.

Programmtext 239: Methoden mit generischen Typen

struct SomeStruct<T> {

a: T,

b: T

}

impl<T> SomeStruct<T> {

fn some_method(&self) -> (&T, &T) {

(&self.a, &self.b)

}

}

fn main() {

let s = SomeStruct { a: 5, b: 10 };

let (a, b) = s.some_method();

println!("s.a = {}, s.b = {}", a, b);

}   

s.a = 5, s.b = 10

Direkt hinter dem Schlüsselwort impl geben wir den generischen Typ an, für den Methoden implementiert werden sollen. Wenn wir beabsichtigen, eine Methode nur für einen bestimmten Typ zu implementieren, während das Struct aber weiterhin generische Typen zulässt, dann können wir auch dies angeben:

Programmtext 240: Methoden für bestimmte Typen in generischen Structs

struct SomeStruct<T> {

a: T,

b: T

}

impl<T> SomeStruct<T> {

fn some_method(&self) -> (&T, &T) {

(&self.a, &self.b)

}

}

impl SomeStruct<i32> {

fn only_i32(&self) {

println!("only_i32()");

}

}

fn main() {

let s = SomeStruct { a: 5, b: 10 };

let (a, b) = s.some_method();

println!("s.a = {}, s.b = {}", a, b);

s.only_i32();

}   

s.a = 5, s.b = 10
only_i32()

Dabei geben wir keinen Typ explizit hinter dem Schlüsselwort impl an, sondern definieren diesen Block direkt für den Typ SomeStruct<i32>. Das bedeutet, dass nur Instanzen vom Typ SomeStruct<i32> auf diese Methode zugreifen können. Alle anderen Instanzen vom Typ SomeStruct<T> besitzen diese Methode nicht.

Die generischen Parameter einer Methode müssen dabei nicht dieselben wie die des Structs sein. Es ist auch möglich, weitere generische Parameter einzuführen, die sich möglicherweise von denen in der Definition des Structs unterscheiden.

Programmtext 241: Weitere generische Typen in der Methodendefinition

struct SomeStruct<T, U> {

a: T,

b: U

}

impl<T, U> SomeStruct<T, U> {

fn add<V, W>(&self, other: SomeStruct<V, W>) -> (&T, &U, V, W) {

(&self.a, &self.b, other.a, other.b)

}

}

fn main() {

let s1 = SomeStruct { a: 5, b: 10.5 };

let s2 = SomeStruct { a: true, b: 'c' };

let (a, b, c, d) = s1.add(s2);

println!("a = {}, b = {}, c = {}, d = {}", a, b, c, d);

}   

a = 5, b = 10.5, c = true, d = c

Die Methode add nimmt eine weitere Instanz vom Typ SomeStruct<T, U> als Parameter entgegen. Dabei müssen die generischen Parameter dieser Instanz jedoch nicht vom selben Typ sein wie die Parameter der aufrufenden Instanz. Es werden also einmal generische Parameter für das Struct selbst definiert und einmal für die Methode add.

Generische Typen können unseren Code vielseitig einsetzbar machen. Das Schöne dabei ist, dass die Verwendung von generischen Typen keine schlechtere Performance als die Verwendung von konkreten Typen aufweist. Der Rust Compiler kümmert sich nämlich beim Kompilieren darum, dass die generischen Typen durch konkrete Typen ersetzt werden. Wir bekommen davon nichts mit und freuen uns über eine gleichbleibende Performance.

Traits

Ein Trait teilt dem Compiler mit, dass ein Typ eine bestimmte Funktionalität besitzt. Zum Beispiel können wir einen Trait verwenden, um für einen generischen Typ anzugeben, dass dieser Platzhalter nur durch Typen ersetzt werden darf, welche eine bestimmte Funktionalität mitbringen.

Einen Trait definieren

Möchten wir einen Trait definieren, so verwenden wir das Schlüsselwort trait. Dahinter geben wir diesem Trait einen Namen und innerhalb des zugehörigen Blocks können wir nun festlegen, welche Methoden enthalten sein sollen.

Dabei geben wir nur die Namen der Methoden, die zugehörigen Parameterlisten und gegebenenfalls die Rückgabetypen an. Der Methodenrumpf wird an einer anderen Stelle implementiert.

Programmtext 242: Einen Trait definieren

pub trait Print {

fn print(&self);

}       

Beabsichtigen wir, dass eine Methode einen Wert zurückgibt, dann können wir die Notation fn some_function() -> <Typ>; nutzen, um den Rückgabetyp anzugeben (anstelle von <Typ> befindet sich dabei der tatsächliche Rückgabetyp). Ein Trait kann mehrere Methoden enthalten und ist ähnlich zu sogenannten Interfaces in einigen anderen Programmiersprachen.

Einen Trait implementieren

Sobald wir einen Trait definiert haben, können wir diesen Trait in einem impl Block implementieren. Die Syntax dazu sieht folgendermaßen aus:

Programmtext 243: Einen Trait implementieren

pub trait Print {

fn print(&self);

}

pub struct Rechteck {

breite: u32,

hoehe: u32

}

impl Print for Rechteck {

fn print(&self) {

println!("breite={}, hoehe={}", &self.breite, &self.hoehe);

}

}

pub struct Punkt {

x: i32,

y: i32

}

impl Print for Punkt {

fn print(&self) {

println!("x = {}, y = {}", &self.x, &self.y);

}

}

fn main() {

let r = Rechteck { breite: 10, hoehe: 15 };

let p = Punkt { x: 5, y: 10 };

r.print();

p.print();

}       

breite=10, hoehe=15
x = 5, y = 10

Wenn wir einen Trait implementieren, dann müssen wir alle in dem Trait enthaltenen Methoden definieren. Das bedeutet, dass wir nun den tatsächlichen Inhalt der Methoden festlegen.

Wir können bereits sehen, dass der Inhalt dieser Methoden unterschiedlich sein kann, wenn ein Trait von mehreren Structs implementiert wird. In diesem Beispiel gibt es nur eine Methode innerhalb des Traits Print, nämlich die Methode print. Diese wird von zwei Structs implementiert. Der Inhalt der Methode print ist dabei für beide Structs verschieden.

Nachdem ein Trait implementiert wurde, können wir die darin definierten Methoden wie gewohnt aufrufen.

Standardimplementierungen

Wenn wir einen Trait implementieren, dann müssen wir alle darin enthaltenen Methoden definieren. Manchmal kann es aber hilfreich sein, wenn wir einer Methode eine Standardimplementierung mitgeben können, sodass wir nicht jedes Mal, wenn wir einen Trait implementieren, alle darin enthaltenen Methoden definieren müssen.

Um eine solche Standardimplementierung zu verwenden, definieren wir eine Methode inklusive Methodenrumpf in der Trait Definition.

Programmtext 244: Eine Standardimplementierung einer Methode

pub trait Print {

fn print(&self) {

println!("Hallo, Welt!");

}

}       

Wenn wir diesen Trait nun implementieren, sind wir nicht mehr dazu gezwungen, die Methode print zu definieren. Lassen wir die Definition weg, so wird die Standardimplementierung verwendet.

Programmtext 245: Verwendung einer Standardimplementierung

pub trait Print {

fn print(&self) {

println!("Hallo, Welt!");

}

}

pub struct Rechteck {

breite: u32,

hoehe: u32

}

impl Print for Rechteck {

}

fn main() {

let r = Rechteck { breite: 10, hoehe: 15 };

r.print();

}       

Hallo, Welt!

Sollten wir trotzdem eine Definition für die Methode print hinzufügen, dann wird die Standardimplementierung überschrieben und ebendiese neue Definition der Methode verwendet.

Programmtext 246: Überschreiben einer Standardimplementierung

pub trait Print {

fn print(&self) {

println!("Hallo, Welt!");

}

}

pub struct Rechteck {

breite: u32,

hoehe: u32

}

impl Print for Rechteck {

fn print(&self) {

println!("breite={}, hoehe={}", &self.breite, &self.hoehe);

}

}

fn main() {

let r = Rechteck { breite: 10, hoehe: 15 };

r.print();

}       

breite=10, hoehe=15

Dieses Programm verhält sich nun erneut so, als würde keine Standardimplementierung vorhanden sein, weil wir die Standardimplementierung überschrieben haben.

Traits als Parameter

Wir können Traits auch dazu verwenden, um anzugeben, welche Typen als Parameter einer Funktion akzeptiert werden. Beispielsweise können wir verlangen, dass ein Parameter einen bestimmten Trait implementiert.

Programmtext 247: Traits als Parameter

pub trait Print {

fn print(&self);

}

pub struct Rechteck {

breite: u32,

hoehe: u32

}

impl Print for Rechteck {

fn print(&self) {

println!("breite={}, hoehe={}", &self.breite, &self.hoehe);

}

}

fn main() {

let r = Rechteck { breite: 10, hoehe: 15 };

some_function(r);

}

fn some_function(a: impl Print) {

a.print();

}       

breite=10, hoehe=15

Dabei geben wir nun keinen speziellen Typ für den Parameter a an, sondern verwenden das Schlüsselwort impl, um festzulegen, dass der Parameter den angegebenen Trait implementieren muss. Dadurch können wir uns sicher sein, dass alle in dem Trait Print enthaltenen Methoden definiert sind und somit problemlos auf diese Methoden zugreifen.

Ebenfalls möglich wäre es, die folgende Syntax zu verwenden:

Programmtext 248: Traits als Parameter

fn some_function<T: Print>(a: T) {

a.print();

}       

Der Effekt bleibt dabei derselbe. Diese Schreibweise kann beispielsweise etwas angenehmer sein, wenn wir mehrere Parameter haben, die alle denselben Trait implementieren sollen:

Programmtext 249: Traits als Parameter

fn some_function<T: Print>(a: T, b: T) {

// ...

}       

Im Vergleich dazu wäre die Schreibweise mit dem Schlüsselwort impl etwas länger:

Programmtext 250: Traits als Parameter

fn some_function(a: impl Print, b: impl Print) {

// ...

}       

Sollten wir die Schreibweise mit einem generischen Parameter wählen, so gilt zu beachten, dass beide Parameter denselben Typ T haben müssen. Möchten wir auch unterschiedliche Typen zulassen, die den Trait Print implementieren, dann können wir entweder die Variante mit dem Schlüsselwort impl verwenden oder einen zweiten generischen Parameter verwenden, der ebenfalls den Trait Print implementieren muss.

Programmtext 251: Traits als Parameter

fn some_function<T: Print, U: Print>(a: T, b: U) {

// ...

}       

Angenommen, wir möchten verlangen, dass ein Parameter mehrere Traits implementiert. Dazu verwenden wir den Operator +.

Programmtext 252: Mehrere Traits für einen Parameter

fn some_function(a: impl Trait1 + Trait2) {

// ...

}       

Hier würde also verlangt werden, dass der Parameter a die Traits Trait1 und Trait2 implementiert. Auch hier können wir wieder die alternative Schreibweise anwenden:

Programmtext 253: Mehrere Traits für einen Parameter

fn some_function<T: Trait1 + Trait2>(a: T) {

// ...

}       

Wenn wir nun eine Funktion mit mehreren Parametern haben, wobei jeder Parameter seine eigenen Traits implementieren soll, dann kann die Signatur der Funktion sehr lang und unübersichtlich werden:

Programmtext 254: Eine sehr lange Funktionssignatur

fn some_function<T: Trait1 + Trait2, U: Trait2 + Trait3>(a: T, b: U) {

// ...

}       

Deshalb gibt es die Möglichkeit, das Schlüsselwort where zu verwenden, um die Signatur der Funktion kurz und bündig zu halten.

Programmtext 255: Eine sehr lange Funktionssignatur

fn some_function<T, U>(a: T, b: U)

where T: Trait1 + Trait2,

U: Trait2 + Trait3

{

// ...

}       

Dadurch können wir dafür sorgen, dass Funktionsname, Parameterliste und Rückgabetyp dicht beieinander stehen.

Traits als Rückgabetyp

Wir können auch bei dem Rückgabetyp einer Funktion angeben, dass dieser Typ einen bestimmten Trait implementiert. Auch dazu benutzen wir das Schlüsselwort impl.

Programmtext 256: Traits als Rückgabetyp

pub trait Print {

fn print(&self);

}

pub struct Rechteck {

breite: u32,

hoehe: u32

}

impl Print for Rechteck {

fn print(&self) {

println!("breite={}, hoehe={}", &self.breite, &self.hoehe);

}

}

fn main() {

let r = some_function(10, 15);

r.print();

}

fn some_function(a: u32, b: u32) -> impl Print {

Rechteck { breite: a, hoehe: b }

}       

breite=10, hoehe=15

Auf diese Art und Weise können wir angeben, dass die Funktion some_function einen Typ zurückgibt, der den Trait Print implementiert. Den konkreten Typ geben wir dabei aber nicht an.

Soeben haben wir gelernt, wie wir mit Hilfe von Traits das Verhalten von Parametern festlegen können. Jetzt kommen wir auf das fehlerhafte Beispiel aus dem Abschnitt zu generischen Typen zurück, wo wir eine Funktion definieren wollten, die das größte Element in einer Liste von Elementen findet und dabei einen generischen Typ benutzt.

Programmtext 257: Funktion zum Finden des größten Elements in einer Liste

fn main() {

let list1 = vec![1,2,3,4,5];

println!("max1 = {}", max(&list1));

let list2 = vec![6,7,8];

println!("max2 = {}", max(&list2));

}

fn max<T>(list: &[T]) -> T {

let mut max = list[0];

for &num in list {

if max < num {

max = num;

}

}

max

}       

Das Problem bei diesem Programm: Wir versuchen, max mit num zu vergleichen, ohne vorher sichergestellt zu haben, dass Elements des Typs T überhaupt mit dem Operator < vergleichbar sind. In Rust gibt es den Trait std::cmp::PartialOrd, welcher von allen Typen implementiert wird, die sich vergleichen lassen. Wir können unser Programm daher wie folgt anpassen:

Programmtext 258: Der Trait std::cmp::PartialOrd

fn main() {

let list1 = vec![1,2,3,4,5];

println!("max1 = {}", max(&list1));

let list2 = vec![6,7,8];

println!("max2 = {}", max(&list2));

}

fn max<T: PartialOrd>(list: &[T]) -> T {

let mut max = list[0];

for &num in list {

if max < num {

max = num;

}

}

max

}       

Wir können direkt PartialOrd statt std::cmp::PartialOrd schreiben, weil der Trait bereits standardmäßig in unserem Scope vorhanden ist.

Jedoch gibt es immer noch ein Problem in unserem Programm: Dadurch, dass T ein beliebiger Typ sein kann, ist nicht sichergestellt, dass der Wert von list[0] in der Zeile let mut max = list[0]; in die Variable max kopiert wird. Es kann auch ein move stattfinden, falls der Typ T kein primitiver Typ ist.

Primitive Typen implementieren alle den Trait Copy, welcher dafür sorgt, dass Werte dieses Typs nicht bewegt, sondern kopiert werden. Wir können uns dies zunutze machen, indem wir fordern, dass an unsere Funktion übergebene Parameter den Trait Copy implementieren müssen.

Programmtext 259: Der Trait Copy

fn main() {

let list1 = vec![1,2,3,4,5];

println!("max1 = {}", max(&list1));

let list2 = vec![6,7,8];

println!("max2 = {}", max(&list2));

}

fn max<T: Copy + PartialOrd>(list: &[T]) -> T {

let mut max = list[0];

for &num in list {

if max < num {

max = num;

}

}

max

}       

max1 = 5
max2 = 8

Jetzt funktioniert unser Programm endlich! Fortan können wir die Funktion max zum Beispiel auch für Listen einsetzen, die aus Kommazahlen bestehen.

Methoden abhängig von Traits definieren

Bevor wir das Thema Traits abschließen, sehen wir uns noch an, wie wir Traits dazu nutzen können, um Methoden nur für Instanzen zu definieren, die bestimmte Traits implementieren. Dazu betrachten wir einmal das folgende Beispiel:

Programmtext 260: Methoden abhängig von Traits definieren

use std::fmt::Display;

struct SomeStruct<T> {

a: T,

b: T

}

impl<T> SomeStruct<T> {

fn new(a: T, b: T) -> Self {

Self { a, b }

}

}

impl<T: PartialOrd + Display> SomeStruct<T> {

fn max(&self) {

if &self.a > &self.b {

println!("max = {}", &self.a);

} else {

println!("max = {}", &self.b);

}

}

}

fn main() {

let s = SomeStruct::new(5, 10);

s.max();

}       

max = 10

In diesem Programm definieren wir zuerst ein Struct SomeStruct. Darauf folgt eine Funktion new, um eine neue Instanz dieses Structs zu erzeugen. Diese Funktion ist immer verfügbar. Im nächsten impl Block definieren wir eine Methode max, die aber nur für Datentypen, welche die Traits PartialOrd und Display implementieren, verfügbar ist. Zum einen fordern wir, dass der Trait PartialOrd implementiert sein muss, damit wir den Vergleich if &self.a > &self.b durchführen können. Zum anderen muss der Trait Display implementiert sein, damit wir einen Wert vom Typ T ausgeben lassen können.

Der Trait Display sorgt dafür, dass Elemente mittels {} formatiert werden können. Aufgrund dessen, dass der Trait Display nicht standardmäßig in unserem Scope verfügbar ist, brauchen wir noch die Zeile use std::fmt::Display; zu Beginn des Dokumentes.

Jetzt haben wir auch den Grund dafür kennengelernt, dass wir nicht alle Elemente mittels {} formatieren können. Immer, wenn wir stattdessen {:?} schreiben, ist nicht der Trait Display, sondern der Trait Debug implementiert (auch für die Formatierung mit Hilfe von {:#?} ist der Trait Debug nötig).

Darüber hinaus können wir einen Trait für jeden Datentyp implementieren, welcher selbst bestimmte Traits implementiert.

Programmtext 261: Traits abhängig von Traits implementieren

use std::fmt::Display;

trait Print {

fn print(&self);

}

impl<T: Display> Print for T {

fn print(&self) {

println!("{}", &self);

}

}

fn main() {

let a = 5;

a.print();

}       

5

Dieser Programmcode implementiert den Trait Print für alle Datentypen, die den Trait Display implementieren. Da der Datentyp i32 den Trait Display implementiert, ist nun auch die Methode print für diesen Typ definiert.

Lifetimes

Lifetimes sollen dafür sorgen, dass eine Referenz solange wie nötig gültig ist. Jede Referenz hat eine sogenannte Lifetime (Lebenszeit). Ähnlich wie auch Datentypen durch den Compiler festgestellt werden können, können auch Lifetimes häufig automatisch bestimmt werden. Es gibt aber auch hier einige Fälle, in denen wir die Lifetime explizit angeben müssen.

Lifetimes sollen auch dafür sorgen, dass es keine ungültigen Pointer (Zeiger) gibt. Das heißt, dass eine Referenz auf eine Speicherstelle nur dann existieren darf, wenn sichergestellt ist, dass diese Speicherstelle noch verwendet wird und nicht bereits freigegeben wurde. Sehen wir uns zunächst einmal das folgende Beispiel an:

Programmtext 262: Ungültige Referenzen

fn main() {

let a;

{

let b = 15;

a = &b;

}

println!("a = {}", a);

}   

Dieses Beispiel ist fehlerhaft und kompiliert nicht. Die Fehlermeldung teilt uns mit, dass die Referenz &b nicht ausreichend lange »lebt«. Wenn wir genau hinsehen, dann erkennen wir, dass die Variable b ihren Scope verlässt und ungültig wird, nachdem wir eine Referenz darauf erstellt haben. Die Referenz selbst bleibt weiterhin erhalten und wäre somit ungültig. Deshalb erhalten wir eine Fehlermeldung von dem Compiler. Der Compiler verhindert an dieser Stelle, dass eine ungültige Referenz existieren kann. Dazu wird der sogenannte Borrow Checker eingesetzt.

Sehen wir uns als nächstes an, warum es sinnvoll sein kann, Lifetimes explizit anzugeben. Dazu betrachten wir eine Funktion, die zwei Parameter vom Typ &str entgegennimmt und dann die längere dieser beiden Zeichenketten zurückgibt. Ohne explizit gesetzte Lifetimes erhalten wir dabei aber noch einen Fehler.

Programmtext 263: Eine fehlerhafte Funktion ohne explizite Lifetimes

fn main() {

let s1 = "Hallo, Welt!";

let s2 = String::from("Hallo!");

println!("{}", some_function(s1, s2.as_str()));

}

fn some_function(s1: &str, s2: &str) -> &str {

if s1.len() > s2.len() {

s1

} else {

s2

}

}   

Damit wir die übergebenen Parameter auch nach dem Aufruf der Funktion noch verwenden können, wollen wir der Funktion lediglich Referenzen übergeben. Zudem ist es auf diese Weise möglich, sowohl Slices eines String Typs als auch Parameter vom Typ &str zu übergeben.

Unser Beispiel hat nun folgende Probleme: Abhängig von der Auswertung der if Abfrage geben wir entweder eine Referenz auf s1 oder eine Referenz auf s2 zurück. Der Compiler weiß nicht, was nun zurückgegeben wird, weil dies erst während der Laufzeit des Programms ermittelt wird. Außerdem kann der Borrow Checker nicht herausfinden, wie die Lifetimes von s1 und s2 mit der Lifetime des Rückgabewertes zusammenhängen. Wir können nun explizit Lifetimes angeben, damit dieses Problem behoben wird.

Durch eine explizite Angabe der Lifetimes können wir die Lifetimes der Variablen nicht beeinflussen, sondern teilen dem Compiler lediglich mit, wie die Lifetimes der Variablen aussehen. Solche sogenannten Annotationen beschreiben also die Beziehung zwischen den Lifetimes von verschiedenen Referenzen, ohne die Lifetimes selbst zu beeinflussen.

Eine Lifetime Annotation beginnt mit einem Apostroph ('), gefolgt von dem Namen der Referenz. Vor dem Apostroph befindet sich das Zeichen &, um anzugeben, dass es sich um eine Referenz handelt. Üblicherweise wird der Bezeichner 'a gewählt.

Kommen wir nun zu unserem Programmbeispiel zurück. Wie generische Typen geben wir auch generische Lifetimes in spitzen Klammern an, also zum Beispiel durch <'a>. Wenn wir diese Lifetime 'a nun zu beiden Parametern hinzufügen, geben wir damit an, dass beide Parameter dieselbe Lifetime haben müssen. Der nachfolgende Programmcode beinhaltet nun generische Lifetimes.

Programmtext 264: Eine Funktion mit generischen Lifetimes

fn main() {

let s1 = "Hallo, Welt!";

let s2 = String::from("Hallo!");

println!("{}", some_function(s1, s2.as_str()));

}

fn some_function<'a>(s1: &'a str, s2: &'a str) -> &'a str {

if s1.len() > s2.len() {

s1

} else {

s2

}

}   

Jetzt kompiliert das Programm fehlerfrei und liefert die nachfolgende Ausgabe:

Hallo, Welt!

Der Rust Compiler weiß nun, dass beide Parameter mindestens so lange »leben« wie die Lifetime 'a. Gleiches gilt für den durch die Funktion zurückgegebenen Wert. Damit ist die Lifetime der zurückgegebenen Referenz gleich der kleineren Lifetime der beiden Parameter (weil die zurückgegebene Referenz mindestens so lange »lebt« wie die kleinere der beiden Lifetimes der Parameter).

Wie bereits erwähnt, ändert eine solche Annotation die Lifetimes der Variablen nicht, sondern teilt dem Compiler lediglich mit, dass übergebene Parameter diese Voraussetzungen erfüllen müssen. Wie auch bei generischen Typen wird die generische Lifetime 'a durch eine konkrete Lifetime ersetzt (nämlich durch die kürzeste Lifetime der Parameter). Da auch der Rückgabewert mit 'a annotiert ist, muss auch dieser so lange wie die kürzeste Lifetime der Parameter gültig sein.

Das folgende Beispiel soll verdeutlichen, wozu es nützlich ist, dass der Compiler gewisse Lifetimes der Parameter sicherstellt.

Programmtext 265: Lifetimes bei Rückgabewerten

fn main() {

let s1 = "Hallo, Welt!";

let return_value;

{

let s2 = String::from("Hallo!");

return_value = some_function(s1, s2.as_str());

}

println!("{}", return_value);

}

fn some_function<'a>(s1: &'a str, s2: &'a str) -> &'a str {

if s1.len() > s2.len() {

s1

} else {

s2

}

}   

Dieses Programm ist erneut fehlerhaft, weil die Variable return_value nicht ausreichend lange gültig ist. Wir haben zuvor festgestellt, dass der zurückgegebene Wert die kleinste Lifetime der Parameter besitzt. Da die Variable s2 die kleinere Lifetime hat, wird diese Lifetime auch für die Variable return_value übernommen. Die Variable s2 ist aber bereits vor dem println! Aufruf nicht mehr gültig. Somit gilt dasselbe für return_value. Würde der Compiler diesen Fall nicht verhindern, dann würden wir nun innerhalb von println! versuchen, auf eine Variable zuzugreifen, die nicht mehr gültig ist. Der Compiler bewahrt uns vor solchen Fehlern. Dazu müssen wir dem Compiler aber manchmal die Lifetimes der Parameter mitteilen, da er diese nicht immer herausfinden kann.

Selbstverständlich müssen nicht alle Parameter mit Lifetimes annotiert werden, wenn der Rückgabewert nicht durch alle Parameter beeinflusst werden kann. Sollte der zurückgegebene Wert beispielsweise ausschließlich von dem ersten Parameter abhängen, so würden wir die Annotation bei allen weiteren Parametern weglassen.

Es gibt auch Spezialfälle, in denen Lifetime Annotationen weggelassen werden können, weil der Compiler die Lifetimes erraten kann. Das ist aber nur in seltenen Fällen der Fall. Der Compiler verwendet die folgenden drei Regeln, um Lifetime Annotationen zu ermitteln:

Jeder Parameter, der eine Referenz ist, bekommt seine eigene Lifetime zugewiesen. Beispiel:

fn some_function(p1: &str, p2: &str)

wird zu

fn some_function<'a, 'b>(p1: &'a str, p2: &'b str).

Wenn es genau einen Parameter mit einer zugewiesenen Lifetime gibt, dann wird diese Lifetime allen Rückgabewerten zugewiesen. Beispiel:

fn some_function<'a>(p1: &'a str) -> &str

wird zu

fn some_function<'a>(p1: &'a str) -> &'a str.

Wenn es mehrere Parameter geben sollte, aber einer davon &self oder &mut self ist, dann wird die Lifetime von self allen Rückgabewerten zugewiesen.

Sollte der Compiler keine dieser Regeln anwenden können und es gibt weiterhin Referenzen ohne explizit angegebene Lifetimes, dann erhalten wir eine Fehlermeldung, weil der Compiler diese Lifetimes nicht automatisch ermitteln kann.

Lifetimes in Structs

Structs können auch Referenzen enthalten. Dann ist aber eine Lifetime Annotation notwendig.

Programmtext 266: Ein Struct mit Referenzen

struct SomeStruct<'a> {

a: &'a str

}       

Eine solche Annotation bedeutet, dass eine Instanz des Structs nicht länger leben kann als die darin enthaltene Referenz.

Wenn wir Methoden für ein Struct mit Lifetimes implementieren möchten, dann verwenden wir auch dort die Lifetime Parameter.

Programmtext 267: Lifetimes in Methodendefinitionen

struct SomeStruct<'a> {

a: &'a str

}

impl<'a> SomeStruct<'a> {

fn some_method(&self) -> &str {

&self.a

}

}

fn main() {

let s = SomeStruct { a: "Hallo" };

let a = s.some_method();

println!("a = {}", a);

}       

a = Hallo

In diesem Beispiel haben wir bei der Definition der Methode keine Lifetimes explizit angegeben. Das ist aufgrund der zuvor vorgestellten Regeln nicht nötig. Wir könnten die Lifetimes dennoch explizit angeben und würden genau das gleiche Resultat erzielen.

Die Lifetime static

static ist eine besondere Lifetime, die angibt, dass eine Referenz für die Dauer des gesamten Programmes leben kann. Sie kann folgendermaßen verwendet werden:

Programmtext 268: Die Lifetime static

fn main() {

let s: &'static str = "Hallo, Welt!";

}       

Die Lifetime eines String Literals ist immer static, da der Text einer solchen Zeichenkette direkt in der Binärdatei des Programmes fest kodiert wird.

Zusammenfassung dieses Kapitels

Generische Typen können dazu verwendet werden, Definitionen für beliebige Typen zu erstellen.

Traits erlauben es, bestimmte Funktionalitäten vorzugeben, die dann konkret implementiert werden müssen.

Lifetimes verhindern ungültige Referenzen und können bereits vor der Laufzeit durch den Compiler überprüft werden.

Tests

Tests können Fehler aufdecken, aber niemals beweisen, dass ein Programm fehlerfrei ist. Tests sind eine wichtige Hilfe, um die Korrektheit eines Programmes weitestgehend sicherzustellen.

Dieses Kapitel soll zeigen, wie wir Tests schreiben können, um unseren Programmcode automatisiert testen zu können. Der Compiler stellt bereits einige Eigenschaften sicher, die dazu beitragen, dass unser Programm möglichst korrekt arbeitet. Jedoch prüft der Compiler nicht, ob unser Programm wirklich das tut, was es tun soll.

Testfunktionen sind Funktionen, welche überprüfen, ob die anderen Funktionen (die nicht für Testzwecke gedacht sind) so funktionieren, wie man es erwarten würde.

Für die folgenden Beispiele erstellen wir ein neues Projekt mit Hilfe von Cargo. Dann können wir alle Testfunktionen mit Hilfe des Kommandos cargo test ausführen.

Um eine Funktion als Testfunktion zu markieren, benutzen wir das Attribut #[test]. Wenn wir dann cargo test ausführen, dann werden alle Funktionen, die mit diesem Attribut gekennzeichnet sind, ausgeführt.

Programmtext 269: Das Attribut #[test]

#[test]

fn some_test() {

}

Wenn wir eine Programmbibliothek erstellen (cargo new <Name> --lib), dann enthält die automatisch generierte Datei lib.rs auch bereits einige Zeilen an Testcode.

Programmtext 270: Die Struktur eines Testmoduls

#[cfg(test)]

mod tests {

#[test]

fn it_works() {

assert_eq!(2 + 2, 4);

}

}

Damit können wir direkt loslegen, eigene Tests zu schreiben, ohne jedes Mal die Struktur nachschauen zu müssen. Innerhalb des Testmoduls ist es weiterhin nötig, alle Testfunktionen als solche zu kennzeichnen. Es ist nämlich möglich, dass dieses Modul Funktionen enthält, die nicht für das Testen bestimmt sind.

Der Makro assert_eq! fordert, dass die beiden übergebenen Parameter gleich sein müssen. Sollten die Parameter nicht denselben Wert haben, so schlägt der Testfall fehl. In diesem Fall muss also 2 + 2 = 4 gelten. Führen wir nun cargo test aus, so stellen wir fest, dass alle Tests erfolgreich absolviert wurden.

Wir können nun weitere Testfunktionen hinzufügen. Das nachfolgende Beispiel besitzt nun einen zweiten Testfall, der immer fehlschlägt.

Programmtext 271: Einen Test fehlschlagen lassen

#[cfg(test)]

mod tests {

#[test]

fn it_works() {

assert_eq!(2 + 2, 4);

}

#[test]

fn it_panics() {

panic!("it_panics() failed!");

}

}

Sobald wir die Tests nun ausführen, sehen wir, dass ein Test erfolgreich war und ein Test fehlgeschlagen ist. Wir erhalten auch eine Information, welcher Test fehlgeschlagen ist und welche Ausgabe dabei produziert wurde.

Um Ergebnisse zu überprüfen, ist der Makro assert! häufig hilfreich. Dieser prüft, ob eine übergebene Bedingung erfüllt ist (zu true auswertet). Wenn die übergebene Bedingung erfüllt ist, so ist der Test bestanden, ansonsten nicht (intern wird der Makro panic! aufgerufen, falls die Bedingung zu false auswertet). Betrachten wir dazu doch einmal das folgende Beispiel:

Programmtext 272: Ein Test für die Methode is_square

struct Rechteck {

breite: u32,

hoehe: u32

}

impl Rechteck {

fn is_square(&self) -> bool {

&self.breite == &self.hoehe

}

}

#[cfg(test)]

mod tests {

use super::*;

#[test]

fn rect_is_square() {

let r = Rechteck {breite: 5, hoehe: 5};

assert!(r.is_square());

}

}

Hier testen wir, ob die Methode is_square für das gegebene Rechteck den richtigen Wert zurückgibt. Wir können auch sehen, dass die Zeile use super::*; neu hinzugekommen ist. Ohne diese Zeile könnten wir innerhalb des Moduls tests nicht auf das Struct Rechteck zugreifen, da dieses außerhalb des Moduls tests definiert wird.

Wir können nun einen weiteren Test hinzufügen, der sicherstellt, dass ein Rechteck mit unterschiedlich langen Seiten kein Quadrat sein darf:

Programmtext 273: Ein weiterer Test für die Methode is_square

struct Rechteck {

breite: u32,

hoehe: u32

}

impl Rechteck {

fn is_square(&self) -> bool {

&self.breite == &self.hoehe

}

}

#[cfg(test)]

mod tests {

use super::*;

#[test]

fn rect_is_square() {

let r = Rechteck {breite: 5, hoehe: 5};

assert!(r.is_square());

}

#[test]

fn rect_is_no_square() {

let r = Rechteck {breite: 5, hoehe: 15};

assert!(!r.is_square());

}

}

Um sicherzustellen, dass eine Bedingung nicht gelten darf, können wir den Operator ! zur Negation verwenden. Angenommen, wir hätten nun einen Fehler (Bug) in unserem Code. Dann sollte im Optimalfall mindestens einer der Testfälle fehlschlagen, sodass wir merken, dass etwas nicht stimmt.

Neben dem Makro assert! sind die Makros assert_eq! und assert_ne! noch wichtig. Wenn wir auf einen bestimmten Wert prüfen möchten, könnten wir den Makro assert! in Kombination mit dem Operator == verwenden.

Einfacher geht es, indem wir den Makro assert_eq! verwenden. Dieser stellt sicher, dass zwei Werte gleich sind (analog dazu stellt assert_ne! sicher, dass zwei Werte ungleich sind). Außerdem werden die verglichenen Werte auch ausgegeben, falls der Test fehlschlagen sollte (das wäre bei assert! nicht der Fall).

Somit können wir leichter nachvollziehen, was genau schiefgelaufen ist. Folgendes Beispiel demonstriert die Nutzung des Makros assert_eq!:

Programmtext 274: Der Makro assert_eq!

fn square(x: i32) -> i32 {

x * x

}

#[cfg(test)]

mod tests {

use super::*;

#[test]

fn it_squares() {

assert_eq!(144, square(12));

}

}

Ebenfalls möglich ist es, benutzerdefinierte Fehlermeldungen anzugeben. Diese werden dann angezeigt, wenn ein Test fehlschlagen sollte.

Programmtext 275: Benutzerdefinierte Fehlermeldungen

fn square(x: i32) -> i32 {

x * x

}

#[cfg(test)]

mod tests {

use super::*;

#[test]

fn it_squares() {

assert_eq!(144, square(12), "12 * 12 sollte 144 ergeben");

}

}

Wenn der Test nun fehlschlagen sollte, wird die übergebene Fehlermeldung ausgegeben. Wir können auf dieselbe Art auch bei den Makros assert! und assert_ne! benutzerdefinierte Fehlermeldungen angeben.

Darüber hinaus kann es vorkommen, dass wir testen möchten, ob unser Programm eventuell auftretende Fehler korrekt behandelt.

Das Attribut #[should_panic] bietet uns die Möglichkeit, zu prüfen, ob ein bestimmter Testfall dazu führt, dass das Programm abstürzt.

Programmtext 276: Das Attribut #[should_panic]

fn first<'a, T>(x: &'a Vec<T>) -> &'a T {

&x[0]

}

#[cfg(test)]

mod tests {

use super::*;

#[test]

#[should_panic]

fn it_panics() {

let v: Vec<i32> = Vec::new();

first(&v);

}

}

In diesem Beispiel haben wir eine Funktion first, welche das erste Element eines Vektors zurückgibt. Sollte der Vektor jedoch leer sein, so sollte die Funktion zu einem panic! führen. Unser Testfall stellt sicher, dass ein leerer Vektor als Parameter ein panic! als Folge hat.

Da der Datentyp Vec bereits vordefiniert ist, können wir davon ausgehen, dass ein Zugriff auf einen leeren Vektor zu einem Fehler führt. Deshalb ist der obige Testfall natürlich nicht sonderlich sinnvoll, sondern sollte lediglich zeigen, wie das Attribut #[should_panic] verwendet werden kann. Sinnvoller wäre es, wenn wir eine Funktion haben, die nur Werte in einem bestimmten Bereich akzeptieren soll und bei fehlerhaften Werten zu einem panic! führt.

Nun kann es natürlich vorkommen, dass der Testfall erfolgreich ist, wenn der Code aus einem anderen Grund ein panic! verursacht hat. Das wollen wir natürlich verhindern. Deshalb gibt es noch den optionalen Parameter expected, den wir dem Attribut #[should_panic] hinzufügen können. Dieser stellt sicher, dass die Fehlermeldung von panic! den übergebenen Text enthält. Dabei ist es ausreichend, dass der Text enthalten ist. Der Text muss nicht exakt derselbe sein wie die Fehlermeldung. Das nächste Beispiel stellt dies dar.

Programmtext 277: Der optionale Parameter expected

fn some_function(val: i32) {

if val <= 0 {

panic!("val is too small! Must be greater than 0.");

} else if val >= 1000 {

panic!("val is too big! Must be smaller than 1000.");

}

}

#[cfg(test)]

mod tests {

use super::*;

#[test]

#[should_panic(expected="val is too small!")]

fn too_small() {

some_function(-5);

}

#[test]

#[should_panic(expected="val is too big!")]

fn too_big() {

some_function(5000);

}

}

Nehmen wir für dieses Beispiel an, dass die Funktion some_function nur Werte größer 0 und kleiner 1000 akzeptiert. In allen anderen Fällen soll ein panic! ausgelöst werden. Wir können nun für jeden Testfall explizit angeben, welche Fehlermeldung erwartet wird. Ohne den Parameter expected wäre ein Test mit dem Attribut#[should_panic] sehr ungenau, weil dieser Test immer erfolgreich ist, wenn irgendein panic! aufgelöst wird. Mit dem Parameter expected hingegen stellen wir sicher, dass der Test nur dann erfolgreich ist, wenn das ausgelöste panic! auch den angegebenen Text in seiner Fehlermeldung enthält.

Übrigens können wir auch den Typ Result<T, E> in Testfunktionen verwenden. Damit können wir zum Beispiel Err zurückgeben, statt ein panic! auszulösen.

Programmtext 278: Eine Testfunktion mit Result<T, E>

fn square(x: i32) -> i32 {

x * x

}

#[cfg(test)]

mod tests {

use super::*;

#[test]

fn it_squares() -> Result<(), String> {

if square(12) == 144 {

Ok(())

} else {

Err(String::from("12 * 12 sollte 144 ergeben"))

}

}

}

Anstelle des Makros assert_eq! geben wir im Erfolgsfall Ok zurück. Sollte der Test hingegen nicht bestanden werden, so geben wir Err mit einer Fehlermeldung zurück. Der Vorteil dieser Vorgehensweise liegt darin, dass wir den Operator ? innerhalb der Testfunktion verwenden können. Dies kann in einigen Situationen sehr hilfreich sein, beispielsweise wenn Tests fehlschlagen sollen, falls eine bestimmte Operation darin einen Err zurückgibt.

Die Ausführung von Tests konfigurieren

Wir haben bereits gelernt, dass wir unsere Testfälle mit dem Befehl cargo test ausführen können. Standardmäßig werden die Tests dabei alle parallel ausgeführt und sobald ein Test fehlschlägt, wird abgebrochen. Dafür ist es natürlich wichtig, dass Tests nicht voneinander abhängig sind (da sie gleichzeitig ausgeführt werden). Das bedeutet auch, dass Tests nicht dieselbe Datei lesen oder schreiben sollten, weil dadurch Konflikte entstehen können, falls alle Tests gleichzeitig laufen.

Tests sequentiell ausführen

Wenn es nun aber nötig ist, dass Tests in derselben Datei arbeiten, dann können wir auch explizit angeben, dass die Tests alle nacheinander ausgeführt werden sollen. Dies funktioniert folgendermaßen:

cargo test -- --test-threads=1

Die Option test-threads lässt uns angeben, wie viele Threads verwendet werden sollen, um die Tests auszuführen. Ist der Wert 1, so werden alle Tests nacheinander gestartet, weil ein Thread immer nur einen Test zur selben Zeit durchführen kann. Das führt natürlich auch dazu, dass die Ausführung der Tests etwas länger dauern kann, weil wir nun keine parallele Ausführung mehr haben.

Auffällig ist an dieser Stelle, dass vor der Option --test-threads noch ein Separator -- vorhanden ist. Das liegt daran, dass wir einmal Optionen an den Befehl cargo test geben können (dafür ist der vordere Separator --). Darüber hinaus können wir noch Optionen für die Ausführung der resultierenden Binärdatei, welche die Tests enthält, geben. In unserem Beispiel oben haben wir also eine Option für die Ausführung der binären Testdatei angegeben.

Für eine Übersicht aller möglichen Optionen können wir den Befehl cargo test --help bzw. cargo test -- --help verwenden. Ersterer listet uns eine Liste an Optionen für cargo test, letzterer eine Liste an Optionen für die resultierende Binärdatei auf.

Ausgaben von Funktionen anzeigen

Betrachten wir doch einmal das folgende Beispiel:

Programmtext 279: Eine Funktion mit einer Ausgabe

fn square(x: i32) -> i32 {

println!("x = {}", x);

x * x

}

#[cfg(test)]

mod tests {

use super::*;

#[test]

fn it_works() {

assert_eq!(square(5), 25);

}

#[test]

fn it_fails() {

assert_eq!(square(4), 25);

}

}       

Für alle Tests, die erfolgreich sind, wird die Ausgabe der Funktion nicht ausgegeben. Das heißt, dass wir nirgendwo den Text x = 5 finden können. Für den Test, der fehlschlägt, wird x = 4 hingegen ausgegeben. Allgemein ist es nämlich so, dass Ausgaben einer Funktion nur dann ausgegeben werden, wenn der Testfall nicht erfolgreich war. Möchten wir dieses Verhalten ändern und auch dann eine Ausgabe erhalten, wenn ein Test erfolgreich ist, so können wir das mit dem Befehl cargo test -- --nocapture erreichen.

Die Ausgaben können dabei durcheinander sein, da die Tests parallel ausgeführt werden. Wenn dies verhindert werden soll, können wir die Optionen nocapture und test-threads=1 miteinander kombinieren.

Eine bestimmte Menge an Testfällen ausführen

Wenn wir ein sehr großes Programm mit vielen Testfällen haben, dann kann es einige Zeit dauern, bis alle Testfälle komplett ausgeführt worden sind. Deshalb ist es manchmal sehr praktisch, wenn man nur eine bestimmte Menge an Testfällen ausführen kann, statt direkt alle verfügbaren Tests zu starten.

Beispielsweise können wir den Namen eines Tests, der ausgeführt werden soll, direkt angeben. Das eignet sich in Fällen, wo nur einzelne Tests gestartet werden sollen. Betrachten wir dazu das folgende Beispiel:

Programmtext 280: Ein Beispiel mit drei Testfunktionen

fn square(x: i32) -> i32 {

x * x

}

#[cfg(test)]

mod tests {

use super::*;

#[test]

fn square_minus_10() {

assert_eq!(square(-10), 100);

}

#[test]

fn square_minus_5() {

assert_eq!(square(-5), 25);

}

#[test]

fn square_0() {

assert_eq!(square(0), 0);

}

}       

Wenn wir nun den Befehl cargo test verwenden, werden alle drei Testfälle gestartet. Möchten wir nur einen bestimmten Test ausführen, zum Beispiel square_0, dann können wir das mit dem Befehl cargo test square_0 tun. Dann wird nur dieser eine Test ausgeführt. Gleichzeitig wird uns angezeigt, dass zwei Testfälle »herausgefiltert« wurden. Das heißt, es gibt zwei weitere Testfälle, die aber nicht ausgeführt wurden.

Auf diese Art und Weise können wir aber nur einzelne Tests starten. Wenn wir jetzt aber zwei Tests ausführen wollen würden, dann funktioniert das nicht, indem wir beide Namen angeben, weil immer nur der erste angegebene Wert berücksichtigt wird. Wir können aber einen Teil eines Namens angeben. Dann wird jede Testfunktion, dessen Name diesen Teil enthält, ausgeführt. Beispielsweise können wir durch den Befehl cargo test square_minus die beiden Testfälle square_minus_10 und square_minus_5 ausführen, weil beide den Text square_minus in ihrem Namen enthalten haben. Der Test square_0 hingegen wird herausgefiltert und somit nicht ausgeführt.

Es ist auch denkbar, dass es einige wenige Testfälle gibt, die besonders zeitaufwendig sind. Rust bietet uns die Möglichkeit, das Attribut #[ignore] zu verwenden, um damit gekennzeichnete Testfunktionen bei der Ausführung der Tests zu ignorieren.

Programmtext 281: Das Attribut #[ignore]

fn square(x: i32) -> i32 {

x * x

}

#[cfg(test)]

mod tests {

use super::*;

#[test]

#[ignore]

fn square_5() {

assert_eq!(square(5), 25);

}

#[test]

fn square_minus_5() {

assert_eq!(square(-5), 25);

}

}       

Wenn wir nun cargo test ausführen, dann sehen wir, dass nur der Test square_minus_5 berücksichtigt wird. Es wird ebenfalls angezeigt, dass es einen ignorierten Test gibt (square_5). Auf diese Weise können wir sehr zeitaufwendige Tests ausschließen. Wenn wir dann später doch einmal die ignorierten Testfunktionen ausführen möchten, dann können wir dies mit Hilfe der Option ignored tun, indem wir den Befehl cargo test -- --ignored benutzen.

Tests organisieren

Bisher haben wir Tests für einzelne Funktionen betrachtet. Diese fallen in die Kategorie von Unit Tests, also Tests, die ein einzelnes Modul testen sollen. Daneben sind häufig auch noch Integration Tests hilfreich, also Tests, welche unser Programm so benutzen, wie es externe Programme tun würden. Das heißt, dass für solche Tests auch nur öffentliche Schnittstellen zur Verfügung stehen. Ein Test kann dabei aber mehrere Module abdecken.

Beide Arten von Tests sind wichtig, um sicherzustellen, dass unser Code so funktioniert, wie wir es beabsichtigen. Die einzelnen Bestandteile des Codes sollen natürlich unabhängig voneinander korrekt arbeiten, aber auch fehlerfrei miteinander kombiniert werden können.

Unit Tests

Aufgrund dessen, dass Unit Tests einzelne Bestandteile unseres Codes testen sollen, werden diese einzelnen Teile isoliert getestet. Unit Tests werden direkt in der Datei platziert, welche auch den zu testenden Programmcode enthält. Üblicherweise wird dazu ein separates Modul verwendet, welches mit #[cfg(test)] annotiert wird (wie wir es bereits gesehen haben).

Diese Annotation teilt dem Compiler mit, dass der Inhalt dieses Moduls nur dann ausgeführt werden soll, wenn cargo test gestartet wird. Wenn wir hingegen cargo build einsetzen, wird dieses Modul nicht beachtet. Somit sparen wir Zeit beim Kompilieren ein, weil das Testmodul dafür nicht relevant ist.

Da sich die Testfunktionen in einem separaten Modul befinden, stellen sich einige Leser möglicherweise die Frage, ob und wie private Funktionen getestet werden können. Auch wenn einige Programmierer der Meinung sind, dass private Funktionen nicht getestet werden sollten, bietet Rust die Möglichkeit an, dies zu tun.

Um eine private Funktion zu testen, können wir diese wie eine öffentliche Funktion behandeln und sie normal in unserem Testmodul aufrufen.

Programmtext 282: Private Funktionen testen

fn square(x: i32) -> i32 {

x * x

}

pub fn add(x: i32, y: i32) -> i32 {

x + y

}

#[cfg(test)]

mod tests {

use super::*;

#[test]

fn square_5() {

assert_eq!(square(5), 25);

}

}       

Obwohl die Funktion square nicht mit dem Schlüsselwort pub als öffentlich gekennzeichnet wurde und somit privat ist, können wir sie problemlos aufrufen, nachdem wir sie mit Hilfe von use in den Scope geladen haben.

Integration Tests

Integration Tests befinden sich außerhalb unseres Programmcodes. Sie greifen auf unsere Programmbibliothek über die öffentliche Schnittstelle zu und können somit auch nur solche Funktionen aufrufen, die dieser öffentlichen Schnittstelle angehören. Das Ziel dabei ist, zu testen, ob die einzelnen Teile der Programmbibliothek korrekt zusammenarbeiten. Es kann schließlich passieren, dass die Teile einzeln korrekt funktionieren, aber Fehler im Zusammenspiel dieser Komponenten vorhanden sind.

Um Integration Tests zu erstellen, benötigen wir zunächst ein Verzeichnis tests. Dieses befindet sich auf dem gleichen Level unseres Projektes wie das Verzeichnis src, sodass diese beiden Verzeichnisse nebeneinander liegen. Sobald das Verzeichnis tests erstellt wurde, können wir darin eine neue Datei tests/integration_test.rs anlegen. In dieser Datei können nun Testfunktionen definiert werden.

Für das folgende Beispiel wurde ein neues Projekt mit dem Befehl cargo new beispiel --lib angelegt. Unsere Quellcodedateien sehen dabei folgendermaßen aus:

Programmtext 283: Inhalt der Datei src/lib.rs

pub fn add(x: i32, y: i32) -> i32 {

x + y

}

#[cfg(test)]

mod tests {

use super::*;

#[test]

fn add_one() {

assert_eq!(add(5, 1), 6);

}

}       

Programmtext 284: Inhalt der Datei tests/integration_test.rs

use beispiel;

#[test]

fn is_commutative() {

assert_eq!(beispiel::add(5, 10), beispiel::add(10, 5));

}       

Hierbei gilt es zunächst zu beachten, dass die Funktion add eine öffentliche Funktion ist. Wäre dies nicht gegeben, dann könnten wir diese Funktion nicht innerhalb der Datei tests/integration_test.rs aufrufen. Zusätzlich ist es jetzt notwendig, dass wir die Programmbibliothek src/lib.rs in den Scope laden. Dafür benutzen wir die Zeile use beispiel;. Cargo behandelt das Verzeichnis tests automatisch als ein Verzeichnis nur für Testfälle. Deshalb ist die Annotation #[cfg(test)] hier nicht mehr nötig. Wenn wir nun den Befehl cargo test ausführen, dann werden sowohl die Unit Tests als auch die Integration Tests in dem Verzeichnis tests ausgeführt.

Für das obige Beispiel hat das zur Folge, dass beide Testfunktionen durchlaufen werden. Es ist auch für Integration Tests möglich, den Namen einer Testfunktion hinter cargo test zu spezifizieren, damit nur diese eine Funktion ausgeführt wird.

Mittels cargo test is_commutative könnten wir zum Beispiel nur die Funktion is_commutative ausführen. Des Weiteren können wir die Option --test gefolgt von einem Dateinamen angeben, um alle Testfälle innerhalb einer bestimmten Datei aus dem Verzeichnis tests auszuführen. In unserem Beispiel könnten wir also mit cargo test --test integration_test alle Integration Tests aus der Datei tests/integration_test.rs starten. Dann werden nur Tests aus dieser Datei ausgeführt. Alle anderen Tests bleiben unbeachtet.

Natürlich ist es möglich, mehrere Dateien in dem Verzeichnis tests zu erstellen. Dateien in dem Verzeichnis tests verhalten sich aber nicht genauso wie Dateien in dem Verzeichnis src. Beispielsweise könnte es sein, dass wir eine Funktion zur Vorbereitung aller Tests haben, die in einer eigenen Datei tests/before.rs liegt. Diese Funktion soll dann innerhalb der einzelnen Tests aufgerufen werden. Wenn wir dann den Befehl cargo test ausführen, wird diese Datei ebenfalls in der Ausgabe aufgeführt (mit 0 ausgeführten Tests).

Für solche Fälle können wir ein Unterverzeichnis erstellen, welches die Datei before.rs beinhaltet. Dateien in einem Unterverzeichnis werden nicht als eigene Crates behandelt (Dateien direkt im Verzeichnis tests hingegen schon) und werden somit auch nicht in der Ausgabe angezeigt. Unsere Programmstruktur sieht dann wie folgt aus:

Programmtext 285: Inhalt der Datei src/lib.rs

pub fn add(x: i32, y: i32) -> i32 {

x + y

}

#[cfg(test)]

mod tests {

use super::*;

#[test]

fn add_one() {

assert_eq!(add(5, 1), 6);

}

}       

Programmtext 286: Inhalt der Datei tests/integration_test.rs

use beispiel;

mod startup;

#[test]

fn is_commutative() {

startup::initialization();

assert_eq!(beispiel::add(5, 10), beispiel::add(10, 5));

}       

Programmtext 287: Inhalt der Datei tests/startup/mod.rs

pub fn initialization() {

// ...

}       

Wir können beliebige Unterverzeichnisse anlegen. Dabei gilt einzig zu beachten, dass die Datei darin als mod.rs bezeichnet werden sollte, damit wir diese dann in der eigentlichen Testdatei mit der Zeile mod startup; einbinden können. startup gibt dabei den Namen des Unterverzeichnisses an. Die darin enthaltene Datei muss dann mod.rs heißen.

Integration Tests sind für Programmbibliotheken (also mit cargo new <Name> --lib erstellte Projekte) äußerst sinnvoll. Bei Binärdatei-Crates, welche nur eine main.rs Datei enthalten (mit cargo new <Name> erstellt) bieten sich meistens Unit Tests besser an. Unit Tests sind gleichermaßen für Programmbibliotheken eine sinnvolle Sache.

Zusammenfassung dieses Kapitels

Mit cargo test können alle Testfälle (durch das Attribut #[test] gekennzeichnete Funktionen) ausgeführt werden.

Die Makros assert!, assert_eq! und assert_ne! erlauben es uns, zu prüfen, ob Bedingungen erfüllt sind oder Werte übereinstimmen bzw. sich unterscheiden.

Es gibt viele Optionen, mit denen wir die Ausführung der Tests konfigurieren können. Diese können mittels cargo test --help bzw. cargo test -- --help angezeigt werden.

Es gibt zwei wichtige Arten von Tests: Unit Tests und Integration Tests.

Aufgabe 11

In den Aufgaben am Ende von dem Kapitel „Zeichenketten“ wurden verschiedenste Funktionen definiert. Testen Sie nun zwei dieser Funktionen durch jeweils zwei bis drei Testfälle.

Closures und Iteratoren

Closures

Closures sind anonyme Funktionen, die in Variablen gespeichert werden können oder anderen Funktionen als Parameter übergeben werden können.

Programmtext 288: Ein Beispiel für eine Closure

fn main() {

let square = |val| {

val * val

};

println!("5 * 5 = {}", square(5));

}   

5 * 5 = 25

Bei der Definition einer Closure beginnen wir mit vertikalen Strichen, innerhalb derer wir die Parameter angeben. Gibt es keine Parameter, so ist der Platz dazwischen leer. Mehrere Parameter werden wie gewohnt durch Kommata getrennt. Hinter den Parametern beginnt der Inhalt der Funktion. Besteht der Inhalt aus mehreren Befehlen, sind geschweifte Klammern notwendig, ansonsten können diese auch weggelassen werden. Es ist wichtig zu verstehen, dass die Variable square nun die Definition einer anonymen Funktion enthält. Wir können die Funktion nun über die Variable aufrufen, wie wir es bereits gewohnt sind.

Außerdem stellen wir fest, dass wir den Datentyp des Parameters nicht angeben. Aufgrund dessen, dass Closures in der Regel kurz sind und nur in einem kleinen Kontext relevant sind, kann der Compiler die Typen der Parameter sehr zuverlässig erschließen. Auch den Rückgabetyp haben wir weggelassen. Wir können die Datentypen dennoch explizit angeben, genau wie bei Variablen auch.

Programmtext 289: Eine Closure mit explizit angegebenen Datentypen

fn main() {

let square = |val: i32| -> i32 {

val * val

};

println!("5 * 5 = {}", square(5));

}   

5 * 5 = 25

Wie soeben erwähnt, gibt es verschiedene Möglichkeiten, dieselbe Closure auszudrücken. Das kommende Beispiel beinhaltet alle drei zuvor erwähnten Varianten, um dieselbe Closure zu definieren.

Programmtext 290: Unterschiedliche Schreibweisen für dieselbe Closure

fn main() {

let square_1 = |val: i32| -> i32 { val * val };

let square_2 = |val| { val * val };

let square_3 = |val| val * val;

println!("square_1(5) = {}", square_1(5));

println!("square_2(5) = {}", square_2(5));

println!("square_3(5) = {}", square_3(5));

}   

square_1(5) = 25
square_2(5) = 25
square_3(5) = 25

Dabei ist die erste Variante am ähnlichsten zu einer Funktionsdefinition.

Eine weitere interessante Sache sind Structs, die sowohl eine Closure als auch den Wert für den Aufruf der Closure speichern. Dies ist besonders spannend, wenn wir eine Closure haben, dessen Berechnung sehr aufwendig ist und wir die Berechnung deshalb möglichst selten wiederholen möchten.

Dann können wir ein solches Struct als Cache (Zwischenspeicher) verwenden, um das Ergebnis zu speichern und die Berechnung nicht zu wiederholen (ein solches Muster ist auch oft unter dem Begriff Memoization bekannt).

Um ein derartiges Struct zu erstellen, müssen wir den Typ der Closure festlegen. Schließlich verlangt ein Struct, dass wir die Typen aller enthaltenen Felder vorher festlegen.

Programmtext 291: Closures in Structs speichern

struct ClosureCache<T>

where T: Fn(i32) -> i32

{

closure: T,

value: Option<i32>

}   

Unsere Closure nimmt Parameter vom Typ i32 entgegen und gibt Werte desselben Typs zurück. Daraus ergibt sich der Typ Fn(i32) -> i32. Alle Closures implementieren den Trait Fn, welcher von der Standardbibliothek bereitgestellt wird. Für unser Struct bedeutet dies, dass jede Closure, die wir darin speichern möchten, einen Parameter vom Typ i32 besitzen muss und einen Wert vom Typ i32 zurückgeben muss.

Wir können dieses Struct nun beispielsweise um folgende Implementierungen erweitern, um es sinnvoll einsetzen zu können:

Programmtext 292: Implementierungsblock für unser Struct

struct ClosureCache<T>

where T: Fn(i32) -> i32

{

closure: T,

value: Option<i32>

}

impl <T> ClosureCache<T>

where T: Fn(i32) -> i32

{

fn new(closure: T) -> ClosureCache<T> {

ClosureCache {closure, value: None}

}

fn calc_value(&mut self, val: i32) -> i32 {

match self.value {

Some(v) => v,

None => {

let v = (self.closure)(val);

self.value = Some(v);

v

}

}

}

}   

Unser Struct ist nun einsatzbereit. Wir können die Funktion new benutzen, um eine neue Instanz des Structs zu erzeugen. Zu Beginn ist das Feld value mit None initialisiert, weil noch kein Wert berechnet wurde. Sobald wir den durch die Closure berechneten Wert benötigen, können wir mit der Methode calc_value darauf zugreifen. Diese berechnet den Wert, falls er zuvor noch nicht berechnet worden ist. Anderenfalls wird der zuvor berechnete Wert direkt zurückgegeben.

Der nachfolgende Programmausschnitt zeigt, wie wir diese Implementierungen nun aufrufen können.

Programmtext 293: Verwendung unseres Structs

fn main() {

let mut c = ClosureCache::new(|v| v * v);

println!("value = {}", c.calc_value(5));

println!("value = {}", c.calc_value(10));

}   

value = 25
value = 25

Wir können nun eine Closure als Parameter an die Funktion new übergeben. Diese wird dann zur Berechnung des Wertes verwendet. Ist der Wert jedoch einmal berechnet worden, wird er nicht erneut berechnet, sondern jedes Mal das zuvor gespeicherte Ergebnis zurückgegeben. In diesem Beispiel ist die Berechnung natürlich nicht zeitaufwendig, aber wenn wir eine Closure mit einer sehr aufwendigen Berechnung hätten, dann wäre ein solcher Cache eine sinnvolle Sache.

Sollte es gewünscht sein, mehrere Werte abzuspeichern, so können wir dazu eine HashMap einsetzen. Dann wird der Code jedoch deutlich komplexer.

Programmtext 294: Erweiterung unseres Structs

use std::collections::HashMap;

use std::collections::hash_map::Entry;

struct ClosureCache<T>

where T: Fn(i32) -> i32

{

closure: T,

values: HashMap<i32, i32>

}

impl <T> ClosureCache<T>

where T: Fn(i32) -> i32

{

fn new(closure: T) -> ClosureCache<T> {

ClosureCache {closure, values: HashMap::new()}

}

fn calc_value(&mut self, val: i32) -> i32 {

match self.values.entry(val) {

Entry::Occupied(e) => *e.into_mut(),

Entry::Vacant(e) => {

*e.insert((self.closure)(val))

}

}

}

}

fn main() {

let mut c = ClosureCache::new(|v| {

println!("Calculating {} * {}...", v, v);

v * v

});

println!("value = {}", c.calc_value(5));

println!("value = {}", c.calc_value(10));

println!("value = {}", c.calc_value(5));

}   

Calculating 5 * 5...
value = 25
Calculating 10 * 10...
value = 100
value = 25

Auch hier benötigen wir erneut den Dereferenzierungsoperator *. Jetzt können wir tatsächlich mehrere Werte in diesem Struct speichern, wobei kein Wert doppelt berechnet werden muss.

Falls wir ebenfalls erlauben möchten, dass Closures, welche Parameter vom Typ u32 bekommen und Werte vom Typ u32 zurückgeben, in unserem Struct verwendet werden können, dann können wir dazu weitere generische Parameter einführen. In diesem Fall wären wir etwas flexibler und nicht auf den Typ i32 beschränkt.

Eine weitere spannende Anwendung von Closures zeigt das nächste Beispiel. Dort sehen wir, dass wir eine lokale Variable innerhalb der Closure verwenden können.

Programmtext 295: Die lokale Umgebung in Closures verwenden

fn main() {

let a = 10;

let times_a = |b| a * b;

println!("a * 10 = {}", times_a(10));

}   

a * 10 = 100

Dieses Programm kann fehlerfrei kompiliert und ausgeführt werden. Würden wir versuchen, anstelle einer Closure eine Funktion zu verwenden, so hätten wir damit keinen Erfolg (sondern würden eine Fehlermeldung bekommen).

In den drei verschiedenen Fn Traits ist festgelegt, wie Closures die lokale Umgebung benutzen:

FnOnce bewegt Werte aus der Umgebung in die Closure und ergreift somit Besitz (Ownership) dieser Werte.

FnMut erzeugt eine veränderbare Referenz.

Fn erzeugt eine unveränderbare Referenz.

Der Rust Compiler ermittelt eigenständig, welcher dieser Traits benutzt werden sollte. Dazu betrachtet der Compiler, wie die Closure Werte aus der Umgebung verwendet. Alle Closures implementieren FnOnce. Wenn Closures die Variablen aus der Umgebung nicht bewegen, sondern nur Referenzen davon ausleihen, dann wird ebenfalls der Trait FnMut implementiert. Wird keine änderbare Referenz benötigt (weil eine unveränderbare Referenz ausreichend ist), so wird auch der Trait Fn implementiert.

In unserem Beispiel wird lediglich eine unveränderbare Referenz der Variablen a benötigt, da der Wert darin nur gelesen wird. Dementsprechend implementiert die Closure den Trait Fn. Möchten wir die Closure dazu bringen, Werte aus der Umgebung zu bewegen und somit Ownership dieser Werte zu ergreifen, so können wir das Schlüsselwort move vor die Parameterliste setzen.

Programmtext 296: Das Schlüsselwort move

fn main() {

let s = String::from("Hallo");

let compare_str = move |x| s == x;

println!("s == Hallo: {}", compare_str(String::from("Hallo")));

println!("s = {}", s); // Fehler!

}   

Hier erhalten wir nun einen Fehler, da wir die Variable s in die Closure bewegt haben und danach versuchen, auf die Variable s zuzugreifen. Ohne das Schlüsselwort move hätten wir dieses Problem nicht, da die Closure dann lediglich eine Referenz der Variablen s ausleihen würde.

Iteratoren

Ein Iterator erlaubt es uns, Operatoren auf einer Sequenz an Elementen auszuführen. Wir haben auch schon zuvor gesehen, wie wir einen Iterator erzeugen und verwenden können.

Programmtext 297: Einen Iterator mit der Methode iter erzeugen

fn main() {

let array = [1, 3, 5, 7, 9];

for element in array.iter() {

println!("value: {}", element);

}

}   

value: 1
value: 3
value: 5
value: 7
value: 9

Bereits im Zusammenhang mit Arrays haben wir diesen Iterator verwendet, um über ein Array zu iterieren. Dabei haben wir bislang erstmal hingenommen, dass die Methode iter dazu nützlich ist, ohne uns genauer anzusehen, was dort eigentlich passiert.

Alle Iteratoren implementieren den Trait Iterator, welcher in der Standardbibliothek definiert ist. Die Definition ist folgendermaßen aufgebaut:

Programmtext 298: Die Definition des Traits Iterator

pub trait Iterator {

type Item;

fn next(&mut self) -> Option<Self::Item>;

// ...

}   

Innerhalb dieses Traits wird ein sogenannter assoziierter Typ definiert. Die Bedeutung dahinter ist die Folgende: Wenn der Trait Iterator implementiert wird, dann muss ebenfalls der Typ Item definiert werden.

Dieser ist – ähnlich wie generische Typen – ein Platzhalter für einen Datentyp. Der Unterschied zu generischen Typen ist dabei, dass ein Trait mit einem assoziierten Typ für genau einen Typ implementiert wird. Ein Trait mit generischem Typ kann dagegen für mehrere Typen implementiert werden, weshalb eine Typannotation bei jeder Implementierung erforderlich ist.

Neben dem assoziierten Typ muss auch die Methode next von dem Code, der den Trait Iterator implementiert, definiert werden.

Anstelle einer Schleife kann diese Methode next auch direkt aufgerufen werden.

Programmtext 299: Die Methode next direkt aufrufen

fn main() {

let array = [1, 3, 5, 7, 9];

let mut array_iter = array.iter();

println!("{:?}", array_iter.next());

println!("{:?}", array_iter.next());

println!("{:?}", array_iter.next());

}   

Some(1)
Some(3)
Some(5)

Aufgrund dessen, dass ein Aufruf der Methode next zu einer Änderung des Zustandes eines Iterators führt, müssen wir den Iterator als änderbar markieren, indem wir das Schlüsselwort mut nutzen.

Die Methode iter erstellt eine unveränderbare Referenz der Werte in unserem Array. Möchten wir stattdessen Besitz dieser Werte ergreifen, können wir die Methode into_iter einsetzen. Analog können wir die Methode iter_mut benutzen, um über änderbare Referenzen zu iterieren.

Iteratoren in Rust sind lazy, das heißt, sie haben erst dann einen Effekt, wenn wir Methoden aufrufen, die den Iterator »konsumieren«. Neben solchen Methoden gibt es auch Methoden, die andere Iteratoren »produzieren«.

Zum Beispiel gibt es eine Methode sum, die unseren Iterator konsumiert und alle Werte der Sequenz aufsummiert:

Programmtext 300: Die Methode sum

fn main() {

let array = [1, 3, 5, 7, 9];

let sum: i32 = array.iter().sum();

println!("Die Summe ist {}.", sum);

}   

Die Summe ist 25.

Die Methode sum ergreift Besitz des Iterators. Das bedeutet, dass wir den Iterator danach nicht weiterverwenden können (auch dann nicht, wenn wir ihn in einer Variablen gespeichert hätten).

Andere Methoden erlauben es, Iteratoren in neue Iteratoren umzuwandeln, also neue Iteratoren zu produzieren. Beispielsweise gibt es die Methode map, welche eine Closure als Parameter nimmt und diese dann auf jedes Element anwendet, um einen neuen Iterator zu erzeugen.

Programmtext 301: Die Methode map

fn main() {

let array = [1, 3, 5, 7, 9];

let map = array.iter().map(|e| 2 * e);

println!("{:?}.", map);

}   

Map { iter: Iter([1, 3, 5, 7, 9]) }

Wir stellen an dieser Stelle fest, dass die von uns definierte Closure nicht aufgerufen wurde. Das liegt daran, dass Iteratoren lazy sind und wir erst eine Methode aufrufen müssen, welche den Iterator konsumiert. Dafür eignet sich die Methode collect: Diese Methode konsumiert den Iterator und sammelt die resultierenden Werte ein (in einer Collection).

Programmtext 302: Die Methode map in Kombination mit collect

fn main() {

let array = [1, 3, 5, 7, 9];

let map: Vec<_> = array.iter().map(|e| 2 * e).collect();

println!("{:?}", map);

}   

[2, 6, 10, 14, 18]

Nun wurde tatsächlich jedes der Elemente mit dem Wert 2 multipliziert. Zusätzlich sehen wir, dass die Elemente nun nicht mehr in einem Array, sondern in einem Vektor sind. Das liegt daran, dass die Methode collect die Werte nur in einer Collection sammeln kann. Ein Array ist jedoch keine Collection.

In diesem Beispiel können wir auch erkennen, dass die Methode map in Kombination mit Closures sehr flexibel eingesetzt werden kann. Wir können damit beliebige Operationen auf Sequenzen von Elementen anwenden.

Eine weitere interessante Methode ist die Methode filter. Damit können wir eine Bedingung auf jedes Element anwenden. Nur Elemente, für welche diese Bedingung zu true auswertet, werden dann in dem Iterator behalten.

Programmtext 303: Die Methode filter in Kombination mit collect

fn main() {

let array = [1, 3, 5, 7, 9];

let f: Vec<_> = array.iter().filter(|&e| *e > 3).collect();

println!("{:?}", f);

}   

[5, 7, 9]

In der resultierenden Collection sind nun ausschließlich Elemente enthalten, für die unsere Closure true zurückgegeben hat (also nur Elemente echt größer als 3). Wir können auch sehen, dass wir der Closure eine Referenz &e als Parameter übergeben und diese dann mit Hilfe des Operators * dereferenzieren. Das liegt daran, dass filter die ursprünglichen Elemente in einem neuen Iterator zurückgibt (während map neue Elemente zurückliefert).

Eigene Iteratoren definieren

Zudem ist es möglich, selbst Iteratoren zu erstellen, indem wir den Trait Iterator implementieren. Dazu brauchen wir zunächst einen Typ, für den wir diesen Iterator implementieren möchten. Wir erstellen uns also folgendes Struct:

Programmtext 304: Definition des Structs Points

struct Points {

x: u32,

y: u32

}       

Dieses Struct speichert einen Punkt, welcher durch eine x-Koordinate und eine y-Koordinate definiert wird. Unser Iterator soll nun über die x-Achse iterieren, also den x-Wert inkrementieren, aber den y-Wert unverändert lassen. Damit wir nicht den gesamten Wertebereich von u32 durchlaufen, kann der Iterator zu Demonstrationszwecken maximal bis zum Wert 5 iterieren.

Ein solcher Iterator kann nun folgendermaßen implementiert werden:

Programmtext 305: Den Trait Iterator für das Struct Points implementieren

impl Iterator for Points {

type Item = (u32, u32);

fn next(&mut self) -> Option<Self::Item> {

self.x += 1;

if self.x <= 5 {

Some((self.x, self.y))

} else {

None

}

}

}       

Zunächst müssen wir den zugehörigen Typ Item definieren. Da wir über den Datentyp u32 iterieren, aber gleichzeitig auch den y-Wert vom Typ u32 zurückgeben möchten, verwenden wir also das Tupel (u32, u32). Die Methode next bestimmt nun den nächsten Wert, also wird der x-Wert um Eins erhöht. Wie bereits erwähnt, brechen wir nach dem Wert 5 ab, damit wir nicht den gesamten Wertebereich von u32 durchlaufen.

Nun können wir über das Struct Points iterieren, zum Beispiel indem wir die Methode next direkt aufrufen.

Programmtext 306: Aufruf der Methode next für unser Struct Points

fn main() {

let mut p = Points {x: 0, y: 5};

println!("{:?}", p.next());

println!("{:?}", p.next());

println!("{:?}", p.next());

println!("{:?}", p.next());

println!("{:?}", p.next());

}       

Some((1, 5))
Some((2, 5))
Some((3, 5))
Some((4, 5))
Some((5, 5))

Interessanter ist aber, dass wir jetzt auch ganz leicht über unser Struct iterieren können, indem wir eine Schleife einsetzen.

Programmtext 307: Mit Hilfe einer Schleife über das Struct Points iterieren

fn main() {

let points = Points {x: 0, y: 5};

for p in points {

println!("p = {:?}", p);

}

}       

p = (1, 5)
p = (2, 5)
p = (3, 5)
p = (4, 5)
p = (5, 5)

Jetzt sehen wir auch, dass wir die Werte direkt ausgegeben bekommen und keine Optionen mehr erhalten.

Übrigens ist es ebenfalls möglich, dass Schlüsselwort type an beliebigen anderen Stellen im Programm zu verwenden. Dadurch kann ein Alias erzeugt werden, sodass wir einen anderen Namen für einen bereits existierenden Typen festlegen können.

Programmtext 308: Mit Hilfe von type einen Alias festlegen

fn main() {

type Integer = i32;

let a: Integer = 15;

let b: i32 = 20;

println!("a + b = {}", a + b);

}       

In diesem Beispiel wird der Name Integer als Alias für den Typ i32 definiert. Somit werden alle Variablen vom Typ Integer wie Variablen vom Typ i32 behandelt. Daher können wir beispielsweise problemlos eine Variable vom Typ Integer zu einer Variablen vom Typ i32 addieren. Dabei erhalten wir das folgende Ergebnis:

a + b = 35

Auf den ersten Blick ist dieses Feature möglicherweise noch nicht sehr nützlich. Betrachten wir also das folgende Beispiel:

Programmtext 309: Ein Beispiel für eine lange Typbezeichnung

fn some_function(a: (u32, u32, u32, u32)) -> (u32, u32, u32, u32) {

// ...

}       

Eine Funktion, die Parameter und/oder Rückgabewerte besitzt, die einem sehr langen Typ angehören, kann schnell sehr unübersichtlich werden, da die Signatur sehr lang ist. Ein Alias kann Abhilfe schaffen, um die Signatur übersichtlich zu halten.

Programmtext 310: Einen Alias für eine lange Typbezeichnung festlegen

type Quad = (u32, u32, u32, u32);

fn some_function(a: Quad) -> Quad {

// ...

}       

Zusammenfassung dieses Kapitels

Mit Hilfe von Closures können wir anonyme Funktionen definieren, die wir auch als Parameter an Funktionen übergeben können.

Iteratoren ermöglichen es uns, Operationen auf Sequenzen von Elementen ausführen.

Closures und Iterationen sind sogenannte zero-cost abstractions, d.h. die Performance unseres Programmes verschlechtert sich auch bei der Nutzung dieser Konzepte nicht.

Das Schlüsselwort type erlaubt uns, einen Alias für einen Typ festzulegen.

Aufgabe 12

Schreiben Sie eine Funktion, welche einen aus Zahlen bestehenden Vektor als Parameter bekommt und dann den Durchschnitt dieser Zahlen berechnet und zurückgibt. Beispiel: Die Eingabe [1, 2, 3, 4] soll 2.5 als Ergebnis liefern.

Schreiben Sie eine Funktion, welche einen aus Zahlen bestehenden Vektor als Parameter bekommt und dann die Summe der Quadratzahlen aller geraden Zahlen dieses Vektors zurückgibt. Dabei sollen Closures und/oder Iteratoren verwendet werden. Beispiel: Die Eingabe [1, 2, 3, 4] soll 20 als Ergebnis liefern ().

}

Pointer

Ein sogenannter Pointer ist eine Variable, die eine Adresse im Speicher beinhaltet. Sie »zeigt« also im Prinzip auf eine spezielle Stelle im Speicher. Im Verlauf dieses Buches haben wir auch schon Pointer kennengelernt, nämlich Referenzen. Mit dem Symbol & gekennzeichnete Variablen referenzieren Daten, welche sie nicht selbst besitzen, sondern lediglich ausleihen.

Zusätzlich gibt es in Rust sogenannte Smart Pointer. Diese verhalten sich grundsätzlich erstmal wie normale Pointer auch. Darüber hinaus besitzen Smart Pointer noch weitere Funktionalitäten, die normale Pointer nicht haben. Dementsprechend verbrauchen Smart Pointer auch mehr Ressourcen als einfache Pointer.

Ein wichtiger Unterschied zwischen Smart Pointern und Referenzen liegt darin, dass Referenzen lediglich Daten »ausleihen« (sie sind nicht der Owner der Daten). Smart Pointer besitzen hingegen auch tatsächlich in vielen Fällen die Daten, auf die sie zeigen (sie sind der Owner der Daten).

Wir haben in vergangenen Kapiteln auch schon Smart Pointer verwendet, ohne dies zu wissen. Die Typen String und Vec<T> sind nämlich Smart Pointer. Beide Typen besitzen einen Speicherbereich und enthalten zusätzliche Metadaten (wie beispielsweise die Länge).

Meistens werden Smart Pointer als Structs implementiert. Dabei implementieren sie die Traits Deref und Drop, wodurch sie sich von normalen Structs unterscheiden. Der Trait Deref hat den Effekt, dass sich Instanzen wie Referenzen verhalten und der Trait Drop lässt uns den Code anpassen, welcher ausgeführt wird, wenn eine Instanz ihren Scope verlässt. Wir werden uns in diesem Kapitel noch mit beiden Traits genauer auseinandersetzen.

Der Smart Pointer Box<T>

Mit Hilfe von Box<T> können wir dafür sorgen, dass Daten auf dem Heap statt auf dem Stack gespeichert werden. Auf dem Stack befindet sich dann lediglich ein Pointer zu den Daten auf dem Heap.

Beispielsweise eignet sich dieser Smart Pointer dann, wenn wir viele Daten haben und diese Daten den Besitzer wechseln (Ownership abgeben) sollen, wir die Daten dabei aber nicht kopieren möchten. Würden wir große Datenmengen kopieren, dann kann die Performance dadurch stark beeinträchtigt werden.

Sehen wir uns aber zunächst einmal an, wie wir den Smart Pointer Box<T> überhaupt benutzen können.

Programmtext 311: Verwendung des Smart Pointers Box<T>

fn main() {

let b = Box::new(20);

println!("b = {}", b);

}   

b = 20

In diesem Beispiel nutzen wir einen ganzzahligen Wert, der nun auf dem Heap statt auf dem Stack gespeichert wird. Sobald wir wieder auf diesen Wert zugreifen möchten, können wir dies genau wie bei normalen Variablen auch tun. Verlässt eine Box ihren Scope, so wird der Speicherplatz wieder freigegeben und die Variable ist somit ungültig.

Natürlich ist es nicht sinnvoll, eine einzelne ganze Zahl auf dem Heap abzulegen. Deshalb betrachten wir jetzt Situationen, in denen der Smart Pointer Box<T> tatsächlich hilfreich ist.

Wenn wir einen rekursiven Typ definieren, dann ist zur Kompilierzeit nicht bekannt, wie viel Speicherplatz dieser Typ verbrauchen wird. Das liegt daran, dass ein solcher rekursiver Typ einen Wert desselben Typs enthalten kann und diese Verschachtlung beliebig oft fortgesetzt werden kann.

Ein verbreitetes Beispiel für einen rekursiven Typ ist eine Liste von Werten, wobei auf jeden Wert ein weiterer Wert folgen kann oder aber die Liste danach beendet ist. Wir überlegen uns folgende rekursive Definition für eine Liste, die Werte vom Typ i32 beinhalten kann:

Programmtext 312: Eine rekursive Definition des Typs List

enum List {

Cons(i32, List),

Nil

}   

Leider erhalten wir nun eine Fehlermeldung, wenn wir versuchen, diesen Programmcode zu kompilieren. Der Rust Compiler teilt uns mit, dass der Typ List eine unendliche Größe hat. Wie bereits zuvor erwähnt, können wir theoretisch gesehen eine beliebig lange Liste von Werten erzeugen. Beispielsweise könnten wir nun auf die folgende Art und Weise Listen dieses Typs erzeugen, falls das Programm erfolgreich kompilieren würde:

Programmtext 313: Verwendung des Typs List

enum List {

Cons(i32, List),

Nil

}

fn main() {

use crate::List::{Cons, Nil};

let list1 = Cons(10, Cons(15, Cons(20, Cons(25, Nil))));

let list2 = Nil;

let list3 = Cons(10, Nil);

let list4 = Cons(10, Cons(15, Nil));

}   

Wir bringen zunächst Cons und Nil in den Scope, damit wir nicht jedes Mal List:: davor schreiben müssen. Dann definieren wir uns einige Listen. Wenn man sich diese Listen nun in Listenschreibweise vorstellen möchte, dann würden sie wie folgt aussehen:

Programmtext 314: Eine einfachere Schreibweise für Listen

fn main() {

let list1 = [10, 15, 20, 25];

let list2 = [];

let list3 = [10];

let list4 = [10, 15];

}   

Hier ist gut zu erkennen, wie der Typ List funktioniert. Wenn auf einen Wert ein weiterer Wert folgen soll, so nutzen wir Cons. Das Ende der Liste wird dann mit Nil markiert, d.h. nach Nil folgt kein weiterer Wert mehr.

Kommen wir nun zu unserem Problem zurück: Der Rust Compiler weiß nicht, wie groß der Typ List ist und kann dementsprechend nicht herausfinden, wie viel Speicherplatz reserviert werden muss. Normalerweise würde sich der Compiler unser Enum anschauen und alle darin enthaltenen Typen betrachten. Da bei Verwendung des Enums ohnehin nur genau einer dieser enthaltenen Typen verwendet wird, reicht es aus, wenn der Compiler weiß, wie viel Speicherplatz der größte darin enthaltene Typ benötigt. Dann kann entsprechend viel Speicherplatz reserviert werden (wenn der größte enthaltene Typ mit diesem Speicherplatz auskommt, dann kommen auch alle anderen enthaltenen Typen damit aus).

Bei einem rekursiven Typ hat der Compiler mit diesem Vorgehen aber keinen Erfolg. In unserem Beispiel würde der Compiler prüfen, ob Nil oder Cons mehr Speicherplatz benötigt. Natürlich würde Cons mehr Speicher brauchen, aber der Compiler weiß nicht, wie viel. Wenn er nun weiter versuchen würde, dies herauszufinden, dann würde dieser Prozess unendlich lange fortgesetzt werden, weil Cons wieder den Typ List beinhaltet.

An dieser Stelle kommt der Smart Pointer Box<T> ins Spiel, welcher uns erlaubt, einen solchen rekursiven Typ zu definieren. Da Box<T> ein Pointer ist, ist die Größe davon bekannt. Die Größe eines Pointers verändert sich nicht, egal wie groß die Datenmenge ist, auf die er zeigt. Wir können unseren Programmcode also anpassen und erhalten folgendes Resultat:

Programmtext 315: Eine rekursive Typdefinition mit Hilfe von Box<T>

enum List {

Cons(i32, Box<List>),

Nil

}   

Da lediglich die Größe von List unbekannt ist, brauchen wir auch nur diesen Typ in einer Box zu »verpacken«. Die Größen von Nil, i32 und Box<T> sind alle bekannt und somit ist diese Definition fehlerfrei. Jetzt können wir unseren rekursiven Typ List verwenden, ohne eine Fehlermeldung zu erhalten.

Programmtext 316: Verwendung des Typs List mit Hilfe von Box<T>

#[derive(Debug)]

enum List {

Cons(i32, Box<List>),

Nil

}

fn main() {

use crate::List::{Cons, Nil};

let list2 = Nil;

let list3 = Cons(10, Box::new(Nil));

let list4 = Cons(10, Box::new(Cons(15, Box::new(Nil))));

println!("list2 = {:?}", list2);

println!("list3 = {:?}", list3);

println!("list4 = {:?}", list4);

}   

list2 = Nil
list3 = Cons(10, Nil)
list4 = Cons(10, Cons(15, Nil))

Jetzt funktioniert unser Programm ohne Fehler. Dafür ist es notwendig, dass wir den hinteren Wert bei jedem Cons in eine Box packen, weil das Enum nun die Struktur Cons(i32, Box<List>) vorgibt.

Der Trait Deref

Die Implementierung des Traits Deref erlaubt es, das Verhalten des Dereferenzierungsoperators * zu definieren. Dieser Operator ist uns bereits mehrfach begegnet und es wurde jedes Mal auf einen späteren Zeitpunkt im Buch verwiesen. Jetzt betrachten wir diesen Operator etwas genauer.

Wir erinnern uns, dass eine Referenz ein Pointer auf eine bestimmte Stelle im Speicher darstellt. Eine Referenz zeigt also auf eine Speicherstelle. Wenn wir nun aber den in dieser Speicherstelle enthaltenen Wert erfahren möchten, dann benötigen wir den Dereferenzierungsoperator *. Dieses Verhalten können wir uns an einem einfachen Beispiel klar machen:

Programmtext 317: Die Verwendung des Dereferenzierungsoperators *

fn main() {

let a = 15;

let b = &a;

assert_eq!(a, *b);

}   

Die Variable b speichert eine Referenz auf die Variable a. b ist somit ein Pointer auf eine Speicherstelle. Wenn wir nun a mit b vergleichen, dann ist es nötig, b zunächst zu dereferenzieren. Dies geschieht durch Verwendung des Dereferenzierungsoperators *. Würden wir den Dereferenzierungsoperator * nicht verwenden, dann erhalten wir eine Fehlermeldung, die uns mitteilt, dass die Typen {integer} und &{integer} nicht miteinander verglichen werden können, weil der Vergleichsoperator == dafür nicht definiert ist.

Auf dieselbe Art und Weise können wir den Smart Pointer Box<T> dereferenzieren:

Programmtext 318: Dereferenzierung des Smart Pointers Box<T>

fn main() {

let a = 15;

let b = Box::new(a);

assert_eq!(a, *b);

}   

Dies ist möglich, weil der Trait Deref von Box<T> implementiert wird. Wir können auch eigene Typen definieren, die diesen Trait implementieren.

Wenn wir einen neuen Typ definieren und dann versuchen, den Dereferenzierungsoperator für diesen Typ zu benutzen, dann stellen wir fest, dass wir einen Fehler erhalten:

Programmtext 319: Einen neuen Typ definieren

struct NewBox<T>(T);

fn main() {

let a = 5;

let b = NewBox(a);

assert_eq!(a, *b); // Fehler!

}   

Damit wir den Dereferenzierungsoperator einsetzen können, müssen wir zuerst den Trait Deref implementieren. Dieser befindet sich in der Standardbibliothek.

Programmtext 320: Den Trait Deref implementieren

use std::ops::Deref;

struct NewBox<T>(T);

impl<T> Deref for NewBox<T> {

type Target = T;

fn deref(&self) -> &Self::Target {

&self.0

}

}

fn main() {

let a = 5;

let b = NewBox(a);

assert_eq!(a, *b);

}   

Der Trait Deref verlangt von uns, dass wir den Typ Target und die Methode deref definieren. Der Typ Target bestimmt den Ergebnistyp nach der Dereferenzierung. In unserem Beispiel ist Target also T, da wir den in der Box enthaltenen Typ zurückgeben möchten. Die Methode deref liefert nun eine Referenz des enthaltenen Wertes zurück, sodass der Dereferenzierungsoperator darauf angewendet werden kann. Im Hintergrund wird in der Zeile assert_eq!(a, *b); eigentlich assert_eq!(a, *(b.deref())); ausgeführt. Dadurch wird der Dereferenzierungsoperator auf eine Referenz angewendet – und wir bekommen den enthaltenen Wert zurückgeliefert. Wir können den zusätzlichen Aufruf der Methode deref jedoch der Einfachheit halber weglassen.

Außerdem werden Referenzen, die als Parameter an Funktionen und Methoden übergeben werden, automatisch dereferenziert, falls eine automatische Dereferenzierung möglich ist. Dies wird auch als deref coercion bezeichnet. Das folgende Beispiel demonstriert dieses Szenario.

Programmtext 321: »Deref Coercion«

fn main() {

let b = Box::new("Hallo, Welt!");

print_str(&b);

}

fn print_str(s: &str) {

println!("s = {}", s);

}   

s = Hallo, Welt!

Tatsächlich funktioniert auch der folgende Programmcode, wo wir den Typ String innerhalb einer Box verwenden:

Programmtext 322: »Deref Coercion«

fn main() {

let b = Box::new(String::from("Hallo, Welt!"));

print_str(&b);

}

fn print_str(s: &str) {

println!("s = {}", s);

}   

Die Ausgabe ändert sich dabei nicht. Diese beiden Beispiele funktionieren auf diese Art und Weise, weil der Trait Deref von Box implementiert wird. Nur deswegen ist eine automatische Dereferenzierung möglich.

Würde eine automatisierte Dereferenzierung nicht möglich sein, so müssten wir den Operator * verwenden. Für Programmtext 322, wo wir auch noch den Typ String zu dem Typ &str umwandeln müssten, wäre ohne eine automatisierte Dereferenzierung folgender Funktionsaufruf nötig:

Programmtext 323: Programmtext 322 ohne »Deref Coercion«

fn main() {

let b = Box::new(String::from("Hallo, Welt!"));

print_str(&(*b)[..]);

}

fn print_str(s: &str) {

println!("s = {}", s);

}   

Mit dem Teil *b erhalten wir zunächst den Typ String aus der Box, danach wandeln wir diesen noch mit Hilfe von & und [..] in den Typ &str um, damit der Parameter den geforderten Wert hat.

Eine solche Syntax ist äußerst unleserlich, weshalb die automatische Dereferenzierung sehr vorteilhaft ist.

Neben dem Trait Deref, um den Dereferenzierungsoperator * für unveränderbare Referenzen zu definieren, gibt es auch noch den Trait DerefMut, um diesen Operator auch für veränderbare Referenzen zu definieren.

Der Trait Drop

Ein weiterer wichtiger Trait ist der Trait Drop. Dieser Trait definiert, was passieren soll, wenn ein Wert seinen Scope verlässt. Beispielsweise wird bei dem Typ Box der reservierte Speicherplatz auf dem Heap wieder freigegeben, wenn ein Wert dieses Typs seinen Scope verlässt.

Das nachfolgende Beispiel soll zeigen, wie der Trait Drop implementiert werden kann.

Programmtext 324: Der Trait Drop

struct SomeNewType {

text: String

}

impl Drop for SomeNewType {

fn drop(&mut self) {

println!("Drop fuer {}.", self.text);

}

}

fn main() {

let a = SomeNewType { text: String::from("Hallo") };

let b = SomeNewType { text: String::from("Welt") };

println!("Der Scope endet gleich..");

}   

Der Scope endet gleich..
Drop fuer Welt.
Drop fuer Hallo.

Um den Trait Drop zu implementieren, ist es notwendig, die Methode drop zu definieren. Dabei bekommt diese Methode als Parameter eine änderbare Referenz von self. Anhand der Ausgabe des Programmes sehen wir, dass diese Methode immer dann aufgerufen wird, wenn ein Wert seinen Scope verlässt.

Für den Fall, dass wir einen Wert bereits vor dem Ende seines Scopes freigeben möchten, können wir die Funktion std::mem::drop aufrufen.

Programmtext 325: Die Funktion std::mem::drop

struct SomeNewType {

text: String

}

impl Drop for SomeNewType {

fn drop(&mut self) {

println!("Drop fuer {}.", self.text);

}

}

fn main() {

let a = SomeNewType { text: String::from("Hallo") };

let b = SomeNewType { text: String::from("Welt") };

drop(a);

println!("Der Scope endet gleich..");

}   

Dann erhalten wir folgendes Ergebnis:

Drop fuer Hallo.
Der Scope endet gleich..
Drop fuer Welt.

Neben den hier vorgestellten Smart Pointern gibt es noch weitere vordefinierte Smart Pointer, die wir im Rahmen dieses Buches aber nicht näher betrachten werden.

Zusammenfassung dieses Kapitels

Der Typ Box<T> erlaubt es, Daten auf dem Heap zu speichern.

Der Dereferenzierungsoperator * dereferenziert eine Referenz und liefert somit den in einer bestimmten Speicherstelle gespeicherten Wert zurück.

Der Trait Deref lässt uns das Verhalten des Dereferenzierungsoperators * definieren.

Der Trait Drop lässt uns das Verhalten eines Wertes beim Verlassen seines Scopes definieren.

Nebenläufigkeit

Effiziente und sichere nebenläufige Programmierung soll durch das Typsystem von Rust ermöglicht werden. In diesem Kapitel werden wir einen Blick auf nebenläufige Programmierung in Rust werfen. Dabei beginnen wir zunächst mit Threads.

Threads

Threads werden dazu verwendet, um mehrere Programmteile unabhängig voneinander zur gleichen Zeit auszuführen. Dadurch kann die Performance des Programmes verbessert werden, jedoch wird das Programm gleichzeitig auch komplexer.

Wenn mehrere Threads gleichzeitig laufen, dann gibt es keine feste Reihenfolge. Das kann zu Problemen führen. Beispielsweise kann es vorkommen, dass zwei Threads dieselben Daten gleichzeitig bearbeiten und dadurch Inkonsistenzen entstehen (race conditions). Nebenläufige Programmierung ist sehr fehleranfällig, weshalb der Rust Compiler versucht, bereits vor der Laufzeit auf mögliche Fehler hinzuweisen. Dennoch kann der Compiler nicht alle möglichen Fehler finden, weshalb der Programmierer besonders wachsam agieren sollte.

Erzeugen von Threads

Beginnen wir zunächst mit dem Erstellen neuer Threads. Für diesen Zweck stellt Rust die Funktion thread::spawn zur Verfügung.

Programmtext 326: Erzeugen eines neuen Threads mit thread::spawn

use std::thread;

fn main() {

thread::spawn(|| {

for i in 1..10 {

println!("Iteration {} in thread", i);

}

});

for i in 1..4 {

println!("Iteration {} in main", i);

}

}       

Iteration 1 in main
Iteration 1 in thread
Iteration 2 in main
Iteration 2 in thread
Iteration 3 in thread
Iteration 3 in main

Hierbei sind gleich mehrere Dinge bemerkenswert. Zunächst können wir sehen, dass die Reihenfolge der Ausgabe total zufällig ausfällt. Wenn wir das Programm erneut ausführen, dann kann die Ausgabe völlig anders aussehen. Zudem stellen wir fest, dass die Schleife in dem Thread nicht zu Ende berechnet wurde. Das liegt daran, dass der Thread gestoppt wird, sobald das Ende der Funktion main erreicht wird. Damit wissen wir auch, dass für diesen Programmcode niemals garantiert werden kann, dass der Thread überhaupt ausgeführt wird.

Um den Thread vollständig durchlaufen zu lassen, können wir den Rückgabewert der Funktion thread::spawn in einer Variablen abspeichern und die Methode join darauf aufrufen. Dies führt dazu, dass der Thread, in welchem die Funktion main ausgeführt wird, solange angehalten (blockiert) wird, bis der neu erzeugte Thread seine Ausführung beendet hat.

Programmtext 327: Verwendung der Methode join

use std::thread;

fn main() {

let t = thread::spawn(|| {

for i in 1..10 {

println!("Iteration {} in thread", i);

}

});

for i in 1..4 {

println!("Iteration {} in main", i);

}

t.join().unwrap();

println!("Ende von main");

}       

Iteration 1 in main
Iteration 1 in thread
Iteration 2 in thread
Iteration 3 in thread
Iteration 4 in thread
Iteration 2 in main
Iteration 3 in main
Iteration 5 in thread
Iteration 6 in thread
Iteration 7 in thread
Iteration 8 in thread
Iteration 9 in thread
Ende von main

Jetzt ist sichergestellt, dass der neu erzeugte Thread die Schleife komplett durchläuft. Es gilt zu beachten, dass die Reihenfolge weiterhin variieren kann, falls das Programm mehrfach ausgeführt wird.

Deshalb ist es entscheidend, wo der Aufruf der Methode join stattfindet. Angenommen, wir würden den Aufruf von join vor der Schleife in der Funktion main platzieren. Dann würde erst die Schleife aus dem neu erzeugten Thread durchlaufen und im Anschluss die Schleife aus der Funktion main gestartet werden. In diesem Fall wäre die Reihenfolge fest und auch bei mehrfacher Ausführung des Programmes immer dieselbe.

Manchmal möchten wir vielleicht auch Werte aus der Funktion main in unserem neuen Thread benutzen. In dem Kapitel über Closures haben wir gesehen, wie wir dies tun können. Wenn wir nun folgendes Beispiel betrachten, erhalten wir aber eine Fehlermeldung:

Programmtext 328: Die Umgebung in einem neuen Thread verwenden

use std::thread;

fn main() {

let v = vec![10, 23, 55, 63];

let t = thread::spawn(|| {

println!("v = {:?}", v); // Fehler!

});

t.join().unwrap();

}       

In diesem Programmcode wird innerhalb der Closure lediglich eine Referenz von v benötigt. Daher versucht die Closure, sich den Wert auszuleihen, was aber zu einem Problem führt. Der Compiler weiß nicht, wie lange der Thread laufen wird und kann daher nicht garantieren, dass die Referenz während der gesamten Dauer gültig bleibt.

Wir können dieses Problem beheben, indem wir das Schlüsselwort move verwenden, um die Werte in die Closure zu bewegen. Das führt dann dazu, dass die Closure zum Besitzer der Werte wird und das Programm wieder fehlerfrei ist.

Programmtext 329: Die Umgebung in einem neuen Thread verwenden

use std::thread;

fn main() {

let v = vec![10, 23, 55, 63];

let t = thread::spawn(move || {

println!("v = {:?}", v);

});

t.join().unwrap();

}       

v = [10, 23, 55, 63]

Kommunikation zwischen Threads mittels Nachrichten

Es gibt verschiedene Wege, um Daten zwischen zwei Threads auszutauschen. Eine Möglichkeit wäre zum Beispiel, einen geteilten Speicher zu verwenden, auf den mehrere Threads zugreifen können. Dies ist jedoch sehr riskant, wenn es darum geht, sichere Programme zu entwickeln, weil hier viele Fehler auftreten können. In Rust gibt es sogenannte Channel (Kanäle), über die Threads miteinander kommunizieren können. Dies bedeutet, dass Threads nicht über einen gemeinsamen Speicher koordiniert werden, sondern sich gegenseitig Nachrichten zuschicken.

Ein Channel hat zwei Endpunkte: Einen Sender und einen Empfänger. Wir wollen nun im Folgenden einen Channel implementieren. Dabei soll ein Thread Daten an einen anderen Thread schicken. Um einen Channel zu erzeugen, verwenden wir die Funktion mpsc::channel. Dabei soll mpsc eine Abkürzung für »multiple producer, single consumer« sein. Das bedeutet, dass der Channel mehrere Sender besitzen kann, aber es nur einen Empfänger gibt, der die Daten verarbeitet.

Der folgende Programmausschnitt soll zeigen, wie ein Channel erstellt werden kann.

Programmtext 330: Erstellen eines Channels mit der Funktion mpsc::channel

use std::sync::mpsc;

use std::thread;

fn main() {

let (tx, rx) = mpsc::channel();

thread::spawn(move || {

let s = String::from("Hallo, Welt!");

tx.send(s).unwrap();

});

}       

Die Funktion mpsc::channel gibt ein Tupel zurück, wobei das erste Element der Sender ist und das zweite Element der Empfänger ist. Oftmals werden die Abkürzungen tx und rx verwendet (für transmitter und receiver). Ebenfalls setzen wir das Schlüsselwort move ein, damit wir tx innerhalb der Closure verwenden können, um eine Nachricht zu verschicken.

Die Methode send liefert den Typ Result<T, E> zurück. Sollte der Sender nicht mehr gültig sein, so erhalten wir nämlich einen Fehler. Das obige Beispiel nutzt unwrap, um dann panic! auszulösen. Realistischer wäre es natürlich, den Fehler geeignet abzufangen und mit der Ausführung des Programmes fortzufahren. Im nächsten Schritt fügen wir nun das Empfangen der Nachricht hinzu.

Programmtext 331: Senden und Empfangen einer Nachricht über einen Channel

use std::sync::mpsc;

use std::thread;

fn main() {

let (tx, rx) = mpsc::channel();

thread::spawn(move || {

let s = String::from("Hallo, Welt!");

tx.send(s).unwrap();

});

let msg = rx.recv().unwrap();

println!("msg = {}", msg);

}       

msg = Hallo, Welt!

Wir haben nun Daten aus dem neu erzeugten Thread zu dem Thread, welcher die Funktion main ausführt, gesendet. Dabei blockiert die Methode recv (receive) den Thread solange, bis eine Nachricht über den Channel empfangen wird. Die empfangene Nachricht besitzt dann auch wieder den Typ Result<T, E>.

Neben der Methode recv ist auch noch die Methode try_recv interessant. Diese blockiert den Thread nicht, sondern liefert sofort ein Ergebnis vom Typ Result<T, E> zurück. Ist bereits eine Nachricht eingetroffen, so wird der Wert Ok erhalten, anderenfalls Err. Diese Methode kann sinnvoll in einer Schleife eingesetzt werden, sodass ein Thread regelmäßig nach neuen Nachrichten schauen kann. Sollten noch keine neuen Nachrichten eingetroffen sein, kann der Thread in der Zwischenzeit weitere Aufgaben erledigen, weil die Methode den Thread nicht blockiert.

Das folgende Beispiel zeigt, wie wir mehrere Nachrichten verschicken können. Dabei schickt ein Thread fünf Nachrichten an einen anderen Thread, welcher diese dann auf der Konsole ausgibt.

Programmtext 332: Mehrere Werte über einen Channel verschicken

use std::sync::mpsc;

use std::thread;

use std::time::Duration;

fn main() {

let (tx, rx) = mpsc::channel();

thread::spawn(move || {

let values = vec![1, 2, 3, 4, 5];

for val in values {

tx.send(val).unwrap();

thread::sleep(Duration::from_secs(1));

}

});

for received in rx {

println!("{}", received);

}

}       

Jetzt verschicken wir fünf Werte hintereinander und warten nach dem Senden jeder Nachricht eine Sekunde. Anstelle der Methode recv nutzen wir rx nun als Iterator. Das Iterieren endet, sobald der Channel geschlossen wird. Nach einigen Sekunden erhalten wir dann die folgende Ausgabe:

1
2
3
4
5

Wie bereits erwähnt, kann es auch mehrere Sender geben. Das kommende Programm demonstriert, wie wir dies implementieren können.

Programmtext 333: Ein Channel mit mehreren Sendern

use std::sync::mpsc;

use std::thread;

use std::time::Duration;

fn main() {

let (tx, rx) = mpsc::channel();

let tx1 = mpsc::Sender::clone(&tx);

thread::spawn(move || {

let values = vec![1, 2, 3, 4];

for val in values {

tx1.send(val).unwrap();

thread::sleep(Duration::from_secs(1));

}

});

thread::spawn(move || {

let values = vec![5, 6, 7, 8];

for val in values {

tx.send(val).unwrap();

thread::sleep(Duration::from_secs(1));

}

});

for received in rx {

println!("{}", received);

}

}       

In diesem Fall ist es notwendig, dass wir den Sender klonen, damit das Programm fehlerfrei läuft. Das liegt daran, dass wir tx in die Closure bewegen und somit außerhalb der Closure nicht mehr mit tx arbeiten können. Mit zwei Sendern können wir jeder Closure einen Sender übergeben. Die Ausgabe des Programmes sieht dann beispielsweise wie folgt aus:

1
5
2
6
7
3
8
4

Auch hier kann es wieder vorkommen, dass die Ausgabe unterschiedlich ausfällt, wenn das Programm mehrfach ausgeführt wird.

Kommunikation zwischen Threads über geteilten Speicher

Bisher haben wir Threads über Nachrichten miteinander kommunizieren lassen. Threads können sich auch Speicher teilen, um Informationen zu übermitteln. Dabei muss natürlich darauf geachtet werden, dass sich die Threads nicht in die Quere kommen. Es darf kein gleichzeitiger Zugriff auf eine Ressource stattfinden, damit race conditions vermieden werden.

Der Typ Mutex<T> sorgt dafür, dass immer nur ein Thread gleichzeitig auf eine Ressource zugreifen kann. Dazu wird die Ressource gesperrt, sobald sie einem Thread zugeteilt wurde. Erst wenn dieser Thread die Ressource wieder freigibt, kann sie von anderen Threads verwendet werden.

Im folgenden Programmcode wird der Typ Mutex<T> verwendet, um mehrere Threads auf dieselbe Variable zugreifen zu lassen.

Programmtext 334: Die Verwendung von Mutex<T>

use std::sync::{Arc, Mutex};

use std::thread;

fn main() {

let mutex = Arc::new(Mutex::new(10));

let mut threads = vec![];

for _ in 0..3 {

let mutex = Arc::clone(&mutex);

let t = thread::spawn(move || {

let mut new_val = mutex.lock().unwrap();

*new_val *= 10;

});

threads.push(t);

}

for t in threads {

t.join().unwrap();

}

println!("mutex = {}", *mutex.lock().unwrap());

}       

mutex = 10000

Zunächst erstellen wir eine Variable mutex, die von mehreren Threads verwendet werden soll. Diese nutzt den Typ Mutex<T>. Zusätzlich stellen wir fest, dass auch der Typ Arc<T> verwendet wird. Dieser ist notwendig, damit mehrere Threads dieselbe Variable verwenden dürfen. Anderenfalls würde das Ownership Konzept dies verbieten. Jeder Thread liest den Wert der Variablen mutex und multipliziert diesen mit 10.

Das Sperren der Variablen müssen wir explizit anfordern, indem wir die Methode lock aufrufen. Die Sperrung wird jedoch automatisch wieder aufgehoben, sobald der Scope verlassen wird. In diesem Beispiel ist das also der Fall, nachdem der Wert mit 10 multipliziert worden ist.

Zuletzt warten wir wieder darauf, dass alle Threads fertig sind. Im Anschluss daran geben wir dann den Wert der Variablen mutex aus. Auch hier ist ein Aufruf von lock notwendig, weil der Typ Mutex<T> dies immer verlangt. Dabei muss die Variable mutex dereferenziert werden, weil der Typ Mutex<T> ein Smart Pointer ist.

Zusammenfassung dieses Kapitels

Nebenläufige Programmierung ist sehr fehleranfällig und Fehler sind schwierig zu lokalisieren.

Threads erlauben es uns, mehrere Aufgaben gleichzeitig auszuführen.

Threads können entweder über Nachrichten oder über einen geteilten Speicher miteinander kommunizieren.

Weitere Features

Bisher haben wir bereits eine Vielzahl an verschiedenen Features der Programmiersprache Rust kennengelernt. Darüber hinaus gibt es noch viele weitere Features, welche in diesem Buch aber nicht näher behandelt werden sollen. Dennoch sollen an dieser Stelle zwei weitere interessante Features kurz erwähnt werden, damit man davon schon gehört hat.

Unsafe Rust

Wir haben Rust als eine sichere Programmiersprache kennengelernt, die bereits zur Kompilierzeit gewisse Garantien ermöglicht. Dazu wird eine statische Programmanalyse verwendet. Wenn sich der Compiler nicht sicher ist, ob ein Programm sicher ist, dann wird das Programm sicherheitshalber als »unsicher« angenommen und ein Fehler zurückgegeben. Dabei kann es auch passieren, dass ein Programm, welches eigentlich sicher ist, als unsicher eingestuft wird und der Compiler es nicht kompiliert.

Deshalb ist es möglich, auf diese Garantien durch den Compiler zu verzichten. Dieses Feature wird als »unsafe Rust« bezeichnet und erlaubt dem Programmierer, dem Compiler mitzuteilen, dass dieser weniger streng sein soll. Natürlich birgt dieses Vorgehen das Risiko, dass der Programmierer Fehler macht, die vorher eigentlich durch den Compiler abgefangen wurden, aber jetzt unbemerkt bleiben. Für spezielle Aufgaben kann es notwendig sein, unsicheren Code zu erlauben, weshalb dieses Feature ebenfalls ein wichtiger Bestandteil der Programmiersprache Rust ist.

Um unsicheren Code zu verwenden, kennzeichnet das Schlüsselwort unsafe einen Block, in dem dann unsicherer Code eingesetzt werden darf. Das Schlüsselwort unsafe deaktiviert aber nicht das Ownership Konzept. Referenzen in unsicheren Codeblöcken werden weiterhin überprüft. Dadurch gibt es weiterhin ein gewisses Level an Sicherheit in einem unsicheren Codeblock.

Natürlich muss durch unsafe markierter Code auch nicht zwangsmäßig zu Fehlern führen. Der Programmierer muss lediglich ohne die Hilfe vom Compiler sicherstellen, dass unsicherer Code wie gewünscht funktioniert und keine Probleme verursacht. Da Programmierer aber Fehler machen, sollte unsafe so wenig wie möglich verwendet werden, um die Suche nach Fehlern zu erleichtern.

Makros

Im Verlauf dieses Buches haben wir an einigen Stellen immer wieder mit Makros wie zum Beispiel println! oder panic! zu tun gehabt. Doch was ist eigentlich der Unterschied zwischen einer normalen Funktion und einem Makro? Darum soll es an dieser Stelle gehen.

Makros werden für sogenannte Metaprogrammierung genutzt. Metaprogrammierung bedeutet, dass wir Code schreiben, der dann wiederum anderen Code generiert. Unser Programmcode erzeugt also selbst weiteren Programmcode. Außerdem bieten Makros weitere Möglichkeiten an, welche von Funktionen nicht bereitgestellt werden. Beispielsweise kann ein Makro eine variable Anzahl an Parametern entgegennehmen. Der Makro println! tut dies auch und erlaubt uns somit, beliebig viele Werte auszugeben. Eine Funktion hingegen hat immer eine feste Anzahl an Parametern.

Grundsätzlich sind Makros also etwas mächtiger als Funktionen (was bedeutet, dass man damit mehr machen kann als mit Funktionen). Dadurch sind Makros aber auch deutlich komplizierter zu implementieren. Rust erlaubt es, eigene Makros zu definieren, jedoch werden wir im Rahmen dieses Buches nicht näher darauf eingehen.

Schlusswort

Geschafft!

An dieser Stelle möchte ich mich ganz herzlich bei Ihnen für das Lesen dieses Buches bedanken.

Ich hoffe, dass Ihnen das Lesen eine Freude bereitet hat und dieses Buch hilfreich war. Falls Ihnen dieses Buch gefallen hat, würde ich mich sehr über eine 5-Sterne Amazon-Bewertung freuen. Damit helfen Sie auch anderen Menschen, eine erfolgreiche Kaufentscheidung zu treffen.

Falls Sie Verbesserungsvorschläge oder sonstige Rückfragen haben sollten, kontaktieren Sie mich doch gerne unter der folgenden E-Mail Adresse:

books@malte-luttermann.de

Natürlich konnte dieses Buch nicht alle Features der Programmiersprache Rust in voller Tiefe behandeln. Dennoch hoffe ich, dass ein möglichst einfacher Einstieg in die Programmiersprache Rust ermöglicht wurde.

Lösungsvorschläge zu den Aufgaben

Unter diesem Abschnitt befinden sich Beispiellösungen zu den Aufgaben aus diesem Buch. Bitte bedenken Sie, dass es immer mehrere Möglichkeiten gibt, eine Aufgabe zu lösen. Das bedeutet, dass man die Aufgaben nicht unbedingt auf die gleiche Art und Weise lösen muss, wie es hier vorgeschlagen wird.

Lösungsvorschlag zu Aufgabe 1

Um zwei Zeilen auszugeben, nutzen wir zweimal den Makro println!.

Programmtext 335: Mehrere Zeilen ausgeben

fn main() {

println!("Hallo, Welt!");

println!("Dies ist mein erstes Programm in Rust!");

}

Hallo, Welt!
Dies ist mein erstes Programm in Rust!

Alternativ können wir auch nur einen Aufruf von println! verwenden, indem wir manuell einen Zeilenumbruch einfügen. Ein Zeilenumbruch wird durch das spezielle Zeichen \n dargestellt.

Programmtext 336: Mehrere Zeilen ausgeben

fn main() {

println!("Hallo, Welt!\nDies ist mein erstes Programm in Rust!");

}

Hallo, Welt!
Dies ist mein erstes Programm in Rust!

Lösungsvorschlag zu Aufgabe 2

Eine änderbare Variable wird mit Hilfe des Schlüsselwortes mut definiert.

Programmtext 337: Veränderbare Variablen

fn main() {

let mut a = 15;   

println!("a = {}", a);

a = 25;   

println!("a = {}", a);

}       

a = 15
a = 25

Um einen Wert auszurechnen, weisen wir einer Variablen diesen Wert zu.

Programmtext 338: Einen Wert ausrechnen

fn main() {

let a = 39 * (72 / (12 - 6));

println!("a = {}", a);

}       

a = 468

Wir bezeichnen das Ergebnis aus dem vorherigen Aufgabenteil nun als x und nutzen diesen Wert für eine weitere Rechnung.

Programmtext 339: Einen weiteren Wert ausrechnen

fn main() {

let x = 39 * (72 / (12 - 6));

println!("x = {}", x);

let a = 10 * (x / 18);

println!("a = {}", a);

}       

x = 468
a = 260

Lösungsvorschlag zu Aufgabe 3

Um ein Array auszugeben, verwenden wir den Platzhalter {:?}. Für die Ausgabe eines einzelnen Elementes wird der Platzhalter {} benutzt.

Programmtext 340: Definition eines Arrays

fn main() {

let array = ['a', 'b', 'c', 'd'];

println!("Gesamtes Array: {:?}", array);

println!("Erster Wert: {}", array[0]);

}       

Gesamtes Array: ['a', 'b', 'c', 'd']
Erster Wert: a

Das Vorgehen ist ähnlich zu dem Vorgehen des ersten Aufgabenteils, wobei wir jetzt aber einen Punkt anstelle von eckigen Klammern benötigen, um auf ein Element zuzugreifen.

Programmtext 341: Definition eines Tupels

fn main() {

let tupel = ('a', true, 42, 5.5);

println!("Gesamtes Tupel: {:?}", tupel);

println!("Erster Wert: {}", tupel.0);

}       

Gesamtes Tupel: ('a', true, 42, 5.5)
Erster Wert: a

Lösungsvorschlag zu Aufgabe 4

In diesem Beispiel erfolgt der Funktionsaufruf direkt innerhalb des Makros println!, sodass keine zusätzlichen Variablen gebraucht werden.

Programmtext 342: Eine Funktion zur Addition von Zahlen

fn main() {

println!("10 + 10 = {}", add(10, 10));

}

fn add(a: i32, b: i32) -> i32 {

a + b

}       

10 + 10 = 20

Die neue Funktion ist vom Aufbau her ähnlich zu der Funktion zuvor, jedoch gibt diese Funktion nun einen Wahrheitswert zurück.

Programmtext 343: Eine Funktion zum Vergleich von Zahlen

fn main() {

println!("15 > 10? {}", is_greater_than(15, 10));

println!("10 > 10? {}", is_greater_than(10, 10));

}

fn is_greater_than(a: i32, b: i32) -> bool {

a > b

}       

15 > 10? true
10 > 10? false

Lösungsvorschlag zu Aufgabe 5

Zur Berechnung der Summe verwenden wir eine Schleife.

Programmtext 344: Die Summe der Elemente eines Arrays berechnen

fn main() {

let array = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

println!("Ergebnis: {}", array_sum(&array));

}

fn array_sum(arr: &[i32]) -> i32 {

let mut sum = 0;

for e in arr.iter() {

sum += e;

}

sum

}       

Ergebnis: 55

Jetzt addieren wir anstelle von e auf die Variable sum.

Programmtext 345: Die Summe der Quadratzahlen der Elemente berechnen

fn main() {

let array = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

println!("Ergebnis: {}", squared_array_sum(&array));

}

fn squared_array_sum(arr: &[i32]) -> i32 {

let mut sum = 0;

for e in arr.iter() {

sum += e * e;

}

sum

}       

Ergebnis: 385

Eine Zahl ist genau dann gerade, wenn sie durch 2 teilbar ist. Das bedeutet, dass es keinen Rest bei einer Division durch 2 geben darf.

Programmtext 346: Gerade Zahlen finden

fn main() {

println!("Ist 5 gerade? {}", is_even(5));

println!("Ist 10 gerade? {}", is_even(10));

}

fn is_even(a: i32) -> bool {

a % 2 == 0

}       

Ist 5 gerade? false
Ist 10 gerade? true

Lösungsvorschlag zu Aufgabe 6

Wir iterieren über die einzelnen Zeichen der Zeichenkette und fügen sie dann jeweils vorne an das Ergebnis an, um die Reihenfolge zu vertauschen.

Programmtext 347: Eine Zeichenkette umdrehen

fn main() {

println!("Hallo <-> {}", reverse("Hallo"));

}

fn reverse(s: &str) -> String {

let mut result = String::new();

for c in s.chars() {

result = format!("{}{}", c, result);

}

result

}       

Hallo <-> ollaH

Übrigens besitzt Rust bereits eine eingebaute Funktion, welche eine Zeichenkette umdrehen kann. Um zu verstehen, wie diese Funktion verwendet werden kann, benötigen wir jedoch Wissen aus späteren Kapiteln. Dennoch soll sie hier kurz angegeben werden:

Programmtext 348: Eine Zeichenkette umdrehen

fn main() {

let s = String::from("Hallo");

let reversed: String = s.chars().rev().collect();

println!("Hallo <-> {}", reversed);

}       

Hallo <-> ollaH

Für diese Aufgabe können wir die Funktion zum Umdrehen einer Zeichenkette aus dem vorherigen Aufgabenteil verwenden.

Programmtext 349: Palindrome finden

fn main() {

println!("Eingabe 'anna': {}", is_palindrome("anna"));

println!("Eingabe 'hallo': {}", is_palindrome("hallo"));

}

fn reverse(s: &str) -> String {

let mut result = String::new();

for c in s.chars() {

result = format!("{}{}", c, result);

}

result

}

fn is_palindrome(s: &str) -> bool {

s.to_string() == reverse(s)

}       

Eingabe 'anna': true
Eingabe 'hallo': false

Falls wir Eingaben unabhängig von Groß- und Kleinschreibung behandeln möchten, könnten wir dies mit Hilfe der vordefinierten Methode to_lowercase tun. Diese Methode wandelt alle Großbuchstaben einer Zeichenkette in Kleinbuchstaben um.

Programmtext 350: Palindrome finden

fn main() {

println!("Eingabe 'Anna': {}", is_palindrome("Anna"));

println!("Eingabe 'hallo': {}", is_palindrome("hallo"));

}

fn reverse(s: &str) -> String {

let mut result = String::new();

for c in s.chars() {

result = format!("{}{}", c, result);

}

result

}

fn is_palindrome(s: &str) -> bool {

s.to_lowercase() == reverse(s).to_lowercase()

}       

Eingabe 'Anna': true
Eingabe 'hallo': false

Wir nutzen eine Schleife und geben true zurück, sobald wir das gesuchte Zeichen gefunden haben. Falls wir es nicht finden konnten, wird false zurückgegeben.

Programmtext 351: Zeichen in Zeichenketten suchen

fn main() {

println!("'a' in 'Hallo'? {}", contains("Hallo", 'a'));

println!("'b' in 'Hallo'? {}", contains("Hallo", 'b'));

}

fn contains(s: &str, c: char) -> bool {

for ch in s.chars() {

if ch == c {

return true;

}

}

false

}       

'a' in 'Hallo'? true
'b' in 'Hallo'? false

Wir nutzen erneut eine Schleife, um die Zeichenkette in jeder Iteration ein weiteres Mal an das Ergebnis anzufügen.

Programmtext 352: Zeichenketten wiederholen

fn main() {

println!("Eingabe ('Hallo', 3): {}", repeat("Hallo", 3));

println!("Eingabe ('Hallo', 5): {}", repeat("Hallo", 5));

}

fn repeat(s: &str, x: u32) -> String {

let mut result = String::new();

for _ in 1..=x {

result = format!("{}{}", result, s);

}

result

}       

Eingabe ('Hallo', 3): HalloHalloHallo
Eingabe ('Hallo', 5): HalloHalloHalloHalloHallo

Lösungsvorschlag zu Aufgabe 7

Da das Feld alter keine Werte größer als 255 benötigt, reicht an dieser Stelle der Datentyp u8 aus. Außerdem kann das Feld geschlecht bereits durch ein einziges Zeichen eindeutig angegeben werden.

Programmtext 353: Definition des Structs Mensch

struct Mensch {

name: String,

alter: u8,

geschlecht: char

}       

Eine Funktion erhält nicht das Schlüsselwort self als Parameter.

Programmtext 354: Eine Funktion für das Struct Mensch implementieren

#[derive(Debug)]

struct Mensch {

name: String,

alter: u8,

geschlecht: char

}

impl Mensch {

fn create(name: &str, alter: u8, geschlecht: char) -> Mensch {

Mensch { name: name.to_string(), alter, geschlecht }

}

}

fn main() {

let p = Mensch::create("Peter", 29, 'm');

println!("p = {:?}", p);

}       

p = Mensch { name: "Peter", alter: 29, geschlecht: 'm' }

Eine Methode hingegen bekommt immer self als ersten Parameter übergeben.

Programmtext 355: Eine Methode für das Struct Mensch implementieren

#[derive(Debug)]

struct Mensch {

name: String,

alter: u8,

geschlecht: char

}

impl Mensch {

fn create(name: &str, alter: u8, geschlecht: char) -> Mensch {

Mensch { name: name.to_string(), alter, geschlecht }

}

fn ist_volljaehrig(&self) -> bool {

self.alter >= 18

}

}

fn main() {

let p = Mensch::create("Peter", 29, 'm');

println!("Ist Peter volljaehrig? {}", p.ist_volljaehrig());

}       

Ist Peter volljaehrig? true

Lösungsvorschlag zu Aufgabe 8

Zunächst erstellen wir mit dem Befehl cargo new exercise ein neues Projekt. Dann fügen wir die Abhängigkeit rand in der Datei exercise/Cargo.toml hinzu und können das Crate anschließend in unserem Programm verwenden.

Programmtext 356: Inhalt der Datei exercise/Cargo.toml

[package]

name = "exercise"

version = "0.1.0"

authors = ["Malte"]

edition = "2018"

[dependencies]

rand = "0.5"       

Programmtext 357: Inhalt der Datei exercise/src/main.rs

use rand::Rng;

fn main() {

let r = rand::thread_rng().gen_range(1, 100);

println!("r = {}", r);

}       

Zur Simulation eines Münzwurfes lassen wir zufällig die Zahlen 0 und 1 mit gleicher Wahrscheinlichkeit generieren. Abhängig von der Zufallszahl geben wir dann »Kopf« oder »Zahl« auf der Konsole aus.

Programmtext 358: Inhalt der Datei exercise/src/main.rs

use rand::Rng;

#[derive(Debug)]

enum Muenze {

Kopf,

Zahl

}

fn main() {

let r = rand::thread_rng().gen_range(0, 2);

match r {

0 => println!("{:?}", Muenze::Kopf),

_ => println!("{:?}", Muenze::Zahl)

}

}       

Wir lesen zunächst die Eingabe des Nutzers ein. Anschließend vergleichen wir den Tipp des Nutzers mit dem tatsächlichen Ergebnis. Sollte die Eingabe des Nutzers ungültig sein, so wird das Programm sofort durch den Makro panic! beendet.

Programmtext 359: Erraten eines Münzwurfes

use rand::Rng;

use std::io;

#[derive(Debug, PartialEq)]

enum Muenze {

Kopf,

Zahl

}

fn main() {

println!("Kopf (k) oder Zahl (z)?");

match read() == generate() {

true => println!("Korrekt!"),

false => println!("Leider falsch!")

}

}

fn read() -> Muenze {

let mut user_input = String::new();

io::stdin()

.read_line(&mut user_input)

.expect("Fehler beim Lesen der Eingabe");

match user_input.trim() {

"k" => Muenze::Kopf,

"z" => Muenze::Zahl,

_  => panic!("Ungueltige Eingabe.")

}

}

fn generate() -> Muenze {

match rand::thread_rng().gen_range(0, 2) {

0 => Muenze::Kopf,

_ => Muenze::Zahl

}

}       

Lösungsvorschlag zu Aufgabe 9

Damit wir die HashMap ändern können, übergeben wir der Funktion store eine änderbare Referenz der HashMap. Bei der Ausgabe verwenden wir die Methode unwrap, welche den Wert aus einer Option extrahiert, sofern ein Wert vorhanden ist (falls der Wert nicht existiert, wird das Programm beendet).

Programmtext 360: Simulation von 50 Münzwürfen

use rand::Rng;

use std::collections::HashMap;

#[derive(Debug)]

enum Muenze {

Kopf,

Zahl

}

fn main() {

let mut results: HashMap<String, u32> = HashMap::new();

for _ in 1..=50 {

store(&mut results, generate());

}

println!("Anzahl Kopf: {}", results.get("Kopf").unwrap());

println!("Anzahl Zahl: {}", results.get("Zahl").unwrap());

}

fn generate() -> Muenze {

match rand::thread_rng().gen_range(0, 2) {

0 => Muenze::Kopf,

_ => Muenze::Zahl

}

}

fn store(map: &mut HashMap<String, u32>, res: Muenze) {

let entry = map.entry(format!("{:?}", res)).or_insert(0);

*entry += 1;

}       

Wir lesen nun zusätzlich eine Eingabe vom Nutzer ein und die Anzahl der Iterationen hängt dann von dieser Eingabe ab. Falls die Eingabe nicht in eine ganze Zahl umgewandelt werden kann, so beendet sich das Programm sofort.

Programmtext 361: Simulation einer variablen Anzahl an Münzwürfen

use rand::Rng;

use std::collections::HashMap;

use std::io;

#[derive(Debug)]

enum Muenze {

Kopf,

Zahl

}

fn main() {

println!("Wie oft soll eine Muenze geworfen werden?");

let num = read();

let mut results: HashMap<String, u32> = HashMap::new();

for _ in 1..=num {

store(&mut results, generate());

}

println!("Anzahl Kopf: {}", results.get("Kopf").unwrap());

println!("Anzahl Zahl: {}", results.get("Zahl").unwrap());

}

fn read() -> u32 {

let mut user_input = String::new();

io::stdin()

.read_line(&mut user_input)

.expect("Fehler beim Lesen der Eingabe");

user_input.trim().parse::<u32>().unwrap()

}

fn generate() -> Muenze {

match rand::thread_rng().gen_range(0, 2) {

0 => Muenze::Kopf,

_ => Muenze::Zahl

}

}

fn store(map: &mut HashMap<String, u32>, res: Muenze) {

let entry = map.entry(format!("{:?}", res)).or_insert(0);

*entry += 1;

}       

Lösungsvorschlag zu Aufgabe 10

Anders als zuvor nutzen wir jetzt ein Tupel statt einer HashMap und speichern die Werte dann anschließend in einer Textdatei ab.

Programmtext 362: Ergebnisse in einer Textdatei speichern

use rand::Rng;

use std::fs;

use std::io;

#[derive(Debug)]

enum Muenze {

Kopf,

Zahl

}

fn main() {

let mut results = (0, 0);

println!("Wie oft soll eine Muenze geworfen werden?");

for _ in 1..=read() {

match generate() {

Muenze::Kopf => results.0 += 1,

Muenze::Zahl => results.1 += 1

}

}

let result = format!("K: {}, Z: {}", results.0, results.1);

match store(&result[..]) {

Ok(_) => println!("Ergebnis gespeichert."),

Err(e) => println!("Es ist ein Fehler aufgetreten: {}", e)

};

}

fn read() -> u32 {

let mut user_input = String::new();

io::stdin()

.read_line(&mut user_input)

.expect("Fehler beim Lesen der Eingabe");

user_input.trim().parse::<u32>().unwrap()

}

fn generate() -> Muenze {

match rand::thread_rng().gen_range(0, 2) {

0 => Muenze::Kopf,

_ => Muenze::Zahl

}

}

fn store(results: &str) -> Result<(), io::Error> {

fs::write("result.txt", results)?;

Ok(())

}       

Bevor wir den Inhalt der Textdatei lesen können, müssen wir testen, ob die Textdatei überhaupt existiert. Falls die Textdatei noch nicht existiert, erstellen wir sie. Ansonsten lesen wir den Inhalt und addieren die neuen Ergebnisse dazu, nachdem wir den Inhalt in drei Werte des Typs u32 umgewandelt haben.

Programmtext 363: Ergebnisse in einer Textdatei aktualisieren

use rand::Rng;

use std::fs;

use std::fs::File;

use std::io;

use std::io::Read;

#[derive(Debug)]

enum Muenze {

Kopf,

Zahl

}

fn main() {

let mut results = (0, 0);

println!("Wie oft soll eine Muenze geworfen werden?");

let num = read();

for _ in 1..=num {

match generate() {

Muenze::Kopf => results.0 += 1,

Muenze::Zahl => results.1 += 1

}

}

match store(results.0, results.1, num) {

Ok(_) => println!("Ergebis gespeichert."),

Err(e) => println!("Es ist ein Fehler aufgetreten: {}", e)

};

}

fn read() -> u32 {

let mut user_input = String::new();

io::stdin()

.read_line(&mut user_input)

.expect("Fehler beim Lesen der Eingabe");

user_input.trim().parse::<u32>().unwrap()

}

fn generate() -> Muenze {

match rand::thread_rng().gen_range(0, 2) {

0 => Muenze::Kopf,

_ => Muenze::Zahl

}

}

fn store(mut k: u32, mut z: u32, mut t: u32) -> Result<(), io::Error> {

let result = match File::open("results.txt") {

Ok(mut f) => {

let mut content = String::new();

f.read_to_string(&mut content)?;

let content_vec: Vec<_> = content.split("\n").collect();

k += content_vec[0].parse::<u32>().unwrap();

z += content_vec[1].parse::<u32>().unwrap();

t += content_vec[2].parse::<u32>().unwrap();

format!("{}\n{}\n{}", k, z, t)

},

Err(_) => format!("{}\n{}\n{}", k, z, t)

};

fs::write("results.txt", result)?;

Ok(())

}       

Lösungsvorschlag zu Aufgabe 11

Wir fügen ein Modul tests hinzu, welches mit #[cfg(test)] annotiert ist. Darin platzieren wir unsere Testfunktionen, welche die Annotation #[test] besitzen.

Programmtext 364: Testfälle für die Funktion reverse

fn main() {

println!("Hallo <-> {}", reverse("Hallo"));

}

fn reverse(s: &str) -> String {

let mut result = String::new();

for c in s.chars() {

result = format!("{}{}", c, result);

}

result

}

#[cfg(test)]

mod tests {

use super::*;

#[test]

fn reverse_test_1() {

assert_eq!(reverse("Hallo, Welt!"), "!tleW ,ollaH");

}

#[test]

fn reverse_test_2() {

assert_eq!(reverse("Dies ist ein Test"), "tseT nie tsi seiD");

}

#[test]

fn reverse_test_3() {

assert_eq!(reverse("Rust is fun"), "nuf si tsuR");

}

}       

test result: ok. 3 passed; 0 failed; 0 ignored;
0 measured; 0 filtered out

Das Vorgehen ist auch für die anderen Funktionen dasselbe.

Programmtext 365: Testfälle für die Funktion contains

fn main() {

println!("'a' in 'Hallo'? {}", contains("Hallo", 'a'));

println!("'b' in 'Hallo'? {}", contains("Hallo", 'b'));

}

fn contains(s: &str, c: char) -> bool {

for ch in s.chars() {

if ch == c {

return true;

}

}

false

}

#[cfg(test)]

mod tests {

use super::*;

#[test]

fn contains_test_1() {

assert!(contains("Autoreifen", 'A'));

}

#[test]

fn contains_test_2() {

assert!(!contains("Autoreifen", 'E'));

}

#[test]

fn contains_test_3() {

assert!(contains("Autoreifen", 'f'));

}

}       

test result: ok. 3 passed; 0 failed; 0 ignored;
0 measured; 0 filtered out

Lösungsvorschlag zu Aufgabe 12

Wir nutzen die zuvor vorgestellte Methode sum, um die Werte des Vektors aufzusummieren. Dabei nutzen wir die Notation sum::<i32>, um Rust mitzuteilen, dass die Summe ein Wert vom Typ i32 ist. Da der Durchschnitt eine Kommazahl sein kann, wandeln wir sowohl Summe als auch Länge des Vektors in den Typ f32 um.

Programmtext 366: Durchschnitt eines Vektors berechnen

fn main() {

let vector = vec![1, 2, 3, 4];

println!("Durchschnitt von [1, 2, 3, 4]: {}", avg(vector));

}

fn avg(vector: Vec<i32>) -> f32 {

(vector.iter().sum::<i32>() as f32) / (vector.len() as f32)

}       

Durchschnitt von [1, 2, 3, 4]: 2.5

Wir benutzen an dieser Stelle eine Aneinanderreihung verschiedener Methoden: Zuerst filtern wir die geraden Zahlen aus dem Vektor heraus, dann quadrieren wir sie und zuletzt summieren wir alle Werte auf.

Programmtext 367: Summe der Quadratzahlen aller geraden Zahlen

fn main() {

let v = vec![1, 2, 3, 4];

println!("Eingabe [1, 2, 3, 4]: {}", squared_even_sum(v));

}

fn squared_even_sum(vector: Vec<i32>) -> i32 {

vector.iter().filter(|e| *e % 2 == 0).map(|e| e * e).sum()

}       

Eingabe [1, 2, 3, 4]: 20

Referenzen

Bevor es losgeht

»Rust (Programmiersprache) – Wikipedia«, https://de.wikipedia.org/wiki/Rust_(Programmiersprache), zuletzt aufgerufen am 12.11.2018

»The Rust Programming Language«, https://www.rust-lang.org/en-US/, zuletzt aufgerufen am 12.11.2018

Die Installation von Rust

»Installation · Die Programmiersprache Rust«, https://www.rust-lang.org/de-DE/install.html, zuletzt aufgerufen am 13.11.2018

Ein »Hallo, Welt!« Programm in Rust

»Hello World – Rust By Example«, https://doc.rust-lang.org/rust-by-example/hello.html, zuletzt aufgerufen am 13.11.2018

»Comments – Rust By Example«, https://doc.rust-lang.org/rust-by-example/hello/comment.html, zuletzt aufgerufen am 20.11.2018

Variablen

»Primitives – Rust By Example«, https://doc.rust-lang.org/rust-by-example/primitives.html, zuletzt aufgerufen am 27.11.2018

»Naming conventions«, https://doc.rust-lang.org/1.0.0/style/style/naming/README.html, zuletzt aufgerufen am 03.03.2019

»A – Keywords – The Rust Programming Language«, https://doc.rust-lang.org/book/second-edition/appendix-01-keywords.html, zuletzt aufgerufen am 26.11.2018

»Variables and Mutability – The Rust Programming Language«, https://doc.rust-lang.org/book/ch03-01-variables-and-mutability.html, zuletzt aufgerufen am 06.03.2019

Datentypen

»Primitives – Rust By Example«, https://doc.rust-lang.org/rust-by-example/primitives.html, zuletzt aufgerufen am 30.11.2018

»Data Types – The Rust Programming Language«, https://doc.rust-lang.org/book/ch03-02-data-types.html, zuletzt aufgerufen am 06.03.2019

Funktionen

»How Functions Work – The Rust Programming Language«, https://doc.rust-lang.org/book/ch03-03-how-functions-work.html, zuletzt aufgerufen am 12.03.2019

Kontrollstrukturen

»Control Flow – The Rust Programming Language«, https://doc.rust-lang.org/book/ch03-05-control-flow.html, zuletzt aufgerufen am 24.09.2019

Das Ownership Konzept

»Understanding Ownership – The Rust Programming Language«, https://doc.rust-lang.org/book/ch04-00-understanding-ownership.html, zuletzt aufgerufen am 08.10.2019

Zeichenketten

»The Slice Type – The Rust Programming Language«, https://doc.rust-lang.org/book/ch04-03-slices.html, zuletzt aufgerufen am 08.10.2019

»Formatted print – Rust By Example«, https://doc.rust-lang.org/rust-by-example/hello/print.html, zuletzt aufgerufen am 08.10.2019

»Storing UTF-8 Encoded Text with Strings – The Rust Programming Language«, https://doc.rust-lang.org/book/ch08-02-strings.html, zuletzt aufgerufen am 14.10.2019

Benutzerdefinierte Typen

»Using Structs to Structure Related Data – The Rust Programming Language«, https://doc.rust-lang.org/book/ch05-00-structs.html, zuletzt aufgerufen am 08.10.2019

»Custom Types - Rust By Example«, https://doc.rust-lang.org/rust-by-example/custom_types.html, zuletzt aufgerufen am 08.10.2019

»Enums and Pattern Matching – The Rust Programming Language«, https://doc.rust-lang.org/book/ch06-00-enums.html, zuletzt aufgerufen am 09.10.2019

»Patterns and Matching – The Rust Programming Language«, https://doc.rust-lang.org/book/ch18-00-patterns.html, zuletzt aufgerufen am 18.12.2019

Der Projektmanager Cargo

»Hello, Cargo! – The Rust Programming Language«, https://doc.rust-lang.org/book/2018-edition/ch01-03-hello-cargo.html, zuletzt aufgerufen am 26.11.2018

Projektmanagement

»Managing Growing Projects with Packages, Crates, and Modules – The Rust Programming Language«, https://doc.rust-lang.org/book/ch07-00-managing-growing-projects-with-packages-crates-and-modules.html, zuletzt aufgerufen am 11.10.2019

»More about Cargo and Crates.io – The Rust Programming Language«, https://doc.rust-lang.org/book/ch14-00-more-about-cargo.html, zuletzt aufgerufen am 24.11.2019

Collections

»Common Collections – The Rust Programming Language«, https://doc.rust-lang.org/book/ch08-00-common-collections.html, zuletzt aufgerufen am 13.10.2019

»std::collections::HashSet – Rust«, https://doc.rust-lang.org/std/collections/struct.HashSet.html, zuletzt aufgerufen am 20.10.2019

»std::collections::LinkedList – Rust«, https://doc.rust-lang.org/std/collections/struct.LinkedList.html, zuletzt aufgerufen am 20.10.2019

Fehlerbehandlung

»Error Handling – The Rust Programming Language«, https://doc.rust-lang.org/book/ch09-00-error-handling.html, zuletzt aufgerufen am 20.10.2019

Generics, Traits, Lifetimes

»Generic Types, Traits, and Lifetimes – The Rust Programming Language«, https://doc.rust-lang.org/book/ch10-00-generics.html, zuletzt aufgerufen am 23.10.2019

Tests

»Writing Automated Tests – The Rust Programming Language«, https://doc.rust-lang.org/book/ch11-00-testing.html, zuletzt aufgerufen am 14.11.2019

Closures und Iteratoren

»Functional Language Features: Iterators and Closures – The Rust Programming Language«, https://doc.rust-lang.org/book/ch13-00-functional-features.html, zuletzt aufgerufen am 17.11.2019

Pointer

»Smart Pointers – The Rust Programming Language«, https://doc.rust-lang.org/book/ch15-00-smart-pointers.html, zuletzt aufgerufen am 24.11.2019

Nebenläufigkeit

»Fearless Concurrency – The Rust Programming Language«, https://doc.rust-lang.org/book/ch16-00-concurrency.html, zuletzt aufgerufen am 03.12.2019

Weitere Features

»Advanced Features - The Rust Programming Language«, https://doc.rust-lang.org/book/ch19-00-advanced-features.html, zuletzt aufgerufen am 19.12.2019

Weitere Bücher des Autors

C# lernen: Eine ausführliche Anleitung mit vielen Beispielen, erstmalig erschienen am 30. März 2018, ISBN: 978-1980659549
Direktlink: https://www.amazon.de/dp/1980659540

Programmieren lernen mit Java: Eine ausführliche Anleitung mit vielen Beispielen, erstmalig erschienen am 15. Oktober 2018, ISBN: 978-1723755293
Direktlink: https://www.amazon.de/dp/1707882614

Programmierung sicherer Systeme mit Rust: Eine Einführung, erstmalig erschienen am 1. April 2020, ISBN: 979-8627144504
Direktlink: https://www.amazon.de/dp/B086PQXH3N