Lebenszeiten von Referenzen

Der folgende Schnipsel wird in Rust bereits zur Kompilierzeit abgefangen, damit es nicht zu Speicherzugriffsverletzungen zur Laufzeit kommt.

#![allow(unused)]
fn main() {
let r;

{
    let s = String::from("Welt");
    r = &s;
}

println!("{r}");
}

Quiz

Warum würde es hier zu einer Speicherzugriffsverletzung kommen, wenn der Compiler die Übersetzung nicht verweigert hätte?

Lebenszeiten (engl. lifetimes) von Referenzen oder Zeigern auf Speicheradressen sind in vielen Programmiersprachen wichtig. In Rust wird im Gegensatz zu anderen Programmiersprachen die Gültigkeit einer Referenz bereits zur Kompilierzeit überprüft. Dazu braucht der Compiler in mehrdeutigen Situationen Hilfe. Teile des Typs müssen dann entsprechend annotieren. Das Ziel von Lifetimes ist es, baumelnde Referenzen auf ungültige Speicherbereiche (engl. dangling reference) zu verhindern. Zu diesem Zweck zeigt ein Liftime-Parameter dem Compiler an, wie lange eine Referenz benötigt wird. Der Compiler kann dann entscheiden, ob das Programm diese Anforderung unter allen Umständen erfüllen kann. Diese Überprüfung kann zu restritktiv sein, denn der Rust Compiler verfährt nach dem Motto better safe than sorry.

Generische Lifetime-Parameter von Funktionen

Im folgenden Schnispel versuchen wir eine Referenz auf eine lokale Variable zurückzugeben. Der Compiler verweigert glücklicherweise die Übersetzung.

#![allow(unused)]
fn main() {
// kompiliert nicht
fn f() -> &f32 {
    let x = 0.0;
    &x
}
}

Während man in C++ mit einer entsprechenden Funktion eine Speicherzugriffsverletzung zur Laufzeit produziert hätte, bekommt man von einem bestimmten Teil des Rust Compilers, dem sogenannten Borrow-Checker, die folgende Meldung.

4 | fn f() -> &f32 {
  |           ^ expected named lifetime parameter

Lifetimes von Referenzen werden mit einem Parameter annotiert. Die Syntax ist etwas unüblich. Lifetime-Parameter starten immer mit einem ' und sind üblicherweise kurz und klein benamt. Wenn in einer Funktion ein Lifetime-Parameter verwendet wird, muss dieser Parameter einmal in spitzen Klammern direkt nach dem Funktionsnamen deklariert werden und in allen benötigten Typen der Argumente und möglicherweise im Rückgabetypen annotiert werden.

#![allow(unused)]
fn main() {
// kompiliert nicht
fn f<'a>() -> &'a f32 {
    let x = 0.0;
    &x
}
}

In diesem Fall sind wir dem Wunsch des Compilers nachgekommen und haben den Liftetime-Parameter 'a hinzugefügt. Nun sagt uns der Compiler richtigerweise er könne keine Referenz auf eine lokale Variable zurückgeben. Wenn eine Funktion Referenzen zurückgeben möchte, muss sicher gestellt sein, dass nach Beendigung der Funktion die Referenzen noch gültig sind. Beispielsweise können wir den kleineren zweier Strings bestimmen, ohne die Strings zu verschieben oder zu klonen.

#![allow(unused)]
fn main() {
fn smallest<'a>(s1: &'a str, s2: &'a str) -> &'a str {
    if s1.len() < s2.len() {
        s1
    } else {
        s2
    }
}
let s1 = String::from("Hallo");
let s2 = String::from("Welt");
println!("{}", smallest(&s1, &s2));
}

Der Lifetime-Parameter 'a verbindet die Argumente s1 und s2 mit dem Rückgabewert. Er besagt also, dass der Rückgabewert solange wie beide Eingabeparameter valide sein muss. Eigentlich muss der Rückgabewert nur solange valide sein wie der kürzere der beiden Strings. Welcher das bei Aufruf der Funktion sein wird, kann der Compiler aber im Allgemeinen nicht wissen. Dadurch, dass wir die Lebenszeiten aller 3 Referenzen verknüpfen, verändern wir nicht, wie lange die dazugehörigen Referenzen valide sind. Wir sagen nur dem Borrow-Checker, dass er alle Referenzen ablehnen soll, die unsere Anforderungen an Lifetimes nicht erfüllen. Den Parameter 'a nennt man auch generischen Parameter. Wenn die Funktion konkret verwendet wird, bekommt 'a einen konkreten Wert und zwar die kürzerer der Lebenszeiten der Eingabereferenzen. Im folgenden Beispiel verwenden wir Eingabereferenzen mit unterschiedlichen Lebenszeiten.

fn smallest<'a>(s1: &'a str, s2: &'a str) -> &'a str {
    if s1.len() < s2.len() {
        s1
    } else {
        s2
    }
}
fn main() {
    let s1 = String::from("Hallo");
    {
        let s2 = String::from("Welt");
        println!("{}", smallest(&s1, &s2));
    }
}

Während Referenzen auf s1 bis ans Ende der Mainfunktion valide sind, begrenzt sich die Lebenszeit von s2-Referenzen auf den inneren Geltungsbereich. Die generische Lebenszeit 'a entspricht in diesem Fall der Lebenszeit von &s2, da diese die kürzere der beiden ist. Um das zu verfizieren versuchen wir im folgenden Beispiel auf das Ergebnis der Funktion außerhalb des Geltungsbereichs von s2 zuzugreifen.

//kompiliert nicht
fn smallest<'a>(s1: &'a str, s2: &'a str) -> &'a str {
    if s1.len() < s2.len() {
        s1
    } else {
        s2
    }
}
fn main() {
    let s2 = String::from("Welt");
    let result;
    {
        let s1 = String::from("Hallo");
        result = smallest(&s1, &s2);
    }
    println!("{}", result);
}

Der Borrow-Checker teilt uns mit, dass s1 nicht lange genug lebt. Wir sehen natürlich, dass die Funktion smallest gar nicht s1 sondern s2 zurückgegeben hätte. Der Compiler kann das aber nicht erkennen und ist an dieser Stelle zu restriktiv.

Aufgabe

Erstelle verschiedene Versionen und Variationen der Funktion smallest mit unterschiedlichen Lifetime-Annotationen und versuche vor Ausführung vorherzusagen, ob der Compiler das akzeptieren wird.

Statische Lebenszeiten

Wenn kein Argument sondern nur der Rückgabewert einer Funktion eine Referenz ist, gibt es 2 Möglichkeiten:

  1. Das Programm wird nicht übersetzt, da eine Referenz auf eine lokale Variable der Funktion zurückgegeben wird.
  2. Es handelt sich um eine Referenz auf ein Literal mit statischer Lebenszeit. Das wird dem Compiler durch die Annotation 'static mitgeteilt.
#![allow(unused)]
fn main() {
fn f() -> &'static str {
    let literal = &"Hallo Welt";
    literal
}
println!("{}", f());
}

Wie alle Literale von Zeichenketten hat die Variable literal den Typ &'static str. In den wenigsten Fällen wird eine statische Lebenszeit wirklich benötigt. Das kann durchaus auch dann der Fall sein, wenn der Compiler vorschlägt eine Lebenszeit als 'static zu annotieren.

Auslassung von Lebenszeiten

Das folgende Beispiel funktioniert, obwohl keine Lifetime-Parameter verwendet werden.

#![allow(unused)]
fn main() {
fn f(s: &str, i: usize) -> &str {
    &s[i..]
}
println!("{}", f(&"ABC", 1));
}

Dieser Fall ist eindeutig. Da es nur eine Eingabe- und einen Ausgabereferenz gibt, nimmt der Compiler automatisch an, dass die Lebenszeiten identisch sind. Das Weglassen von Lifetime-Parametern wird auch Lifetime Elision genannt. Es gibt 3 Regeln für das Weglassen von Lifetime-Parametern:

  1. Jedes Argument dessen Typ eine Referenz ist, bekommt einen eigenen Lifetime-Parameter. Das heißt f(n: &i32) entspricht f<'a>(n: &'a i32), f(n: &i32, m: &i32) entspricht f<'a, 'b>(n: &'a i32, m: &'b i32), ... .
  2. Wenn es nur ein Referenz-Argument gibt, werden alle Rückgabereferenzen mit dem Lifetime-Parameter des Arguments versehen. Beispielsweise erhalten wir f<'a>(n: &'a i32) -> (&'a i32, &'a i32).
  3. Bei Methoden wird der Lifetime-Parameter von &self für alle relevanten Rückgabewerte verwendet.

Falls sich aus den drei Regeln eine nicht-eindeutige Situation ergibt, verweigert der Compiler die Übersetzung.

Strukturtypen mit Referenzen

Wenn wir eine Referenz in einem Strukturtypen verwenden wollen, benötigen wir zwingend generische Liftime-Parameter. Das heißt,

#![allow(unused)]
fn main() {
struct Words {
    data: Vec<&str>, 
}
}

kompiliert nicht. Wir müssen bei Strukturtypen und auch Aufzählungstypen immer Referenzen angeben.

struct Words<'a> {
    data: Vec<&'a str>, 
}
fn main() {
    let data = vec!["Today", "is", "a", "good", "day"];
    let words = Words{ data };
}

Damit sagen wir, das Instanzen des Typs Words nicht länger im Geltungsbereich sein dürfen, als die Variable data. Implementierungsblöcke von Aufzählungs- und Strukturtypen benötigen ebenfalls Lifetime-Parameter.

struct Words<'a> { data: Vec<&'a str> } 
impl<'a> Words<'a> {
    fn sort_alphabetically(&'a mut self) {
        self.data.sort();
    }
    fn sort_by_len(&'a mut self) {
        self.data.sort_by_key(|v| v.len());
    }
}
fn main() {
    let data = vec!["Today", "is", "a", "good", "day"];
    let mut words = Words{ data };
    words.sort_by_len();
    words.sort_alphabetically();
}

Das vorherige Beispiel kompiliert allerdings nicht.

error[E0499]: cannot borrow `words` as mutable more than once at a time

Wir haben fälschlicherweise den Lifetime-Parameter 'a zur Markierung der &self-Parameter verwendet. Damit teilen wir dem Compiler mit, dass die Lebenszeiten der veränderlichen Referenzen auf self der Länge den Lebenszeiten der Elemente von data gleichen. Damit denkt der Compiler zwei veränderliche Referenzen sind im kompletten Geltungsbereich von main aktiv und mehrere Referenzen auf eine Instanz sind nicht zulässig, sobald eine veränderlich ist. Wir können die Referenzen von Methoden unabhängig von der Lebenszeit des Strukturtypen vergeben. In diesem Fall brauchen wir sie gar nicht angeben, da die Situation eindeutig ist und der Compiler sie herleiten kann. Der folgende Schnipsel kompiliert dementsprechend.

struct Words<'a> { data: Vec<&'a str> } 
impl<'a> Words<'a> {
    fn sort_alphabetically(&mut self) {
        self.data.sort();
    }
    fn sort_by_len(&mut self) {
        self.data.sort_by_key(|word| word.len());
    }
}
fn main() {
    let data = vec!["today", "is", "a", "good", "day"];
    let mut words = Words{ data };
    words.sort_by_len();
    println!("{:?}", words.data);
    words.sort_alphabetically();
    println!("{:?}", words.data);
}

Übrigens werden die Vec-Methoden sort und sort_by_key freundlicherweise von der Rust Standardbibliothek bereit gestellt. Während sort eine dem Datentyp entsprechende Sortierung vornimmt, kann der Programmierer bei Verwendung von sort_by_key durch eine anonyme Funktion selber entscheiden, wonach sortiert werden soll. Die anonyme Funktion bildet ein Element des Vektors auf das gewünschte Krieterium ab. Hier verwenden wir |word: &str| word.len() um die Länge der einzelnen Wörter zu berechnen.