Wege nicht mit den 'borrow checker' zu kämpfen 27. November 2022 | 6 minute Lesen

Wege nicht mit den 'borrow checker' zu kämpfen

Das Problem

Der Borrow Checker ist warscheinlich die größte Hürde für Rust Neueinsteiger. Viele Kämpfen regelrecht mit diesen und geben oftmals auf. Dies hat einerseits damit zu tun, weil der Borrow Checker von Rust ziemlich einzigartig ist und außerdem damit, dass viele Programmierer Konzepte aus anderen Programiersprachen statisch übernehmen wollen. Je länger Programmierer jedoch in Rust schreibt, desto seltener sehen sie Fehlermeldungen vom Borrow Checker und ab einen gewissen Zeitpunkt wird der Borrow Checker vom “Feind” zum “Freund”. Ich habe hier ein paar Tips zusammen gefasst, damit Neueinsteiger weniger mit den Borrow Checker kämpfen.

Tipp 1: Kläre wer Eigentümer von etwas ist

Viele Neuanfänger machen den Fehler und strukturieren ihre Rust Programme wie Programme in C / C++ oder Java. In diesen Sprachen ist es oftmals üblich, Pointer und Referenzen überall zu benutzen und ein wahres Netz an Eigentümern zu generieren. Rust jedoch bevorzugt es exakt einen Eigentümer für einen Speicherbereich zu haben. Die beste Struktur für Programm in Rust ist die eines Baumes. Es ist zwar möglich diese Struktur mit Hilfsmitteln aufzubrechen (z.B. Smart Pointern), jedoch tendieren Rust Programme diese weniger zu benutzen und legen mehr Wert auf eine Klärung der Frage “wer besitzt was” und der genauen Planung der Lifetimes von Objekten.

Dies ist jedoch kein “Quick Win” Tipp und erst mit mehr Erfahrung in Rust wird der Programmierer Anfangen seine Programmstrukturen an die Gegebenheiten von Rust anzupassen. Die Anpassung der Programmstruktur und Ausführungslogik an die Fragestellung “wen gehört was” ist jedoch der wichtigste Punkt um weniger mit den Borrow Checker zu kämpfen und den Vorteil von Rust zu nutzen.

Tipp 2: Vermeide Strukturen mit Lifetimes

Dies ist warscheinlich einer der größten Fallstricke den die meisten Programmierer anderer Sprachen antreffen werden. Viele Neuanfänger benutzen Referenzen wie Pointer oder Smart Pointer in anderen Sprachen und wollen diese in Strukturen speichern:

struct Container<'a> {
    a_reference: &'a u32,
    b_reference: &'a u32,
}

fn main() {
    let a = 1;
    let b = 1;

    let list = vec![Container {
        a_reference: &a,
        b_reference: &b,
    }];

    dbg!(list.len());
}

Dies ist zwar generell möglich, jedoch ist nun die Liste “list” mit einer Lifetime behaftet, welche sich durchs ganze Programme ziehen wird. Wird nun diese Liste z.B. von einer Funktion zurück gegegben, wird es schnell zu Problemen mit der Lifetime kommen.

Generell ist zu empfehlen: Halte die Lifetime von Referenzen so kurz wie möglich. Strukturen mit Lifetimes sollten nur benutzt werden, wenn die Lifetime dieser klar definiert sind. Dies ist zum Beispiel der Fall, wenn man Dinge in Phasen abarbeitet (z.b. klassisch in Lexer / Parser / Compiler) oder aber die Strukturen nur temporär sind (z.B. Strukturen zum Konfigurieren).

Sollen trotzdem Referenzen in Strukturen oder Containern gehalten werden, dann sollte man Smart Pointer wie Rc oder Arc benutzen (Siehe Tipp 4).

Tipp 3: Copy für einfache Datentypen

Strukturen die nur aus einfache Datentypen bestehen, können den Trait Copy implementieren. Einfache Datentypen sind in diesen Fall die Datentypen, die selber den Trait Copy implementieren. Dies sollte jedoch nur für Strukturen implementiert werden, die auch wirklich “einfach” zu kopieren sind, da sonst unnötige Kopieroperationen ausgeführt werden würden, die die Performance negativ beinträchtigen würden:

#[derive(Debug, Copy, Clone)]
struct Container {
    a: usize,
    v: usize,
}

fn a_function_that_moves(a: Container) {
    dbg!(a);
}

fn main() {
    let a = Container { a: 0, v: 0 };
    a_function_that_moves(a);
    a_function_that_moves(a);
}

Copy ist eher als Vereinfachung zu sehen, damit nicht immer eine Struktur mit “clone” kopiert werden muss und diese implizit für den Programmierer kopiert wird.

Tipp 4: Benutze Rc / Arc

