Fehlersuche im Eigentumsmodell

Borrow-Checker: die üblichen Meldungen und ihr Umbau

Die Meldung nennt die Zeile, das Problem sitzt aber im Entwurf. Wer die vier üblichen Fälle einmal auseinandernimmt, braucht dafür beim nächsten Mal Minuten statt eines Nachmittags.

KI-generiertDieses Bild wurde mit KI erzeugt · Yves Hoppe / KI / cmt
Seit 1997 am Markt Kleine Gruppen Präsenz und Live-Online Zertifizierte Trainer
Worum es geht

Der Compiler beschreibt ein Symptom, die Ursache sitzt im Datenmodell

Der Ablauf ist fast immer gleich. Der Code entsteht so, wie er in Java oder Python entstanden wäre, und er übersetzt auch, bis zu dem Punkt, an dem zwei Stellen dieselben Daten berühren. Dann kommt eine Meldung, die exakt die Zeile benennt, und der Reflex ist, diese Zeile zu reparieren. Genau deshalb kommt derselbe Fehler drei Zeilen weiter wieder.

Hinter fast jeder Meldung stehen zwei Regeln. Erstens: Jeder Wert hat genau einen Besitzer, und wenn dessen Gültigkeitsbereich endet, wird der Wert freigegeben. Zweitens: Solange ein Wert ausgeliehen ist, gibt es entweder genau eine veränderliche Ausleihe oder beliebig viele lesende, nie beides zusammen. Jede Meldung ist eine dieser beiden Regeln, angewendet auf deinen Code.

Wer stattdessen umgeht, bezahlt später. Der billige Ausweg ist clone an jeder störenden Stelle, und wo clone nicht reicht, Rc mit RefCell. Beides übersetzt. Klonen kostet Laufzeit und verschleiert die Struktur, und RefCell verschiebt die Prüfung von der Übersetzungszeit in die Laufzeit, das Programm bricht also genau in dem Moment ab, in dem die Regel verletzt wird, unter Last und im Betrieb. Aus einem Compilerfehler ist dann ein Vorfall geworden.

KI-generiertDieses Bild wurde mit KI erzeugt · Yves Hoppe / KI / cmt
Symptom, Ursache, Lösung

Die Fälle, die im Alltag wirklich auflaufen

Sortiert nach dem, was du siehst, nicht nach dem Regelwerk dahinter. Zu jedem Fall steht die Regel, aus der er folgt, und der Umbau, der ihn dauerhaft erledigt.

Symptom

Der Compiler meldet, dass sich derselbe Wert nicht mehr als einmal gleichzeitig veränderlich ausleihen lässt, obwohl die beiden Zugriffe im Quelltext klar nacheinander stehen.

Ursache

Eine veränderliche Ausleihe lebt so lange, wie ihr Ergebnis noch gebraucht wird, und nicht bis zum Semikolon. Typisch ist ein verschachtelter Aufruf, bei dem in derselben Anweisung zweimal auf dieselbe Struktur zugegriffen wird, einmal als Empfänger der Methode und einmal als Argument.

Lösung

Zieh den inneren Zugriff heraus und binde ihn mit let an eine Variable, bevor du die verändernde Methode aufrufst. Wenn zwei Felder derselben Struktur gleichzeitig veränderlich gebraucht werden, hilft es, die Struktur in einem Ausdruck zu zerlegen: Der Compiler unterscheidet einzelne Felder, ein Methodenaufruf leiht dagegen immer die ganze Struktur aus.

Symptom

Die Meldung sagt, eine Sammlung lasse sich nicht veränderlich ausleihen, weil sie bereits lesend ausgeliehen ist. Ausgelöst hat es eine Schleife, in der du beim Durchlaufen Einträge ergänzt oder entfernst.

Ursache

Der Iterator hält eine lesende Ausleihe auf die gesamte Sammlung, solange die Schleife läuft. Ein Einfügen oder Entfernen im Rumpf bräuchte gleichzeitig eine veränderliche Ausleihe, und beides zusammen ist ausgeschlossen. Die Regel verhindert genau das, was in C++ zu ungültigen Iteratoren führt, wenn ein Vektor beim Wachsen umzieht.

Lösung

Zum Entfernen nach einer Bedingung retain nehmen, das erledigt die Sammlung selbst in einem Durchlauf. Zum Ändern an Ort und Stelle über iter_mut laufen. Wenn wirklich beim Durchlaufen eingefügt werden muss, sammle die neuen Einträge in einem zweiten Vec und häng sie nach der Schleife mit extend an.

