zurück zum Artikel

Programmiersprache: Rust für Neugierige

Stefan Baumgartner

(Bild: Dilok Klaisataporn/Shutterstock.com)

Rust ist stark im Aufwind. Rust-Neulinge lernen in diesem Artikel die Gründe dafür sowie die einzigartigen Fähigkeiten der Programmiersprache kennen.

Die Programmiersprache Rust wurde in diesem Jahr erneut zur beliebtesten in der jährlichen Stack-Overflow-Entwicklerumfrage [1] gewählt – zum achten Mal in Folge. Doch nicht nur Entwicklerinnen und Entwickler schätzen die Programmiersprache, sondern auch die Industrie schenkt ihr Beachtung. Große Unternehmen wie Microsoft, AWS und Google setzen in vielen Bereichen auf ihre einzigartigen Eigenschaften. Was macht diese Sprache so beliebt?

Rust wurde Anfang der 2010er Jahre von Graydon Hoare bei Mozilla im Rahmen der experimentellen Browser-Plattform Servo entwickelt. Das grundlegende Ziel war es, das Erstellen neuer Browserfunktionen zu ermöglichen und im gleichen Zug die Probleme der Speicherverwaltung und Speicherzuweisung von C und C++ zu beseitigen. Ähnlich wie die Programmiersprache Go sollte Rust performante Software ermöglichen und obendrein sollte die Entwicklung Spaß machen.

Dabei weist Rust Einflüsse aus vielen anderen Programmiersprachen auf. Die Syntax ähnelt stark C, aber es sind auch Spuren funktionaler Programmiersprachen vorhanden, insbesondere OCaml, worin die erste Version des Rust-Compilers geschrieben wurde.

pub fn score(word: &str) -> u64 {
    let mut score = 0;
    for ch in word.to_lowercase().chars() {
        score = score + match ch {
            'a' | 'e' | 'i' | 'o' | 'u' | 'l' | 'n' | 'r' | 's' | 't' => 1,
            'd' | 'g' => 2,
            'b' | 'c' | 'm' | 'p' => 3,
            'f' | 'h' | 'v' | 'w' | 'y' => 4,
            'k' => 5,
            'j' | 'x' => 8,
            'q' | 'z' => 10,
            _ => 0
        }
    }
    score
}

Listing 1: Berechnung eines Scrabble-Scores

Listing 1 zeigt eine Rust-Funktion, die die Punkte für ein Wort in einem Scrabble-Spiel berechnet. Interessanterweise ist dieser Codeausschnitt genauso lesbar wie entsprechender Python-Code, enthält aber auch Bequemlichkeitsfunktionen wie Ausdrücke.

Diese ermöglichen es, einen match-Ausdruck direkt neben der Addition von zwei Zahlen zu haben. Der match-Ausdruck selbst ermöglicht Pattern Matching über eine Reihe von gültigen Werten. In diesem Fall geht das Pattern Matching alle möglichen Werte des char-Werteraums durch. Das Schlüsselwort match verlangt von der Anwendung, alle möglichen Werte zu behandeln. Da es mehr Zeichen als die des Alphabets gibt, kann ein Standardfall mit dem Unterstrichzeichen alle verbleibenden Werte abfangen.

Neben der neuen und modernen Syntax besticht Rust durch weitere Eigenschaften. Das Projekt "Rust in Linux" listet einige Funktionen auf, die die Sprache zu einer wertvollen Ergänzung des Linux-Kernels machen:

Dank dieser Eigenschaften hat Rust das Interesse großer Unternehmen auf sich gezogen. Teile der AWS-Infrastruktur wie Lambda oder Fargate sind in Rust geschrieben. Mit den neuesten Versionen von Windows wurde Rust ebenfalls ausgeliefert und Teile von Microsoft Azure sind in Rust geschrieben. Das Android-Projekt verfügt bereits über rund 1,5 Millionen Zeilen Rust-Code, und das Google-Chrome-Projekt arbeitet daran, Rust ebenfalls an vorderster Stelle einzusetzen.

