Entwicklung: Warum Rust die Antwort auf miese Software und Programmierfehler ist

Rust bringt so viele schlaue Ideen mit, dass die Programmiersprache viele Experten sofort überzeugt hat. Und hoffentlich einer rosigen Zukunft entgegensieht.

Lesezeit: 9 Min.
In Pocket speichern
vorlesen Druckansicht Kommentare lesen 1233 Beiträge

(Bild: Callum Bainbridge / Shutterstock.com)

Von
  • Felix von Leitner

Die 2000er- und 2010er-Jahre sind goldene Zeiten für neue Programmiersprachen. Es gab eine veritable kambrische Explosion der Vielfalt. Darunter etwa neue Versionen altbekannter Sprachen (C++ hatte in der Zeit gleich mehrere inhaltlich große Sprünge in der Evolution) oder einen Trend zu eigenen Sprachen für Unternehmen (Swift von Apple, TypeScript von Microsoft, Go von Google, Kotlin von JetBrains, Rust von Mozilla). Dazu jede Menge ernsthafter neuer Programmiersprachen außerhalb von existierenden Firmen-Ökosystemen, zu Beispiel Elm für Apps in Webbrowsern, Julia für numerische Mathematik, Elixir für die Erlang-VM etc.

Eine Analyse von Felix von Leitner

Felix von Leitner ist Hacker alter Schule. Er bietet mit seiner Firma Code Blau Beratungsdienstleistungen für IT-Sicherheit an, und plaudert gelegentlich auf Konferenzen über seine Arbeit aus dem Nähkästchen. Bekannt ist er auch für sein Blog und den Podcast "Alternativlos". Kernfrage seiner Forschung ist, wieso die Menschheit anscheinend nicht in der Lage ist, vertrauenswürdigen und belastbaren Code zu produzieren. Er ist sich sicher, dass es nicht technische Probleme, sondern soziale und ökonomische Anreize sind, die das verhindern.

Einer der Gründe für diese vielen neuen Sprachen ist, dass die meisten Aspekte von Sprachen wegautomatisiert wurden. Den Parser generiert man sich automatisch aus einer Grammatik, und als Backend kann man LLVM nehmen, wenn man nicht auf die Java-VM zielt oder auf eine andere VM wie die von Erlang.

In diesem Artikel geht es um Rust.

Im Gegensatz zu praktisch allen anderen modernen Programmiersprachen macht Rust nicht Werbung damit, die Produktivität zu steigern. Das macht hellhörig, ist Produktivitätssteigerung doch für den Rest der Branche der heilige Gral, dem man ewig hinterherhechelt. Rust will natürlich auch die Produktivität steigern, aber nicht bloß beim Code-Schreiben, sondern auch die spätere Wartung, das Debugging, und beim Finden und Wegräumen von Sicherheitslücken.

Gut, den Anspruch des sicheren Codes und der reduzierten Wartung hatte auch Java, wo das Finden von Code Smells und das Refactoring heute eine eigene Branche der Tooling-Industrie durchfüttern. Insofern lohnt sich ein Blick darauf, was Rust denn konkret tut, um diese Ziele zu erreichen. Um das in den Kontext zu rücken, müssen wir allerdings ein bisschen ausholen und schauen, was für Ansätze es da schon gab – und warum die nicht funktioniert haben. Letztlich ist "Wie können wir dafür sorgen, dass unsere Programmierer besseren Code schreiben" ja von Anfang an die Kernfrage in der Softwareentwicklung.

Eine frühe Theorie war, dass die Programmiersprachen zu abstrakt waren und man sie näher an die menschliche Sprache heranbringen muss. Dann ist der Übersetzungsabstand geringer, so die Idee. COBOL hat das ausprobiert. Funktioniert hat es nicht. OK, dann müssen wir das vielleicht stärker formalisieren. Wir lassen unsere Programmiersprache wie mathematische Formeln aussehen. APL hat das ausprobiert. Erfolgreich war es nicht.

Dann versuchen wir es mal mit Komplexitätsreduktion. Modularisierung wird uns retten, später war die Modularisierung sogar zwischen Konzepten, nicht bloß zwischen Quellcodedateien, und wir nannten es Objektorientierung und sprachen von Kapselung. Unter dem Strich hat auch das keinen sichereren oder besseren Code produziert.

Lesen Sie auch:

Das ist über die Jahrzehnte die zentrale Einsicht: Das Umfeld ist nicht statisch. Es ist dynamisch und Markteinflüssen unterworfen. Wenn wir durch einen Eingriff dafür sorgen, dass die Komplexität beherrschbarer wird, dann führt das nicht dazu, dass die Branche die alten Probleme besser löst, sondern dass man größere Probleme angeht oder dass man die eingeplante Zeit pro Problem reduziert. Das Ergebnis ist, dass die Position auf der Pfusch-Achse konstant bleibt. Selbst hocherfolgreiche Konzepte wie starke Typisierung, Modularisierung, Abstraktion und Kapselung bei objektorientierten Programmiersprachen haben unter dem Strich sehr geholfen, aber die Systeme sind am Ende nicht besser geworden, nur viel größer (und sehr viel langsamer).

Rust kommt von Mozilla, den Leuten hinter dem Firefox-Browser. Der Firefox-Browser ist sozusagen der Worst Case für traditionelle Softwareentwicklung. Es ist eine vergleichsweise riesige Software und trotz Anwendung aller oben genannten Konzepte eine beständige Quelle von Sicherheitslücken. Die Release-Abstände wurden über die Jahre immer kleiner, aber ein Release ohne kritische Sicherheitslücken war nicht darunter. Die Mozilla-Leute spüren also am eigenen Leib, dass die bisherigen Ansätze nicht hinreichen, und sie haben sich hingesetzt und das Problem analysiert.

Es gibt in der Softwareentwicklung mehrere fundamentale Probleme und Hindernisse, und keines davon ist technisch.

Programmierer arbeiten nicht mit Formeln, sondern mit Annahmen und Heuristiken. Man hat Zugriff auf eine mehr oder weniger riesige Bibliothek aus fertigen Lösungen für Detailprobleme und stöpselt aus denen das Gesamtsystem zusammen. Eine rigorose Analyse, ob die Annahmen der zusammengebastelten Komponenten überhaupt gleichzeitig erfüllt sein können, macht niemand. Im Allgemeinen liegen die Annahmen der Komponenten auch gar nicht explizit niedergeschrieben vor.

Softwareentwicklung ist in der Praxis daher immer eine Iteration. Man stöpselt etwas zusammen, dann lässt man den Compiler bauen, wenn man eine Testsuite hat, lässt man die laufen, und dann shippt man. Wenn auf dem Weg Fehler auftreten und noch Zeit übrig ist, dann geht man noch mal zurück und ändert etwas. Von außen erinnert das ein bisschen an Machine Learning, und es hat damit den Aspekt gemein, dass das Ergebnis eher gelernt als konstruiert ist und man gar nicht wirklich sagen kann, warum das jetzt gerade beziehungsweise unter welchen Rahmenbedingungen es auch für andere Eingaben funktioniert. Man hat bloß die Hybris des Programmierers als Grundlage, der selbstverständlich annimmt, die eigene Software sei praktisch fehlerfrei und funktioniere in allen Fällen, außer vielleicht, wenn der Anwender sie falsch einsetzt.

Programmieren selektiert wie Bergwandern nach Leuten mit unrealistischer Selbsteinschätzung ("klar kann ich das!"), die sich fortlaufend über den Fortschritt des Projektes selbst belügen können ("Wir sind so gut wie da! Nur noch um die eine Ecke da vorne, da muss es dann sein!"). Wenn man das ohne Selbstbelügen und mit realistischem Projektmanagement betreiben würde, bekäme man keinen einzigen Zuschlag, weil der Rest des Feldes mit Leuten gefüllt ist, die glauben, dass sie das Geforderte mal eben am Wochenende herunterhacken können.

Wenn man die Hybris der Programmierer mit dem ewigen Marktdruck kombiniert, werden in vielen Fällen Dinge aufgeschoben. Ja klar, das könnte man hier auch ordentlich machen, aber das wird in der Praxis schon nicht so schlimm sein, und wenn doch, dann kann ich ja zurückkommen und nachbessern. Programmieren ist in der Praxis eher ein Optimierungsproblem (was ist der geringste Aufwand, den ich treiben muss, damit der Kunde mir das abnimmt) als eine konstruktive Ingenieurskunst. Schlimmer noch: Wenn man einen Programmierer findet, der alles ordentlich machen will, dann ist der im Markt nicht konkurrenzfähig gegen die ganzen Abkürzungen der Pfuscher der Konkurrenz.

Der Ansatz von C und C++ war und ist, dass man viele Dinge einfach als "das darfst du halt nicht machen" deklariert. Man schreibt dann in den Standard "das ist implementationsspezifisch" oder "das ist undefiniertes Verhalten", und dann kann man dem Programmierer sagen, er hätte halt kein undefiniertes Verhalten produzieren sollen: Man hat sich in seinem theoretischen Elfenbeinturm die Hände reingewaschen von dem Schmutz der Praxis. Ein illustrierendes Beispiel dafür ist std::vector in C++, wo man mit vektor[n] indiziert auf Elemente zugreifen kann. Der Vektor weiß, wie viele Elemente er hat. Der Vektor liefert die Implementation von operator[]. Aber der Standard sagt, der Vektor muss nicht prüfen, ob der Index gültig ist. Wenn er das nicht ist, ist das in der Theorie undefiniertes Verhalten und in der Praxis eine Sicherheitslücke. Das C++-Komitee findet das schon OK so. Wer den Index geprüft haben will, kann ja statt vektor[n] einfach vektor.at(n) schreiben.

Das ist absolut konsistent für C und C++. In C prüft der indizierte Array-Zugriff auch nichts, und C++ hat das einfach übernommen.

Es gibt einen Weg, das sicher zu machen, aber der ist nicht Default und er ist mit mehr Tippaufwand verbunden und sieht weniger idiomatisch aus. Natürlich macht das in der Praxis niemand.

Auch das ist absolut konsistent über die Zeit. Wenn man in C einen Integerwert haben will, kann man "int" nehmen. Dann ist die Länge plattformabhängig. Könnte 16 Bit sein oder 32 Bit oder 64 Bit. Wer 32 Bit haben will, nimmt int32_t. In der Praxis sieht man wenig überraschend überall int und nicht int32_t. Das ist weniger Tippaufwand, und der Programmierer ist sich ja eh sicher, dass er schon weiß, was er tut. Genau wie im Autoverkehr immer alle glauben, Autounfälle würden nur den anderen passieren, und man selbst sei ein überdurchschnittlich begabter Autofahrer.

Schlimmer noch: Wenn man in C sagen will, dass ein Wert nur gelesen und nicht geschrieben werden darf, dann muss man "const" dranschreiben. Aus "int" wird dann "const int". Der Tippaufwand hat sich verdreifacht! Wenig überraschend ist alter C++-Code voll von nicht als const deklarierten Werten, die aber eigentlich const gemeint waren oder hätten sein können. Der Compiler kann aber nur meckern, wenn man gesagt hat, dass das als const gemeint war.

Im Maschinenbau kennt man das Konzept von "fail safe". Man nimmt an, dass jede Komponente irgendwann ausfallen kann, und baut das Gesamtgerät so, dass es dann keinen rauchenden Krater hinterlässt, sondern eine heruntergefahrene Maschine. In Zügen gibt es zum Beispiel einen "Totmannschalter". Wenn der Zugführer einen Herzinfarkt hat und am Arbeitsplatz tot umkippt, dann kommt der Zug automatisch zum Stehen und rast nicht unkontrolliert durch die Gegend.

(Bild: Callum Bainbridge / Shutterstock.com)

Und hier kommt der Teil, der Rust so bemerkenswert macht. Die haben sich das angesehen und sich entschieden, dass sie kurzfristig Schmerzen aushalten wollen, wenn die Codebasis dadurch insgesamt sicherer wird. Sie haben sich C++ angesehen, die aktuelle Plattform von Firefox, und gesehen, dass das Design praktisch überall nicht "fail safe" ist. Wenn der Compiler smart genug ist, kann er dich warnen, aber der unklare Teil, bei dem der Compiler noch nicht schlau genug ist, da "failed" er unsafe und gibt keine Warnung aus. Schlimmer noch: Wenn der Compiler schlauer wird und mehr Codestellen bemängelt, dann schalten die meisten Programmierer lieber das Warnlevel herab oder ignorieren die Warnungen alle. Und auch dass man an etwas const dranschreiben muss, ist ein "fail unsafe".

Rust macht das so, dass man für einen 32 Bit Integer ohne Vorzeichen "u32" schreibt – aber der ist dann per Default const! Wer nicht const will, muss "mut u32" schreiben. Wenn der Programmierer den Tippaufwand scheut, dann ist das Ergebnis nicht schlechterer Code, der trotzdem vom Compiler akzeptiert wird, und dann vielleicht im Feld bricht, sondern der Compiler kann den Code direkt ablehnen. Kurzfristig ist das schmerzhaft. Rust macht es trotzdem.

Natürlich ist das nicht das Ende der Dinge, die Rust anders macht. Eine andere Rust-Idee muss hier noch gezeigt werden: Das Konzept von Ownership.

Nehmen wir an, wir haben einen Container mit Werten drin. Wir rufen irgendeine Funktion auf, die damit etwas machen soll. In C++ schreibt man das einfach so hin, ohne darüber nachdenken zu müssen, wer jetzt eigentlich für den Container zuständig ist. Irgendjemand muss ja den Container freigeben, wenn man damit fertig ist. Übergibt man den Container nur als Eingabe für eine Operation an diese Funktion oder übergibt man ihn permanent und macht danach selber nichts mehr damit? Wenn die Funktion fertig ist, wer gibt dann den Container frei?

In vielen einfachen Programmen spielt diese Frage keine große Rolle. Das Hauptprogramm instanziiert alles und gibt am Ende alles frei. Für Codemonster wie einen Webbrowser funktioniert das aber so nicht, da braucht man bessere Konzepte. Das häufigste Konzept in Webbrowsern ist Reference Counting. Niemand ist Eigentümer. Der Container zählt selber, wie viele Referenzen es auf ihn gibt, und wenn der Wert 0 erreicht, gibt er sich selbst frei. Das funktioniert leidlich, aber es macht es praktisch unmöglich, belastbare Aussagen über den Code zu machen.

Zur Programmiersprache Rust siehe auch:

Rust macht daher die Ownership explizit. Die Grundregeln sind wie in C++. Wenn das Scope schließt, werden die darin deklarierten Variablen freigegeben. Da endet aber auch schon die Ähnlichkeit. Wenn man in Rust eine Variable A einer Variablen B zuweist, dann hat man sie nicht nur kopiert, sondern transferiert. Es kann immer nur einen Eigentümer geben. Wenn man danach lesend auf A zugreift, lässt der Compiler das nicht zu. Zusätzlich gibt es noch das Konzept des Verleihens (Borrowing). Man kann einer Funktion eine Referenz auf A geben, und für die Laufzeit der Funktion ist der Eigentümer-Status an die Funktion ausgeliehen. Wenn die Funktion fertig ist, geht der Eigentums-Status zurück auf den Aufrufer. Die Funktion darf dann keine Kopie der Referenz gemacht haben.

Man kann sich vorstellen, was für eine große Umstellung das für bestehende Codebasen wäre, wenn man die nachträglich an das explizite Eigentumskonzept von Rust umstellen müsste. In den meisten Fällen weiß man das nämlich selbst nicht mehr, wenn man ein Stück Legacy-Code hat, wie das gemeint war. Insofern erscheint dieser Aufwand womöglich auf den ersten Blick unangemessen. Doch er hat eine Nebenwirkung: Er macht nebenläufiges Programmieren realistisch möglich. Typische Probleme wie "müssen wir hier was locken?" gibt es nicht, denn wenn der Code eine Referenz hat, ist er auch Eigentümer und niemand anderes darf mit der Variablen was machen. Und das ist im Gegensatz zu C++ nicht bloß Wunschdenken, sondern der Compiler garantiert das.

Natürlich ist es nicht ganz so einfach wie ich es jetzt beschrieben habe, denn man braucht ja auch noch einen Weg, wie man einem anderen Thread Zugriff auf eine Variable gibt. Dafür sieht Rust Channels vor. Aber es soll ja hier nicht darum gehen, wie man ein konkretes Problem in Rust löst, sondern was Rust zu einer interessanten Programmiersprache macht, die das Zeug hat, die grundlegenden Probleme der Softwareentwicklung zu lösen.

