Auf Nummer sicher: Sicheres Programmieren mit Rust

Seite 2: Ownership und Borrowing

Inhaltsverzeichnis

Hinter den Stichworten "Ownership" und "Borrowing" verbirgt sich bei Rust die Implementierung mächtiger Konzepte, die die sichere Verwendung von Speicher zur Laufzeit eines Programms bereits bei der Übersetzung durch den Compiler sicherstellen. Um das Konzept zu illustrieren, zeigt Listing 2 ein Beispiel in C++.

std::vector<int> v { 1, 2 };
int *vp = &v[1];
v.emplace_back(3);
std::cout << *vp;

Listing 2: Potenzieller "use-after-free"-Fehler

Der Code erzeugt einen Vektor mit zwei Elementen und eine Referenz, die auf ein Element innerhalb dieses Vektors verweist. Anschließend erhält der Vektor ein weiteres Element hinzu und das Element, auf welches die Referenz verweist, wird ausgegeben.

Das Beispiel sieht zunächst nicht sonderlich gefährlich aus. Die Gefahr geht hier jedoch von der Implementierung des Vektors sowie der Referenz auf das Element innerhalb des Vektors aus. Ein Vektor in C++ ist ein dynamisch wachsender Container gleichartiger Elemente. Das Erzeugen eines Vektors reserviert den Speicherbereich für eine bestimmte Anzahl von Elementen. Überschreiten die Elemente beim Hinzufügen die Kapazität des Vektors, so löst das die Reservierung eines neuen, größeren Speicherbereiches aus und die Elemente des bisherigen Vektors werden dorthin verschoben. Der bisher genutzte, nun nicht mehr benötigte Speicherbereich wird freigegeben. Spannend bleibt dabei die Frage, was nun mit der Referenz auf das Element innerhalb des Vektors geschieht, die ja lediglich einen Verweis auf eine Speicheradresse darstellt. Hierbei handelt es sich um undefiniertes Verhalten. Man spricht in diesem Fall auch von "pointer invalidation" beziehungsweise sogenannten "use-after-free"-Fehlern.

Ein äquivalentes Beispiel lässt sich auch in Rust implementieren, wie Listing 3 demonstriert.

fn main() {
    let mut v = vec![1, 2];
    let vp = &mut v[1];
    v.push(3);
    println!("{}", *vp);
}

Listing 3: Potenzieller "use-after-free"-Fehler, diesmal in Rust

Das Übersetzen des Beispiels führt jedoch zu einem Compiler-Fehler (s. Listing 4.).

error[E0499]: 
cannot borrow `v` as mutable more than once at a time
 --> src/main.rs:4:5
  |
3 |     let vp = &mut v[1];
  |                   - first mutable borrow occurs here
4 |     v.push(3);
  |     ^ second mutable borrow occurs here
5 |     println!("{}", *vp);
  |                    --- first borrow later used here

Listing 4: Fehlermeldung beim Versuch, Listing 3 zu übersetzen

In diesem Beispiel wechselte der Wert (der Vektor mit den Elementen 1 und 2) durch die Definition der veränderbaren Referenz vp den Besitzer. Eine spätere Modifikation des Vektors über den Aufruf der push-Methode auf der Referenz v ist somit nicht mehr möglich.

Was zunächst wie eine massive Einschränkung und eher unflexibel anmutet, ist eines der Grundprinzipien von Rust: das Ownership-Konzept. Ein Wert in einem Speicherbereich, den ein Rust-Programm zur Laufzeit reserviert, hat einen eindeutig festgelegten Besitzer (Owner). Wenn der Lebenszyklus des Besitzers endet (beispielsweise am Ende eines Blocks), gibt das Programm den reservierten Speicherbereich frei. Dieses Konzept wird bereits während der Übersetzung eines Programms durch den Rust-Compiler durchgesetzt.

Im weiteren Verlauf dieses Artikels wird sich herauskristallisieren, dass das Konzept, so einschränkend es auf den ersten Blick auch wirken mag, in der Vermeidung von Speicherfehlern ausgesprochen mächtig ist. Es ist möglich, in Rust ähnlich flexibel und produktiv Software zu entwickeln wie in C oder C++. Listing 5 verdeutlicht das Ownership-Konzept.

fn consume(v: Vec<i32>) {
    println!("{:?}", v);
}

fn main() {
    let mut v = vec![1, 2];
    consume(v);
    v.push(3);
}

Listing 5: Fehlerhaftes Rust-Programm illustriert Ownership-Konzept

