Erhalten Sie Zugang zu diesem und mehr als 300000 Büchern ab EUR 5,99 monatlich.
Sichere Programmierung für Profis - Kompakte Einführung und fortgeschrittene Themen - Praktische Beispiele wie Webanwendungen, Microservices, Mocking oder Language Bindings - Alle Codebeispiele online verfügbarDieses Buch vermittelt Anwendungsentwicklern Theorie und Praxis der Sprache Rust und zeigt, wo sie gewinnbringend in neuen Projekten verwendet und wie sie sich in bestehende Projekte gut integrieren lässt. Es illustriert alle Inhalte mit zahlreichen Beispielen. Nach einer Einführung in die Grundlagen, Nebenläufigkeit und das Testen mit Rust kommt der praktische Teil. Anhand einer Webapplikation und ihrer Aufteilung in Microservices werden die Vorteile und Eigenheiten der Sprache anschaulich vermittelt. Systemnahe Programmierung, die Kommunikation mit Sprachen wie Java, aber auch die Verwendung von WebAssembly werden ebenfalls betrachtet. Nach der Lektüre dieses Buchs kann man produktiv in Rust programmieren und hat neben den Grundlagen ein gutes Verständnis für typische Anwendungsbereiche der Sprache wie WebAssembly, Systemnahe Programmierung oder Einbindung in bestehende Umgebungen über Language Bindings.
Sie lesen das E-Book in den Legimi-Apps auf:
Seitenzahl: 591
Das E-Book (TTS) können Sie hören im Abo „Legimi Premium” in Legimi-Apps auf:
Marco Amann hat Softwaretechnik studiert und arbeitet bei Digital-Frontiers als Consultant. Er ist als einer der Experten der Digital Frontiers für das Thema Rust verantwortlich und hat Schwerpunkte in den Bereichen systemnaher Programmierung und robuster Systeme.
Dr. Joachim Baumann ist Management Consultant und Geschäftsführer der Digital Frontiers GmbH & Co. KG. Er verfügt über mehr als 30 Jahre Erfahrung in der IT, als Entwickler, Architekt, Projektleiter, Scrum-Master und Berater und beschäftigt sich seit dem Jahr 2000 mit agilen Vorgehensweisen. Sein Wissen gibt er gerne in Form von Büchern, aber auch als Hochschuldozent und in Schulungen weiter, er ist aber auch immer noch Committer in Open-Source-Projekten.
Marcel Koch vermittelt – ob zwischen Technologien oder Menschen. Er versteht es, in verschiedenste Technologien und Gebiete einzutauchen, die Vorteile zu nutzen und die Essenzen zu erklären. Als Kommunikationscoach setzt er auf gewaltfreie Kommunikation, Transaktionsanalyse und Radical Candor. Als Softwarearchitekt baut er auf konservative Technologien, wie zum Beispiel Kotlin oder Spring, und ergänzt diese bedarfsgerecht mit neueren wie WebAssembly oder Rust. www.marcelkoch.net
Zu diesem Buch – sowie zu vielen weiteren dpunkt.büchern – können Sie auch das entsprechende E-Book im PDF-Format herunterladen. Werden Sie dazu einfach Mitglied bei dpunkt.plus+:
www.dpunkt.plus
Marco Amann · Joachim Baumann · Marcel Koch
Konzepte und Praxis für die sichere Anwendungsentwicklung
Marco Amann · Joachim Baumann · Marcel Koch
Lektorat: René Schönfeldt
Projektkoordinierung: Anja Weimer
Copy-Editing: Annette Schwarz, Ditzingen
Satz: Veronika Schnabel
Herstellung: Stefanie Weidner
Umschlaggestaltung: Helmut Kraus, www.exclam.de
Bibliografische Information der Deutschen Nationalbibliothek
Die Deutsche Nationalbibliothek verzeichnet diese Publikation in der Deutschen Nationalbibliografie; detaillierte bibliografische Daten sind im Internet über http://dnb.d-nb.de abrufbar.
ISBN:
978-3-86490-878-1
978-3-96910-614-3
ePub
978-3-96910-615-0
mobi
978-3-96910-616-7
1. Auflage 2022
Copyright © 2022 dpunkt.verlag GmbH
Wieblinger Weg 17
69123 Heidelberg
Hinweis:
Dieses Buch wurde auf PEFC-zertifiziertem Papier aus nachhaltiger Waldwirtschaft gedruckt. Der Umwelt zuliebe verzichten wir zusätzlich auf die Einschweißfolie.
Schreiben Sie uns:
Falls Sie Anregungen, Wünsche und Kommentare haben, lassen Sie es uns wissen: [email protected].
Die vorliegende Publikation ist urheberrechtlich geschützt. Alle Rechte vorbehalten. Die Verwendung der Texte und Abbildungen, auch auszugsweise, ist ohne die schriftliche Zustimmung des Verlags urheberrechtswidrig und daher strafbar. Dies gilt insbesondere für die Vervielfältigung, Übersetzung oder die Verwendung in elektronischen Systemen.
Es wird darauf hingewiesen, dass die im Buch verwendeten Soft- und Hardware-Bezeichnungen sowie Markennamen und Produktbezeichnungen der jeweiligen Firmen im Allgemeinen warenzeichen-, marken- oder patentrechtlichem Schutz unterliegen.
Alle Angaben und Programme in diesem Buch wurden mit größter Sorgfalt kontrolliert. Weder Autor noch Verlag können jedoch für Schäden haftbar gemacht werden, die in Zusammenhang mit der Verwendung dieses Buches stehen.
5 4 3 2 1 0
Vorwort
Danksagung
1Rust – Einführung
Teil IDie Sprache
2Syntax von Rust-Programmen
3Variablen
4Datentypen
5Musterabgleich
6Funktionen
7Einführung in das Speichermodell
8Generische Datentypen
9Objektorientierte Konzepte
10Problembehandlung in Rust
11Standarddatentypen von Rust
12Makros
13Strukturierung von Projekten
14Zusammenfassung
Teil 2Fortgeschrittene Techniken
15Ownership im Detail
16Nebenläufige und parallele Programmierung
17Testen
18Webprogrammierung
19Microservices
20Systemnahe Programmierung
21Spracherweiterungen (Language Bindings)
22WebAssembly
23Zusammenfassung und Ausblick
Vorwort
Danksagung
1Rust – Einführung
1.1Warum Rust?
1.1.1Rust und der Speicher
1.1.2Rust und Objektorientierung
1.1.3Rust und funktionale Programmierung
1.1.4Rust und Parallelverarbeitung
1.2Ein Beispielprogramm
1.3Installation von Rust
1.3.1Installation von rustup
1.4IDE-Integration
1.4.1Rust Language Server und Rust-Analyzer
1.4.2Visual Studio Code
1.4.3IntelliJ IDEA
1.4.4Eclipse
1.4.5Welche Entwicklungsumgebung ist die beste?
1.5Unsere erste praktische Erfahrung
1.6Das Build-System von Rust
1.6.1Die Struktur von Rust-Programmen
1.6.2Die Erzeugung eines Packages
1.6.3Übersetzen und Ausführen eines Packages
1.6.4Verwaltung von Abhängigkeiten
1.6.5Workspaces
1.6.6Weitere nützliche Befehle von Cargo
1.7Entwicklung der Sprache und Kompatibilität
Teil IDie Sprache
2Syntax von Rust-Programmen
2.1Programmstruktur
2.2Anweisungsblöcke
2.3Rangfolge von Operatoren
2.4Gängige Kontrollflussstrukturen
2.4.1Das If-Konstrukt
2.4.2Das Loop-Konstrukt
2.4.3Die While-Schleife
2.4.4Die For-Schleife
3Variablen
3.1Veränderbare und nicht veränderbare Variablen
3.2Weitere Arten der Variablendefinition
3.2.1Globale Variablen
3.2.2Konstanten
4Datentypen
4.1Skalare Datentypen
4.1.1Ganzzahlen
4.1.2Fließkommazahlen
4.1.3Logische Werte
4.1.4Zeichen
4.1.5Typkonvertierung
4.2Tupel und Felder
4.2.1Tupel
4.2.2Felder
4.3Strukturierte Datentypen
4.3.1Unterstützung bei der Initialisierung
4.4Tupelstrukturen
4.5Aufzählungstypen
4.5.1In Aufzählungen eingebettete Datentypen
5Musterabgleich
5.1Das Match-Konstrukt
5.1.1Einfache Verwendung
5.1.2Rückgabewerte
5.1.3Zusätzliche Bedingungen für das Muster
5.1.4Zuweisungen im Muster
5.2Andere Datentypen und das Match-Konstrukt
5.3Weitere Musterabgleiche
5.3.1Das »If Let«-Konstrukt
5.3.2Das »While Let«-Konstrukt
5.3.3Das Makro matches!
6Funktionen
6.1Referenzen auf Funktionen
7Einführung in das Speichermodell
7.1Stack und Heap
7.2Rust und der Speicher
7.3Das Modell für skalare Datentypen
7.3.1Wechsel von Gültigkeitsbereichen
7.3.2Aufruf von Funktionen
7.4Das allgemeinere Modell
7.4.1Wechsel von Gültigkeitsbereichen
7.4.2Aufruf von Funktionen
7.5Referenzen in Rust
7.5.1Lesereferenzen auf nicht veränderbare Variablen
7.5.2Lesereferenzen auf veränderbare Variablen
7.5.3Veränderbare Referenzen
7.6Verwendung von Variablen und Referenzen
7.7Vor- und Nachteile des Modells
7.7.1Nachteile
7.7.2Vorteile
8Generische Datentypen
8.1Typparameter in Datenstrukturen
8.2Typparameter in Funktionen
8.3Typparameter in Aufzählungstypen
9Objektorientierte Konzepte
9.1Methoden
9.1.1Die Verwendung von Typparametern
9.2Module und Sichtbarkeiten
9.2.1Importieren von Elementen aus anderen Namensräumen
9.2.2Hierarchische Module
9.2.3Erweiterte Sichtbarkeiten
9.2.4Aufteilung in mehrere Dateien
9.3Traits
9.3.1Erzeugung und Verwendung
9.3.2Abhängigkeit von anderen Traits
9.3.3Verwendung in Funktionen
9.3.4Verwendung mit generischen Datentypen
9.3.5Einschränkung von Typparametern mit Traits
9.3.6Polymorphe Rückgabetypen
9.3.7Assoziierte Datentypen
9.3.8Die Größe von Instanzen
9.3.9Dynamische Trait-Objekte
9.3.10Traits, die Rust bereitstellt
9.3.11Der Trait Drop
9.3.12Das Attributsmakro Derive
10Problembehandlung in Rust
10.1Der Datentyp Option
10.2Der Datentyp Result
10.3Interoperabilität von Option und Result
10.4Der ?-Operator
10.5Nicht behebbare Fehler
10.6Bewertung
11Standarddatentypen von Rust
11.1Kollektionen
11.1.1Sequenzdatentypen
11.1.2Map-Datentypen
11.1.3Mengen
11.1.4Verschiedene Datentypen in Kollektionen
11.1.5Der Datentyp Slice
11.1.6Zeichenketten
11.2Der Datentyp Range
11.3Closures
11.3.1Verwendung als anonyme Funktion
11.3.2Der umgebende Gültigkeitsbereich
11.4Iteratoren
11.4.1Erzeugung von Iteratoren
11.4.2Erste Verwendung von Iteratoren
11.4.3Weitere Verarbeitungsmöglichkeiten
11.4.4Erzeugung von Iteratoren aus Iteratoren
11.4.5Erzeugung neuer Kollektionen
12Makros
12.1Bekannte Makros
12.2Beispiele für weitere Makros
12.2.1Assertionen
12.2.2Makros für Zeichenketten
12.3Arten von Makros
12.4Ein eigenes deklaratives Makro
13Strukturierung von Projekten
13.1Konfiguration des Packages
14Zusammenfassung
Teil 2Fortgeschrittene Techniken
15Ownership im Detail
15.1Näheres zum bekannten Ownership-Modell
15.1.1Move, Copy, Clone, Borrow
15.1.2Lifetimes
15.1.3Die Sicherheit von Rust
15.2Smart Pointer
15.2.1Box
15.2.2Rc
15.2.3RefCell und Cell
15.2.4Zusammenfassung
15.3Vergleich mit anderen Sprachen ohne Ownership
16Nebenläufige und parallele Programmierung
16.1Grundlagen
16.2Channels
16.3Shared State
16.3.1Arc
16.3.2Send und Sync
16.3.3Mutex
16.3.4RwLock
16.4Einfache Parallelisierung mit Rayon
16.5Sicherheit trotz Parallelität
16.6Async/Await
16.7Zusammenfassung
17Testen
17.1Arten von Tests
17.1.1Unit-Tests
17.1.2Integration-Tests
17.1.3UI-Tests
17.1.4Testpyramide, Nachwort
17.2Rust, Cargo und Tests
17.2.1Platzierung von Testcode
17.3Ausführung
17.3.1Erwartungen der Testergebnisse (Assertions)
17.3.2Benennung der Testfunktionen
17.4Mocking
17.4.1Erste Schritte ohne Framework
17.4.2Einsatz eines Frameworks: Mockall
17.4.3Abschließendes zu Mockall
17.5Snapshot-Tests mit insta
17.6Der Rust-Compiler sieht viel, aber nicht alles
17.6.1Überläufe (Overflows)
17.6.2OutOfBoundsCheck
17.6.3Stockungen (Deadlocks)
17.7Fazit
18Webprogrammierung
18.1Einführung
18.1.1Warum Rust für Webprogrammierung?
18.1.2Warum nicht Rust für Webprogrammierung?
18.1.3Themen in diesem Kapitel
18.1.4Eine kleine Warnung vorab
18.2Grundlagen von Rocket
18.2.1Handler
18.2.2Return Types
18.2.3Ein Blick hinter die Kulissen
18.2.4Shared State
18.3Das Kontaktformular
18.3.1Routen
18.3.2Formulare
18.3.3Datenbankverbindung
18.3.4Was macht Rust bis hierher so besonders?
18.3.5Middleware
18.3.6Guards
18.3.7Fairings oder Guards?
18.3.8Serverside-Templates
18.3.9Testen mit Rocket
18.4Betrieb
18.4.1Logging
18.4.2Konfiguration
18.4.3Deployment
18.5Fazit
19Microservices
19.1Eignet sich Rust für Microservices?
19.2Aufteilung der Webanwendung in Microservices
19.3Vorbereitungen
19.3.1Build mit Docker
19.3.2Cross Compilation
19.4Die Microservices
19.4.1Anfragen annehmen: der »Web«-Service
19.4.2Gemeinsame Funktionalität
19.4.3Speichern der Anfragen in der Datenbank
19.4.4Mail verschicken
19.5Betrieb
19.5.1Metriken und Monitoring
19.5.2Tracing
19.5.3Skalierung
19.6Zusammenfassung
20Systemnahe Programmierung
20.1Unsafe Rust
20.1.1Pointer-Grundlagen
20.1.2Unsafe in std: RefCell als Beispiel
20.2Systemaufruf
20.2.1Systemaufruf in Handarbeit
20.2.2Systemaufruf mit dem Crate libc
20.3Integration von externen Bibliotheken in Rust
20.3.1Fallstricke
20.4Performanceuntersuchung
20.4.1Erste Schritte
20.4.2Benchmarks
20.4.3Untersuchungen
20.4.4Optimierung
20.5Zusammenfassung
21Spracherweiterungen (Language Bindings)
21.1Java
21.1.1Grundsätzliches – Java ruft Rust auf
21.1.2j4rs
21.1.3Zusammenfassung
21.2Node.js
21.3Fazit
22WebAssembly
22.1Aktueller Stand von WebAssembly
22.1.1Im Browser
22.1.2Außerhalb des Browsers – ein Anfang
22.2Rust & WASM
22.2.1Warum Rust für WASM?
22.2.2Im Browser: wasm-bindgen & wasm-pack
22.2.3Auf dem Server
22.3Fazit
23Zusammenfassung und Ausblick
23.1Zusammenfassung
23.2Ausblick
Index
Dieses Buch richtet sich an Entwickler, die über den Tellerrand normaler Programmierung hinausschauen wollen. Mit Rust bietet sich die Möglichkeit, eine neue und interessante Programmiersprache kennenzulernen, die einiges anders macht als die gängigen Programmiersprachen.
Sie sollten als Entwickler bereits Erfahrung in der objektorientierten Programmierung haben. Wissen um typische Konzepte gerade objektorientierter Sprachen sollte vorhanden sein. Wir erklären auch keine gängigen Programmierkonstrukte wie if oder while, sondern setzen die dafür notwendigen Kenntnisse voraus.
Dieses Buch wiederholt weder die leicht zu findenden Tutorials noch die exzellente Online-Dokumentation zu Rust. Es ist auch keine Einführung für Menschen, die das Programmieren erst lernen wollen (das Buch wäre sonst mehr als doppelt so dick).
Unser Ziel ist es, Sie mit den Inhalten dieses Buchs in die Lage zu versetzen, auch komplexere praktische Programme mit Rust zu schreiben.
Natürlich hoffen wir außerdem, dass Sie genau wie wir die Freude dabei verspüren, Programme zu schreiben, bei denen Speicherlecks der Vergangenheit angehören.
Zum Schluss ist die Auseinandersetzung mit dem Ownership-Modell von Rust eine interessante Erfahrung, die uns auch in anderen Sprachen zu besseren Programmierern macht.
Zu diesem Buch haben wir ein git-Repository eingerichtet, in dem Sie viele Codebeispiele direkt zum Klonen und Ausprobieren finden:
https://rust-buch.de/repository
Das Schreiben eines Buches ist, wenig überraschend, ein langwieriges und aufwendiges Unterfangen, das unsere Familien sehr stark unterstützt haben. Und dies, obwohl wir dadurch weniger aufmerksam waren, mehr Zeit mit unverständlichen Dingen zubrachten, ständig »Oh, da muss ich auch noch dran denken …« vor uns hinmurmelten und generell leicht geistig abwesend waren. Vielen Dank für die Geduld und das Verständnis.
Auch wenn dies eher unüblich ist, möchten wir ganz explizit unserer Firma Digital Frontiers danken, die mit ihrem ungewöhnlichen Arbeitsmodell das Schreiben eines Buches in der Arbeitszeit ermöglicht und derartige inhaltliche Arbeit stark fördert.
Ich möchte mich bei meinen beiden Co-Autoren Marco und Joachim für ihr Wissen, ihren Humor und ihre Geduld bedanken. Das Privatleben gab mir nicht immer so viel Zeit frei, wie wir uns alle gewünscht hätten.
Ich danke ausdrücklich meiner Frau Johanna und meinem Sohn Niklas. Wie oben schon erwähnt, mussten sie sehr oft auf mich verzichten. Vielleicht lest Ihr, Niklas und Larissa, das Buch auch mal im Informatikgeschichtsunterricht.
Ihr drei seid meine Sonne und Hoffnung!
In diesem Kapitel werfen wir einen ersten Blick auf Rust-Programme, betrachten die Installation von Rust und der Sprachunterstützung in verschiedenen Entwicklungsumgebungen, sodass wir möglichst schnell praktische Schritte mit der Sprache unternehmen, ein Beispielprogramm schreiben und mit dem Rust-eigenen Build-System übersetzen und starten können.
Rust ist eine moderne Sprache, die sehr stark auf Geschwindigkeit und Parallelverarbeitung ausgelegt ist. Vielfach wird Rust als Systemprogrammiersprache und Ersatz für C dargestellt, der Anwendungsbereich ist aber sehr viel breiter. Betrachten wir ein paar der interessanten Eigenschaften von Rust.
Das absolute Alleinstellungsmerkmal ist die Art, wie Rust mit Speicher umgeht. Rust kann garantieren, dass durch die Verwaltung des Speichers zur Übersetzungszeit keine Fehler zur Laufzeit auftreten können. Damit braucht Rust auch keinen Garbage Collector. Das verhindert unbeabsichtigte Unterbrechungen im Programmablauf, um den Speicher aufzuräumen. Wir haben also nicht nur korrektere Programme, die schneller laufen, sie verhalten sich auch deterministischer.
Um dies zu erreichen, wird für jeden Wert ein Eigentümer festgelegt. Dies kann ein primitiver Wert sein oder eine beliebig komplexe Struktur. Ein Wert lebt, solange der Eigentümer lebt.
Der Eigentümer kann wechseln, und für den Zugriff auf ein Objekt können Referenzen ausgeliehen werden (Borrowing). Ausgeliehene Referenzen sind im Normalfall Lesereferenzen, es kann aber alternativ auch maximal eine Schreib-/Lese-Referenz auf einen Wert definiert werden. Dies impliziert, dass wir keine aktive Lesereferenz haben. Die Beschränkung auf eine einzige schreibende Instanz sorgt bei Neulingen meist für Überraschungen, hat aber den großen Vorteil, dass es keine undefinierten Zustände durch gleichzeitiges Schreiben oder nicht synchronisiertes Lesen geben kann.
Dieses Ownership genannte Konzept ist extrem mächtig, braucht aber zum vollständigen Verinnerlichen etwas Zeit und Übung. Wir werden dies in Abschnitt 7.2 kennenlernen und in Kapitel 15 im Detail beleuchten.
Rust ist eine Programmiersprache, die mit der Kapselung von Daten und Funktionen und Methoden auf diesen Daten objektorientierte Konzepte unterstützt.
Rust erreicht dies durch die Einführung von Modulen, die private und öffentliche Daten und Funktionen enthalten. Polymorphismus wird durch das Konzept der Traits erreicht, die inzwischen in vielen anderen Programmiersprachen wie Kotlin oder Scala auch verwendet werden. Eine vergleichbare Funktionalität gibt es in Java seit der Version 8 mit den Default-Methoden in Interface-Spezifikationen.
Rust bietet allerdings anders als die gewohnten objektorientierten Sprachen keine Vererbung. Dies mag im ersten Moment überraschen und ist eine Abkehr vom normalen objektorientierten Denken, hat aber gute Gründe.
Aus konzeptioneller Sicht ist es problematisch, dass wir bei der Vererbung nicht kontrollieren können, welche Teile unserer Elternklasse wir erben möchten. Dies kann dazu führen, dass wir in abgeleiteten Klassen Funktionalität haben, die dort nicht gewollt ist.
Das praktischere Argument ist aber, dass durch Verzicht auf Vererbung ein hoher Aufwand zur Identifikation der richtigen auszuführenden Methode/Funktion wegfällt. Dies macht Rust-Programme deutlich laufzeiteffizienter.
Wir werden uns mit objektorientierten Konzepten in Kapitel 9 auseinandersetzen.
Zur Unterstützung funktionaler Programmierung bietet Rust Closures, anonyme Funktionen, die auf ihre Umgebung zur Zeit der Definition zugreifen können. Dieses vielseitige Konstrukt findet sich in mehr und mehr Sprachen und erlaubt eine sehr elegante Kapselung von Funktionalität und Daten.
Zusammen mit Iteratoren, die die Verarbeitung von Sammlungen von Daten kapseln, erlauben Closures sehr mächtige funktionale Abstraktionen. Iteratoren und Closures werden wir in Kapitel 11 kennenlernen.
Rust bietet eine direkte Abstraktion der Thread-Funktionalität des unterliegenden Betriebssystems. Dies sorgt für den geringstmöglichen Mehraufwand zur Laufzeit, beschränkt aber natürlich die Flexibilität in der Verwendung von Threads auf die Unterstützung durch das unterliegende System. Bei Bedarf können allerdings auch Thread-Module verwendet werden, die eine unabhängige und damit flexiblere Implementierung anbieten. Dies erlaubt uns, von Fall zu Fall zu entscheiden, ob wir die größere Flexibilität oder den geringeren Speicherbedarf bevorzugen. Während die Entscheidung in vielen Fällen in Richtung der Flexibilität getroffen werden wird, gibt es eingeschränkte Umgebungen (wie zum Beispiel Mikro-Controller), in denen die Möglichkeit der expliziten Wahl sehr vorteilhaft ist.
Viele der Probleme, die bei der normalen Programmierung von paralleler Verarbeitung zu sehr hoher Komplexität und damit zu schwer auffindbaren Fehlern führen, finden wir in Rust nicht. Dies entsteht durch das Ownership-Modell, das dafür sorgt, dass der Compiler problematische Stellen im Quelltext sehr früh identifizieren und damit entfernen kann. Das heißt nicht, dass Rust alle Probleme im Zusammenhang mit Parallelprogrammierung löst. Es erlaubt uns aber, uns auf die wirklich schwierigen Probleme zu konzentrieren.
Threads in Rust können kommunizieren, indem sie Nachrichten in verschiedene Kanälen senden oder aus diesen empfangen. Zusätzlich können sie Teile ihres Zustands geschützt durch eine Mutex-Abstraktion mit anderen Threads teilen.
Parallelprogrammierung in Rust ist sehr mächtig, und wir werden uns in Kapitel 16 eingehend damit beschäftigen.
Als ein erstes Beispiel, um Ihren Appetit für Rust zu wecken, betrachten wir ein kleines Programm, das bereits viele Eigenschaften von Rust zeigt. Da dieses deutlich über das klassische »Hallo Welt«-Programm hinausgeht, sollten Sie sich keine Sorgen machen, wenn einzelne Funktionalitäten noch nicht vollständig klar sind. Die Erklärungen sind an dieser Stelle notwendigerweise etwas kurz, alle angesprochenen Eigenschaften werden wir später deutlich detaillierter betrachten.
Listing 1–1Zeilenweises Lesen und Ausgabe einer Datei
Wir beginnen mit dem Import benötigter Funktionalität (wie auch aus Java bekannt). Wir benennen den aus dem Namensraum beziehungsweise Modul std::fs (durch den Pfadtrenner :: getrennte Namen sind hierarchische Pfadangaben) importierten Typ File um in Datei und importieren im nächsten Schritt die beiden Typen BufReader und BufRead. Der Typ BufReader unterstützt gepuffertes Lesen aus einer Quelle und ist damit deutlich effizienter als ein direktes Lesen.
Dann folgt die Definition unserer ersten Funktion, gekennzeichnet durch das Schlüsselwort fn. In unserem Fall ist dies die Funktion main(), die keine Parameter und keinen Rückgabewert hat. Der Körper der Funktion findet sich im durch geschweifte Klammern definierten Block von Anweisungen, die durch Semikolon getrennt sind. Wie in vielen anderen Sprachen hat diese Funktion eine Sonderrolle: Sie ist der Einstiegspunkt in unser Programm und wird als Erstes aufgerufen, um den Programmabfluss zu starten.
Wir versuchen mit der Methode open() (eine Methode des Typs Datei, unserem umbenannten Typ File) eine Datei mit dem Namen hallo.txt zu öffnen. Dieser Aufruf liefert ein Objekt vom Typ Result zurück, das entweder das Ergebnis des erfolgreichen Aufrufs oder den durch den Aufruf ausgelösten Fehler enthält. Die Funktion expect() nimmt dieses Objekt und liefert im Erfolgsfall das Ergebnis zurück, im Fehlerfall wird die als Parameter übergebene Nachricht ausgegeben und das Programm mit einem Fehler beendet.
Hintergrund
Tatsächlich erzeugt die Funktion expect() einen nichtbehandelbaren Fehler vom Typ std::Panic. Dies ist ein völlig normaler Weg für Rust, Probleme zu behandeln, solange es der eigene Quelltext ist oder wir uns in der Prototypphase befinden. Im Falle von Produktionssoftware oder aber Bibliotheken sind andere Wege zur Fehlerbehandlung zu bevorzugen.
Wir weisen das Ergebnis, ein Objekt vom Typ Datei, der Variable file zu und erzeugen im nächsten Schritt ein Objekt vom Typ BufReader darauf, das wir in der Variable reader halten.
Nun iterieren wir mit einer For-Schleife über die einzelnen Zeilen der Datei in einem Iterator, den wir über den Aufruf von reader.lines() erhalten. Hierbei ist wichtig, dass die Funktion lines() die Zeilen ohne abschließenden Zeilenvorschub liefert. Der nachfolgende Block (durch geschweifte Klammern definiert) wird für jede Zeile ausgeführt. Das Ergebnis des Leseversuches landet als Result-Objekt in der Variablen line.
Auch hier extrahieren wir die eigentliche Zeichenkette wieder mit einem Aufruf der Funktion expect() mit einer Fehlermeldung, falls das Lesen nicht erfolgreich war. Das Ergebnis weisen wir der Variablen line zu. Der Effekt ist, dass wir keinen Zugriff mehr auf das Result-Objekt haben, das uns der Iterator zurückgegeben hat, sondern jetzt das eigentliche Resultat, die Zeichenkette mit der aktuellen Zeile der Datei, verwenden.
Hintergrund
Diese Shadowing genannte Funktionalität von Rust ist in vielen Fällen sehr vorteilhaft und elegant, kann aber bei Missbrauch zu schlechter Lesbarkeit führen. Der Umgang mit Result-Objekten ist einer der Fälle, in denen dies die Absicht des Programmierers klar kommuniziert.
Die letzte Anweisung unseres Programmes ist der Aufruf des Makros println!, das die Argumente mit einem abschließenden Zeilenvorschub ausgibt, so wie es die Formatzeichenkette (das erste Argument) vorgibt. Die Variante ohne Zeilenvorschub heißt print!.
Makros werden gekennzeichnet durch das Ausrufezeichen am Ende des Namens. Makros haben insbesondere aufgrund der Herausforderungen im Zusammenhang mit dem C-Präprozessor einen schlechten Ruf. In C entsteht dieser aus der direkten Ersetzung von Makroaufrufen durch ihren Inhalt, ohne dass der Präprozessor in irgendeiner Weise prüft, ob die Änderung syntaktisch korrekten Quelltext hinterlässt. Rust-Makros sind hier deutlich besser, da die Umsetzung durch den Compiler erfolgt und grundsätzlich gültige Ausdrücke erzeugt werden.
Das Makro println! ist ein exzellentes Beispiel für die Eleganz von Makros. In Rust müssen wir Funktionen mit der vollständigen Anzahl ihrer Parameter definieren, ein wichtiger Teil der Funktionalität von println! ist aber gerade, mit einer beliebigen Zahl von Parametern umgehen zu können. Das Rust-Makro println! erzeugt nun aus dem jeweiligen Quelltext den korrekten Aufruf der zugehörigen Bibliotheksfunktionen. Dies führt dazu, dass wir println! mit einer der Formatierungszeichenkette entsprechenden Anzahl von Argumenten aufrufen können.
Hintergrund
Es gibt zwei Arten von Makros in Rust, die deklarativen und die prozeduralen. In beiden Fällen übernimmt der Rust-Compiler die Aufgabe der Makroübersetzung, was Fehler sehr viel schneller und besser erkennen lässt.
Die deklarativen Makros sind relativ einfach zu schreiben, aber in ihrer Mächtigkeit etwas beschränkt. Wir werden später ein eigenes definieren.
Prozedurale Makros in Rust sind eine elegante Art der Metaprogrammierung, anders als der C-Präprozessor, der nur einfache Textersetzungen durchführt. Sie operieren direkt auf dem Abstract Syntax Tree, den der Rust-Compiler aus dem Quelltext erzeugt. Dies erlaubt eine extrem hohe Mächtigkeit dieser Art von Makros, dafür sind sie schwerer zu schreiben.
Das erste Argument von println! ist diese Formatierungszeichenkette (ein Literal), die Formatierungsanweisungen und Platzhalter für die Ausgabe enthält. Die Zeichenkette muss ein Literal sein, da das Makro aus dieser den eigentlichen Code generiert (präziser: die Manipulationen des Abstract Syntax Tree durchführt).
In unserem Fall enthält diese Zeichenkette einfach einen Platzhalter {}, der durch das zweite Argument, unsere aktuelle Zeile, belegt wird. Dies führt dazu, dass alle Zeilen der Datei hallo.txt ausgegeben werden.
Die Rust-Entwicklung schreitet sehr schnell voran. Um dies zu reflektieren, werden in vergleichsweise kurzen Abständen (zum Zeitpunkt der Veröffentlichung alle 6 Wochen) neue Versionen von Rust veröffentlicht. Um die Installation jederzeit aktuell halten zu können, einfach zwischen verschiedenen Kanälen (stable, beta, nightly) wechseln zu können oder zum Beispiel Übersetzungen für andere Zielarchitekturen zu ermöglichen, stellt das Rust-Projekt das Werkzeug rustup zur Verfügung, das die Installation und Aktualisierung sehr stark vereinfacht. Hierbei werden normalerweise alle Werkzeuge im Verzeichnis .cargo im Benutzerverzeichnis installiert. Konfigurationsoptionen erlauben aber auch eine systemweite Installation.
Natürlich stehen auch jeweils aktuelle Installationspakete für die manuelle Installation bereit, aber für die Entwicklung mit Rust ist die Verwendung von rustup die beste Wahl.
Detaillierte Anweisungen inklusive aller Varianten zur Installation von rustup finden sich unter:
https://rust-lang.github.io/rustup/installation/index.html
Deshalb werden wir hier nur den einfachen Installationspfad betrachten.
Gehen Sie zur Website
https://www.rust-lang.org/tools/install
und laden Sie Rustup-Init.exe herunter. Nach der Ausführung des Installationsprogrammes können Sie den Erfolg der Installation testen, indem Sie ein CMD-Fenster öffnen und rustc –version eingeben. Falls dies nicht klappt, prüfen Sie, ob der Pfad korrekt erweitert wurde, und versuchen Sie den Aufruf mit %userprofile%/.cargo/bin/rustc –version.
Die allgemeine Methode, um rustup zu installieren, funktioniert für OSX, Linux, aber auch für das Linux-Subsystem unter Windows. Hierbei wird ein Skript ausgeführt, das von einem Server heruntergeladen wird. Man kann argumentieren, dass dies gefährlich ist. Es besteht aber natürlich die Möglichkeit, das Skript vor der Ausführung zu betrachten und zu prüfen. Es prüft, auf welcher Plattform es läuft, wählt dementsprechend das Installationsprogramm aus, lädt es herunter und führt es aus. Führen Sie das Skript mit dem folgenden Befehl aus:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
Wenn Sie dem Skript nicht trauen, dann können Sie das Installationsprogramm auch von Hand identifizieren und herunterladen.
Neben der direkten Installation gibt es auch die Möglichkeit der Installation über Paketmanager. Unter OSX gibt es Homebrew und MacPorts als bekannteste Paketmanager, unter Windows gibt es Chocolatey oder Scoop. Auch unter gängigen Linux-Distributionen gibt es die Möglichkeit der Installation über Paketmanager, für die Entwicklung wird aber generell die Verwendung von rustup empfohlen.
Auch hier besteht natürlich das Problem des Vertrauens, es kann aber durchaus sinnvoll sein, dem Ersteller eines Pakets für den verwendeten Paketmanager mehr zu vertrauen als einem Skript von einem Webserver. Diese Entscheidung liegt alleine bei Ihnen.
Neben der direkten Verwendung der durch rustup zur Verfügung gestellten Werkzeuge auf der Kommandozeile gibt es auch sehr gute Unterstützung für die Programmierung in Rust durch verschiedene Entwicklungsumgebungen (IDE – Integrated Development Environment). Insbesondere das von den IDEs angebotene Auto-Vervollständigen, die Auflistung der Parameter von aufgerufenen Funktionen und die Unterstützung für Refactoring machen die Entwicklung deutlich effizienter. Die starke Integration eines Debuggers und einer Versionsverwaltung tut ihr Übriges, um schnell Software zu entwickeln.
Rust bietet zur Unterstützung von Entwicklungsumgebungen seit langer Zeit bereits den Rust Language Server RLS an, der im Hintergrund läuft und die IDE durch Informationen zu verwendeten Symbolen unterstützt. Diese Unterstützung beinhaltet Dokumentation, Umformatierung, Autovervollständigung, Refactoring, das Auffinden der Definition eines Symbols (dies ermöglicht in der Entwicklungsumgebung das Springen zur Funktion, die man aufruft) oder auch die Übersetzung im Hintergrund. Dies funktioniert in den meisten Fällen auch akzeptabel, allerdings wird RLS seit längerer Zeit nicht mehr weiterentwickelt und ist im Wartungsmodus.
Hintergrund
Die Idee eines Language Servers und des damit verbundenen Protokolls LSP (Language Server Protocol) wurde ursprünglich von Microsoft für Visual Studio Code entwickelt. Das dahinterliegende Konzept ist, dass der Aufwand für die Entwicklung von sprachspezifischen Funktionen wie Syntaxhervorhebung, Autovervollständigung, Refactoring bis hin zur Übersetzung aus der Entwicklungsumgebung extrahiert und in einen eigenen Prozess ausgelagert wird. Dies erlaubt die Entkopplung und Verwendung des Language Servers in verschiedenen Entwicklungsumgebungen. Das zugehörige Protokoll wurde standardisiert und wird mehr und mehr auch von anderen Entwicklungsumgebungen verwendet.
Die Alternative ist der Rust-Analyzer, eine Neuimplementierung des RLS. Dieser ist zwar noch in einer frühen Phase, trotzdem aber schon weiter entwickelt als RLS und bietet eine deutlich vollständigere Unterstützung. Nachdem inzwischen auch das Rust-Projekt selbst an einer Transition von RLS zu Rust-Analyzer arbeitet und sogar der Originalentwickler des RLS ganz explizit sagt, man solle Rust-Analyzer verwenden, empfehlen auch wir die Verwendung des Rust-Analyzers anstelle des RLS. Dieser wird zwar (zum Zeitpunkt der Veröffentlichung) immer noch als Preview und Alphaversion bezeichnet, wir haben aber mit dieser Implementierung nur positive Erfahrungen gemacht.
Visual Studio Code ist eine kostenlose IDE von Microsoft, die auf dem Electron-Framework basiert. Damit ist sie plattformübergreifend in allen gängigen Systemumgebungen verfügbar. Visual Studio Code bietet zwei Erweiterungen, die die Entwicklung mit Rust unterstützen. Sie können diese IDE hier herunterladen:
https://code.visualstudio.com/
Es gibt zwei Erweiterungen, die die Entwicklung von Rust in Visual Studio Code unterstützen: »Rust for Visual Studio Code« und »Rust-Analyzer«. Die erste Erweiterung nutzt den Rust Language Server, die zweite den Rust-Analyzer.
Da der Rust-Analyzer der von Rust empfohlene Language Server ist, ist die zugehörige Erweiterung die logische Wahl. Auch diese Erweiterung wird wie der Rust-Analyzer selbst (zum Zeitpunkt der Veröffentlichung) immer noch als Preview und Alphaversion bezeichnet, unsere Erfahrungen sind aber ausnahmslos positiv.
Zur Installation wechseln Sie in die Extensions-Sicht und geben in dem Suchfeld »Rust« ein. Die beiden beschriebenen Plugins sollten direkt angezeigt werden. Wählen Sie das »Rust-Analyzer«-Plugin aus und klicken Sie auf »Install«. Das Plugin installiert den Rust-Analyzer mit, sodass sie hier keinen zusätzlichen Aufwand haben.
Abb. 1–1Debugging-Session mit Visual Studio Code
IntelliJ IDEA ist eine sehr leistungsfähige Entwicklungsumgebung der Firma JetBrains, die es sowohl in einer kostenlosen Community-Version als auch in einer kommerziellen Ultimate-Version gibt. Sie finden die verschiedenen Versionen der IDE hier:
https://www.jetbrains.com/de-de/idea/
Das für diese IDE verfügbare Rust-Plugin ist unabhängig sowohl von RLS als auch von Rust-Analyzer implementiert, und es bietet eine sehr weitreichende Unterstützung für Rust an. Insbesondere die Refactoring-Unterstützung ist vorbildlich.
Der Nachteil ist allerdings, dass Debugging mit dem Rust-Plugin nur in der Ultimate-Edition freigeschaltet wird, es also keine kostenlose Unterstützung für Debugging gibt.
Aber auch ohne Debugging bietet das Plugin eine große Menge an Funktionalität und ist empfehlenswert, insbesondere wenn IntelliJ bereits für andere Projekte in Verwendung ist.
Zur Installation wechseln Sie in die Einstellungen (Preferences), wählen dort Plugins, suchen nach »Rust« und installieren das Rust-Plugin.
Abb. 1–2Jetbrain Intellij IDEA in Aktion
Eclipse bietet mit der »Corrosion«-Erweiterung eine Unterstützung für Rust-Programmierung, die auf dem Rust-Analyzer basiert.
Diese Erweiterung für Eclipse ist gefühlt noch nicht ganz so weit wie zum Beispiel die Unterstützung in Visual Studio Code, aber durchaus verwendbar. Aufgrund der Tatsache, dass der Rust-Analyzer verwendet wird, stehen sämtliche der hierdurch bereitgestellten Funktionalitäten ähnlich zur Verfügung wie in Visual Studio Code. Sie finden die verschiedenen Versionen der Eclipse-IDE hier:
https://www.eclipse.org/downloads/
Corrosion erwartet eine Installation des Rust-Analyzers, die Sie getrennt vornehmen müssen. Das jeweils aktuelle Release finden Sie auf Github zum Herunterladen.
Abb. 1–3Programmausführung mit Eclipse Corrosion
Aufgrund der sehr guten Unterstützung der Sprachspezifika durch den Rust-Analyzer, der sowohl von Visual Studio Code als auch von Eclipse Corrosion integriert wird, lässt sich die Entscheidung frei nach dem eigenen Geschmack treffen, wenn Sie eine kostenlose Entwicklungsumgebung nutzen wollen. Egal ob Sie sich mit Eclipse oder mit Visual Studio Code wohler fühlen, Sie bekommen in beiden Fällen eine gute Unterstützung.
Wenn Sie bereit sind, Geld auszugeben, oder falls Sie die Ultimate Edition von IntelliJ IDEA für andere Zwecke bereits erworben haben, dann haben Sie hier eine fantastische Unterstützung durch das zugehörige Rust-Plugin.
Zu guter Letzt können Sie auch mit einem Editor wie dem VIM oder Emacs mit Syntaxhervorhebungen gut arbeiten. Diese bieten ebenso Unterstützung für das Language-Server-Protokoll an und damit ähnliche Funktionalität wie die bereits genannten Entwicklungsumgebungen.
Werkzeuge
Wenn wir uns über die Kommandozeile Gedanken machen, kommen wir irgendwann auch zum Thema Debugging. Rust bietet nicht nur die Unterstützung für den seit 30 Jahren konstant weiterentwickelten GDB an, sondern auch den neueren LLDB, der auf der LLVM-Infrastruktur basiert (auch Visual Studio Code bietet die Möglichkeit, nicht nur GDB zu verwenden, sondern über eine Erweiterung auch den LLDB). Die zugehörigen Kommandos lauten rust-gdb und rust-lldb.
Aktuell schlagen wir die Verwendung von GDB vor, da es für diesen eine schier endlose Menge an Frontends gibt, die die Verwendung vereinfachen.
Nachdem wir jetzt einen ersten Blick auf die Rust-Syntax geworfen, Rust auf unserem System installiert und uns die zur Verfügung stehenden Entwicklungsumgebungen kurz angeschaut haben, wollen wir die ersten praktischen Schritte machen.
Für diese Erfahrung wählen wir das klassische HelloWorld-Programm, mit dem wir die ersten Tests der Rust-Werkzeuge durchführen. Im folgenden Listing finden wir den Quelltext für dieses simple Programm. Natürlich können Sie genauso gut das Programm aus unserem ersten Listing verwenden.
Listing 1–2Das klassische HelloWorld-Programm
fn main() {
println!("Hallo Welt!");
}
Wir definieren die Funktion main(), in der wir das Makro println! aufrufen mit der Zeichenkette »Hallo Welt!«. Wir speichern dieses Programm unter dem Namen hallo_welt.rs (.rs ist die Endung, die typischerweise für Quelltext in Rust verwendet wird).
Aufruf des Rust-Compilers
Um dieses Programm zu übersetzen, rufen wir den Rust-Compiler auf der Kommandozeile auf:
> rustc hallo_welt.rs
Der Compiler übersetzt jetzt den Quelltext und produziert ein ausführbares Programm mit dem gleichen Namen wie der Quelltext hallo_welt. Wir starten das Programm auf der Kommandozeile:
> ./hallo_welt
Hallo Welt!
>
Wir können beobachten, dass durch den einfachen Aufruf des Compilers die Standardbibliotheken zur Verfügung gestellt wurden und automatisch die gesamte Laufzeitumgebung hinzugefügt wurde. Damit ist das Programm eigenständig und ohne Laufzeitabhängigkeiten zu Rust-Bibliotheken ausführbar.
Tipps und Tricks
Tatsächlich gibt es je nach System, für das wir übersetzen, Abhängigkeiten zu den typischen dynamischen Bibliotheken wie libc unter Linux. Der Compiler erlaubt aber zu spezifizieren, ob man dynamische Abhängigkeiten zu den Betriebssystembibliotheken oder statische Bibliotheken wie musl verwenden möchte.
Tatsächlich kommen wir aber nur selten in die Verlegenheit, den Compiler direkt aufzurufen, denn Rust hat ein exzellentes Build-System namens Cargo.
Cargo unterstützt in der Erzeugung und Verwaltung von Softwarepaketen unterschiedlichster Größe (Packages in Rust) inklusive Verwaltung der Abhängigkeiten, Ausführung von Tests und Bauen von Bibliotheken und/oder ausführbaren Programmen (diese Erzeugnisse werden in Rust Crates genannt).
Wir haben damit eine physische Struktur, bei der ein Package aus einem oder mehreren Crates besteht, die jeweils eine oder mehrere Dateien enthalten. Oberhalb dieser gibt es auch noch eine größere Struktur namens Workspace, die mehrere Packages zusammenfasst und diese gemeinsam übersetzt. Dies ermöglicht uns die Strukturierung größerer Projekte. Wir werden diese am Ende dieses Abschnitts betrachten.
Zusätzlich werden wir im Lauf des Buchs eine logische Strukturierung in Module kennenlernen. Diese Abstraktion erlaubt uns, sehr genau zu steuern, welche Teile unseres Programmes an welchen Stellen sichtbar sind.
Tatsächlich haben wir diese Strukturierung schon in unserem ersten Rust-Quelltext kennengelernt. Die Anweisung
use std::fs::File
drückt aus, dass wir das Objekt File (bestehend aus einer Struktur und zugeordneten Funktionen) aus dem Submodul fs des Moduls std verwenden möchten.
cargo new
Um ein neues Package zu erzeugen, verwenden wir die Kommandozeilenoption new gefolgt von dem Namen unseres neuen Packages:
> cargo new hallo_welt
Created binary (application) `hallo_welt` package
>
Dies erzeugt ein Verzeichnis mit dem angegebenen Namen des Packages und darunter die typische Struktur eines Packages für Rust wie folgt:
hallo_welt/
Cargo.toml
src/
main.rs
Unter dem neu angelegten Verzeichnis hallo_welt finden wir die Datei Cargo.toml (.toml steht für »Toms Obvious, Minimal Language«), die Metainformation wie Namen und Version unseres Packages, aber auch die Abhängigkeiten unseres Packages von anderen Packages (im Normalfall Bibliotheken) enthält.
Weiterhin hat Cargo für uns das Verzeichnis src angelegt und darin die Datei main.rs platziert. Wenn wir diese Datei main.rs öffnen, dann finden wir darin eine Main-Methode mit dem üblichen HelloWorld-Programm. Diese dient als bequeme Basis für den Start. Den auszugebenden Text können wir optional anpassen.
cargo init
Falls das Verzeichnis, in dem wir unser neues Package erzeugen wollen, schon existiert, verwenden wir stattdessen:
> cargo init
Created binary (application) package
>
cargo build
Der nächste Schritt ist die Übersetzung unseres Programms. Dies machen wir mit:
> cargo build
Compiling hallo_welt v0.1.0 ([...]/hallo_welt)
Finished dev [[...] debuginfo] target(s) in 1.82s
>
Hierbei werden alle definierten Abhängigkeiten bei Bedarf heruntergeladen und der Compiler wird gestartet, um unseren Quelltext zu übersetzen. Bei Erfolg finden wir das erzeugte Programm unter:
target/debug/hallo_welt
Build-Konfigurationen
Cargo unterstützt verschiedene Build-Konfigurationen mit jeweils eigenen Einstellungen für die Übersetzung. Die von vornherein unterstützten Konfigurationen sind dev, release, test, und bench, deren Build-Ergebnisse in jeweils eigenen Build-Verzeichnissen landen. Wir können auch beliebige eigene Konfigurationen von diesen ableiten und die Einstellungen modifizieren. Die gewählten Voreinstellungen sind für den Normalfall aber durchaus sinnvoll. Sie können bei Bedarf in der Datei Cargo.toml modifiziert werden.
Die verschiedenen Konfigurationen werden je nach Cargo-Befehl automatisch ausgewählt. Wir können diese aber auch explizit anwählen. Wir verwenden zum Beispiel die Option --release, um von der Konfiguration dev zur Konfiguration release zu wechseln beziehungsweise von der Konfiguration test zur Konfiguration bench.
Im vorangehenden Beispiel sehen wir die Auswahl der dev-Konfiguration bei der Ausführung des Kommandos cargo build. Im folgenden Beispiel wählen wir explizit die release-Konfiguration, um eine optimierte (aber für das Debugging nicht mehr geeignete) Version zu erzeugen.
> cargo build --release
Compiling hallo_welt v0.1.0 ([...]/hallo_welt)
Finished release [optimized] target(s) in 0.22s
>
cargo check
Häufig wollen wir während der Entwicklung kurz überprüfen, ob unser Quelltext noch übersetzbar ist. Mit cargo check wird der Übersetzungslauf gestartet, ohne dass tatsächlich ein ausführbares Programm erzeugt wird. Dies liefert die vollständige Information über die Übersetzbarkeit, ist aber deutlich schneller als die Ausführung von cargo build und ist damit gern und häufig genutztes Mittel, um nebenher die Korrektheit des Quelltextes zu prüfen.
cargo run
Wir können mit cargo auch unser Programm ausführen. Dies machen wir mit:
> cargo run
Compiling hallo_welt v0.1.0 ([...]/hallo_welt)
Finished dev [[...| debuginfo] target(s) in 0.70s
Running `target/debug/hallo_welt`
Hello, world!
>
Falls das Programm bereits übersetzt war, wird es nicht erneut übersetzt, unabhängig davon werden gecachte Ergebnisse und Bibliotheken mit verwendet (daher bei der zweiten Übersetzung die geringere Zeit). Auch hier können wir eine Build-Konfiguration zur Steuerung der Übersetzung angeben.
cargo install
Wenn wir mit der Funktionalität unseres Programmes zufrieden sind, können wir es installieren, sodass es uns allgemein zur Verfügung steht. Hierzu verwenden wir den Befehl:
> cargo install --path <Pfad zum Projektverzeichnis>
> cargo install --path .
Installing hallo_welt v0.1.0 ([...]/hallo_welt)
Compiling hallo_welt v0.1.0 ([...]/hallo_welt)
Finished release [optimized] target(s) in 3.08s
Installing /Users/jbaumann/.cargo/bin/hallo_welt
Installed package `hallo_welt v0.1.0
([...]/hallo_welt)` (executable `hallo_welt`)
>
Dies installiert unser ausführbares Programm in dem Verzeichnis, in dem auch unsere Rust-Werkzeuge liegen. Mit entsprechenden Optionen kann auch ein anderer Ort, zum Beispiel für die globale Installation, gewählt werden.
cargo uninstall
Um unser installiertes Programm zu entfernen, verwenden wir:
> cargo uninstall hallo_welt
Removing /Users/jbaumann/.cargo/bin/hallo_welt
>
Damit wird unser Programm gelöscht und der vorherige Zustand wiederhergestellt.
cargo clean
Um alle Übersetzungsergebnisse zu verwerfen und zu einem sauberen Ausgangszustand zu kommen, verwenden wir:
> cargo clean
>
Hierdurch wird das gesamte Verzeichnis target mit seinen Unterverzeichnissen gelöscht. Dies kann insbesondere bei größeren Projekten sinnvoll sein, wenn man für mehrere Architekturen und verschiedene Konfigurationen (wie debug oder release) gebaut hat, da hier schnell temporäre Dateien mit großem Platzbedarf erzeugt werden (das Verzeichnis kann durchaus auf mehr als 10GB anwachsen).
Die Standardbibliothek von Rust, die automatisch installiert wird, ist sehr klein und bietet nur die notwendigsten Funktionen an. Für weitergehende Funktionalität verwenden wir externe Crates, die uns Funktionen (als Bibliothek oder als Programm) zur Verfügung stellen. Dies ist volle Absicht, und tatsächlich sind immer wieder Funktionen aus der Standardbibliothek in externe Crates gewandert. Dies erlaubt eine Entkopplung in der Entwicklung von Basisfunktionalität und weitergehenden Funktionen.
Die Standardanlaufstelle für Crates ist die Website:
https://crates.io
Auf dieser finden wir jede relevante Funktionalität und jedes relevante Rust-Programm als Crate.
Tipps und Tricks
Tatsächlich lassen sich auch Abhängigkeiten aus anderen Quellen wie zum Beispiel Git Repositories definieren, inklusive spezifischer Branches und Tags.
Die Website bietet ein zentrales Suchfeld an, in dem wir nach der benötigten Funktionalität interaktiv suchen können. Hier findet sich auch die erste Dokumentation für die Verwendung. Zusätzlich steht eine API zur Verfügung, die von Cargo verwendet wird, um Abhängigkeiten zu suchen, einzubinden, transparent herunterzuladen und in den Übersetzungsprozess zu integrieren.
Als Beispiel wählen wir eine kleine Bibliothek (ein Crate) namens Hex, die die Umwandlung von Zeichenketten in ihr Hexadezimaläquivalent und zurück anbietet.
Geben Sie auf der Website https://crates.io die Zeichenkette »hex« ein, so wird Ihnen neben vielen anderen Treffern dieses Crate angeboten. Die zugehörige Detailseite beschreibt die Verwendung inklusive Beispielen und der notwendigen Installation.
cargo search
Alternativ können wir auch ein Cargo-Kommando nutzen, das eine Liste von möglichen Crates anbietet inklusive der notwendigen Information zur Installation.
Die Verwaltung der Metainformationen unseres Packages geschieht in der Datei Cargo.toml. Neben Information über das Package selbst finden wir einen Bereich namens dependencies, in dem wir Abhängigkeiten zu anderen Crates angeben. Als Eintrag nehmen wir die Information, die wir auf der Website unter dem Punkt Installation finden, oder alternativ den Eintrag, der uns von cargo search zurückgeliefert wird. Wir können diesen Eintrag inklusive Kommentar einfügen, sodass wir später genau wissen, was die Bedeutung jedes Eintrags ist.
Die Versionsnummer ist hierbei optional, wir können also entweder eine spezifische Version wählen oder, wenn wir leere Anführungszeichen angeben, einfach die neueste.
Hintergrund
Die Versionsnummern bei Rust beruhen grundsätzlich auf semantischer Versionierung (siehe https://semver.org/), die festlegt, dass Versionsnummern aus einer Major-Version, Minor-Version und einem Patchlevel bestehen. Änderungen im Patchlevel bedeuten ausschließlich rückwärtskompatible Behebung von Fehlern; Änderungen in der Minor-Version bedeuten API-kompatible Änderungen und Erweiterungen; Änderungen in der Major-Version bedeuten Inkompatibilitäten in der API.
Mit diesem Hintergrund erlaubt Cargo uns die Spezifikation von Versionen, die »kompatibel« sind. Wir können hier alle Versionen mit der gleichen Major-Version zulassen, wir können Bereiche von Versionen zulassen, wir können aber zum Beispiel auch die Kompatibilität auf unterschiedliche Patchlevel bei gleicher Minor-Version beschränken. Folgende Notationen werden unterstützt:
^
Die größte Nummer ungleich 0 ist unveränderlich.
~
Wenn nur Major-Version, dann verschiedene Minor-Versions und Patchlevel, ansonsten nur Patchlevel
*
Der Stern ist ein Platzhalter für beliebige Nummern.
<,=,>
Mathematischer Vergleich mit einer Versionsnummer
Während die Notationen mit Caret und Tilde immer wieder zu Fragen führen, sind die anderen beiden Notationen sehr einfach zu verstehen und deshalb zu bevorzugen. Beliebige Kombinationen sind durch Aufzählung, getrennt durch Komma möglich. Ein Beispiel hierfür sei ">=1.1, < 1.7". Mit dieser Spezifikation erlauben wir alle Versionen zwischen 1.1.x und 1.6.x.
Im Folgenden fügen wir unserer Datei Cargo.toml eine Abhängigkeit zum Crate hex hinzu, einmal mit der Abhängigkeit zur spezifischen Version 0.4.3, einmal ohne Versionsangabe (Cargo erlaubt keine Mehrfachspezifikation von Abhängigkeiten, deshalb ist die erste Abhängigkeit auskommentiert).
Listing 1–3Die Erweiterung der Datei Cargo.toml
Wenn wir eine neue Abhängigkeit in der Datei Cargo.toml eintragen, dann wird Cargo bei der nächsten Ausführung eines Builds diese Abhängigkeit herunterladen und lokal ablegen. Voreingestellt das Verzeichnis .cargo/registry, das wir im Benutzerverzeichnis finden. Dieses Verzeichnis können wir übrigens auch ohne Nachteile löschen (Cargo lädt dann beim nächsten Aufruf die benötigten Abhängigkeiten erneut herunter).
> cargo build
Updating crates.io index
Downloaded hex v0.4.3
Downloaded 1 crate (13.3 KB) in 0.55s
Compiling hex v0.4.3
Compiling hallo_hex v0.1.0 (.../hallo_hex)
Finished dev [[...] debuginfo] target(s) in 1.53s
>
Alternativ können wir andere Repositories, aber auch lokale Pfade in unserem Dateisystem als Quellen für Crates angeben. Hierfür verwenden wir die folgenden Notationen:
In der ersten Zeile referenzieren wir ein Crate bsp_lib, die auf Github vom Benutzer user als Repository bsp_lib abgelegt ist. In der zweiten Zeile binden wir ein Crate beispiel_lib ein, das in unserem Dateisystem neben unserem aktuellen Crate liegt (zum Beispiel als Teil eines größeren Projekts).
cargo tree
Ein weiterer Befehl von Cargo erlaubt uns einen schnellen Überblick über alle Abhängigkeiten, die wir in unserem Package haben.
> cargo tree
hallo_hex v0.1.0 (.../hallo_hex)
└──hex v0.4.3
>
Hiermit erhalten wir eine hierarchische Repräsentation aller direkten und indirekten Abhängigkeiten, mit der wir sehr schnell verstehen können, ob wir irgendwelche Probleme in unserer Abhängigkeitsspezifikation haben.
Cargo.lock
Sobald wir eine Abhängigkeit eingetragen und das erste Mal durch Cargo unser Package gebaut haben, trägt Cargo die tatsächlich verwendete Version aller Abhängigkeiten in die Datei Cargo.lock ein. Bei allen folgenden Übersetzungen wird dann die Version, die in dieser Datei festgelegt ist, verwendet (und nicht mehr die potenziell sehr schwammige Definition in der Datei Cargo.toml). Dies führt dazu, dass die Bereitstellung neuer Versionen von Bibliotheken nicht überraschend dazu führt, dass wir Schwierigkeiten in unserem Projekt bekommen. Zusätzlich bedeutet dies, dass andere unser Package mit den exakt gleichen Abhängigkeiten bauen können, die wir verwendet haben, unabhängig davon wie spezifisch wir in der Cargo.toml vorgegangen sind. Dieses einzigartige Verhalten sorgt für sehr viel Stabilität gerade bei der verteilten Zusammenarbeit.
cargo update
Die Datei Cargo.lock sollte aber nicht manuell bearbeitet werden, sondern nur durch Cargo. Dies bedeutet aber, dass wir eine Möglichkeit brauchen, um referenzierte Abhängigkeiten kontrolliert aktualisieren zu können. Hierfür stellt uns Cargo ein eigenes Kommando zur Verfügung.
> cargo update -p hex
Updating crates.io index
Updating hex v0.3.2 -> v0.4.3
>
Im Beispiel sehen wir eine Aktualisierung einer alten Version der Bibliothek hex von der Version 0.3.2 auf die Version 0.4.3 nach dem Aufruf von cargo update. Solange dieser Aufruf nicht explizit durchgeführt wird, bleibt das Projekt bei der vorher selektierten Version, was die problemfreie Übersetzbarkeit garantiert. Ohne Angabe der Option -p werden alle Abhängigkeiten aktualisiert.
Dies ermöglicht, in eleganter Weise sicherzustellen, dass die für die Übersetzung unseres Projektes verwendeten Versionen der Bibliotheken diejenigen sind, mit denen wir die Funktion getestet und für gut befunden haben.
Ein kleines Beispiel basierend auf unserer Diskussion illustriert, wie wir Abhängigkeiten in Rust verwenden. Wir erzeugen ein neues Package mit cargo new hallo_hex. Wir benutzen die Bibliothek hex, um eine Konvertierung von Zeichenketten in ihr Hexadezimaläquivalent zu erreichen. Hierfür definieren wir die Abhängigkeit zur Bibliothek wie oben gezeigt in der Datei Cargo.toml.
Listing 1–4Die Datei Cargo.toml
In unserem Quelltext in main.rs können wir jetzt diese Bibliothek verwenden:
Listing 1–5Verwendung der Bibliothek in unserem Quelltext
Wir importieren zuerst die Funktion encode() aus dem Crate hex und definieren dann die Funktion main(), in der wir diese Funktion verwenden, um die Zeichenkette »Hallo Welt!« in Hexadezimalcodierung umzuwandeln, und geben diese zum Schluss aus.
Wenn wir dieses Programm starten, erhalten wir folgendes Ergebnis:
> cargo run
Finished dev [[...] debuginfo] target(s) in 0.00s
Running `target/debug/hallo_hex`
48616c6c6f2057656c7421
>
In den Fällen, in denen uns die Strukturierungsmöglichkeiten eines Packages nicht ausreichen, bietet uns Rust die Möglichkeit, mehrere Packages in einen Workspace zusammenzufassen. Hierdurch kann die Verwaltung von Übersetzungsergebnissen, Abhängigkeiten und allgemeinen Einstellungen für alle Packages gemeinsam geschehen und damit optimiert werden.
Die Struktur eines Workspace ist sehr einfach: In einem Verzeichnis liegen untergeordnete Packages, die jeweils mit den schon bekannten Befehlen erzeugt werden. Im Workspace-Verzeichnis selbst liegt eine Datei Cargo.toml, die die Workspace-Definition und gemeinsame Konfigurationsoptionen enthält. Für ein Workspace-Verzeichnis workspace und zwei Packages package_1 und package_2 (die wir jeweils mit cargo new erzeugt haben) sieht dies wie folgt aus:
workspace/
Cargo.toml
package_1/
...
package_2/
..
Der Workspace wird hierbei nicht über eine Kommandozeilenoption des Befehls cargo erzeugt, wir müssen die Datei Cargo.toml, die diesen definiert, vielmehr von Hand erzeugen.
Die Definition des Workspace innerhalb der Datei Cargo.toml ist hierbei sehr einfach, sie findet in einem Bereich [workspace] statt, den wir unserer Datei hinzufügen. Hier können wir die untergeordneten Packages unter members als Liste von Namen eintragen:
Listing 1–6Die Datei Cargo.toml für unseren Workspace
Wenn wir jetzt im Workspace-Verzeichnis cargo build ausführen, sehen wir nicht nur, dass die untergeordneten Packages gebaut werden, sondern auch, dass die Ergebnisse aller Packages im Verzeichnis target landen. Dies hindert uns aber nicht daran, auch lokal in den einzelnen Packages weiterhin die Übersetzung, beschränkt auf das jeweilige Package, anzustoßen.
Tipps und Tricks
Tatsächlich gibt es noch eine zweite Variante von Workspaces, bei denen die Packages nicht in einem leeren Workspace-Verzeichnis platziert werden, sondern in einem weiteren Package-Verzeichnis. In diesem Fall tragen wir die Information in die Datei Cargo.toml des Eltern-Packages ein. Dies ist eine Alternative, wenn wir ein Haupt-Package und mehrere abhängige Unter-Packages haben.
cargo fmt
Rust bietet optional ein Werkzeug zur Formatierung des Quelltextes namens rustfmt an. Dieses können wir mit Rustup installieren, indem wir folgendes Kommando ausführen:
> rustup component add rustfmt
info: downloading component 'rustfmt'
info: installing component 'rustfmt'
>
Rustfmt ist ein Werkzeug zur Formatierung von Rust-Quelltexten, das sehr weitgehend konfigurierbar ist. Hierzu liest es seine Konfigurationsdatei (namens rustfmt.toml oder .rustfmt.toml) aus dem Projektoder einem übergeordneten Verzeichnis oder, falls es hier nicht fündig wird, aus dem Benutzerverzeichnis und formatiert die Quelltexte entsprechend der Vorgaben. Mit
> cargo fmt
>
führen wir diese Formatierung für alle Projektdateien aus. Dies ermöglicht es uns, zusammen mit einer projektspezifischen Konfigurationsdatei sehr schnell eine einheitliche Formatierung unserer Quelltexte zu erreichen.
cargo fix
Wir werden noch häufig sehen, dass der Rust-Compiler Warnungen ausgibt, die wir manuell korrigieren können. Alternativ bietet Cargo an, diese mit dem Befehl
> cargo fix
automatisch zu korrigieren. Dies geht allerdings nur, wenn der Quelltext des Projektes abgesehen von den Warnungen problemlos übersetzbar ist. Neben dem schnellen Korrigieren von Warnungen unterstützt dieser Befehl auch dabei, automatisch eine Migration von einer Version der Sprache auf die nächste durchzuführen, wenn dies notwendig sein sollte.
cargo help
Cargo bietet noch viele weitere Funktionen, die aber im Rahmen unseres Buches nicht vordringlich sind. Es lohnt sich aber auf jeden Fall, sich über die genannten Befehle hinaus mit diesem Build-Werkzeug auseinanderzusetzen. Eine Übersicht über diese Befehle erhalten Sie mit dem Kommando cargo help und cargo help <command>.
Jede Programmiersprache, die in aktiver Verwendung ist, entwickelt sich weiter. Wir können in dieser Weiterentwicklung zwei unterschiedliche Varianten differenzieren: die kompatible Weiterentwicklung und die, in der eine neue Version der Sprache inkompatibel zu alten Versionen ist.
Rust macht dies explizit, indem es zwischen Versionen und Editionen unterscheidet. Eine neue Version innerhalb der gleichen Edition ist auf Sprachebene kompatibel zu vorherigen Versionen der gleichen Edition. Verschiedene Editionen der Sprache sind hingegen nicht kompatibel. Ein Beispiel für eine derartige Inkompatibilität ist die Definition eines neuen Befehlswortes innerhalb der Sprache, das in alten Editionen als normaler Variablenname verwendet werden durfte.
Programme werden für eine spezifische Edition übersetzt. Diese wird in der Datei Cargo.toml im Eintrag edition festgehalten. Der Wechsel von einer Edition in die nächste ist damit eine bewusste Entscheidung des Programmautors, die mit der Änderung dieses Eintrages einhergeht. Alle Versionen des Rust-Compilers berücksichtigen die Edition. Das heißt, auch der neueste Compiler wird entsprechend der angegebenen Edition übersetzen. Damit können wir immer die neueste Version des Compilers unabhängig von der verwendeten Edition benutzen.
Diese Auswahl einer Edition wird grundsätzlich Crate-spezifisch gemacht, und Rust stellt sicher, dass Crates unterschiedlicher Editionen problemlos miteinander zusammenarbeiten können. Damit haben wir nicht den Zwang, eine gesamte Anwendung inklusive aller verwendeter Crates auf einen Schlag umstellen zu müssen. Wir können frei entscheiden, ob und wann wir diese Umstellung durchführen.
Zusätzlich gibt es für jede neue Edition Werkzeuge, die die Umstellung auf diese nahezu vollständig automatisieren. Damit reduziert sich in vielen Fällen die Umstellung auf den Aufruf:
cargo fix --edition
Die Kombination dieser Kompatibilitätszusicherungen mit der einfachen, semiautomatischen Migration zwischen Editionen sorgt dafür, dass auf der einen Seite die Sprache ständig weiterentwickelt werden kann, ohne durch eine große Softwarebasis gebremst zu werden. Auf der anderen Seite erlaubt sie die problemlose und zeitlich entzerrte Migration von Teilen unserer Programme in neue Editionen, wann immer dies in unsere Zeit- und Projektpläne passt.
In diesem Kapitel werfen wir einen Blick auf die grundlegende Programmstruktur inklusive Anweisungsblöcken, auf Operatoren und gängige Kontrollflussstrukturen.
Damit die Diskussion über die Kontrollkonstrukte nicht zu unbeholfen wird, halten wir jetzt schon fest, dass wir Variablen mit nur lesendem Zugriff mit dem Befehl let definieren können, veränderliche Variablen durch den Befehl let mut.
Listing 2–1Verschiedene Arten von Variablen
Wir definieren zuerst eine Variable mit reinem Lesezugriff. Die zweite Zeile ist auskommentiert, weil sich hier der Compiler zu Recht beschweren würde, dass ein Schreibzugriff nicht erlaubt ist (Kommentare in Rust haben zwei Formen, die hier verwendete einzeilige und die mehrzeilige Form /* */). Dann definieren wir eine Variable mit Lese- und Schreibzugriff, um diese in der vierten Zeile zu inkrementieren.
Wie wir bereits im vorherigen Kapitel gesehen haben, ist die Programmierung eines einfachen Rust-Programmes sehr simpel. Wir definieren eine Funktion main() mithilfe des Schlüsselwortes fn und eines Anweisungsblocks. Diese Funktion main() ist – wie bei vielen anderen Sprachen wie Java oder C# – besonders, da sie vom Laufzeitsystem von Rust zum Programmstart aufgerufen wird.
Auch ohne explizit andere Module referenzieren zu müssen, stehen uns einige Funktionen und Makros direkt zur Verfügung. Diese sind in der kleinen Standardbibliothek von Rust definiert, die immer eingebunden wird.
Hintergrund
Dies stimmt nicht ganz, es gibt auch die Möglichkeit, eine no_std-Umgebung zu nutzen, in der die Standardbibliothek nicht verwendet wird. Dies ist insbesondere für die Programmierung von Mikro-Controllern relevant. Wir werden diese Variante im Buch allerdings aus Platzgründen nicht betrachten.
Zwischen Makros und normalen Funktionen gibt es einige Unterschiede. Im Moment genügt als Unterschied zwischen normalen Funktionen und Makros, dass Makros mit einem Ausrufezeichen enden und bei der Übersetzung direkt expandiert werden.
Hier erneut das minimale Beispielprogramm aus dem ersten Kapitel. Es definiert eine main()-Funktion und ruft das Makro println!() mit der Zeichenkette »Hallo Welt« auf.
Listing 2–2Ein minimales »Hallo Welt!«-Programm
fn main() {
println!("Hallo Welt!");
}
Lassen Sie uns einen kurzen Blick auf Anweisungsblöcke werfen. Diese sind durch geschweifte Klammern gekennzeichnet und enthalten einzelne, durch Semikolon getrennte Anweisungen. Der Anweisungsblock braucht nach der schließenden Klammer kein Semikolon.
Interessant bei diesen Anweisungsblöcken ist, dass sie einen Rückgabewert haben können. Dieser wird bestimmt durch den letzten Ausdruck im Block, sofern dieser nicht durch ein Semikolon beendet wird und damit zu einer Anweisung wird. Wir können einen Anweisungsblock auch durch eine explizite return-Anweisung mit Rückgabewert beenden. Wenn es keinen letzten Ausdruck (ohne Semikolon), sondern nur eine letzte Anweisung (mit Semikolon) abgesehen von der return-Anweisung gibt, wird ein leerer Wert zurückgeliefert. Wir werden an vielen Stellen in der Diskussion der Sprachmerkmale sehen, dass dieser Rückgabewert zur besseren Lesbarkeit genutzt wird. Wenn dieser Rückgabewert im Rahmen von anderen Anweisungen verwendet wird, dann müssen diese natürlich ganz normal mit einem Semikolon beendet werden. Wir sehen also in diesem Kontext ein Semikolon nach der schließenden Klammer.
Ein Beispiel illustriert diesen Rückgabewert:
Listing 2–3Der Rückgabewert von Anweisungsblöcken
In unserem Beispiel beginnen wir mit einem Block, der keinen Rückgabewert hat. Dieser steht für sich und braucht kein Semikolon, um das Ende einer Anweisung zu kennzeichnen. Darauf folgt ein Anweisungsblock mit einem Rückgabewert. Hier braucht der Compiler das Semikolon, um keinen Fehler zu melden, schließlich ist da ein Rückgabewert, mit dem wir nichts anfangen.
Die darauf folgenden Zuweisungen haben alle den gleichen Effekt, die Variable temperatur hat hinterher den Wert 22. Allerdings wird der Rust-Compiler in der dritten Zuweisung vorschlagen, die überflüssigen Klammern zu entfernen, und in der vierten Zuweisung die erste Anweisung (die keinen Effekt hat) wegoptimieren.
Rust definiert wie die meisten Programmiersprachen eine Rangfolge der Operatoren. Diese folgt der Regel der geringsten Überraschung und ist genauso definiert wie in Java, C oder anderen »normalen« Sprachen, weshalb wir diese nicht weiter austreten müssen.
Um den Ablauf unseres Programms anhand der verarbeiteten Daten beeinflussen zu können, nutzen wir Kontrollflussstrukturen, die wir aus anderen Programmiersprachen kennen. Es gibt allerdings folgende Besonderheiten für Java-Programmierer: Zum einen werden die Bedingungen für die Kontrollflussstrukturen ohne Klammern geschrieben (ähnlich wie zum Beispiel in Python). Zum anderen müssen grundsätzlich Anweisungsblöcke für die jeweilige Struktur verwendet werden (anders als in C, Java und anderen Sprachen, wo auch einzelne Anweisungen genutzt werden können). Zum Schluss sind die meisten Kontrollkonstrukte anders als in vielen Sprachen auch Ausdrücke, die einen Rückgabewert haben können, nämlich den des letzten ausgeführten Anweisungsblocks. Dies erlaubt in vielen Fällen eine sehr elegante Formulierung von Programmen, die aber für Rust-Neulinge manchmal überraschend sein können.
Das einfachste und am häufigsten benutzte Konstrukt ist die bedingte Anweisung, die es in verschiedenen wohlbekannten Varianten gibt.
Mit if Bedingung { Block; } werden Anweisungen im Block nur dann ausgeführt, wenn die Bedingung erfüllt wird. Mit if Bedingung { Block1; } else { Block2;} entscheidet die Bedingung, ob Block 1 oder Block 2 ausgeführt wird. Die komplizierteste Variante erlaubt die Verkettung mehrerer Bedingungen (die nacheinander geprüft werden) und zugeordneter Anweisungsblöcke wie im folgenden Beispiel gezeigt:
Listing 2–4Die Verwendung des If-Konstrukts
Wir definieren zuerst eine Variable temperatur mit dem Wert 22 und prüfen dann mit dem ersten If-Konstrukt, ob der Wert unter 20 ist. Sollte dies der Fall sein, bitten wir, die Heizung einzuschalten. Im nächsten Schritt prüfen wir mit der else-if-Anweisung, ob der Wert kleiner als 22 Grad ist, und damit also zwischen 20 und 22 Grad. Falls dies der Fall ist, drücken wir unsere Zufriedenheit über die Temperatur aus. Zum Schluss haben wir den finalen else-Zweig, der nur dann ausgeführt wird, wenn die vorherigen Bedingungen nicht zugetroffen haben, in unserem Beispiel also eine Temperatur von mindestens 22 Grad.
Um das Programm zu testen, erzeugen wir mit cargo new if_test ein neues Package, ändern die Datei main.rs, sodass sie den Quelltext enthält, und rufen das Programm mit cargo runauf. Ändern Sie bei Bedarf für Ihre Tests den Initialisierungswert für die Variabletemperatur, um die verschiedenen Zweige auszuführen.
Das If-Konstrukt als Ausdruck
Das If-Konstrukt ist wie bereits erwähnt ein Ausdruck, der das Ergebnis des letzten ausgeführten Anweisungsblocks zurückgibt. Wir können also Zuweisungen zu Variablen mit dem Ergebnis eines If-Ausdrucks machen. Das folgende Beispiel demonstriert dies:
Listing 2–5Das If-Konstrukt als Ausdruck
Wir definieren zuerst eine Variable temp und weisen ihr einen Wert zu. Danach entscheiden wir in einem If-Ausdruck, ob an die zweite Variable warm der boolsche Wert true oder false zugewiesen wird. Im nächsten Schritt entscheidet der Wert dieser boolschen Variable darüber, mit welcher Zeichenkette die Variable text initialisiert wird.
Das Loop-Konstrukt ist eines der drei in Rust zur Verfügung stehenden Schleifenkonstrukte. Es benötigt keine Bedingung, und der zugehörige Anweisungsblock wird so lange durchgeführt, bis er durch die Anweisung break verlassen wird. Ein optionales Argument für diesen Befehl wird als Rückgabewert des Anweisungsblocks und damit auch des Loop-Konstrukts verwendet.
Listing 2–6Das Loop-Konstrukt
Im Beispiel definieren wir zuerst eine veränderbare Variable temperatur und weisen anschließend das Ergebnis des folgenden Loop-Konstrukts der Variable text zu.
Der zur Schleife gehörende Anweisungsblock prüft, ob der Wert der Variable temperatur hoch genug ist. Falls die Bedingung erfüllt ist, wird die Schleife mit break und dem Rückgabewert "Bitte Heizung ausschalten" beendet. Falls dies nicht der Fall ist, gibt der Anweisungsblock eine Nachricht aus und inkrementiert den Wert der Variablen temperatur. Im nächsten Schritt prüfen wir, ob die Temperatur größer als 16 ist, und falls ja, springen wir zum nächsten Schleifendurchlauf.
Andernfalls geben wir eine weitere Nachricht aus. Die Schleife wiederholt sich, bis der Wert von temperatur hoch genug ist, sodass die break-Anweisung ausgeführt wird. Nun wird die zurückgelieferte Zeichenkette ausgegeben und das Programm wird beendet.
Anders als das Loop-Konstrukt verlangt die While-Schleife eine Bedingung. Solange diese Bedingung zum boolschen Wert true evaluiert wird, wird der zugehörige Anweisungsblock ausgeführt. Anders als bei den bisherigen Kontrollkonstrukten liefert die While-Schleife keinen Rückgabewert zurück.
Hintergrund
Das stimmt nicht ganz. Tatsächlich liefert die While-Schleife einen leeren Wert zurück, der aber nicht weiter verwendet werden kann.
Dies ist verständlich, denn aus konzeptueller Sicht ist die Frage, wie der Rückgabewert der While-Schleife genau definiert sein sollte. Wenn die Schleife nicht durch ein Break-Statement verlassen wird, dann ist die letzte Anweisung, die durchgeführt wird, die erfolglose Prüfung der Bedingung.
Um hier keine Mehrdeutigkeit oder auch nur Diskussionen zu erzeugen, wird der leere Wert (das Unit- oder Einheitstupel, das wir später noch kennenlernen werden) als Rückgabewert gewählt.
Das folgende Beispiel demonstriert die Verwendung der While-Schleife:
Listing 2–7DieWhile-Schleife
Wir definieren zuerst eine veränderbare Variable temperatur und weisen einen initialen Wert zu. Die folgende While-Schleife hat als Bedingung zur Ausführung des Anweisungsblocks, dass der Wert dieser Variablen kleiner als 25 ist.
Im Anweisungsblock der While-Schleife geben wir zuerst eine Nachricht aus und inkrementieren dann die Variable temperatur. Sofern der Wert größer als 16 ist, springen wir mit dem Befehl continue in den nächsten Schleifendurchlauf, zu dessen Beginn erneut die Bedingung der While-Schleife geprüft wird.
Falls der Wert der Variablen temperatur größer als 21 ist, wird die While-Schleife mit dem break-Befehl verlassen.
Tipps und Tricks
Auch bei der While