Gucken wir uns das doch mal aus der Perspektive der Psychologie und des Marktes an. Das bisherige Problem der Abkürzungen und Selbstüberschätzung räumt Rust gezielt aus dem Weg.

Psychologisch gibt es aber noch den Effekt, das Programmierer gerne der Freiheit hinterherlaufen. Der Autor dieser Zeilen fing mit Basic an, ging dann zu Pascal (weil damit mehr ging), und dann zu C. Für den Schritt zu C gab es rückblickend keine gute Begründung. C versprach mehr Freiheiten, aber das ist nur ein guter Deal, wenn einen die eigene Hybris glauben lässt, dass einen die negativen Aspekte schon nie betreffen werden, weil man so ein begnadeter Superprogrammierer ist und die anderen alle doof sein müssen, wenn sie Schwierigkeiten haben. Dieses Problem könnte Rust auch haben, dass es sich weniger "frei" anfühlt. Das war anfangs die Hauptbefürchtung des Autors, aber die hat sich als unbegründet herausgestellt. Rust ist vielleicht nicht ganz so dynamisch wie Go (gemessen am Umfang der Projekte und der verfügbaren Bibliothek), aber es muss sich keinesfalls verstecken und hat diverse andere Plattformen aus dem Stand abgehängt.

Was aber, wenn der Markt weniger Zeit alloziert und Rust-Projekte dann zu lange brauchen und nicht konkurrenzfähig sind?

Das könnte in der Tat eine reale Gefahr für Rust sein. Die Zukunft wird zeigen, wie es ausgeht. Bisher sieht es aber sehr gut aus, denn in Rust gibt es inzwischen gleich mehrere vergleichsweise hochkomplexe Codebasen, sogar wenn man Firefox nicht dazurechnet. Wenn es möglich ist, in Rust ein Tool wie ripgrep zu entwickeln, das programmiersprachenübergreifend seinesgleichen sucht, dann kann Rust ja in der Praxis so schlimm nicht im Weg stehen. Die Geschwindigkeit, mit der Mozilla in Rust signifikante Teile des Browsers neu entwickeln konnte, spricht auch klar für Rust.

Bleibt die Frage, ob sich das Portieren bestehender Projekte lohnt. Diverse Projekte wollen gerne auf Rust umsteigen und haben sich für den Weg dahin Überbrückungstechniken wie gegenseitigen Aufruf von Code zwischen Rust und C++ gebaut, auch Firefox macht das so. Damit sind dann natürlich viele der Zusagen von Rust nicht mehr gültig. Auch hier wird die Zukunft zeigen, ob es nicht besser ist, lieber in Rust neu zu entwickeln, als schrittweise zu portieren.

Rust hat noch viele weitere Neuheiten, die hier nicht weiter behandelt werden, zum Beispiel den Paketmanager cargo. Er kann auf der Haben-Seite in Projekten viel Zeit sparen, aber auf der Kosten-Seite verlässt man sich damit wieder auf anderer Leute Code, und die Updatefrage ist auch noch nicht wirklich geklärt. cargo ist aus meiner Sicht einer der Aspekte von Rust, bei denen man mehr "fail safe" hätte einbauen können. Es ist durchaus möglich, in Projekten alte Versionen von Paketen zu referenzieren. Das ist nicht nur möglich, das ist der Standardzustand. Ergebnis ist, dass, selbst wenn man von einem Projekt die neueste Version einspielt, da trotzdem noch alte, schlechte, unsichere Komponenten verbaut sein können.

Unter dem Strich bringt Rust aber genug schlaue Ideen auf den Tisch, dass es viele Experten aus dem Stand überzeugt hat. Microsoft hat laut die Frage gestellt, ob man nicht Teile ihrer Windows-Infrastruktur in Rust neu schreiben sollte. Google hat in Chrome auch schon ein Rust-Einbau-Projekt am Laufen. Die Zukunft von Rust sieht im Moment rosig aus. Und wer weiß, vielleicht war das die ganze Zeit die Antwort auf unsere Fragen, dass man lieber bei der Entwicklung nicht so viel herumoptimieren soll, weil man sonst den Aufwand bloß in die Wartungsphase verschiebt und unter dem Strich mehr Aufwand anfällt.

(jk)