Hier entsteht ein veränderbarer Vektor mit zwei Elementen, den das Programm an eine Funktion übergibt, die diesen Vektor verarbeitet. Anschließend soll dem Vektor ein weiteres Element hinzutreten, was der Rust-Compiler jedoch in Listing 6 mit einer Fehlermeldung quittiert.

error[E0382]: borrow of moved value: `v`
 --> src/main.rs:8:5
  |
6 |     let mut v = vec![1, 2];
  |         ----- move occurs because `v` has type `Vec<i32>`, 
            which does not implement the `Copy` trait
7 |     consume(v);
  |             - value moved here
8 |     v.push(3);
  |     ^ value borrowed here after move

Listing 6: Fehlermeldung des Rust-Compilers: Der Borrow-Checker akzeptiert den Code aus Listing 5 nicht.

Das Beispiel zeigt, dass der Wert von v mit der Übergabe an die Funktion consume den Besitzer wechselt (value moved here). Der Besitz wird anschließend nicht an die aufrufende Funktion zurückgegeben, auch nicht implizit, was "Move-Semantik" heißt. Stattdessen wird der Speicherbereich, den der Vektor belegt hatte, nach der Verarbeitung durch die Funktion consume wieder frei und v lässt sich nicht weiter verwenden (value borrowed here after move).

Um die weitere Verwendung von v nach der Übergabe an die Funktion zu ermöglichen, gilt es, den Vektor als Referenz an die Funktion zu übergeben. Hierzu ist das Beispiel wie in Listing 7 anzupassen.

fn consume(v: &Vec<i32>) {
    println!("{:?}", v);
}

fn main() {
    let mut v = vec![1, 2];
    consume(&v);
    v.push(3);
}

Listing 7: Korrektur des Codes aus Listing 5 durch Übergabe des Parameters der Funktion als shared reference

Die Funktion consume übergibt nun den Parameter mittels einer Referenz (hier: &). Weiterhin ist auch beim Aufruf der Funktion die explizite Angabe erforderlich, dass der Wert v als Referenz zu übergeben ist. Durch die Änderung wird der Vektor beim Aufruf der Funktion lediglich "ausgeliehen" (borrowed), wechselt jedoch nicht vollständig den Besitzer. Folglich handelt es sich bei der Referenz um eine "shared reference", durch die nur lesender Zugriff auf den referenzierten Wert möglich ist. Der Rust-Compiler wechselt bei solchen Referenzen aus Gründen der Effizienz von der zuvor verwendeten Move-Semantik zu einer Copy-Semantik.

Soll der Vektor hingegen innerhalb der Funktion verändert werden, ist das durch eine Änderung des Codes analog zu Listing 8 möglich.

fn consume(v: &mut Vec<i32>) {
    v.push(4);
}

fn main() {
    let mut v = vec![1, 2];
    consume(&mut v);
    v.push(3);
    println!("{:?}", v);
}

Listing 8: Modifikationen des Wertes innerhalb der Funktion: Nur möglich, wenn der Parameter als mutable reference deklariert wurde.

Das Beispiel ersetzt die zuvor verwendete shared reference durch eine mutable reference, also durch eine veränderbare Referenz mit dem Schlüsselwort &mut. Diese Art von Referenz erlaubt nun auch die Änderung referenzierter Werte innerhalb von Funktionen, selbst, wenn diese nicht die ursprünglichen Besitzer des Werts sind. Allerdings gilt hier die Einschränkung, dass zwar beliebig viele shared references zugleich auf einen Wert aktiv sein dürfen, jedoch nur eine einzige mutable reference.

Veränderbare Referenzen folgen dabei wieder der Move-Semantik. Daraus ergibt sich, dass nicht einmal der ursprüngliche Besitzer eines referenzierten Wertes den Wert verändern kann, solange eine veränderbare Referenz auf den Wert aktiv ist. Dies ist bei nebenläufigen Programmen wichtig, damit sich referenzierte Werte nicht aus unterschiedlichen Threads heraus gleichzeitig ändern lassen. Das verhindert Race Conditions durch gleichzeitige, konkurrierende Zugriffe auf dieselbe Ressource.

Durch die explizite Semantik ist es dem Borrow Checker im Rust-Compiler jederzeit möglich, den Lebenszyklus jedes einzelnen Wertes, der in einem Rust-Programm zum Einsatz kommt, nachzuvollziehen. So lässt sich nicht mehr benötigter Speicher freigeben, sobald sichergestellt ist, dass der betroffene Wert nicht mehr referenziert wird. Das Ergebnis ist automatisch ein sicheres Speichermanagement bereits während der Übersetzung des Programms, wohingegen andere Sprachen wie Java, C# oder Go automatisches Speichermanagement lediglich zur Laufzeit bieten, was jedoch die Performance beeinträchtigen kann.