Rust 1.0 – Alte Liebe rostet nicht

Frank Müller

Alte Liebe nicht, aber manchmal eine junge. Die Sprache Rust hat am 15. Mai die Version 1.0 erreicht und kann sich damit nicht gerade eines hohen Alters rühmen. In Jahren ausgedrückt sind es sechs, die der Mozilla-Mitarbeiter Graydon Hoare und sein Team in die Entwicklung ihrer Alternative zu C und C++ gesteckt haben.

Ursprünglich wirkte Graydon Hoare in C++-Projekten mit. Performance, Speicherverwaltung und die Erzeugung von Binärprogrammen waren die Motivation. Jedoch lernte er hier gerade bei der Entwicklung nebenläufiger Programme die größten Schwächen kennen. Hier ist insbesondere die Speicherverwaltung zu nennen. So kommen ungültige oder nicht gesetzte Pointer zum Einsatz, Speicher wird reserviert, aber nicht freigegeben, Schreibvorgänge halten sich nicht an die für sie vorgesehenen Grenzen, Variablen werden trotz vorgesehener Unveränderlichkeit doch neu geschrieben und zwischen nebenläufigen Bestandteilen des Programms kommt es zu Race Conditions. Hieraus entstand dann der Wunsch nach einer zu C und C++ ähnlichen Programmiersprache, ergänzt um moderne Sprachkonstrukte und eine sichere Speicherverwaltung auf der Basis des Ownerships.

Multiparadigmensprachen – Rust zwischen C und Go

Die Sprache selbst ist wie einige andere aktuelle Vertreter eine Multiparadigmensprache mit einer starken Nähe zu C. Hierin finden sich jedoch Funktionen als Datentypen und Closures wieder, Pattern Matching und Makros sind ebenfalls vorhanden. Auch wenn Rust keine Klassen im Sinne der Objektorientierung kennt, so verfügt die Sprache dennoch über die Möglichkeit, Methoden für Typen zu definieren und die Sichtbarkeit zu steuern. Dazu kommen noch Traits und Generics. Für die Nebenläufigkeit setzt Rust jedoch nicht auf die von Erlang und Go bekannten, leichtgewichtigen Green Threads plus Messaging oder Channels für die Kommunikation. Hier kommen native Threads gemeinsam mit Channels und Typen zur Synchronisation in der Standardbibliothek zum Einsatz.

Funktionen werden ähnlich wie in Go über ein Schlüsselwort deklariert und eventuelle Rückgabewerte folgen nach den Argumenten. Gleiches gilt für die Deklaration von Variablen. Dies unterscheidet sich von C und ist eher mit Go vergleichbar. Das Beispiel in Listing 1 zeigt bei der Konstanten who, dass eine Typenangabe nicht zwingend notwendig ist. Vielmehr wird der Typ hier aus der Zuweisung abgeleitet. Das Beispiel count zeigt hingegen, wie der implizite Typ überschrieben werden kann. Die beiden Deklarationen unterscheiden sich zudem im Schlüsselwort mut. Es steht für mutable, erzeugt also eine veränderbare Variable. Fehlt es, so ist die Variable unveränderlich. Als Basistypen stehen Integerzahlen mit und ohne Vorzeichen mit 8, 16, 32 und 64 Bit, Fließkommazahlen mit 32 und 64 Bit, Boolean, Zeichen in UTF-32-Kodierung und Strings. Weitere Typen sind Tupel, Arrays und Slices, Strukturen, Enumerationen, rekursive Typen, Funktionen und Traits. Deklarierte Variablen müssen immer genutzt und vor der Nutzung gesetzt werden. Andernfalls moniert der Compiler dies mit einer Warnung oder gar einem Fehler.

Listing 1

fn main() {
    let who = "World";
    let mut count: u32 = 10;

    for i in 1..count {
        greet(who)
    }
}

fn greet(who: &'static str) {
    println!("Hello, {}!", who)
}

Ausdrücke in Rust geben immer einen Wert zurück, also anders als in C zum Beispiel auch das if. Dies erlaubt Zuweisungen der Form let s = if x > y { „foo“ } else { „bar“ }. Hierbei wird immer der Wert des letzten Ausdrucks eines Blocks zurückgegeben. Dies gilt auch bei der Mehrfachauswahl mit match. Es übernimmt die Aufgabe des switch in C, geht aber weit darüber hinaus. Der Name leitet sich aus dem Pattern Matching ab und erlaubt wie in Listing 2 gezeigt die Analyse von Alternativen, Bereichen und Enumerationen zusammen mit Bedingungen. Für Schleifen kennt Rust unterschiedliche Konstrukte. So bietet sich loop für Endlosschleifen an, while für Schleifen, die an eine Bedingung geknüpft sind und for für die Verarbeitung der Werte von Iterator-Implementierungen.

