Programmiersprachen: Weniger Frust mit Rust

Programmiersprachen: Weniger Frust mit Rust | Golem Karrierewelt

(Bild: Foto von Magda Ehlers von Pexels)

Von Florian Gilcher

Die Programmiersprache Rust macht nicht nur weniger Fehler, sie findet sie auch früher.

Seit einiger Zeit ist die Programmiersprache Rust ein beliebtes Diskussionsthema. Sicherer soll sie sein, dabei aber genauso schnell wie andere Systemprogrammiersprachen.

Ein starker Fokus der Diskussion liegt auf der Frage, wie viele Fehler am Ende in der geschriebenen Software landen. Hier wird gerne gesagt, dass Rust schwerer zu schreiben sei als andere Programmiersprachen: Der Compiler kreidet viele Fehler an, Laufzeitfehler müssen aktiv ignoriert werden. Klar, wenn Rust-Software erst mal läuft, ist sie oft sehr stabil; der Weg dahin sei aber eine Reise, lautet die Kritik.

Aber genau hier liegt das Missverständnis: Denn die Rigidität der Sprache und des Compilers ist Absicht. Natürlich gibt es immer den Fall, dass etwa ein Sprachfeature fehlt - aber das ist eher ein Bug.

Dass Programme in Entwicklung 90 Prozent der Zeit kaputt sind, ist ein Gedanke von Idris-Erfinder Edwin Brady und er hat ihn als Leitbild für die Entwicklung seiner Sprache formuliert. Brady meinte das sowohl im syntaktischen Sinne (während ich gerade in ein Programm tippe, ist es syntaktisch inkorrekt) als auch auf der semantischen, funktionalen Ebene (während ich eine Funktion baue/ändere, kann ein Codezweig schon das tun, was er soll - der andere nicht).

Das klingt hochgestochen, ist es aber nicht. Denn Bradys Fokus auf den Produktionsprozess ist ein praktischer: Den Produktionsprozess zu verbessern, führt am Ende zu höherer Geschwindigkeit und größerer Stabilität. Dazu ist aber nicht die Mikro-Sicht nötig (Wie lange brauche ich, bis der Code kompiliert?), sondern die Makro-Sicht (Wie lange habe ich benötigt, um das Feature stabil abzuliefern?).

Die Lösung für das syntaktische Problem ist zwar komplex, aber zumindest in der Basis verstanden: Entwicklungsumgebungen - sei es nun eine volle IDE oder ein Editor-Plugin - gehen weite Strecken, um bei eigentlich syntaktisch inkorrektem Code Empfehlungen zu dessen Korrektur zu geben. Diese Tools gibt es auch für Rust und sie funktionieren nach wohlverstandenen Prinzipien. Die Anwendung dieser Idee auf die Sprachebene ist aber ein Schwerpunkt neuerer Sprachen. Denn stand früher die Produktivität beim Runterschreiben im Fokus, steht heute die Korrektheit des Niedergeschriebenen im Zentrum.

Früher an später denken

Rust verschreibt sich gemäß seinem Motto "Empowering everyone to build reliable and efficient software" daher der Makro-Sicht: Wie kann ich Code produzieren, der mit hoher Wahrscheinlichkeit korrekt ist? Dass Rust diesen Ansatz hat, wundert nicht, denn es kommt aus Mozilla Research, einer Firma, die mit Firefox eine der größten Codebasen der Welt produziert und diese alle sechs Wochen veröffentlicht. Funktionale Fehler sind hier kritisch und äußern sich im besten Fall in Abstürzen, im schlimmsten in gravierenden Sicherheitslücken.

Zudem können Fehler sehr teuer werden. Der Microsoft-Mitarbeiter Ryan Levick bezifferte die Entwicklungskosten eines Security-Patches für Windows einmal mit 150.000 US-Dollar - und diese Schätzung enthielt nur die reinen Prozesskosten auf Seiten Microsofts. Je später Fehler im Prozess auffallen, desto teurer werden sie, und ist der Fehler erst beim Kunden, kommt auch noch die Kritikalität des Fehlers ins Spiel.

Rust will daher komplexe Probleme früh lösen - am besten beim Coden - und bietet dazu Strukturen an.

Dateien öffnen und schließen in Rust

