zurück zum Artikel

Rust: Junger C/C++-Herausforderer mit abwechslungsreicher Geschichte

Florian Gilcher, Johann Hofmann

Rust ist eine noch junge systemnahe Programmiersprache, entwickelt und finanziert von Mozilla. Nach etwas mehr als fünf Jahren Entwicklung erscheint das Geisteskind von Graydon Hoare endlich in Version 1.0.0.

Rust: Junger C/C++-Herausforderer mit abwechslungsreicher Geschichte

Rust [1] ist eine noch junge systemnahe Programmiersprache, entwickelt und finanziert von Mozilla. Nach etwas mehr als fünf Jahren Entwicklung erscheint das Geisteskind von Graydon Hoare [2] endlich in Version 1.0.0.

Seit der initialen Schöpfung hat das Projekt mehrfach das Team und die Ausrichtung gewechselt, aber inzwischen seine Nische gefunden. War die Sprache zuerst zur verteilten Programmierung gedacht, hat sie sich inzwischen weit davon entfernt und tritt eher als moderner C/C++-Ersatz an. Der Fokus auf parallele Programmierung und einem dazu geeigneten, auf "unique pointers" basierenden Typsystem ist jedoch geblieben. Die Sprache versucht den Spagat zwischen maschinennaher Programmierung und leistungsfähigen Abstraktionen.

Der Name Rust stammt interessanterweise nicht etwa von Rost, sondern von einer besonderen Pilzsorte [3].

Auf der Rust-Website finden sich stets die neuesten Installer für verschiedene Plattformen. Zusammen mit dem Compiler wird Cargo [4] ausgeliefert, ein Build- und Paketmanagement-Tool ähnlich Bundler [5] aus der Ruby-Welt. Einmal installiert, ist ein neues Rust-Projekt schnell aufgesetzt:

$ cargo new --bin example

Das Kommando generiert folgende Dateistruktur:

~/example$ tree --charset=ascii
.
|-- Cargo.toml
'-- src
'-- main.rs

Die Datei Cargo.toml beschreibt alle Abhängigkeiten und konfiguriert die Build-Schritte. main.rs ist nach Konvention die Hauptdatei für ausführbare Programme – das Resultat wird den Projektnamen example tragen. Diese Einstellungen lassen sich aber in Cargo.toml ändern. Ein Blick in main.rs zeigt, dass Cargo sogar schon das obligatorische "Hello World"-Beispiel generiert hat:

fn main() {
println!("Hello World!")
}

Zum Kompilieren benutzt man den Befehl build:

$ cargo build

und zum Ausführen run:

$ cargo run
Hello World!

oder ruft das erstellte Binary direkt auf:

$ target/debug/example

Zum Kompilieren mit Optimierungen:

$ cargo build --release

Diese sollten auf keinen Fall ausgelassen werden.

Das Setup lässt sich verwenden, um kleine Programme zu schreiben und Rust auszuprobieren.

Eine der Grundideen von Rust ist es, eine explizite Sprache zu bieten, deren Code klar aus einem kleinen Kontext erfassbar ist. Dazu ist es ein Ziel des Compilers, möglichst viele Fehler früh zu erkennen und zu beseitigen. Typinferenz findet zwar statt, endet aber an den Funktionsgrenzen. Generell bevorzugt Rust expliziten Code gegenüber impliziten Annahmen.

Eines der bemerkenswertesten Grundkonzepte von Rust ist Mutabilität. Entgegen den eher funktionalen Trends hin zu unveränderlichen Datenstrukturen geht Rust einen eigenen Weg: Mutabilität von Daten ist Teil aller Signaturen. Der Compiler kann dadurch die Mutation von Daten über das gesamte Projekt hinweg validieren und entsprechende Fehler aufzeigen. Wenn nicht anders angegeben, sind Daten "immutable". Im folgenden Beispiel wird eine Variable nachträglich zu verändern versucht:

fn main() {
let a = 1;
a = 2;
println!("{}", a);
}
$ cargo build
...
src/main.rs:3:5: 3:10 error: re-assignment of immutable variable 'a'
src/main.rs:3 a = 2;
^~~~~
src/main.rs:2:9: 2:10 note: prior assignment occurs here
src/main.rs:2 let a = 1;

Der Compiler zeichnet sich durch klare und gut strukturierte Fehlermeldungen aus. In weiteren Beispielen kürzen die Autoren sie aus Platzgründen.

Wer Daten ändern möchte, muss sie durch das Schlüsselwort mut als veränderbar markieren:

fn main() {
let mut a = 1;
a = 2;
println!("{}", a);
}

Dieser Code kompiliert, man handelt sich aber einen Warnung ein, da a nie zum Einsatz kommt, bevor es nicht verändert wird. Für das Beispiel ist diese Warnung noch uninteressant, sie gewinnt aber im Zusammenhang mit dem nächsten wichtigen Feature von Rust an Bedeutung.

Ein weiteres wichtiges Konzept von Rust ist das sogenannte Borrowing, das Verleihen von Daten. Rusts Typsystem garantiert folgende Regeln: Alle Daten haben einen besitzenden Scope. Er darf Referenzen auf die Daten "verleihen". Das darf exakt einmal veränderbar geschehen oder mehrfach unveränderbar. Veränderbare und unveränderbare Ausleihen schließen sich gegenseitig aus, es darf also entweder das eine oder das andere praktiziert werden. Alternativ lässt sich der Besitz auch abgeben, wenn die Daten nicht verliehen werden.

Das Konzept ähnelt den "unique pointers" aus C++. Es hat den logischen Effekt, dass keine Data Races auftreten können. Zu jeder Zeit hat nur exakt ein Programmteil verändernden Zugriff auf eine Speicherstelle. Diese Eigenschaft lässt sich in Rust statisch nachweisen, entsprechende Fehler werden also von Laufzeit- zu Kompilierfehlern. Sie gilt auch für komplexe Datentypen, so kann man eine unveränderbare Referenz auf eine Baumwurzel nicht in eine veränderbare verwandeln.

Rust bietet – wie D und Haskell – algebraische Datentypen, genannt Enums. Die wichtigsten beiden sind Option und Result. Aus der C-Welt kommend ersetzt Option in Rust den Nullpointer, Result tritt an die Stelle von Rückgabecodes. Beide zeichnen sich dadurch aus, dass sie unnötige Laufzeitkosten vermeiden. Hier ist Rusts Ziel, Abstraktionen ohne Kosten zu bieten, erkennbar.

Option wird durch den Compiler optimiert – bei Typen, für die 0 keine sinnvolle Darstellung ist, verschwindet der Typ zur Laufzeit komplett. Ansonsten werden die beiden Informationen (Anwesenheit und Wert) so klein verpackt wie sinnvoll möglich. Verpackt Option zum Beispiel Pointer-Typen, kompiliert der Datentyp direkt zu "Nullpointer" oder "Pointer" und verbraucht somit keinen weiteren Speicher.

fn main() {
use std::mem::size_of;

// Auf 64-Bit-Architekturen!
// 16 Byte, Kriterium + Wertbreite
println!("{}", size_of::<Option<i64>>());
// 8 Byte, 64 Bit
println!("{}", size_of::<i64>());
// 8 Byte, Länge von Box
println!("{}", size_of::<Option<Box<i32>>>());
// 8 Byte, Länge von Box
println!("{}", size_of::<Box<i32>>());
}

Result ist für Situationen gedacht, in denen immer ein Wert erwartet wird, alternativ aber ein Fehler auftreten kann. Da Rust keine Exceptions besitzt, wird fast immer mit dem Result-Typ gearbeitet.

use std::num::ParseIntError;

fn print_result(result: Result<i32, ParseIntError>) {
match result {
Ok(val) => println!("Value: {}", val),
Err(desc) =>
println!("Error! Description: {}", desc)
}
}

fn main() {
use std::str::FromStr;

print_result(
FromStr::from_str("123")
);
print_result(
FromStr::from_str("abc")
);
}

Rust setzt an dieser Stelle auf generische Typen, um möglichst viel Flexibilität zu bieten – Option ist generisch über den enthaltenen Typ, Result generisch über das erwartete Resultat und den spezifischen Error-Typen. Die Bibliotheken sind an dieser Stelle ergonomisch, Rust bringt zum Beispiel Methoden zum Umwandeln zwischen verschiedenen Error-Typen mit.