Um zu verstehen, warum diese Big-Tech-Unternehmen Rust verwenden, hilft ein genauerer Blick auf drei wesentliche Funktionen:

Jahrzehntelang waren die am häufigsten verwendeten Programmiersprachen in zwei Lager unterteilt: Zum einen war es möglich, Software in Programmiersprachen mit automatischem Speichermanagement zu schreiben. Dazu gehörten Skriptsprachen wie Ruby, Python oder JavaScript, Virtual-Machine-basierte Sprachen wie C# oder Java und einige nativ kompilierte Sprachen wie Go. Zum anderen konnten Entwickler Speicher selbst verwalten, so wie es in C oder C++ möglich ist. Das erlaubte die Entwicklung sehr performanter Software, da keine Laufzeitumgebung erforderlich war, um über Garbage Collection den Speicher zu verwalten.

Rust verfolgt jedoch eine andere Strategie: Es setzt auf eine Speicherzuweisung zur Kompilierzeit, die auf einem strengen Regelwerk basiert, dem Entwicklerinnen und Entwickler folgen müssen. Das erfordert etwas Lernaufwand, führt aber zu hundertprozentig speichersicheren Anwendungen. Und Speichersicherheit ist entscheidend: Die Verwendung von Speicher nach dessen Freigabe, doppelte Speicherfreigaben oder Pufferüberläufe und -überschreibungen führen zu Sicherheitslücken, die sich leicht ausnutzen lassen.

Microsoft [2] und Google [3] haben in Windows und im Chrome-Projekt jeweils 70 Prozent aller schweren Sicherheitsprobleme als Speicherprobleme entlarvt. Eine Programmiersprache, die ohne einen Performance verschlingenden Laufzeit-Garbage-Collector vollständig speichersicheren Code erstellt, kann viele Probleme beseitigen. Das bestätigt auch das Android-Projekt: Im Dezember 2022 waren bereits 20 Prozent des nativen Codes von Android in Rust geschrieben, und bis heute sind im Rust-Code von Android keine Speichersicherheitsprobleme aufgetreten [4].

Rust erreicht die Speichersicherheit durch sein "Ownership and Borrowing"-System. Dessen grundlegendes Prinzip besagt, dass es nur einen Besitzer (Owner) von Daten geben kann. Wenn ein Wert einer Variable zugewiesen wird, wird diese Variable zum Besitzer. Wenn der Besitzer den Gültigkeitsbereich verlässt, wird der Speicher freigegeben. Die Eigentümerschaft (Ownership) lässt sich jedoch übertragen.

Das Beispiel in Listing 2 zeigt ein typisches Szenario, das in einer Programmiersprache wie Java oder JavaScript kein Problem darstellen würde, aber in Rust zu Kompilierfehlern führt. In dem Moment, in dem der Variablen other_numbers der Wert von numbers zugewiesen wird, hat die Variable numbers keinen Wert mehr. other_numbers besitzt die Daten, und der Compiler gestattet Entwicklern nicht, das ursprüngliche Binding weiter zu verwenden.

fn main() {
    let numbers = vec![1, 2, 3, 4];
    let other_numbers = numbers;
    println!("{:?}", numbers);
}

Listing 2: Ein Wert, auf den zwei Variablen zeigen. Dieses Beispiel wird nicht kompiliert.

Allerdings bietet der Rust-Compiler genaue Informationen darüber, was geschehen ist, und bietet in der Regel auch Lösungsvorschläge, wie die Abbildung zeigt.

Der Rust Borrow Checker lässt nicht zu, dass der gleiche Wert an zwei Variablen hängt. Das Ownership-Modell braucht immer einen eindeutigen Besitzer. In diesem Beispiel geht der Besitz der Werte von numbers an other_numbers.

Der Rust Borrow Checker lässt nicht zu, dass der gleiche Wert an zwei Variablen hängt. Das Ownership-Modell braucht immer einen eindeutigen Besitzer. In diesem Beispiel geht der Besitz der Werte von numbers an other_numbers.