Rusts Kernfeature ist das sogenannte Ownership. Es beschreibt die Idee, dass jeder Datentyp gleichzeitig eine Resource darstellt. Rust löst das darüber, dass es jedem Stück Daten einen sogenannten Owner zuweist. Erstelle ich mit File::create eine Datei, bekomme ich im Erfolgsfall nicht nur ein File-Objekt zurück, sondern auch die Verantwortung des Managements. Ich besitze die Datei und bin der einzige Besitzer. Gebe ich den Besitz auf irgendeine Art auf, wird die Datei geschlossen.

Daten in Rust haben also einen modellierten Lebenszyklus. Im Gegensatz zu vielen anderen Sprachen ist dieses Konzept keine Konvention, sondern einer der Grundpfeiler des Typsystems. Deswegen begegnet es einem auch sofort und muss gelernt werden - es führt kein Weg daran vorbei.

Als einfaches Beispiel ein kleines Programm, das eine Datei erstellt, etwas hineinschreibt und die Datei schließt:

Sie finden den Code zum live Ausprobieren auch auf dem offiziellen Rust Playground. Lassen Sie sich von der verbosen Fehlerbehandlung nicht zu sehr beeindrucken - ich habe sie aus Illustrationsgründen ausgeschrieben.

Potenzielle Fehler werden in Rust über enum-Datentypen dargestellt. Diese bilden mehrere Alternativen ab, im Fall von Resultaten zwei. Diese Funktion gibt entweder eine Zahl zurück (die Anzahl der geschriebenen Bytes) oder einen potenziellen I/O-Fehler.

Das zwingt die aufrufende Funktion, auf beide Varianten zu prüfen und eventuell Fehlerbehandlung zu betreiben. Im Gegensatz zu den oben beschriebenen Konzepten ist Result hier ein Typ, der streng genommen kein Sprachbestandteil ist, aber durch die Standardbibliothek mitgeliefert wird.

Das Sprachkonstrukt ist der sogenannte match-Block, der die Fallunterscheidung zwischen den beiden Alternativen Ok (erfolgreich) und Err (Fehler sind aufgetreten) darstellt. Um hier schon mal die Idee des Ownership zu betrachten: Der Aufruf der create-Funktion bringt uns erst mal in den Besitz eines Resultats. Dieses Resultat besitzt die offene Datei - im Erfolgsfall. Möchten wir Zugriff auf die Datei, muss erst geprüft werden, ob dieser auch vorliegt. Rust zwingt uns hier also zur Fehlerprüfung aus dem Ownership-Konzept heraus.

Das mag zu Beginn anstrengend sein, entfaltet seine Wirkung jedoch schnell auf größeren Codebasen. Denn sie führt ganz natürlich dazu, dass keine Fehlerfälle vergessen werden, sondern früh direkt in den Coding-Workflow eingebunden werden.

Rustc ist detailverliebt

Wie wendet Rust diese Ideen weiter an? Als Beispiel möchte ich eine Funktion nehmen, die eine Datei und eine Zeichenkette übernimmt und diese schreibt. Sehen wir uns folgenden Funktionsprototyp an.

Werden Typen einfach nur mit ihrem Namen übergeben, übergibt Rust eben diesen Besitz.

Rust macht es unmöglich, in eine geschlossene Datei zu schreiben

In Rust enthalten Funktionssignaturen also noch viel mehr: Hier übergibt die aufrufende Funktion den Besitz und die aufgerufene. Kurz heißt das hier, dass die Funktion nicht nur eine Datei als Argument kriegt, sondern dabei auch das Management übernimmt.

Da der Lebenszyklus eines File-Pointers über das Besitzkonzept modelliert ist, haben wir die Garantie, dass das File immer offen ist. Es wird erst geschlossen, wenn der Besitzer den Besitz aufgibt. Und das ist auch die einzige Art, die Datei zu schließen, denn eine klassische close-Funktion gibt es nicht.

Da aber kein Garbage-Kollektor zum Einsatz kommt, ist der Vorgang dennoch sicher. Wir können nicht aus Versehen in geschlossene Dateien schreiben. Der zu schreibende String ist auf jeden Fall vorhanden, wir müssen aber nicht prüfen.

Schauen wir uns hier einmal einen Fehler an:

Nach meiner Erklärung zuvor sollte das Problem hier klar sein: Ich muss davon ausgehen, dass die Datei nach dem Aufruf dieser Funktion geschlossen ist - nach Rusts Verständnis also auch kein gültiger File-Pointer mehr existiert. Der Compiler erkennt diese Probleme.

Rust-Fehlermeldung

(Bild: Florian Gilcher)

Die Fehlermeldung in Gänze können Sie in diesem Playground-Link betrachten. Die Lösung für diese Fehler ist allerdings nicht direkt ersichtlich: Denn dieses Verhalten kann, wie später ausgeführt, strukturell gewünscht sein und ein Sicherheitsnetz darstellen.

Aus den Rückgabetypen können wir lesen, dass wir den Besitz nicht zurückbekommen. Wir müssen daher davon ausgehen, dass die Datei dann geschlossen ist. Wir übernehmen auch den Besitz von string_buffer, der danach aus dem Speicher entfernt wird. Möchten wir das nicht, hilft ein weiteres Sprachkonstrukt, das der Referenzen:

Hier wird eine Funktion beschrieben, die zwar das Dateimanagement übernimmt, aber nicht das Management des String-Speichers. Die Referenz ist hier eine sogenannte immutable Referenz: Sie kann mehrfach existieren und geteilt werden, aber der Besitzer des referenzierten Strings ist dafür verantwortlich (in diesem Fall die aufrufende Funktion), sie valide - und damit den String im Speicher - zu halten. Funktionen, die eine immutable Referenz erhalten, können sich sicher sein, dass der Wert hinter der Referenz sich niemals ändert. Der Compiler setzt diese Regel durch.

Ich möchte an dieser Stelle nicht zu stark auf Fehlermeldungen eingehen. Der wichtige Punkt ist hier, dass wir statt des Erstellens zweier Strings einen verwenden können.

Aber wie sieht es mit der Datei aus?

Im Gegensatz zum letzten Prototyp referenziert diese Funktion auch unser File. Allerdings anders, denn wir möchten in diese Datei schreiben. &mut ist hierbei eine sogenannte mutable Referenz.

Mutable Referenzen lösen alle Nebenläufigkeitsprobleme

Mutable Referenzen in Rust garantieren, dass sie die einzige zu diesem Moment existierende Referenz (immutabel oder mutabel) auf diese Datei sind - programmweit! Umgekehrt heißt das, dass niemand unsere Änderung "beobachten" kann. Wir brauchen uns hier also zum Beispiel über Nebenläufigkeit keine Gedanken zu machen. Wir wissen bereits aus der Funktionssignatur - die Funktion wird niemals nebenläufig auf dieselben Daten aufgerufen -, dass wir dazu zwei dieser Referenzen bräuchten. Damit muss unsere Funktionsimplementierung hier keine weiteren Vorkehrungen treffen.

Es gibt eine Menge Arten, mit dieser Signatur Fehler herbeizuführen. Die Eleganz liegt allerdings woanders: Es ist recht schwer, mutable Referenzen überhaupt zu produzieren. Das liegt vor allem an der Eigenschaft, dass sie eben programmweit - zur Kompilierzeit garantiert - einzigartig sind. Wenn wir also die obige Signatur sehen, können wir die Außenwelt (inklusive aller Dinge wie Threads, Nebenläufigkeit und so weiter) vergessen: Sie wären nur problematisch, wenn wir die Daten teilen würden.

Wir haben in diesem Fall also statisch garantierte Freiheit von Nebenläufigkeit. Auch hier: Wir haben dafür gar nicht arbeiten müssen - aber das Thema Nebenläufigkeit früh mitgedacht.

Um es nochmal hervorzuheben: Sowohl der Besitz als auch immutable und mutable Referenzen sind Grundbestandteile der Sprache und die erwähnten Regeln werden durch den Compiler durchgesetzt. Sie erzeugen dadurch auch keine Laufzeitkosten.

Werfen wir als Letztes einen Blick auf den Rückgabewert. Bei aller Sicherheit können bei IO-Vorgängen Fehler auftreten: Das Volume kann voll sein oder Ähnliches. Das wird auch hier durch den Result-Typ dargestellt.

Interessant ist hier, was uns das Resultat mitteilt: einmal eine Zahl (die Zahl der geschriebenen Bytes) und eventuell auftretende Fehler. Das können wir in allen der besprochenen Szenarien machen, ohne Abhängigkeiten zu den Parametern.