Symptom

Der Compiler sagt, ein Wert sei verschoben worden, und markiert die Stelle, an der du ihn danach noch einmal benutzt. Im Quelltext steht nirgends etwas von Verschieben.

Ursache

Beim Übergeben oder Zuweisen eines Werts, der sich nicht billig kopieren lässt, geht der Besitz über. String, Vec und die meisten eigenen Strukturen gehören dazu, Zahlen und Wahrheitswerte nicht. Ein häufiger Auslöser ist eine for-Schleife, die direkt über die Sammlung läuft statt über &sammlung, denn die verbraucht die Sammlung.

Lösung

Soll die Funktion nur lesen, gib ihr eine Referenz und ändere die Signatur entsprechend. In Schleifen über eine Referenz auf die Sammlung oder über iter laufen statt über die Sammlung selbst. Soll die Funktion den Wert wirklich behalten und du brauchst ihn danach noch, ist genau hier ein Klon die richtige Antwort.

Symptom

Beim Zugriff auf ein Feld hinter einer geteilten Referenz meldet der Compiler, dass sich der Wert von dort nicht herausbewegen lässt.

Ursache

Aus etwas, das dir nur geliehen ist, darfst du nichts mitnehmen. Sonst hätte der Besitzer nach dem Ausleihen ein Loch in seinen Daten. Typisch passiert das beim Zugriff auf ein Feld einer Struktur, die als lesende Referenz auf sich selbst vorliegt, oder beim Nachschlagen eines Werts in einer HashMap.

Lösung

Meistens willst du gar nicht mitnehmen, sondern nur lesen: Gib eine Referenz zurück oder arbeite direkt auf ihr. Brauchst du eine eigene Kopie, ist clone hier die vorgesehene Lösung. Und soll der Wert wirklich aus der Struktur heraus, nimm eine Methode, die den Besitz überträgt, etwa remove bei einer HashMap oder std::mem::take für ein Feld, das anschließend durch seinen Standardwert ersetzt werden darf.

Symptom

Der Code übersetzt, aber im Betrieb bricht das Programm gelegentlich mit einem BorrowMutError ab, und zwar nur unter Last.

Ursache

RefCell verschiebt die Prüfung von der Übersetzungszeit in die Laufzeit. Wer die Regel verletzt, bekommt keinen Compilerfehler mehr, sondern einen Abbruch genau in dem Moment, in dem zwei Ausleihen aufeinandertreffen. Auf selten begangenen Pfaden fällt das erst im Betrieb auf.

Lösung

Prüf zuerst, ob RefCell überhaupt nötig war oder nur den Compiler beruhigen sollte. Wird geteilter veränderlicher Zugriff wirklich gebraucht, halte die Ausleihe so kurz wie möglich: in einem eigenen Block und nie über einen Funktionsaufruf hinweg, der wieder in dieselbe Struktur greift. Über Threads hinweg gehört an diese Stelle ohnehin ein Mutex, dort wartet der zweite Zugriff, statt abzubrechen.

Fünf Fragen führen zum richtigen Umbau

  1. 01 Wird die erste Ausleihe noch gebraucht, wenn die zweite beginnt? Sonst hilft ein Block.
  2. 02 Braucht die Funktion den Wert oder reicht ein Blick? Die Signatur beantwortet das.
  3. 03 Wie groß sind die Daten und wie oft läuft die Stelle? Danach fällt clone oder nicht.
  4. 04 Teilen mehrere Stellen dauerhaft dieselben Daten? Dann geht es um Rc oder Arc.
  5. 05 Zeigen Knoten aufeinander? Dann ersetz die Verweise durch Indizes in einem Vec.
Was du mitnimmst

Was du nach dieser Seite in Minuten statt Stunden erledigst

Die sechs folgenden Handgriffe decken den Großteil der Fälle ab, die im Alltag auflaufen. Vier davon ändern nichts an der Struktur, sondern nur an der Reihenfolge und an den Signaturen, und genau die sind es, die man am längsten übersieht.

Die Meldung bis zur letzten Markierung lesen

Der Compiler markiert drei Stellen: wo die erste Ausleihe beginnt, wo die zweite dazukommt und wo die erste immer noch gebraucht wird. Die dritte Markierung enthält die eigentliche Information, und sie ist die, über die alle hinweglesen.

Die Ausleihe früher beenden