In der Programmierung ist es oft notwendig, mehrere Zugriffe auf die gleichen Daten zu gestatten, weshalb es einschränkend sein kann, immer nur einen Besitzer zu haben. Daher erlaubt Rust das Ausleihen (Borrowing) von Daten durch das Erstellen von Referenzen, die auf die gleichen Daten zeigen. Das ursprüngliche Binding behält dabei weiterhin die Eigentümerschaft. Listing 3 zeigt, wie man Referenzen erstellt.

fn main() {
    let numbers = vec![1, 2, 3, 4];
    let other_numbers = &numbers;
    println!("{:?}", numbers);
}

Listing 3: Ein funktionierendes Beispiel mit Referenz

Das kaufmännische Und (&) gehört zum Typ. Es zeigt exakt, was die Funktion erwartet, beispielsweise wenn sie in Funktionssignaturen Referenzen deklariert. Dann überprüft der Rust-Compiler, ob die Variablen, die im Besitz der Daten sind, lange genug leben und nicht außerhalb des Gültigkeitsbereichs verschwinden, bevor die Referenzen dies tun. Wird ein Verweis verwendet, nachdem der Besitzer verworfen wurde, lässt Rust den Code nicht kompilieren.

Das Verändern von Daten erfordert das Deklarieren einer Bindung als veränderlich (mutable, kurz mut). Außerdem sind veränderliche Referenzen zu erstellen, wenn andere Teile des Codes die Daten des Besitzers ändern sollen, wie in Listing 4 gezeigt. Es können mehrere geteilte Referenzen vorhanden sein, aber nur eine veränderliche Referenz. Der Grund liegt darin, dass bei der Änderung von Daten durch die veränderliche Referenz eine neue Allokation entstehen könnte, worduch die geteilten Referenzen auf einen falschen Wertebereich verweisen würden. Rust stellt sicher, dass kein Zeiger auf Daten zeigt, die ungültig werden könnten.

fn append(vec: &mut Vec<u32>) {
    vec.push(5);
}
fn main() {
    let mut numbers = vec![1, 2, 3, 4];
    append(&mut numbers);
    println!("{:?}", numbers);
}

Listing 4: Veränderlicher Zugriff auf numbers zur Änderung der Daten

Der wichtigste Aspekt von Ownership und Borrowing ist, dass im Code deutlich wird, was geschieht. Die Typen zeigen, was Entwickler erwarten können, und der Compiler versteht, wie der Speicher verwaltet werden muss. Unabhängig davon, wie ein Rust-Programm strukturiert ist, bieten die Ownership- und Borrowing-Regeln viele Informationen über die Feinheiten von Zuweisungen und Variablen.

Speichersichere Software ist einer der größten Pluspunkte von Rust, aber Entwicklerinnen und Entwickler möchten nicht nur speichersichere und sichere Anwendungen haben – sie möchten auch Spaß beim Schreiben von Code haben. Eine der Maximen von Rust ist es, Abstraktionen ohne Overhead zu ermöglichen. Das bedeutet, dass sich Programme elegant schreiben lassen, ohne dafür Geschwindigkeitsverluste hinzunehmen. Rust erreicht dieses Ziel durch Traits.

Sie ähneln Interfaces aus anderen Programmiersprachen, da sie ebenfalls gemeinsames Verhalten über Typen hinweg definieren und abstrahieren. Anders als Interfaces lassen sich Traits auch für Typen implementieren, die nicht in der eigenen Projektstruktur beheimatet sind. Dadurch können Bibliotheken eine Kompatibilität mit dem breiteren Rust-Ökosystem von crates.io garantieren.

Listing 5 zeigt eine Implementierung für Fibonacci-Zahlen im 128 Bit breiten vorzeichenlosen Integer-Typ. Die Struktur speichert eine aktuelle und eine nächste Zahl, und die Berechnung der Fibonacci-Zahlen erfolgt mithilfe eines Iterator-Trait. Die Definition des zugehörigen Typs als Item zeigt an, welche Ergebnisse zu erwarten sind. Mit jedem Aufruf von next() wird die nächste Fibonacci-Zahl berechnet und die aktuelle Zahl der Folge ausgegeben.