Listing 2

let message = match x {
    0 | 1   => "not many",
    2 ... 9 => "a few",
    _       => "lots"
};

let foo = match bar {
    Some(x) if x < 10 => process_small(x),
    Some(x)           => process_other(x),
    None              => panic!()
};

Wie bereits gezeigt, werden Funktionen über das Schlüsselwort fn eingeleitet. Sofern auch eine Rückgabe deklariert wurde, ergibt sich diese aus dem letzten Ausdruck der Funktion. Listing 3 zeigt dazu, wie eine Funktion vorzeitig mit dem Schlüsselwort return beendet werden kann. Dabei kann immer nur ein Wert zurückgegeben werden. Ist dies nicht hinreichend, dann bietet sich der Einsatz von Tupeln an. Eine andere Art von Funktionen sind die bereits erwähnten Closures, die über eine kompakte Notation der Form let square = |x| x * x; verfügen. Dies ermöglicht auch elegant-funktionale, hier im Beispiel zugegebenermaßen auch sinnfreie Konstrukte, wie let s = (0..5).map(|x| x * x * x).filter(|n| n % 2 == 1).sum();.

Listing 3
fn min(a: i32, b:i32) -> i32 {
    if a < b {
        return a
    }
    b
}

Freunde der Objektorientierung sollten sich hier jedoch nicht grämen, Rust bietet auch hier wesentliche Aspekte dieses Paradigmas. Die Basis bilden hier in der Regel Strukturen, können jedoch auch andere eigene Datentypen sein. Für diese können dann über das Schlüsselwort impl Konstruktoren und Methoden definiert werden. Mittels pub wird dabei gesteuert, ob diese öffentlich sichtbar sind.

Listing 4 zeigt, dass sich die Methoden von Funktionen ansonsten nur durch self als erstem oder einzigem Argument. Es erlaubt den Zugriff auf die eigene Instanz. Die unterschiedlichen Spielformen self, &self und &mut self steuern dabei, ob und wie eine Veränderung der Instanz erlaubt ist. Die hier genutzte Bezeichnung new für den Konstruktor ist nicht zwingend, hier kann der Entwickler frei wählen.

Die oftmals als zwingend mit der Objektorientierung zu bietende Vererbung kennt Rust nicht, Mittel der Wahl ist hier die Komposition. Dafür lassen sich neben konkreten Typen auch Traits definieren und als Typen für Argumente, Rückgabewerte und Generics einsetzen. Dies erhöht die Flexibilität und Wartbarkeit der Sprache gleichermaßen. Die Implementierung der Traits erfolgt explizit in der Form impl MyTrait for MyType { … }. Die Standardbibliothek der Sprache bietet dabei auch einige Traits, bei deren Implementierung der Compiler Operatoren direkt in Funktionsaufrufe umsetzt, so bei der Implementierung von std::ops::Mul` aus `a * b` ein `a.mul(b).

Listing 4

mod store {
    pub struct Stack { ... }

    impl Stack {
        pub fn new() -> Stack {
            Stack { ... }
        }

        pub fn push(&mut self, v: T) { ... }

        pub fn pop(&mut self) -> T { ... }
    }
}

let int_stack = store::Stack::new();

Auf Nummer sicher

Einige Sicherheitsaspekte beim Zugriff auf Variablen wurden bereits genannt, das wichtigste fehlt jedoch noch. Der Eigentümer einer Variablen ist in Rust sehr genau festgelegt, eine gleichzeitige Veränderung durch unterschiedliche Threads wird so vermieden. Vielmehr muss der Entwickler sehr genau steuern, wenn der Eigentümer wechseln soll. Ohne diese Maßnahmen kommt es hingegen bereits beim Übersetzen eines Programms wie in Listing 5 zu einer Fehlermeldung.