Rusts Geheimnis: Rust betrachtet Systeme

Sehen wir uns das gerade Gelernte an, fällt eine Sache auf: Wir haben uns gar nicht damit beschäftigt, was die Funktion eigentlich intern tut. Trotzdem haben wir mehrere verschiedene Varianten kennengelernt, die alle unterschiedliche Aussagen über die Umwelt treffen.

Die Ownership-Variante hat keine Abhängigkeiten zur Außenwelt, fordert dafür aber ein, dass die aufrufende Funktion Zugriff auf alle Daten verliert. Die zweite Variante fordert dies nur für das File-Handle, erwartet jedoch, dass die aufrufende Funktion die Daten im Speicher hält. Die dritte Variante verlangt weitreichende Garantien: Nicht nur müssen die Referenzen valide bleiben, eine davon muss exklusiv sein.

Funktionssignaturen drücken somit auch die Art der Kopplung zwischen Systemkomponenten aus - und das nicht nur im abstrakten Sinne, sondern ganz konkret durch Sprachkonstrukte. So wäre die erste Funktion eine sehr gute Variante, wenn wir die Aufgabe des Schreibens einfach an ein IO-System abgeben und dann vergessen wollen.

Würden wir hier referenzieren, würden wir plötzlich unsere Applikation an dieses System stärker koppeln. Die zweite und dritte Variante koppeln die aufrufende und die aufgerufene Funktion stärker - sie sollten sich also in derselben Komponente befinden. Insbesondere die dritte Variante verhindert effektive Nebenläufigkeitsfehler, indem sie für die Zeit des Funktionsaufrufs Nebenläufigkeit unterbindet.

Das mag auf den ersten Blick kleinlich erscheinen, liefert aber auf mittlere bis lange Sicht große Vorteile: Rust kann ausdrücken, welche Komponente gerade exklusiven Zugriff auf Speicher oder generell Ressourcen hat, und damit zum Beispiel gefährliche Refactorings unterbinden, die sonst häufig zu Fehlern führen können.

Zusammenfassung

Rust ist mehr als nur eine moderne, speichersichere Systemprogrammiersprache. Das fundamental in der Sprache verankerte und rigorose Ownership-Konzept ist nicht nur für die sichere Speicherverwaltung (ohne Garbage-Collector) geeignet, sondern ermöglicht auch Aussagen, die in "höheren" Sprache wie zum Beispiel Java schwer möglich sind.

Rust ist hier sogar nah an unserem Sprachgebrauch. In der Software-Architektur sprechen wir häufig davon, dass eine Komponente Verantwortung an eine andere "abgibt" - Rust gießt das in ein Sprachkonzept. Und die Frage "Wer besitzt diese Daten gerade?" ist eine, die gerade in nebenläufigen Systemen fundamental ist. Die Unterscheidung in mutable und immutable Zugriffsklassen gibt weitere zusätzliche Sichtbarkeit. All dies geschieht auf Sprachebene und nicht zur Laufzeit.

Um zurück zu Rusts Claim zu kommen: Zuverlässigkeit ist eine Eigenschaft eines Gesamtsystems, weshalb Rust einen starken Fokus darauf legt, wie Komponenten (jeder Größe) zusammenspielen, und es bietet Sprachkonstrukte, um hier neue und bessere Aussagen zu treffen. Das mag in der Konstruktion von Programmen gerade anfangs zu einer gefühlten Verlangsamung führen, sorgt aber auf lange Sicht für eine stabilere Entwicklungskurve.

Florian Gilcher ist seit 2013 im Rust-Bereich unterwegs und trainiert seit 2015 die Sprache professionell. Er hat die Berliner Usergruppe und das RustFest mitgegründet. Seit 2015 ist er auch Teil des Rust-Projekts, momentan als Mitglied des Rust Core Team und Direktor im Vorstand der Rust Foundation. Er ist einer der Gründer und Geschäftsführer von Ferrous Systems, die sowohl Trainings- als auch Implementierungsdienstleistungen rund um Rust anbietet. Auch für die Golem Akademie gibt er einen Rust-Einführungskurs.

veröffentlicht am


Beliebte Fachgebiete in der Golem Akademie