Rust vs. Java

19.06.2023

In meinem Blogbeitrag „Was ist Rust“ habe die beliebte Programmiersprache vorgestellt und meine ersten Erfahrungen damit geschildert. In diesem Teil der Reihe möchte ich den Vergleich zu Java anstellen.

Warum Rust?

Die Rust-Designer haben sich zum Ziel gesetzt, eine robuste Sprache zu schaffen, die aus den Problemen gelernt hat, die frühere Programmiersprachen aufweisen.

Auch wenn man es sich heute nicht mehr so recht vorstellen kann, war Robustheit auch ein Designziel von Java. Damals waren beispielsweise die starke Typisierung, Garbage Collection, Ausnahmebehandlung durch Exceptions oder der Verzicht auf Zeigerarithmetik Maßnahmen zur Erreichung dieses Ziels[1]. Genau wie bei Rust war es in Java auch schon nicht mehr möglich, Referenzen auf ungültige Speicherbereiche zu erzeugen, wie es in C oder C++ ging, bzw. immer noch geht.

Dennoch hat Java aus heutiger Sicht immer noch etliche Eigenschaften, welche die Entstehung von Laufzeitfehlern begünstigen.

You shall not change!

Standardmäßig sind Variablen in Rust immutable, also unveränderbar. Soll sich der Wert einer Variable im Laufe der Programmausführung ändern können, muss dies in der Deklaration mit dem Keyword mut (kurz für mutable) explizit angegeben werden. Dies ist wie bei vielen anderen Aspekten in Rust ein sinnvoller Standard, weil man sich bewusst für die Mutabilität entscheiden muss. In Java verhält es sich andersherum: sollen Variablen unveränderbar sein, muss man dies explizit mit final angeben – was aber oft unterlassen oder vergessen wird.

Veränderbare Variablen können immer dann problematisch sein, wenn sie global zugreifbar sind oder mit anderen Codestellen geteilt werden. Dies macht es zum einen schwieriger, das Laufzeitverhalten des Programms zu verstehen, zum anderen kann das insbesondere in der nebenläufigen Programmausführung zu Fehlern wie etwa Race Conditions führen, wenn unterschiedliche Threads den Wert einer Variablen ändern können. Daher sollten Variablen wann immer möglich unveränderbar sein.

Null oder nicht null, das ist hier die Frage!

Die Tatsache, dass Referenzen null sein können, hat in der Vergangenheit schon zu unzähligen Problemen, Schwachstellen und Systemabstürzen geführt. Tony Hoare, der Konzept der null-Referenzen eingeführt hatte, nannte diesen Umstand seinen „Millarden-Dollar-Fehler“, sowie „eine Fehlerquelle, die schwer zu vermeiden und schwer zu analysieren ist“.

Im Gegensatz zu Java gibt es daher in Rust kein null. Das Konzept von null, das im Grunde ausdrückt dass ein Wert nicht vorhanden ist, wird in Rust durch das Option-Enum (ähnlich der Optional– Klasse in Java) gelöst:

enum Option<T> {
   None,
   Some(T),
}

Variablen, bei denen es möglich ist, dass sie keinen Wert haben (z.B. Rückgabewert der Suche nach einem bestimmten Objekt in einer Liste, das in der Liste aber nicht vorkommt) sind daher in Rust immer vom Typ Option<T>, das entweder None sein kann, oder Some(T), welches den Wert mit dem Typ T enthält.

Soll der Wert aus dem Ergebnis weiterverwendet werden, sorgt der Rust-Compiler dafür, dass auch der Fall None, also „Wert ist nicht vorhanden“ behandelt werden muss. Mit Pattern Matching hat Rust hierfür eine mächtige und handliche Syntax parat:

match find_item() {
   Some(it) => println!(„Found item ‚{}'“, it),
   None => println!(„Nothing found“)
}

Möglich wäre auch eine direkte Verwendung des Wertes in Some(T) mit Hilfe von unwrap():

println!(„Found item ‚{}'“, find_item().unwrap());

Im Fall von None ist hier die Folge allerdings ein panic, ein sofortiger Abbruch des Programms. Im Gegensatz zu Java geht der Entwickler hier allerdings immer bewusst das Risiko eines Laufzeitfehlers ein (was nicht zu empfehlen ist, es sei denn für Ausnahmefälle wie etwa Prototyping). Auf jeden Fall ist die Gefahr, dass es hier krachen kann, an dem unwrap() im Code explizit zu erkennen. In Java dagegen, wo bei jedem Methodenaufruf auf ein Objekt eine NullPointerException geworfen werden kann, ist dies nicht so offensichtlich, wodurch entsprechende Laufzeitfehler sehr oft versehentlich, also ohne bewusste Entscheidung des Entwicklers, entstehen.

Ausnahmsweise

Auch die Ausnahmebehandlung funktioniert in Rust anders. In Java werden alle Fehlerkategorien durch Exceptions abgebildet. Bereits seit es Java gibt, besteht die Debatte, wann checked und wann unchecked Exceptions verwendet werden sollen. Hierzu gibt es unterschiedliche Meinungen, was es Java-Entwicklern nicht einfach macht, entsprechende Best Practices zu finden. Eine Faustregel hierfür hat kürzlich mein Kollege Matthias Fischer in seinem Blogbeitrag aus seiner Serie zum Exception Handling in Java beschrieben.

In Rust gibt es keine Exceptions. Stattdessen wird dort bereits von Haus aus explizit zwischen den zwei Fehlerkategorien „unrecoverable errors“ und „recoverable errors“ unterschieden.

Recoverable errors sind vorhersehbare und behandelbare Probleme, wie z.B. „file not found errors“, oder vorübergehende Verbindungsprobleme, die wir dem Benutzer berichten, bzw. durch die Wiederholung der versuchten Operation behandeln können.

Unrecoverable errors dagegen sind ein Synonym für Bugs, wie beispielsweise der Zugriff auf ein Element nach dem Ende eines Arrays. In solchen Fehlerfällen sollte das Programm sich schnellstmöglich beenden, und der Bug gefixt werden.

Für unrecoverable errors gibt es in Rust das panic! – Makro, das die Ausführung des Programms oder eines Threads sofort beendet.

Für recoverable errors gibt es das Result<T, E> – Enum:

#[must_use]
enum Result<T, E> {
   Ok(T),
   Err(E),
}

Funktionen, die potenziell fehlschlagen können, haben als Rückgabetyp ein Result, bei dem T der Typ des Ergebnisses ist, und E der Typ eines möglichen Fehlers. Die Auswertung erfolgt wie beim Option-Typ per Pattern Matching:

let file_contents: Result<String, io::Error> = read_file(„hello.txt“);
match file_contents {
   Ok(txt) => println!(„File content is: ‚{}'“, txt),
   Err(err) => println!(„An error occured: ‚{}'“, err),
}

Das Result-Enum ist mit #must_use annotiert. Das bedeutet, dass der Compiler eine Warnung ausgibt, wenn ein Rückgabewert vom Typ Result ignoriert wird. Damit wird der Entwickler darauf hingewiesen, dass ein möglicher Fehler nicht behandelt wird.

Fazit: Robusterer Code durch Rust

Keine null-Referenzen, standardmäßige Immutability sowie keine Exceptions, und stattdessen Unterscheidung zwischen recoverable und unrecoverable errors – dies sind nur drei Beispiele für Sprachfeatures, die dem Rust-Entwickler dabei helfen, robusteren Code zu schreiben.

Die Rust-Designer hatten eben die Gelegenheit, aus vielen Problemen zu lernen, mit denen früher entstandene Programmiersprachen zu kämpfen haben. Java ist eine dieser Sprachen, und da sie einige Jahre mehr auf dem Buckel hat, schleppt sie auch einige Altlasten mit sich herum – der Preis für die hohe Rückwärtskompatibilität, welche jedoch wiederum eine der Stärken von Java ist.

Ausblick

In weiteren Beiträgen dieser kleinen Blogserie zu Rust möchte ich und über meine Erfahrung mit der Programmierung eines Rust-Microservice berichten und die Einsatzbereiche von Rust und Java miteinander vergleichen.

[1] https://de.wikipedia.org/wiki/Java_(Programmiersprache)

Zurück zur Übersicht

Kommentar verfassen

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert

*Pflichtfelder

*