Rusts unerwarteter Produktivitätsschub
(lubeno.dev)- Rust erhöht mit starken Sicherheitsgarantien die Produktivität und Wartbarkeit, weil sich selbst in großen Codebasen Refactorings mit Zuversicht durchführen lassen
- Der Compiler erkennt Bugs im Zusammenhang mit asynchronem Scheduling im Voraus und stärkt die Stabilität, indem er undefiniertes Verhalten verhindert
- Sprachen wie TypeScript haben aufgrund ihres lockeren Typsystems häufig asynchrone Bugs, die erst in der Produktionsumgebung entdeckt werden
- Rusts Typsystem zeigt die Auswirkungen von Codeänderungen klar auf und erhöht so in komplexen Projekten Zuverlässigkeit und die Bereitschaft zu experimentieren
- Zig ist im Gegensatz zu Rust bei der Fehlerbehandlung lockerer und kann deshalb Bugs durch Tippfehler übersehen, was die Zuverlässigkeit senkt
Zusammenfassung und Hintergrund
- Das Backend von Lubeno ist zu 100 % in Rust geschrieben, und die Codebasis hat einen Umfang erreicht, bei dem sie sich nicht mehr vollständig im Kopf überblicken lässt
- In großen Projekten ist es generell schwer, Nebenwirkungen von Änderungen zu erkennen, was normalerweise zu Produktivitätsverlusten führt
- Rusts Sicherheitsgarantien machen bei Codeänderungen die Auswirkungen klar sichtbar und nehmen so die Angst vor Refactorings
- Das trägt langfristig zu besserer Wartbarkeit und höherer Produktivität bei
- Der Text beginnt mit einem Fall, in dem der Rust-Compiler einen asynchronen Bug erkannt hat, und untersucht daran Rusts Produktivitätsvorteile
Beispiel für Rusts Sicherheitsgarantien
- Problemsituation: Eine Struktur wird für gleichzeitigen Zugriff in einen Mutex gepackt, nach dem Erwerb des Locks wird asynchrone Arbeit ausgeführt
let lock = mutex.lock(); db.insert_commit(commit).await; - Entdeckung des Problems: rust-analyzer zeigte keinen Fehler an, aber in der Datei mit der Router-Definition trat ein Compilerfehler auf
.route("/api/git/post-receive", post(git::post_receive)) ^^^^^^^^^^^^^^^^^ error: future cannot be sent between threads safely - Ursachenanalyse:
- Das Web-Framework erstellt für jede HTTP-Verbindung einen asynchronen Task, und der Task-Scheduler verschiebt Tasks zwischen Threads
- Ein Mutex erfordert, dass derselbe Thread den Lock wieder freigibt; wenn der Thread an einer
.await-Stelle wechselt, kann undefiniertes Verhalten entstehen - Der Rust-Compiler verfolgt die Lebensdauer des Locks und erkennt, dass es möglicherweise auf einem anderen Thread freigegeben wird
- Lösung: Den Lock vor
.awaitfreigeben - Bedeutung: Rust verhindert asynchrone Bugs bereits zur Compile-Zeit, selbst wenn sie in der Entwicklungsumgebung schwer zu reproduzieren sind
Vergleichsbeispiel mit TypeScript
- Problemsituation: In TypeScript-Code tritt ein Bug bei einer asynchronen Weiterleitung auf
if (redirect) { window.location.href = redirect; } let content = await response.json(); if (content.onboardingDone) { window.location.href = "/dashboard"; } else { window.location.href = "/onboarding"; } - Ursache des Problems:
window.location.hrefführt die Weiterleitung nicht sofort aus, sondern plant sie nur ein; der Code läuft weiter- Durch eine Race Condition kommt es zu einer unbeabsichtigten Weiterleitung
- Lösung: Im
if-Block einreturnergänzenif (redirect) { window.location.href = redirect; return; } - Einschränkung: TypeScript hat keine Nachverfolgung von Lebensdauern oder Borrowing-Regeln und kann solche Bugs daher nicht zur Compile-Zeit erkennen
- Sie werden erst in Produktion entdeckt, und das Debugging kostet viel Zeit
Rusts Vorteile beim Refactoring
- In der Webentwicklung bieten Python, Ruby und JavaScript/Node.js anfangs hohe Produktivität, doch mit wachsender Codebasis werden Änderungen durch lockere Kopplung schwieriger
- Nach Änderungen treten unerwartete Fehler auf, was die Bereitschaft verringert, Code anzupassen
- Rusts Typsystem zeigt die Auswirkungen von Änderungen klar auf und nimmt so die Angst vor Refactorings
- Beispiel: Warnungen wie „Diese Änderung könnte andere Teile beeinflussen“ verhindern Probleme im Voraus
- Selbst wenn die Codebasis wächst, steigt die Produktivität weiter, weil bestehender Code wiederverwendet werden kann und Änderungen stabil bleiben
Vergleich mit Tests
- Tests sind bei Refactorings nützlich, um Regressionen zu vermeiden, aber da der Compiler sie nicht erzwingt, können sie ausgelassen werden
- Das Schreiben von Tests bedeutet eine hohe mentale Belastung, weil entschieden werden muss: Abstraktionsebene, Verhalten vs. Implementierungsdetails und welche Fehler verhindert werden sollen
- Rust blockiert mit dem Compiler typische Fehler im Voraus und reduziert so die Entscheidungsbelastung, die sonst bei Tests anfällt
- Eigenschaften, die sich nicht über das Typsystem prüfen lassen, werden durch Tests ergänzt
Vergleich mit Zig
- Zig ist wie Rust eine Systemprogrammiersprache, ist bei der Fehlerbehandlung jedoch lockerer
- Beispiel für Fehlerbehandlungscode
const FileError = error{ AccessDenied }; fn doSomethingThatFails() FileError!void { return FileError.AccessDenied; } pub fn main() !void { doSomethingThatFails() catch |err| { if (err == error.AccessDenid) { std.debug.print("Access was denied!\n", .{}); } }; } - Durch den Tippfehler
AccessDenidentsteht ein Bug, aber der Zig-Compiler behandelt dies als Zahl, sodass der Code trotzdem kompiliert
- Beispiel für Fehlerbehandlungscode
- Bei Verwendung von switch wird ein Tippfehler erkannt, in einer if-Anweisung jedoch ignoriert, was Zuverlässigkeitsprobleme verursacht
- Rust vermeidet solche Designlücken und prüft Tippfehler und logische Fehler strikt
Implikationen
- Rust verbessert mit Sicherheitsgarantien und einem strengen Typsystem die Produktivität und Stabilität großer Projekte
- Selbst komplexe Probleme wie asynchrone Bugs werden zur Compile-Zeit erkannt, was die Wartungskosten senkt
- Die Beispiele mit TypeScript und Zig zeigen die Risiken lockerer Prüfungen und unterstreichen den Wert von Rusts strengem Compiler
- Rust etabliert sich auch in der Webentwicklung als starkes Werkzeug nicht nur für anfängliche Produktivität, sondern auch für das langfristige Management von Codebasen
3 Kommentare
Immer wenn ich sehe, wie sehr behauptet wird, das sei das Beste und eine mächtige Sprache!!
frage ich mich: Gibt es vielleicht doch nicht so viele Rust-Entwickler, wie man denkt, und deshalb versucht man, Leute zu Rust zu überreden??
Bin ich der Einzige, der bei empfohlenen Rust-Artikeln an den Gourmet aus "Probier's! Probier's!" denken muss?
Hacker-News-Kommentare
Ich habe letztes Jahr einen in Rust geschriebenen virtio-host-Netzwerktreiber portiert. Dabei habe ich das Backend, den Interrupt-Mechanismus und die Umstellung von einer Bibliothek auf einen eigenständigen Prozess geändert. Es war ein komplexes Programm, das Memory Mapping, VM-Interrupts, Netzwerk-Sockets und Multithreading abdeckte. Ich hatte fast keine Rust-Erfahrung und auch nur wenig Erfahrung mit virtio, aber als das Projekt schließlich kompilierte, funktionierte es praktisch perfekt. Abgesehen von einem Bug im Zusammenhang mit
Drop, der leicht zu beheben war. Ich glaube, die Rust-Bibliotheken haben mir sehr geholfen, weil sie so aufgebaut sind, dass man sie kaum falsch verwenden kannIch halte Rust für großartig. Aber ich stimme nicht der Meinung zu, dass der Bug bei der
href-Zuweisung TypeScript anzulasten ist. Der Kern des Problems ist, dass das Setzen vonhrefnicht sofort die Seitennavigation auslöst, sondern erst später verarbeitet wird. Genau dasselbe Problem könnte es auch in Rust geben. Wenn Rust eine Funktionset_hrefhätte und dieses Verhalten später ausgeführt würde, wäre so etwas wie der folgende Code möglich:set_href('/foo')
if (some_condition) { set_href('/bar') }
Ich denke nur nicht, dass man das in Rust so entwerfen würde. Dass ein Setter direkt Verhalten auslöst, ist kein gutes Library-Design, und dass beim Zuweisen von
hrefnicht sofort eine Navigation erfolgt, ist seltsam. In der Rust-Standardbibliothek gäbe es so eine dumme Implementierung wohl nicht. Es ist also kein Problem Rust vs. TypeScript, sondern eher ein Unterschied zwischen Rusts Standardbibliothek und der Web-Platform-API. Ich stimme aber zu, dass Rust so eine User Experience vermutlich nicht bieten würdeFormal betrachtet ist es nicht wünschenswert, Setter so zu gestalten, dass sie sofort Aktionen auslösen. Auch die Benennung sollte eher etwas wie
navigate_to(href)sein. In einer Browser-Umgebung werden alle JS-Codes ohnehin als Callbacks ausgeführt und vom Event Loop gesteuert, daher ist es auch natürlich, dass es nicht sofort passiertDas Rust-Beispiel ist interessant, aber allein anhand des TypeScript-Beispiels lässt sich nicht beurteilen, ob TS für große Projekte geeignet ist. In Ruby muss ich zwar oft Bugs zur Laufzeit finden, was ein ungutes Gefühl ist, aber am Ende funktioniert es vor dem Commit gut, und der Code lässt sich leicht lesen und ändern, was zufriedenstellend ist. Das Problem mit der Navigation ist ein JavaScript-Problem, das TS geerbt hat. Es entsteht, weil JS beliebige Änderungen an Eigenschaften zulässt. Gleichzeitig verschwindet die Seite ja nicht sofort, also ist dieses Verhalten nachvollziehbar, wenn man es einmal kennt
Technisch könnte Rust durch die Rückgabe von
()oder!beiset_hrefdie Bedeutung klarer andeuten. Bei bedingten Redirects wäre es aber weiterhin schwierig, falsche Verwendung zuverlässig zu verhindernIch wollte darauf hinaus, dass man mit Rusts Ownership-Modell eine API so gestalten könnte, dass
window.set_href('/foo')die Ownership vonwindowübernimmt und dadurch ein zweiter Aufruf unmöglich wird. TypeScript hat schon konzeptionell keine Lifetimes, daher ist das dort nicht möglich. Da die JS-API bereits existiert, gibt es auf TypeScript-Seite auch keinen Weg, ein Ownership-System einzuführen. Ich wollte es als Beispiel dafür nennen, wie mehrere Rust-Features zusammen stärkere Garantien liefernDie Begründung, warum Rust besser sein soll, klingt für mich letztlich nach: „Rust-Programmierer sind einfach besser.“ Ich glaube nicht, dass Rust-Programmierer so zirkulär argumentieren würden
Code nach einer Zuweisung läuft weiter, solange man nicht explizit früh zurückkehrt. Ganz ehrlich, ich verstehe nicht, warum man erwarten sollte, dass eine Wertzuweisung die Skriptausführung stoppt. Dem TS-Beispiel fehlt vielleicht Kontext, aber als Beispiel für eine „Data Race“ wirkt es seltsam
Einer Zuweisung an
window.location.hreffolgt als Side Effect, dass der Browser zu diesem Link navigiert. Dieses Verhalten ist überraschend, und weil eine simple Zuweisung eine neue Seite lädt, fühlt es sich fast ein wenig wieexecvean, weshalb es nicht völlig unplausibel ist zu denken, dass die JS-Ausführung sofort stoppt. Beim Programmieren sollte man sich nicht auf so eine Annahme verlassen, aber das Verhalten ist tatsächlich seltsam genug, um Verwirrung zu stiftenUnabhängig davon, ob man so denkt oder nicht: Wenn einen jemand auf so einen Bug hinweist, ist die Korrektur eindeutig. Der eigentliche Punkt des Autors war, dass Bugs dieser Art, die TS nicht einfängt, in der Praxis schwer zu finden sein können und viel Zeit kosten
exit(),execve()usw. stoppen die Ausführung tatsächlich sofort, daher ist es nachvollziehbar, Redirects ähnlich einzuordnenEs ist seltsam, jemandem seine Erfahrung zum Vorwurf zu machen
Diese Zuweisung hat den großen Side Effect, dass man die Seite verlässt. Es ist also nicht völlig abwegig, sie als asynchrone Aktion zu sehen, die sofort wirksam wird. Ich habe diese Annahme auch schon einmal gemacht
Es ist letztlich die Geschichte eines Entwicklers, der erkannt hat, dass statische Typsysteme nützlich sind. Solche Texte finde ich immer amüsant
Kommen die meisten Vorteile am Ende nicht einfach daher, dass man eine statisch typisierte, also kompilierte Sprache verwendet? Das gilt genauso für Java, Go und C++. TypeScript hat ein paar Tricks: Es kompiliert zu JS und erbt dessen Probleme, ist aber trotzdem brauchbar. Rusts Typsystem ist strenger und ermöglicht zusätzliche Compile-Time-Checks, dafür ist es schwerer zu lernen und schwerer zu lesen
Teilweise stimme ich zu, aber Rust hat im Typsystem noch mehr Dimensionen: Ownership, geteilten/exklusiven Zugriff, Thread-Sicherheit, Sum Types usw. Das Ownership/Borrowing-System macht klar, ob ein Argument nur eine temporäre Sicht ist oder vollständig übergeben wird. Das ist bei großen Programmen oder externen Bibliotheken ein großer Vorteil. Zum Beispiel ist beim Slice-Typ in Go zur Laufzeit nicht immer klar, welche Operationen erlaubt sind, und es gibt keinen sauberen Weg, ihn read-only zu borgen. Rust kann Thread-Sicherheit auf Typsystem-Ebene garantieren und so Data Races schon zur Compile-Zeit verhindern, die andere Sprachen oft nur schwer zur Laufzeit finden
Wenn man alle statisch typisierten Sprachen als eine Gruppe betrachtet, hat man die wahre Stärke von Union-(Sum-)Types und Pattern Matching wohl noch nicht erfahren. Wenn man sich einmal an Union Types gewöhnt hat, wirken andere traditionelle statisch typisierte Sprachen oft unbefriedigend
Ein großer Vorteil sind
traitsbzw.impl traits. In Rust kann man einem Typ auch nachträglich Traits hinzufügen, ähnlich wie bei Extension Methods in C#. In den meisten Sprachen ist ein Typ bei seiner Definition in einer Bibliothek weitgehend festgelegt, während man in Rust auf einfache Typen schrittweise weiter Funktionalität aufbauen kann. Diese late-bound-Eigenschaft bringt Dynamik in das Typsystem. Etwas zugespitzt gesagt ist Rusts eigentliche Superkraft nicht der Borrow Checker, sondern die Offenheit und Flexibilität seines Typsystems. Man muss nicht alles von Anfang an vollständig entwerfen, sondern kann schrittweise erweiternNicht alle statisch typisierten Sprachen haben denselben Effekt. Java verlässt sich letztlich auf
Objectund Runtime-Casts. Go hat keine Enums. C++ hat zwarvariant, aber um es sicher zu verwenden, braucht man manuelle Konstrukte ähnlich wie try/except, was strukturell unhandlich istEs heißt immer, Rust sei schwer zu lernen, aber wenn man es einmal wirklich gelernt hat, ist es eigentlich nicht schwierig. Gerade am Anfang des Programmierens ist es wichtig, einfach etwas zusammenzuschreiben, bis es irgendwie läuft, und dafür ist Rust nicht besonders freundlich. Als Einstiegssprache würde ich es nicht empfehlen, aber schwer zu lesen ist es nicht
Rusts starke Sicherheitsgarantien erhöhen mein Vertrauen, wenn ich an einer Codebasis arbeite. Mit diesem Vertrauen scheut man auch Refactorings im Kern nicht mehr, und dadurch steigen Produktivität und Wartbarkeit deutlich. Aber genau dafür gibt es doch Tests. Ohne Tests hilft ein strenger Compiler sicher sehr, aber mit guten Tests kann man in jeder Sprache selbstbewusst refaktorieren
Es ist besser, wenn der Compiler das, was möglich ist, statisch beweist. Tests sollte man optimalerweise nur dort einsetzen, wo statische Garantien schwer zu erreichen sind. Das ideale Endgame wäre formale Verifikation, was in der Praxis sehr schwierig ist, also keine allgemeine Lösung, aber als Prinzip stimmt es
Gute Tests und ein gut genutztes Typsystem sind beide effektiv beim Finden von Bugs. Beim Schreiben von Tests muss ich allerdings immer an den xkcd-Comic „Standards“ denken. Man behebt Probleme, indem man noch mehr Standardisierung schafft — hier also, indem man noch mehr Code schreibt, um Bugs in Code zu finden. Immerhin wird das Typsystem von den Sprachdesignern gepflegt und muss nicht pro Projekt separat verwaltet werden
Bei jedem Refactoring auch noch die Tests refaktorieren zu müssen, verdoppelt die Arbeit
Ich finde, dass die Typsysteme von Rust und F# besonders beim Refactoring glänzen. Der Ausdruck „fearless refactoring“ trifft es genau
Das Zig-Beispiel ist schockierend. Es wirkt so instabil, dass ich nicht nachvollziehen kann, wie man so ein Design gut finden kann
Ich denke, das ist vermutlich ein Bug. Aber in einer Creator-zentrierten Sprache wie Zig ist es wichtig, dass der Creator den Fehler auch als Bug anerkennt, damit er behoben werden kann. Wenn er es für Absicht hält, könnte das Design genau so weitergeführt werden
Jede Sprache hat ein paar instabile Designentscheidungen. In Go oder Zig muss man zum Beispiel
mutex.unlock()immer explizit aufrufen; beim Verlassen des Scopes wird nicht automatisch entsperrt. Andererseits hatte ich mit Rustsas-Operator schon den gegenteiligen Fall, wo implizite numerische Umwandlungen zwischen Zahlentypen zu Bugs geführt haben, nach denen ich den ganzen Tag gesucht habeZuerst hatte ich diesen Fehler gar nicht bemerkt, erst durch diesen Kommentar ist es mir aufgefallen
Ich frage mich, ob ein Linter nicht Warnungen ausgeben könnte, etwa für Verweise auf Fehler, die im System nicht existieren, und Empfehlungen zur Verwendung von
switchIch hatte angenommen, dass Error Sets auf Basis von Funktionssignaturen erzeugt werden. Schon etwas ungewöhnlich
Mir gefällt, dass ein starkes und solides statisches Typsystem so viele Funktionen bieten kann. Ich habe selbst erlebt, wie einfach große Refactorings in einer Haskell-Codebasis mit 1 Million SLOC waren. Auch ohne besonders fortgeschrittene Features war das allein durch das Typsystem möglich
Rust hat zwar korrekt erkannt, dass über eine
await-Grenze hinweg ein Lock gehalten wird, aber ob es tatsächlich sicher ist, dieses Lock vorawaitfreizugeben, hängt von weiterem Kontext ab. Meiner Meinung nach sollte das Lock gehalten werden, bis der Transaction Commit erstellt ist; wenn man es vorher freigibt, könnten Concurrency-Probleme entstehen. Ich kenne mich mit Rust async nicht gut aus, aber nach dem Commit müsste man das doch eher mitjoinoderselectabsichern, oder?Wenn man ein Lock über
awaithinweg halten muss, kann man einen async-aware Mutex verwenden. Die Cratesfuturesodertokioimplementieren so etwas. Solche Locks verwendet man typischerweise, wenn sie lange gehalten werden oder zwischenawait-Punkten bestehen bleiben müssen. Sie sind teurer als normale LocksWenn ein Lock auch über
await-Grenzen hinweg gehalten werden muss, kann man Tokios async-aware Mutex verwenden. Siehe die Dokumentation zu tokio/sync/struct.Mutex