struct Fibonacci {
    curr: u128,
    next: u128,
}
impl Iterator for Fibonacci {
    type Item = u128;
    fn next(&mut self) -> Option<Self::Item> {
        let new_next = self.curr.checked_add(self.next)?;
        self.curr = self.next;
        self.next = new_next;
        Some(self.curr)
    }
}

Listing 5: Erstellen von Fibonacci-Zahlen durch Implementierung des Iterator-Trait

Die Methode next gibt eine Option zurück, die ein Enum mit zwei möglichen Werten enthält: entweder einen Wert oder keinen Wert (None). Rust verlangt, dass Anwendungen beide möglichen Ergebnisse behandeln.

Dazu dienen einerseits die match-Operation (siehe oben) und andererseits eingebaute Mechanismen wie der Iterator, bei dem None ein Zeichen zum Beenden des Iterationsvorgangs ist. Dadurch sind Iteratoren kompatibel mit for-Schleifen. Diese rufen die next-Methode auf, bis die Methode None zurückgibt. Eine Anwendung legt demnach fest: "Iteriere über diese Sammlung."

Die Methode checked_add verhindert Überlauf. Sie gibt ebenfalls eine Option zurück, jedoch kommt hier eine weitere Funktion von Rust ins Spiel: der Fragezeichen-Operator. Mit ihm lässt sich wesentlich kürzerer Code schreiben, da der "schlechte" Fall – None – sofort zurückgegeben und der "gute" Fall – Some – entpackt wird.

Durch diese Operation, als "Bubbling" bezeichnet, muss man sich beim Programmieren keine Gedanken über Fehler oder fehlende Werte machen. Das Ergebnis ist beeindruckend:

Der Rust-Compiler entfernt alle Abstraktionen und erstellt den bestmöglich optimierten Code. Wenn es das Szenario erlaubt, wird der Fibonacci-Iterator zur Kompilierzeit ausgeführt und das Ergebnis einfach in die Binärdatei geschrieben.

Man kann zudem eigene Traits erstellen und für fremde Typen implementieren. Listing 6 zeigt, wie man einen druckbaren Trait entwickelt und ihn für jeden Typ implementiert, der den Debug-Trait implementiert.

trait Printable {
    fn print(&self);
}
impl<T: std::fmt::Debug> Printable for T {
    fn print(&self) {
        println!("{:?}", self);
    }
}

Listing 6: Erstellen eines Print-Trait

Damit hat jeder Typ, der Ausgaben auf der Kommandozeile drucken kann, eine print-Methode, was das schnelle Debuggen noch einfacher macht.

In einer nebenläufigen Umgebung verhindern Rusts Ownership- und Borrowing-System sowie die Abstraktionen in Form von Traits und Typen das Auftreten von Data Races. Voraussetzung dafür sind zwei oder mehr Zeiger, die gleichzeitig auf die gleichen Daten zugreifen, wobei einer der Zeiger Schreibzugriff hat. Wenn keine Mechanismen vorhanden sind, um den Zugriff auf Daten zu synchronisieren, wird sich das Programm in manchen Situationen mit hoher Wahrscheinlichkeit undefiniert verhalten.

Listing 7 erstellt einen Vektor und übergibt ihn an einen frisch gestarteten Thread. Da dieser Thread zu einem undefinierten Zeitpunkt ausgeführt wird, könnten Rusts Ownership-Regeln den Speicher von v freigeben, bevor der Thread ausgeführt wird. Der Rust-Compiler erkennt das und bricht mit einem Fehler ab.

fn main() {
    let v = vec![1, 2, 3];
    let handle = thread::spawn(|| {
        println!("Hier ist ein Vektor: {:?}", v);
    });
    handle.join().unwrap();
}

Listing 7: Starten eines Threads, der den Besitz von Daten außerhalb seines Gültigkeitsbereichs übernimmt

Es ist jedoch erlaubt, den Besitz an den Thread zu übertragen, wie in Listing 8 gezeigt. Das Schlüsselwort move vor der Closure überträgt den Besitz aller referenzierten Variablen an die Closure. Die Closure wird zum Besitzer von v , das nun außerhalb des Threads nicht mehr zugänglich ist.

fn main() {
    let v = vec![1, 2, 3];
    let handle = thread::spawn(move || {
        println!("Hier ist ein Vektor: {:?}", v);
    });
    handle.join().unwrap();
}

Listing 8: Hinzufügen des Schlüsselworts move, um den Besitz an die Closure zu übertragen

In nebenläufigen Szenarien greifen üblicherweise mehrere Threads auf die gleichen Daten zu. Listing 9 erstellt einen Mutex für eine ganze Zahl, die von fünf parallelen Threads inkrementiert wird. Dieses Beispiel ist ebenfalls nicht kompilierbar, da der Besitz des Mutex an den ersten gestarteten Thread übertragen wurde. In der nächsten Iteration gibt es keine Zählvariable, die sich übernehmen lässt.

fn main() {
    let counter = Mutex::new(0);
    let mut handles = vec![];
    for _ in 0..5 {
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();
            *num += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }
    println!(“Result: {}”, *counter.lock().unwrap());
}

Listing 9: Der Besitz von counter wurde in der ersten Iteration übertragen.

In diesem Fall ist sicherzustellen, dass jeder Thread geteilten Zugriff auf den Mutex hat. Über eine Thread-sichere Kontrollstruktur Arc, kurz für Atomic Reference Counter, lassen sich mehrere Referenzen auf den Mutex erzeugen, die allerdings in einer Datenstruktur sind, die in den Besitz der Threads übergehen kann. Die clone-Methode erhöht den Referenzzähler um Eins. Sobald der Klon den Gültigkeitsbereich verlässt, wird der Zähler wieder niedriger. Ist der Zähler auf 0, wird der Speicher freigegeben. Das ist eine sehr simple Form der Garbage Collection, aber mit einem signifikanten Unterschied: Man weiß exakt, dass genau das in diesem Codestück in Listing 10 passiert.

let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..5 {
    let counter = Arc::clone(&counter);
    let handle = thread::spawn(move || {
        let mut num = counter.lock().unwrap();
        *num += 1;
    });
    handles.push(handle);
}

Listing 10: Mit Atomic Reference Counters lässt sich gemeinsamer Zugriff auf den Mutex erlangen.

Rust ist eine Programmiersprache, die sich in den letzten Jahren rapide entwickelt hat und aufgrund ihrer einzigartigen Eigenschaften immer beliebter wird.

Sie bietet nicht nur eine moderne Syntax und Abstraktionen ohne Overhead, sondern glänzt vor allem durch ihr Ownership- und Borrowing-System, das Speichersicherheit ohne Garbage Collection gewährleistet. Dieses System verhindert Speicherfehler wie Data Races und Pufferüberläufe und bietet gleichzeitig die Möglichkeit zur furchtlosen Nebenläufigkeit. Daher ist Rust für Entwicklerinnen und Entwickler, die sicheren und performanten Code schreiben möchten, eine ernsthafte Überlegung wert.

Stefan Baumgartner
ist Softwarearchitekt und Entwickler. Er schreibt Artikel, Tutorials und Guides zu TypeScript, Rust, React und Softwareengineering und bietet über oida.dev Schulungen an.

(mai [5])


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

Links in diesem Artikel:
[1] https://www.heise.de/news/Programmiersprachen-Die-Beliebtheit-von-Rust-bleibt-ungebrochen-9187369.html
[2] https://msrc.microsoft.com/blog/2019/07/16/a-proactive-approach-to-more-secure-code/
[3] https://security.googleblog.com/2021/09/an-update-on-memory-safety-in-chrome.html
[4] https://www.heise.de/news/Android-Mehr-Rust-weniger-C-C-und-weniger-kritische-Schwachstellen-7364247.html
[5] mailto:mai@heise.de