Beide Typen verwenden sich auch gut in Foreign Function Interfaces (FFI) zu C, Option wird hier zum Nullpointer, Result verpackt mit wenig Arbeit das typische C-Pattern mit Rückgabewert für Fehlercodes und (optional validem) Pointer.

Besonders interessant werden die oben beschriebenen Eigenschaften von Rusts Typsystem bei nebenläufigen Programmen. Das Verbot des mehrfachen Teilens mutabler Variablen ist bereits ein großer Teil davon. Darüber hinaus bietet Rust zwei sogenannte Marker: Send und Sync. Typen, die zwischen nebenläufigen Teilen des Programms (seien das nun Threads oder Coroutinen) gesendet werden können, sind Send – sie eignen sich für Message Passing. Kommen sie zum Einsatz, wechseln sie beim Versenden den Besitzer und verlieren ihre Gültigkeit beim Sender.

Da sich Rust als Systemsprache nicht auf einen Ansatz festlegen möchte, ist reines "shared nothing" nicht immer gewünscht. Hier hilft Sync: Der Marker kennzeichnet Typen, die auf irgendeine Art synchronisiert sind, zum Beispiel durch einen Mutex. Referenzen auf Sync-Typen lassen sich dann wieder sicher teilen. (Oder theoretischer: Referenzen auf Sync-Typen erfüllen auch Send.)

Rust besitzt keine Garbage Collection. Stattdessen modelliert der Compiler die Gültigkeit aller Variablen und stellt alle Deallokationen statisch fest. Rust nennt das "Lebenszeiten". Der Deallokationszeitpunkt ist vorhersagbar und definiert – in dem Moment, indem die Variable aus dem Scope fällt. Dadurch wird eine Sprachen mit Garbage Collector gleiche Speichersicherheit erreichen, ohne eine Laufzeitsystem nötig zu machen. Allerdings ist diese Modellierung nicht immer vollständig automatisch möglich, gerade bei Verbindungen von Datenstrukturen sind Annotationen der Art "diese Struktur kann nur Pointer halten,
die länger leben als sie selbst" nötig.

Die Standardbibliothek bietet darüber hinaus Implementierungen für viele Standardherangehensweisen an Speichermanagement ohne automatische Garbage Collection, zum Beispiel eine Reference-Counting-Implementierung.

Alle Aspekte von Rust komplett zu beschreiben, würde den Rahmen dieses Artikels sprengen, dennoch seien einige nicht unerwähnt. Rusts Modell bietet zu reinen Funktionen noch Traits an, die ähnlich denen aus Scala die Möglichkeit bieten, generische Funktionen über konkrete Datentypen zu implementieren. Andere funktional inspirierte Eigenschaften wie Closures gibt es ebenfalls. Innovativ ist Rust dadurch, dass die Sprache soweit wie möglich Situationen verhindert, in denen dynamischer Dispatch oder Aufruf durch einen Funktionszeiger notwendig wird – das Ziel ist immer noch C. Das FFI von Rust ermöglicht das Erstellen C-ABI-kompatibler Bibliotheken, die Sprache lässt sich also als direkter, sicherer Ersatz von C verwenden. Dazu bietet Rust eine unsichere Subsprache (markiert durch das Schlüsselwort "unsafe"), in der einige Prüfungen nicht stattfinden und die zum Beispiel unsichere Casts erlaubt. Der meiste Rust-Code ist allerdings in sicherem Rust geschrieben.

Das Rust-Projekt sieht Dokumentation als "First-Class Citizen" an. Steve Klabnik, unter anderem bekannt durch seine Arbeit an Rails und seinen Blog-Artikeln auf "Rust for Rubyists [6]", wurde von Mozilla als Dokumentationsautor für Rust angestellt. Spätestens seit seinem Engagement wird die Dokumentation von Sprache und Standardbibliothek liebevoll gepflegt und pünktlich aktualisiert. Er ist außerdem Autor des Online-Buchs "The Rust Programming Language [7]", das mit ausführlichen Erklärungen und Beispielen den Einstieg erleichtern soll.

Rust-Code kann spezielle rustdoc-Kommentare im Markdown-Format enthalten, die, ähnlich wie Javadoc, die Nutzung und Funktionsweise von Bibliotheken beschreiben. Aus diesen Kommentaren lässt sich mit dem Befehl cargo doc auch eine HTML-Seite mit vollständig verlinkter und durchsuchbarer Dokumentation generieren. Selbst die Rust-Standardbibliothek ist vollständig mit rustdoc dokumentiert.

Eine Besonderheit von rustdoc sind die sogenannten Doctests. In eine rustdoc lässt sich nicht nur Markdown, sondern auch Beispiel-Code einfügen. Dieser wird dann durch den Befehl cargo test ausgeführt. Es ist gängige Praxis, rustdoc-Beispiele mit assert-Anweisungen zu versehen, die sicherstellen, dass alles korrekt funktioniert. Das wird unter der Kategorie Doctest in der Ausgabe angezeigt.

/// Multiplies two integers
/// # Examples
/// '''
/// let x = multiply(2,2);
/// assert_eq!(x, 4);
/// '''
fn multiply(x: i32, y: i32) -> i32 {
x * y
}

"Diagnostiken sind die User Experience eines Compilers und verdienen genau dieselbe Aufmerksamkeit, die einer Mobil-Applikation oder Web-Design gewidmet wird", sagt Patrick Walton, einer der Köpfe hinter Rust. Entsprechend bietet der Rust-Compiler etliche Hilfestellungen. Die Konsolenausgabe ist gut strukturiert und weist nicht nur auf den Fehler, sondern auch auf die mögliche Ursache hin. Deprecations besitzen üblicherweise einen kleinen Hinweis, warum die Änderung gemacht wurde und was stattdessen verwendet werden sollte.

Der Compiler hat eine ganzen Menge nützlicher und gut erklärter Lints an Bord. Zum Beispiel löst ein nicht weiterverwendetes Result eine Warnung aus – es könnte ja ein Fehler aufgetreten sein, der sonst vergessen würde. Dazu lässt sich die Warnung auch pro Bibliothek in einen Fehler verwandeln.

Aber nicht nur vermutliche Fehler sind abgedeckt: Optional ist es auch, Dokumentation verpflichtend zu machen. Zukünftig geplante (und in den Entwicklungsversionen verfügbare) Features sind die Möglichkeit, Plug-ins für den Compiler zu entwickeln, um zum Beispiel auch Autokomplettierung auf Basis des Typsystems anzubieten.

Obwohl viele Rust-Bibliotheken bis vor einigen Monaten stets die neueste Entwicklungsversion des Compilers verlangten, fühlt sich die Sprache dennoch stabil an. Das hat mehrere Gründe: Obwohl sich Rust im letzten Jahr massiv veränderte, wurden damals schon alle signifikanten Änderungen durch einen RFC-Prozess [8] dokumentiert und diskutiert. Auch Deprecations wurden in dieser Zeit ordentlich durchgeführt, häufig mit gut geschriebenen Meldungen, wodurch das Feature ersetzt wurde. Regelmäßig gab es Releases, auch wenn diese nur Training für den Release-Prozess waren. Das rundet eine seit Dezember anhaltende Alpha- und Beta-Phase mit regelmäßigen Releases ab.

Die Stabilität zeigt sich vor allem auch darin, dass inzwischen viele Bibliotheken die Beta-Versionen verwenden – bald wird man alles Wichtige auf 1.0.0 portiert sehen.

Der Rust-Compiler soll regelmäßig mit neuen Updates und unter Umständen neuen Features versorgt werden. Das wird in dem von Chrome, Firefox und Ember.js praktizierten Release-Modell geschehen:

Das Ziel ist es, die Community in kleinen Schritten an Änderungen heranzuführen. Der Portieraufwand soll gering gehalten werden, um große Umbauarbeiten zu neuen Versionen zu verhindern. Gleichzeitig geht Rust aber strikte Abwärtskompatibilitätsgarantien ein: Was einmal stabil war und veröffentlicht wurde, wird nicht mehr entfernt. Wie gut sich diese Strategie mit den Release-Prozessen der Linux-Distributionen verträgt, wird die Zukunft zeigen.

Ein viel genannter Makel des Ökosystems war bisher die Instabilität der Sprache selbst. Viele Features und sogar vollständige Standardbibliotheken wurden nach langen Diskussionen völlig entfernt oder neu geschrieben. Wer früh zur Sprache gekommen war, war es gewohnt, nach jedem Update auf die neueste Sprachversion Programme und Bibliotheken an die Änderungen anzupassen, bevor sie wieder kompilierten.

Die Kompromisslosigkeit des Rust-Teams während der Entwicklungsphase hat in hohem Maße zur Qualität der Sprache beigetragen. Dennoch hielten die häufigen "breaking changes" gerade Unternehmen bisher von der Adaption ab. Es wird sich zeigen, ob das neue Stabilitätsversprechen von Rust 1.0 das Vertrauen von Industrie und Open-Source-Basis vergrößern kann.

Trotz der lange vorherrschenden Instabilität konnten sich einige Rust-Projekte etablieren. Hier zeigt sich, wo der Kern-Anwendungsbereich von Rust in Zukunft liegen könnte: In den Bereichen Browserentwicklung, Spieleentwicklung, Embedded Systems und als Endpunkt einer nativen Schnittstelle mit Skriptsprachen.

Servo [9] ist eine neuartige Browser-Engine, geschrieben in Rust und finanziert von Mozilla, teilweise ist auch Samsung an dem Projekt beteiligt. Sie setzt es sich zum Ziel, jeden technischen Aspekt eines Webbrowsers von Grund auf zu überdenken und zu überarbeiten. Die Kernpunkte sind ein Höchstmaß an Nebenläufigkeit sowie vollständige Eliminierung von Problemen wie Speicherlecks oder Sicherheitslücken, die durch falsche Speicherverwaltung hervorgerufen werden.

Das Team hinter Servo ist der Meinung, dass herkömmliche Browser mit C++-basierter Architektur und eingefahrener Codebasis diese Herausforderungen nicht mehr lösen können. Stattdessen starten sie von vorne und setzen auf Rust, das Speichersicherheit und Parallelismus ohne Data Races garantiert und dabei vergleichbare Geschwindigkeit wie C++ bietet. Das ermöglicht Servo, sich auf die Implementierung einer performanten Browser-Engine zu konzentrieren, ohne Angst vor den meisten Problemen, mit denen die Entwicklungsteams der etablierten Browser wie Firefox oder Chrome seit Jahren kämpfen.

Servo rendert bereits komplexe Webseiten in zufriedenstellender Qualität. Das Projekt nutzt Cargo als einzige Build-Toolchain und stellt alle Bibliotheken einzeln zur Verfügung.

Skylight [10] ist ein Werkzeug zur Performance-Analyse von Rails-Applikation. Die erste Version wurde in Ruby geschrieben, doch dann stellte Erfinder Yehuda Katz, mittlerweile Mitglied des Rust-Teams, ungewöhnlich hohen Speicherverbrauch der Applikation auf Kundenservern fest. Die Versuche, den Ruby-Code performanter zu gestalten, erzielten nur mäßigen Erfolg.

Katz hatte mit Ruby, einer dynamisch typisierten Sprache mit Garbage Collection, zu wenig Kontrolle über den Systemspeicher und entschied sich zu einem Experiment. Er schrieb die speicherkritischsten Komponenten seiner Applikation in Rust – offenbar mit durchschlagendem Erfolg. Rust wurde fester Bestandteil von Skylight, Katz und Mitgründer Tom Dale zu aktiven Fürsprechern der Sprache.

Rust zeichnet sich für Skylight durch seine Fähigkeit aus, seine eigene Maschinennähe nur bei Bedarf zur Verfügung zu stellen und nahezu immer sicheres und produktives Programmieren performanter Applikationen zu ermöglichen. Katz meint, ohne Rust würde Skylight vielleicht nicht mehr existieren. Es wären Qualitätsstandards "wie bei der NASA" nötig, um C++-Code an die Stabilität von Rust heranzubringen. Besondere Sorge unter C++ bereitete ihm die Möglichkeit von Segmentierfehlern, die die gesamte Kundenapplikation zum Absturz bringen würden.

Technologien sind selten von ihren Communitys zu trennen. Bei Rust ist die Community offen, freundlich und insbesondere aktiv. Zum Treffen der ersten Rust-Usergruppe in Berlin kamen beispielsweise 80 Menschen, und die regelmäßigen Übungsgruppen sind gut besucht.

Bemerkenswert ist es, dass sich das Team hinter Rust für einen Community Code of Conduct [11] entschieden haben, der nicht nur niedergeschrieben, sondern auch gelebt wird. Sowohl das Subreddit [12]- als auch das offizielle Userforum [13] zeichnen sich dank Moderationsteams durch einen guten Umgangston aus, in dem aber auch gerne mal herzhaft gestritten wird, solange der Streit sachlich bleibt. Code-Reviews [14] werden außerdem gerne und freundlich durchgeführt.

Spannungen bleiben, insbesondere, da viele der Rustaceans der ersten Stunde unterschiedliche Vorstellung von der Sprache haben. Gerade die Ausgangssituation zwischen Menschen, die aus dynamischen Sprachen stammen und Rust vor allem zum Implementieren von Bibliotheken verwenden wollen, und denen, die Rust einfach als besseres C++ sehen, führt regelmäßig zu Meinungsverschiedenheiten.

Ein gängiger Witz in der Rust-Community ist: "Go vs. Rust? Nun, das eine ist ein Brettspiel, das andere eine Programmiersprache". Keiner will damit Go schlechtreden, nur werden die beiden Sprachen sehr häufig miteinander verglichen, obwohl sie wenig gemeinsam haben. Nicht ohne Grund: Als Rust zum ersten Mal vorgestellt wurde, ähnelte die Sprache Go durchaus. Sie besaß Interfaces, ein Goroutinen ähnliches Tasksystem und sogar einen Garbage Collector. Auch Channels waren ein Sprachelement und kein Bibliotheksfeature. Gerade diese initiale Ähnlichkeit zu Go hat wohl auch dafür gesorgt, das Rust sich bewusst in eine andere Richtung bewegt hat. Heute sollte man Rust eher im Vergleich zu C/C++ sehen, denn genau in deren Feld will Rust auch hineinwachsen.

Rust hat sich über die letzten zwei Jahre von einer interessanten, wenn auch etwas ziellosen zu einer scharf fokussierten, maschinennahen Sprache gemausert, die C und C++ ernstzunehmende Konkurrenz macht. Sie bedient vor allem ein Feld, das in den letzten Jahren eher wenig Innovationen zu bieten hatte: laufzeitlose Sprachen, die sich gut einbinden lassen und zu Maschinencode kompilieren. Der neu gewonnene Fokus zeigte sich letztes Jahr in Form einer Löschorgie, in der viele halbgare und experimentelle Features entfernt wurden. Das Team hinter Rust sendet damit und mit dem rigiden Veröffentlichungsrythmus vor allem eine Nachricht: Rust ist hier und will verwendet werden.

Florian Gilcher [15]
ist Geschäftsführer der asquera GmbH [16] und Vorstand des Ruby Berlin e.V. [17] Er entwickelt seit anderthalb Jahren in Rust und hilft beim Berliner Rust-Meetup [18] aus.

Johann Hofmann [19]
studiert Informatik in Berlin und arbeitet für das Privacy-Startup ZenMate [20]. Er organisiert das Berliner Rust-Meetup [21].
(ane [22])


URL dieses Artikels:
https://www.heise.de/-2649509

Links in diesem Artikel:
[1] http://www.rust-lang.org/
[2] https://github.com/graydon
[3] http://en.wikipedia.org/wiki/Rust_%28fungus%29
[4] https://crates.io/
[5] http://bundler.io/
[6] http://www.rustforrubyists.com/
[7] https://doc.rust-lang.org/nightly/book/
[8] https://github.com/rust-lang/rfcs
[9] https://github.com/servo/servo
[10] https://www.skylight.io/
[11] http://www.rust-lang.org/conduct.html
[12] http://www.reddit.com/r/rust
[13] https://users.rust-lang.org/
[14] http://www.reddit.com/r/rust/comments/29w2mp/code_review_port_of_asciinemaorgs_terminalc
[15] http://github.com/skade
[16] http://asquera.de
[17] http://www.ruby.berlin/
[18] http://www.meetup.com/Rust-Berlin/
[19] http://github.com/johannhof
[20] https://zenmate.com/
[21] http://www.meetup.com/Rust-Berlin/
[22] mailto:ane@heise.de