Um dies zu vermeiden, könnte print_something() zum Beispiel den empfangenen Wert am Ende wieder zurückgeben. Dann könnte er einer neuen oder der ersten, in diesem Fall mit mut deklarierten Variablen, zugewiesen werden. Alternativ kann das Argument der Druckfunktion als &Something deklariert werden. So werden dann übergebene Variablen an die Funktion verliehen und stellen am Ende deren Scopes wieder zur Verfügung.

Dies ist jedoch nur für ein Lesen hinreichend, eine Veränderbarkeit erfordert eine Deklaration mit &mut Something. Diese genaue Kontrolle der Lesbarkeit und Veränderbarkeit von Variablen wird insbesondere in nebenläufigen Programmen wichtig. Hier lässt Rust keine gleichzeitige Veränderung einer Variablen zu.

Listing 5

// s wird konsumiert aber nicht zurück gegeben.
fn print_something(s: Something) {
	...
}

fn main() {
	let something = create_something();
	// Übergabe von something an print_something.
	print_something(something);
	// Ungültig, daher Fehlermeldung.
	process_something(something);
}

Mit dem Anspruch, dem Entwickler maschinennah die volle Kontrolle über das System zu geben, nutzt Rust native Threads für die Realisierung von Nebenläufigkeit. Hierbei handelt es sich um Closures, die mittels std::thread.spawn() gestartet werden. Für die Kommunikation bietet die Standardbibliothek mit std::sync::mpsc::channel zu Go ähnliche Channels, nur nicht als nativen Bestandteil der Sprache. Sie werden über Generics typisiert und bei der Erzeugung werden ein Empfangs- und ein Sendekanal als Tupel zurückgegeben. Dazu kommen weitere helfende Typen und Funktionen für den Umgang mit Threads und den sicheren Austausch von Daten zwischen ihnen.

Fazit

Rust ist eine junge Sprache mit einem sehr genauen Fokus. Wie Graydon Hoare betont, konzentriert sich seine Sprache auf die Kombination einer sehr hohen Ausführungssicherheit bei gleichzeitiger Systemnähe. Damit differenziert sich Rust von allen Sprachen auf der Basis virtueller Maschinen, wie zum Beispiel Erlang,Scala und Clojure, aber auch vom anfangs oft zum Vergleich herangezogenen Google Go.

Letztere agiert zwar auch näher am System als die VM-Sprachen und erzeugt ein Binärprogramm wie Rust. Jedoch bringt sie eine eigene auf Green Threads, den Goroutinen, basierende Runtime mit. Damit liegt der Fokus von Go noch mehr auf der Entwicklung nebenläufiger Programme als Rust. Den Schutz der Daten durch den Ownership bietet Go hingegen nicht. Hier ist der Entwickler gefragt, sauber die gemeinsame Nutzung von Daten über Channels oder Bibliotheksfunktionen zu kontrollieren. Gezwungen wird er dabei allerdings nicht.

Aus Sicht der Werkzeuge ist Rust ebenfalls noch jung. So liefert die Installation den Compiler rustc sowie das Dokumentationswerkzeug rustdoc. Dieses erzeugt jedoch nicht nur eine Dokumentation aus den Kommentaren im Quelltext, sondern kann auch hierin eingebettet Tests ausführen. Ein wichtiger Bestandteil des Systems Cargo, welches Projekte inklusive Abhängigkeiten von externen Bibliotheken in exakten Versionen verwaltet. Dazu kommt eine Site mit der Bereitstellung von Bibliotheken für die Nutzung in eigenen Programmen.

Rust ist eine besondere Sprache. Ein Blick in die Bibliotheksfunktionen zeigt, dass die Sicherheit nicht kostenlos ist. Sie bringt einen gewissen Overhead. Dieser zahlt sich jedoch für Programme mit einer sehr hohen Sicherheitsanforderung sowie für die Vermeidung einer späteren Fehlerbehebung auf. Insofern wird Rust sicherlich seine Nutzer finden.

Aufmacherbild: Old rusty nut on white background von Shutterstock.com / Urheberrecht: Aggie 11

Geschrieben von
Frank Müller
Frank Müller
Der Oldenburger Frank Müller ist seit über dreißig Jahren in der IT zu Hause und im Netz vielfach als @themue anzutreffen. Das Interesse an Googles Sprache Go begann 2009 und führte inzwischen zu einem Buch sowie mehreren Artikeln und Vorträgen zum Thema.
Kommentare

Hinterlasse einen Kommentar

Hinterlasse den ersten Kommentar!

avatar
4000
  Subscribe  
Benachrichtige mich zu: