Programmiersprache: Rust für Neugierige

Seite 2: Abstraktion ohne Overhead

Inhaltsverzeichnis

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:

  • Nur wenige Zeilen Code waren erforderlich, um eine Methode zu erstellen, die alle möglichen Fibonacci-Zahlen in u128 berechnet.
  • Der Code stößt nicht auf Überlauf oder Nullzeiger-Ausnahmen.
  • Fibonacci ist kompatibel mit for-Schleifen.
  • Der Iterator implementiert über 80 Methoden, die es einer Fibonacci-Struktur ermöglichen, die ersten n Zahlen zu sammeln, wenn gewünscht: let numbers: Vec<u128> = (Fibonacci { curr: 0, next: 1 }).iter().take(5).collect();

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.