Wenn Objekte geteilt werden sollen, deren Lifetime nicht zur Komplierzeit bestimmt werden können, so kann man Smart Pointer benutzen. Rust benutzt dann Reference-Counting um zu entscheiden, ob ein Objekt noch lebt oder nicht. Rust bietet hierzu zwei Imlementationen an: Rc und Arc. Rc benutzt einfaches Reference-Counting und kann benutzt werden, wenn nur ein Thread auf das Objekt zugreift. Arc benutzt hingegen Atomic-Operations um das Reference-Counting zu implementieren und wird dann benutzt, wenn Objekte über Thread-Grenzen geteilt werden sollen.

Hierbei ist jedoch zu beachten, dass Objekte die in Smart Pointer abgespeichert werden nicht mit “Outer Mutability” verändert werden können. Hierzu muss man die in Tip 5 beschriebenen Strukturen benutzen.

Tipp 5: Benutze Cell, RefCell, Mutex und RwLock

Um zur Laufzeit den Zugriff auf bestimmte Speicherbereiche / Objekte zu regeln, können im Single-Threaded-Kontext Cell und RefCell und im Multi-Threaded-Kontext Mutex und RwLock benutzt werden. Diese Container implementieren “Inner Mutability” und ermöglichen unter Verwendung von Smart Pointer den gleichzeitig, aber synchronisierten Zugriff auf Objekte.

Auch können Strukturen hierdurch ein API Interface anbienten, welche keinen exklusive mut Zugriff auf eine Object benötigt:

#[derive(Debug, Default)]
struct Adder {
    value: Cell<u64>,
}

impl Adder {
    fn add(&self, value: u64) -> u64 {
        let old_value = self.value.get();
        self.value.set(old_value + value);
        old_value
    }
}

fn main() {
    // adder don't need to be decalred with `mut`,
    // because we use "inner mutability".
    let adder = Adder::default();
    adder.add(10);
    adder.add(12);
    dbg!(adder);
}

Tipp 6: Arena / Slab

Auch ist die Verwendung einer Arena / Slab oftmals angebracht. Statt Referenzen kann man dann Schlüssel abspeichern. Hinter einer Arena verbirgt sich ein Vec, in welchen Objekt gleicher Art in einen zusammenhängenden, kontinuierlichen Speicher abgelegt werden. Hiermit wir effektiv die Überprüfung, ob das Objekt noch vorhanden ist, in die Domäne der Laufzeit überführt. Diese Art von Speicher hat außerdem den Vorteil, dass sie sehr CPU Cache freundlich ist.

Eine solcher Container kann recht leicht mit einen Vec selbst gebaut werden, oder man benutzt eine der schon veröffentlichen Crates.

Beispiel unter Benutzung von thunderdome:

let mut arena = Arena::new();

let foo = arena.insert("Foo");
let bar = arena.insert("Bar");

assert_eq!(arena[foo], "Foo");
assert_eq!(arena[bar], "Bar");

Tipp 7: Innere Strukturen

Oftmals hat man das Problem, dass man in einer Methode einer Struktur über einen Container mutable iteriert und eine andere Methode der gleichen Struktur aufrufen muss. Da man mut self hält, wird hier der Borrow Checker einen Fehler schmeißen. Dieses Bespiel ist nur dafür da das Problem vereinfach dazustellen:

struct Summer {
    list: Vec<u64>,
    sum: u64,
}

impl Summer {
    fn work(&mut self) {
        // This won't work, since we already borrowed self exclusively.
        self.list.iter_mut().for_each(|x| *x = self.sum(*x));
    }

    fn sum(&mut self, x: u64) -> u64 {
        // In theory this function could access `list` over
        // which wi are already iterating.
        self.sum += x;
        self.sum
    }
}
error[E0500]: closure requires unique access to `*self` but it is already borrowed
  --> example\lib.rs:17:39
   |
17 |         self.list.iter_mut().for_each(|x| *x = self.sum(*x));
   |         -------------------- -------- ^^^      ---- second borrow occurs due to use of `*self` in closure
   |         |                    |        |
   |         |                    |        closure construction occurs here
   |         |                    first borrow later used by call
   |         borrow occurs here

Durch die Verwendung von inneren Strukturen kann man die Zugriff granularer gestallten und diesen Fehler umgehen:

struct Summer {
    list: Vec<u64>,
    inner: InnerSummer,
}

impl Summer {
    fn work(&mut self) {
        self.list.iter_mut().for_each(|x| *x = self.inner.sum(*x));
    }
}

struct InnerSummer {
    sum: u64,
}

impl InnerSummer {
    fn sum(&mut self, x: u64) -> u64 {
        self.sum += x;
        self.sum
    }
}

Tipp 8: Und zu guter letzt: Clone

Und wenn alle Strike reißen, dann ist es, vor allem in Code der nicht Performance-kritisch ist, in Ordnung clone() zu benutzen.

Denn letztendlich ist es besser eine Aktionen ineffektiv durchzuführen, also gar nicht durchführen zu können.

Nils Hasenbanck

Nils Hasenbanck

Nils Hasenbanck ist der Gründer der Tsukisoft GmbH und ein Senior Developer. Seine Leidenschaft ist das Bauen von technisch eleganten, …