Häufig ist die Lösung schlicht ein kürzerer Gültigkeitsbereich. Zieh den inneren Zugriff mit let in eine eigene Zeile, schließ einen Block ab, oder ordne den Ablauf so, dass das Lesen fertig ist, bevor das Schreiben beginnt. Dafür braucht es weder Klon noch zusätzlichen Typ.

Den Besitz über die Signatur klären

Entscheide pro Funktion: Braucht sie den Wert, dann nimmt sie ihn entgegen. Will sie nur hinsehen, nimmt sie eine lesende Referenz. Will sie ändern, eine veränderliche. Ein großer Teil der Doppelausleih-Fehler löst sich auf, sobald die Signaturen ehrlich sind.

Klonen mit Größe und Häufigkeit vor Augen

Ein Klon beim Aufbau, in der Konfiguration oder auf einem kleinen Wert ist richtig und billig. Derselbe Klon in einer heißen Schleife über große Daten ist der Grund, warum die Rust-Fassung langsamer ist als erwartet. Entscheide, statt zu reagieren.

Rc, RefCell, Arc und Mutex richtig zuordnen

Rc für geteilten Besitz innerhalb eines Threads, RefCell für Veränderung durch eine geteilte Referenz mit Prüfung zur Laufzeit, Arc über Threads hinweg, Mutex oder RwLock für geteilte Veränderung zwischen Threads. Zu Rc mit RefCell zu greifen, weil der Compiler gemeckert hat, ist der falsche Grund.

Zeiger durch Indizes ersetzen

Bei Graphen, Bäumen und allem mit Rückverweisen legst du die Knoten in einen Vec und machst aus den Kanten Zahlen. Der Compiler ist zufrieden, die Struktur lässt sich leichter serialisieren, und es ist der Weg, den viele ausgereifte Rust-Bibliotheken selbst gehen.

KI-generiertDieses Bild wurde mit KI erzeugt · Yves Hoppe / KI / cmt

Die zwei Regeln, aus denen alle Meldungen folgen

Die erste Regel betrifft den Besitz. Jeder Wert hat genau einen Besitzer. Endet dessen Gültigkeitsbereich, wird der Wert freigegeben. Eine Zuweisung oder eine Übergabe verschiebt den Besitz, außer bei Typen, die sich billig kopieren lassen, also Zahlen, Wahrheitswerten und Ähnlichem. Aus dieser einen Regel folgt bereits, dass es keine doppelte Freigabe geben kann; dass kein Zugriff auf bereits freigegebenen Speicher übrig bleibt, ergibt sich erst zusammen mit der zweiten Regel.

Die zweite Regel betrifft das Ausleihen. An jedem Punkt im Programm existiert entweder genau eine veränderliche Ausleihe eines Werts oder beliebig viele lesende, nie beides. Daraus folgt der Ausschluss von Datenrennen, und daraus folgt auch, dass eine Sammlung sich nicht ändern lässt, während ein Iterator über sie läuft.

Wichtig für die Fehlersuche ist, wie der Compiler prüft: Er arbeitet innerhalb einer Funktion und nutzt von den aufgerufenen Funktionen nur deren Signatur. Deshalb kann eine Änderung an einer Signatur einen Fehler drei Ebenen weiter oben beheben, und deshalb lässt sich ein Fehler innerhalb einer Funktion praktisch nie durch einen Blick auf den Aufrufer lösen. Wer die lange Erklärung zu einer Meldung sehen will, bekommt sie mit rustc --explain und dem Fehlercode aus der Meldung, samt Beispiel und Gegenbeispiel.

Warum die Ausleihe länger lebt, als es im Quelltext aussieht

Eine Ausleihe endet dort, wo sie zuletzt verwendet wird, und nicht an der schließenden Klammer. Das ist der Grund, warum viele Muster inzwischen durchgehen, die früher abgelehnt wurden, und gleichzeitig der Grund für Meldungen, die zunächst unlogisch wirken: Wenn du eine Referenz zwanzig Zeilen später noch einmal benutzt, lebt sie über diese zwanzig Zeilen hinweg, auch wenn dazwischen scheinbar nichts damit passiert.

Praktisch folgen daraus drei Handgriffe. Einen Teilausdruck mit let herauszuziehen verändert, wann die Ausleihe beginnt und endet. Ein zusätzlicher Block schließt eine Ausleihe ausdrücklich ab, was besonders bei Sperren nützlich ist. Und eine Verzweigung über einen ausgeliehenen Wert hält die Ausleihe für den gesamten Zweig, weshalb ein Schreibzugriff innerhalb eines solchen Zweigs auf denselben Wert nicht durchgeht.

