Spare bis zu 70% auf unsere Weiterbildungsangebote. Aktionszeitraum vom 22.11. - 30.11.2024.
Programmiersprachen: Weniger Frust mit Rust
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).
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.
(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.