Wenn eine Meldung erst nach einer harmlos wirkenden Ergänzung auftaucht, ist fast immer das passiert: Die neue Zeile benutzt eine Referenz später noch einmal und verlängert damit deren Lebensdauer über eine Stelle hinweg, an der bisher der Schreibzugriff Platz hatte.

Klonen ist keine Niederlage, aber auch keine Antwort auf alles

Richtig ist ein Klon bei Konfiguration und Aufbau, bei kleinen Werten, wenn zwei unabhängige Kopien entstehen sollen, die sich anschließend getrennt entwickeln, und immer dann, wenn die Alternative eine Lebensdauer-Angabe wäre, die sich durch drei Module zieht. Diese Fälle sind häufiger, als der schlechte Ruf des Klonens vermuten lässt.

Falsch ist ein Klon in einer Schleife über große Datenmengen, auf einem Wert, der ohnehin nur gelesen wird, und als Reflex auf einen String, der genauso gut als Ausschnitt in die Signatur passen würde. Der teuerste Fall ist der, in dem die Kopie an einer Stelle entsteht, die pro Anfrage einmal läuft und deshalb im Profil erst bei Last auffällt.

Ein Sonderfall wird regelmäßig übersehen: Bei Rc und Arc erhöht das Klonen nur einen Zähler und kopiert die Daten nicht. Es kostet also fast nichts, sieht im Quelltext aber genauso aus wie ein teurer Klon. Wer stattdessen die zugehörige Funktion mit dem Typnamen davor schreibt, macht an der Aufrufstelle sichtbar, dass hier nur ein Zähler steigt.

Rc, RefCell, Arc, Mutex: welches wofür

Rc steht für mehrere Besitzer innerhalb eines Threads. Der Wert lebt, solange noch ein Rc darauf zeigt, und der Inhalt ist von außen nur lesbar. RefCell erlaubt Veränderung durch eine geteilte Referenz und prüft die Ausleih-Regel zur Laufzeit statt beim Übersetzen, bei Verletzung bricht das Programm ab. Die Kombination aus beidem ist der übliche Weg für eine geteilte, veränderliche Struktur innerhalb eines Threads. Achtung bei Kreisen: Wenn zwei Knoten sich gegenseitig über Rc halten, erreicht der Zähler nie null und der Speicher wird nicht frei. Dafür gibt es schwache Verweise, die den Zähler nicht erhöhen.

Arc ist dasselbe wie Rc, nur mit atomarem Zähler, damit es Thread-Grenzen überschreiten darf. Für Veränderung über Threads hinweg kommt ein Mutex oder ein RwLock dazu, und Arc mit Mutex ist damit das Gegenstück zu Rc mit RefCell. Der Compiler achtet über Send und Sync darauf, dass du die beiden Welten nicht verwechselst, weshalb ein Rc, der in einen Thread wandern soll, ein Übersetzungsfehler ist und kein stiller Fehler zur Laufzeit.

Die Reihenfolge ist entscheidend: Zu diesen Typen greifst du, wenn der Entwurf tatsächlich geteilten Besitz braucht, etwa bei einem Baum für eine Oberfläche oder einem Zwischenspeicher, den mehrere Aufgaben benutzen. Nicht, weil eine Meldung im Weg stand. Der Preis ist in jedem Fall eine Speicheranforderung, ein Zähler und, bei RefCell, das Risiko eines Abbruchs zur Laufzeit.

Woran du merkst, dass das Datenmodell aus einer anderen Sprache stammt

Es gibt vier ziemlich verlässliche Anzeichen: ein Feld, das auf ein anderes Objekt desselben Geflechts zeigt, ein zentrales Verwaltungsobjekt, das jeder kennt, ein Zwischenspeicher, in den mehrere Komponenten schreiben, und Objekte, die ihren Elternknoten kennen. Alle vier kosten in einer Sprache mit Speicherbereinigung nichts, und genau deshalb schreibt man sie hin, ohne darüber nachzudenken.

Die drei üblichen Umbauten dazu: Erstens Indizes statt Zeiger, also alle Knoten in einem Vec und Kanten als Zahlen. Zweitens Besitz beim Aufrufer, also Daten nach unten durchreichen statt Referenzen nach oben zu speichern. Drittens Nachrichten statt geteiltem Zustand, also Kanäle, bei denen jede Seite ihre eigenen Daten besitzt und der Besitz mit der Nachricht wandert.

Ein ehrlicher Zusatz gehört dazu: Manchmal war das Modell auch in der alten Sprache schon problematisch, es hat sich dort nur nie beschwert. Ein Zwischenspeicher, in den fünf Komponenten schreiben, ist auch in Java eine Quelle schwer nachvollziehbarer Zustände. Rust macht daraus einen Übersetzungsfehler, und das ist der eigentliche Nutzen an dieser Stelle.

Dazu passende Kurse

Weil sich diese Fälle an fremden Beispielen schlecht üben lassen, sind Rust-Kurse, in denen du eigenen Code mitbringst , der direktere Weg.

Wer aus C oder C++ kommt, erkennt in der zweiten Regel die ungültigen Iteratoren wieder, und dieselben Fallen ohne Compilerhilfe behandeln die Kurse zu C und C++ mit Blick auf Speicherfehler .

Wissen prüfen

Wie sicher bist du beim Thema wirklich?

Lesen fühlt sich schnell nach Können an. Ein kurzer Test zeigt dir, was davon schon sitzt und wo sich ein Kurs lohnt. Kostenlos, ohne Anmeldung, mit einer Erklärung zu jeder Antwort.

Sehr angenehmes, gut vorbereitetes Seminar, kann ich nur empfehlen.
Rust Fortgeschrittenenkurs

Häufige Fragen

Kann ich den Borrow-Checker abschalten?
Nein. Ein unsafe-Block erlaubt eine Handvoll zusätzlicher Operationen, unter anderem das Auflösen roher Zeiger, er schaltet die Prüfung für normale Referenzen aber nicht ab. Wenn eine Struktur wirklich nicht in den Regeln ausdrückbar ist, ist der ehrliche Weg ein kleines, gekapseltes Modul mit aufgeschriebener Zusicherung, und das ist Arbeit für Leute mit Erfahrung, nicht der Ausweg für einen hartnäckigen Fehler.
Warum meldet der Compiler den Fehler manchmal erst nach einer harmlosen Ergänzung?
Weil eine Ausleihe bis zu ihrer letzten Verwendung lebt. Wenn du eine Referenz später im Ablauf noch einmal benutzt, verlängerst du damit ihre Lebensdauer über eine Stelle hinweg, an der bisher ein Schreibzugriff Platz hatte. Die neue Zeile ist nicht die Ursache, sie macht nur sichtbar, dass zwei Zugriffe sich überschneiden.
Ist Rc mit RefCell schlechter Stil?
Nein, aber es ist häufig die falsche erste Antwort. Richtig ist die Kombination, wenn mehrere Teile tatsächlich denselben veränderlichen Zustand innerhalb eines Threads besitzen, etwa in einem Baum für eine Oberfläche. Falsch ist sie, wenn sie nur dasteht, damit der Compiler still ist. Der Preis sind eine Speicheranforderung, ein Zähler, eine Prüfung zur Laufzeit und die Möglichkeit eines Abbruchs im Betrieb.
Wie lange dauert es, bis diese Fehler aufhören?
Ganz hören sie nicht auf, sie werden schneller erledigt. Was sich ändert, ist der Zeitpunkt: Nach einigen Wochen fällt dir beim Entwerfen auf, dass zwei Stellen dieselben Daten verändern wollen, und du löst es dort. Danach ist der Borrow-Checker eher eine Bestätigung als ein Hindernis.

Passt thematisch dazu

Wenn die beiden Regeln neu für dich sind, lohnt vorab ein Blick auf die Grundbegriffe Besitz, Ausleihen und Verschieben .

Persönlich für dich da

Deine Ansprechpartner

Du bist dir nicht sicher, welcher Kurs oder welches Level zu dir passt? Wir beraten dich persönlich und kostenlos.

Yves Hoppe

Yves Hoppe

Weiterbildung & Beratung

Hilft dir, aus dem Rust-Programm den passenden Kurs für deinen Stand zu finden.

Norbert Jansen

Norbert Jansen

Beratung & Inhouse

Plant mit dir Inhouse-Trainings, die auf eure Abläufe und euren Datenbestand zugeschnitten sind.

Eigenen Code mitbringen statt Beispiele durchspielen

In den Rust-Kursen bei cmt gehst du diese Fälle an Code durch, den du selbst geschrieben hast, und siehst dabei, welcher der möglichen Umbauten der kürzere ist.