Keyboard shortcuts

Press or to navigate between chapters

Press ? to show this help

Press Esc to hide this help

Was ist Eigentümerschaft?

Eigentümerschaft (ownership) ist eine Reihe von Regeln, die bestimmen, wie ein Rust-Programm den Speicher verwaltet. Alle Programme müssen den Arbeitsspeicher eines Rechners verwalten, während sie ausgeführt werden. Einige Sprachen verfügen über eine automatische Speicherbereinigung, die während der Programmausführung ständig nach nicht mehr genutztem Speicher sucht. Bei anderen Sprachen muss der Programmierer selbst den Speicher explizit reservieren und freigeben. Rust verwendet einen dritten Ansatz: Der Speicher wird durch ein System aus Eigentümerschaft und einer Reihe von Regeln verwaltet, die der Compiler überprüft. Wenn eine der Regeln verletzt wird, lässt sich das Programm nicht kompilieren. Keine der Eigentümerschaftsfunktionalitäten verlangsamt dein Programm, während es läuft.

Da die Eigentümerschaft für viele Programmierer ein neues Konzept ist, braucht es etwas Zeit, sich daran zu gewöhnen. Die gute Nachricht ist, je mehr Erfahrung du mit Rust und den Regeln der Eigentümerschaft gesammelt hast, desto einfacher findest du es, auf natürliche Weise Code zu entwickeln, der sicher und effizient ist. Bleib dran!

Wenn du Eigentümerschaft verstehst, hast du eine solide Grundlage, um die Funktionalitäten zu verstehen, die Rust einzigartig machen. In diesem Kapitel lernst du Eigentümerschaft kennen, indem du einige Beispiele durcharbeitest, die sich auf eine sehr verbreitete Datenstruktur konzentrieren: Strings.

Stack und Heap

Viele Programmiersprachen erfordern nicht, dass du sehr oft über Stack (Stapelspeicher) und Heap (Haldenspeicher) nachdenken musst. Aber in einer Systemprogrammiersprache wie Rust hat die Frage, ob ein Wert auf dem Stack oder im Heap liegt, einen größeren Einfluss darauf, wie sich die Sprache verhält und warum du bestimmte Entscheidungen treffen musst. Teile der Eigentümerschaft werden später in diesem Kapitel in Bezug auf den Stack und den Heap beschrieben, daher hier eine kurze Erklärung zur Vorbereitung.

Sowohl Stack als auch Heap sind Teile des Arbeitsspeichers, die deinem Code zur Laufzeit zur Verfügung stehen, aber sie sind unterschiedlich strukturiert. Der Stack speichert Werte in der Reihenfolge, in der er sie erhält, und entfernt die Werte in umgekehrter Reihenfolge. Dies wird als last in, first out (LIFO) bezeichnet. Denke an einen Stapel Teller: Wenn du weitere Teller hinzufügst, legst du sie auf den Stapel, und wenn du einen Teller benötigst, nimmst du einen von oben. Das Hinzufügen oder Entfernen von Tellern aus der Mitte oder von unten würde nicht so gut funktionieren! Das Hinzufügen von Daten nennt man auf den Stapel legen, und das Entfernen von Daten nennt man vom Stapel nehmen. Alle im Stack gespeicherten Daten müssen eine bekannte, feste Größe haben. Daten mit einer zur Kompilierzeit unbekannten Größe oder einer Größe, die sich ändern könnte, müssen stattdessen im Heap gespeichert werden.

Der Heap ist weniger organisiert: Wenn du Daten in den Heap legst, forderst du eine bestimmte Menge an Speicherplatz an. Der Speicher-Allokator sucht eine leere Stelle im Heap, die groß genug ist, markiert sie als in Benutzung und gibt einen Zeiger (pointer) zurück, der die Adresse dieser Stelle ist. Dieser Vorgang wird als Allokieren im Heap bezeichnet und manchmal mit Allokieren abgekürzt. (Das Legen von Werten auf den Stack gilt nicht als Allokieren.) Da es sich beim Zeiger um eine bekannte, feste Größe handelt, kannst du den Zeiger auf den Stack legen, aber wenn du die eigentlichen Daten benötigst, musst du dem Zeiger folgen. Stell dir vor, du sitzt in einem Restaurant. Wenn du hineingehst, gibst du die Anzahl der Personen deiner Gruppe an, und der Restaurantbesitzer findet einen leeren, ausreichend großen Tisch und führt euch dorthin. Wenn jemand aus deiner Gruppe zu spät kommt, kann er fragen, wo ihr Platz genommen habt, um euch zu finden.

Das Legen auf den Stack ist schneller als das Allokieren im Heap, da der Speicher-Allokator nie nach Platz zum Speichern neuer Daten suchen muss; dieser Ort ist immer ganz oben auf dem Stapel. Im Vergleich dazu erfordert das Allokieren von Speicherplatz im Heap mehr Arbeit, da der Speicher-Allokator zunächst einen ausreichend großen Platz für die Daten finden und dann Buch führen muss, um die nächste Allokation vorzubereiten.

Der Zugriff auf Daten im Heap ist generell langsamer als der Zugriff auf Daten auf dem Stack, da du einem Zeiger folgen musst, um dorthin zu gelangen. Heutige Prozessoren sind schneller, wenn sie weniger im Speicher herumspringen. Um die Analogie fortzusetzen, betrachte einen Kellner in einem Restaurant, der an vielen Tischen Bestellungen aufnimmt. Es ist am effizientesten, alle Bestellungen an einem Tisch aufzunehmen, bevor man zum nächsten Tisch weitergeht. Eine Bestellung von Tisch A, dann eine Bestellung von Tisch B, dann wieder eine von A und dann wieder eine von B aufzunehmen, wäre ein viel langsamerer Vorgang. Umgekehrt kann ein Prozessor seine Arbeit normalerweise besser erledigen, wenn er mit Daten arbeitet, die nahe beieinander liegen (wie sie auf dem Stack liegen) und nicht weiter voneinander entfernt (wie sie im Heap liegen können). Das Allokieren einer großen Menge an Platz im Heap kann ebenfalls Zeit in Anspruch nehmen.

Wenn dein Code eine Funktion aufruft, werden die an die Funktion übergebenen Werte (einschließlich potentieller Zeiger auf Daten im Heap) und die lokalen Variablen der Funktion auf den Stack gelegt. Wenn die Funktion beendet ist, werden diese Werte vom Stack genommen.

Das Nachverfolgen, welche Codeteile welche Daten im Heap verwenden, das Minimieren der Menge an doppelten Daten im Heap und das Aufräumen ungenutzter Daten im Heap, damit dir der Speicherplatz nicht ausgeht, sind alles Probleme, die durch Eigentümerschaft gelöst werden. Wenn du Eigentümerschaft einmal verstanden hast, brauchst du nicht mehr so oft über Stack und Heap nachzudenken. Aber zu wissen, dass der Hauptzweck der Eigentümerschaft die Verwaltung der Heap-Daten ist, kann helfen zu erklären, warum es so funktioniert, wie es funktioniert.

Eigentumsregeln

Lass uns zunächst einen Blick auf die Eigentumsregeln (ownership rules) werfen. Behalte diese Regeln im Hinterkopf, während wir veranschaulichende Beispiele durcharbeiten:

  • Jeder Wert in Rust hat einen Eigentümer (owner).
  • Es kann immer nur einen Eigentümer zur gleichen Zeit geben.
  • Wenn der Eigentümer den Gültigkeitsbereich verlässt, wird der Wert aufgeräumt.

Gültigkeitsbereich (scope) einer Variable

Da wir nun über die grundlegende Syntax hinausgehen, werden wir nicht mehr den gesamten fn main() {-Code in die Beispiele aufnehmen. Wenn du also weitermachst, musst du die folgenden Beispiele manuell in eine Funktion main einfügen. Folglich werden unsere Beispiele etwas prägnanter sein, damit wir uns auf die eigentlichen Details konzentrieren können, anstatt auch den Code darum herum betrachten zu müssen.

Als erstes Beispiel zu Eigentümerschaft werden wir uns den Gültigkeitsbereich (scope) einiger Variablen ansehen. Der Gültigkeitsbereich ist der Bereich innerhalb eines Programms, in dem ein Element gültig ist. Sieh dir folgende Variable an:

#![allow(unused)]
fn main() {
let s = "Hallo";
}

Die Variable s bezieht sich auf ein String-Literal, wobei der Wert des Strings fest in den Text unseres Programms kodiert ist. Die Variable ist ab der Stelle, an der sie deklariert wurde, bis zum Ende des aktuellen Gültigkeitsbereichs gültig. Listing 4-1 zeigt ein Programm mit Kommentaren, die zeigen, wo die Variable s gültig ist.

#![allow(unused)]
fn main() {
{                      // s ist hier nicht gültig, es wurde noch nicht deklariert
    let s = "Hallo";   // s ist ab dieser Stelle gültig

    // etwas mit s machen
}                      // dieser Gültigkeitsbereich ist nun vorbei,
                       // und s ist nicht mehr gültig
}

Listing 4-1: Eine Variable und der Bereich, in dem sie gültig ist

Mit anderen Worten, es gibt hier zwei wichtige Zeitpunkte:

  • Wenn s in den Gültigkeitsbereich kommt, ist es gültig.
  • Es bleibt gültig, bis es den Gültigkeitsbereich verlässt.

An diesem Punkt ist die Beziehung zwischen Gültigkeitsbereichen und wann Variablen gültig sind ähnlich zu anderen Programmiersprachen. Nun werden wir auf diesem Verständnis aufbauen, indem wir den Typ String einführen.

Der Typ String

Um die Eigentumsregeln zu veranschaulichen, benötigen wir einen Datentyp, der komplexer ist als die, die wir im Abschnitt „Datentypen“ in Kapitel 3 behandelt haben. Die zuvor behandelten Typen haben eine bekannte Größe, können auf dem Stack gelegt und vom Stack entfernt werden, wenn ihr Gültigkeitsbereich beendet ist, und können schnell und trivial kopiert werden, um eine neue, unabhängige Instanz zu erzeugen, wenn ein anderer Teil des Codes denselben Wert in einem anderen Gültigkeitsbereich verwenden muss. Wir wollen uns jedoch Daten ansehen, die im Heap gespeichert sind, und untersuchen, woher Rust weiß, wann es diese Daten aufräumen muss, und der Typ String ist ein gutes Beispiel dafür.

Wir werden uns auf die Teile von String konzentrieren, die sich auf die Eigentümerschaft beziehen. Diese Aspekte gelten auch für andere komplexe Datentypen, unabhängig davon, ob sie von der Standardbibliothek bereitgestellt oder von dir erstellt wurden. Wir werden die Aspekte von String, die nicht mit der Eigentümerschaft zusammenhängen, in Kapitel 8 besprechen.

Wir haben bereits String-Literale gesehen, bei denen ein String-Wert fest in unserem Programm kodiert ist. String-Literale sind praktisch, aber sie eignen sich nicht für jede Situation, in der wir Text verwenden möchten. Ein Grund dafür ist, dass sie unveränderbar sind. Ein anderer Grund ist, dass nicht jeder String-Wert bekannt ist, wenn wir unseren Code schreiben: Was ist zum Beispiel, wenn wir Benutzereingaben entgegennehmen und speichern wollen? Für diese Situationen hat Rust den String-Typ String. Dieser Typ verwaltet Daten, die auf dem Heap allokiert sind, und kann so eine Textmenge speichern, die uns zur Kompilierzeit unbekannt ist. Du kannst einen String aus einem String-Literal erzeugen, indem du die Funktion from wie folgt verwendest:

#![allow(unused)]
fn main() {
let s = String::from("Hallo");
}

Der doppelte Doppelpunkt (::) Operator erlaubt uns, diese spezielle Funktion from mit dem Namensraum des String-Typs zu benennen, anstatt einen Namen wie string_from zu verwenden. Wir werden diese Syntax im Abschnitt „Methoden“ in Kapitel 5 näher betrachten, und wenn wir in Kapitel 7 unter „Mit Pfaden auf ein Element im Modulbaum verweisen“ über den Namensraum mit Modulen sprechen.

Diese Art von String kann verändert werden:

#![allow(unused)]
fn main() {
let mut s = String::from("Hallo");

s.push_str(" Welt!"); // push_str() hängt ein Literal an einen String an

println!("{s}"); // Gibt `Hallo Welt!` aus
}

Was ist hier nun der Unterschied? Warum kann String verändert werden, Literale jedoch nicht? Der Unterschied liegt darin, wie diese beiden Typen mit dem Arbeitsspeicher umgehen.

Speicher und Allokation

Im Falle eines String-Literals kennen wir den Inhalt zum Zeitpunkt der Kompilierung, sodass der Text direkt in die endgültige ausführbare Datei fest kodiert wird. Aus diesem Grund sind String-Literale schnell und effizient. Allerdings ergeben sich diese Eigenschaften nur aus der Unveränderbarkeit des String-Literals. Leider können wir nicht für jedes Stück Text, dessen Größe zum Zeitpunkt der Kompilierung unbekannt ist und dessen Größe sich während der Ausführung des Programms ändern könnte, einen Speicherblock in die Binärdatei packen.

Um mit dem Typ String einen veränderbaren, größenänderbaren Textabschnitt zu unterstützen, müssen wir Speicher im Heap allokieren, dessen Größe zur Kompilierzeit unbekannt ist. Dies bedeutet:

  • Der Speicher muss zur Laufzeit vom Speicher-Allokator angefordert werden.
  • Wir brauchen eine Möglichkeit, diesen Speicher an den Speicher-Allokator zurückzugeben, wenn wir mit unserem String fertig sind.

Der erste Teil wird von uns erledigt: Wenn wir String::from aufrufen, fordert seine Implementierung den Speicher an, den sie benötigt. Dies ist in Programmiersprachen ziemlich einheitlich.

Der zweite Teil ist jedoch anders. In Sprachen mit einer automatischen Speicherbereinigung (garbage collector, GC) behält der GC den Überblick und räumt Speicherplatz, der nicht mehr verwendet wird, auf; wir brauchen nicht darüber nachzudenken. Ohne einen GC liegt es in unserer Verantwortung, zu erkennen, wann Speicherplatz nicht mehr benutzt wird, und Code aufzurufen, der ihn explizit zurückgibt, so wie wir es beim Anfordern auch getan haben. Dies korrekt zu tun, war in der Vergangenheit ein schwieriges Programmierproblem. Wenn wir es vergessen, verschwenden wir Speicher. Wenn wir es zu früh machen, haben wir eine ungültige Variable. Wenn wir es zweimal machen, ist das auch ein Fehler. Wir müssen eine Allokierung mit genau einer Freigabe paaren.

Rust geht einen anderen Weg: Der Speicher wird automatisch zurückgegeben, sobald die Variable, die ihn besitzt, den Gültigkeitsbereich verlässt. Hier ist eine Variante unseres Gültigkeitsbereich-Beispiels aus Listing 4-1, bei der ein String anstelle eines String-Literals verwendet wird:

#![allow(unused)]
fn main() {
{
    let s = String::from("Hallo"); // s ist ab dieser Stelle gültig

    // etwas mit s machen
}                                  // dieser Gültigkeitsbereich ist nun vorbei,
                                   // und s ist nicht mehr gültig
}

Es gibt eine natürliche Stelle, an der wir den Speicher, den unser String benötigt, an den Speicher-Allokator zurückgeben können: Wenn s den Gültigkeitsbereich verlässt. Wenn eine Variable den Gültigkeitsbereich verlässt, ruft Rust für uns eine spezielle Funktion auf: Diese Funktion heißt drop und an dieser Stelle kann der Autor von String Code einfügen, um den Speicher zurückzugeben. Rust ruft drop automatisch an der schließenden geschweiften Klammer auf.

Hinweis: In C++ wird dieses Muster, Ressourcen am Ende der Lebensdauer eines Elements freizugeben, manchmal Resource Acquisition Is Initialization (RAII) genannt. Die Funktion drop in Rust wird dir vertraut vorkommen, wenn du schon RAII-Muster verwendet hast.

Dieses Muster hat einen tiefgreifenden Einfluss auf die Art und Weise, wie Rust-Code geschrieben wird. Es mag im Moment einfach erscheinen, aber das Verhalten von Code kann in komplizierteren Situationen unerwartet sein, wenn wir wollen, dass mehrere Variablen Daten verwenden, die wir im dynamischen Speicher allokiert haben. Lass uns jetzt einige dieser Situationen untersuchen.

Variablen und Daten im Zusammenspiel mit Move

Mehrere Variablen können in Rust auf unterschiedliche Weise mit denselben Daten interagieren. Listing 4-2 zeigt ein Beispiel mit einer ganzen Zahl.

#![allow(unused)]
fn main() {
let x = 5;
let y = x;
}

Listing 4-2: Zuweisen des ganzzahligen Wertes der Variablen x an y

Wir können wahrscheinlich erahnen, was das bewirkt: „Binde den Wert 5 an x; dann erstelle eine Kopie des Wertes in x und binde ihn an y.“ Wir haben jetzt zwei Variablen x und y und beide sind gleich 5. Das ist in der Tat der Fall, denn ganze Zahlen sind einfache Werte mit einer bekannten, festen Größe, und diese beiden Werte 5 werden auf den Stack gelegt.

Schauen wir uns nun die String-Variante an:

#![allow(unused)]
fn main() {
let s1 = String::from("Hallo");
let s2 = s1;
}

Dies sieht sehr ähnlich aus, sodass wir annehmen könnten, dass die Funktionsweise die gleiche wäre: Das heißt, die zweite Zeile würde eine Kopie des Wertes in s1 erstellen und sie an s2 binden. Aber das ist nicht ganz das, was passiert.

Betrachte Abbildung 4-1, um zu sehen, was mit dem String unter der Haube geschieht. Ein String besteht aus drei Teilen, die auf der linken Seite dargestellt sind: Einem Zeiger auf den Speicherbereich, der den Inhalt des Strings enthält, die Länge und die Kapazität. Dieser Datenblock wird auf dem Stack gespeichert. Auf der rechten Seite ist der Speicherbereich im Heap, der den Inhalt enthält.

Zwei Tabellen: Die erste Tabelle enthält die Darstellung von s1 auf
dem Stack, bestehend aus seiner Länge (5), seiner Kapazität (5) und einem Zeiger
auf den ersten Wert in der zweiten Tabelle. Die zweite Tabelle enthält die
Darstellung der String-Daten auf dem Heap, Byte für Byte.

Abbildung 4-1: Speicherdarstellung eines String mit dem Wert „Hallo“, gebunden an s1

Die Länge gibt an, wie viel Speicherplatz in Bytes der Inhalt des Strings derzeit belegt. Die Kapazität ist die Gesamtmenge des Speichers in Bytes, die der String vom Speicher-Allokator erhalten hat. Der Unterschied zwischen Länge und Kapazität ist von Bedeutung, aber nicht in diesem Zusammenhang, deshalb ist es im Moment in Ordnung, die Kapazität zu ignorieren.

Wenn wir s1 an s2 zuweisen, werden die String-Daten kopiert, d.h. wir kopieren den Zeiger, die Länge und die Kapazität, die sich auf dem Stack befinden. Wir kopieren nicht die Daten im Heap, auf die sich der Zeiger bezieht. Die Speicherdarstellung sieht also wie in Abbildung 4-2 aus.

Drei Tabellen: Die Tabellen s1 und s2, die die Strings auf dem Stack
repräsentieren und beide auf die gleichen String-Daten auf dem Heap verweisen.

Abbildung 4-2: Speicherdarstellung der Variable s2, die eine Kopie des Zeigers, der Länge und der Kapazität von s1 hat

Die Darstellung sieht nicht wie Abbildung 4-3 aus, so wie der Speicher aussehen würde, wenn Rust stattdessen auch die Daten im Heap kopieren würde. Würde Rust dies tun, könnte die Operation s2 = s1 bei großen Datenmengen im Heap sehr teuer hinsichtlich der Laufzeitperformanz werden.

Vier Tabellen: Zwei Tabellen, die die Stack-Daten für s1 und s2
darstellen, und jede zeigt auf ihre eigene Kopie der String-Daten auf dem Heap.

Abbildung 4-3: Eine weitere Möglichkeit für das, was s2 = s1 tun könnte, falls Rust auch die Daten im Heap kopieren würde

Vorhin sagten wir, dass Rust automatisch die Funktion drop aufruft und den Heap für diese Variable aufräumt, wenn eine Variable den Gültigkeitsbereich verlässt. Abbildung 4-2 zeigt jedoch, dass beide Datenzeiger auf dieselbe Stelle zeigen. Das ist ein Problem: Wenn s2 und s1 den Gültigkeitsbereich verlassen, werden beide versuchen, den gleichen Speicher freizugeben. Dies wird als doppelter Freigabefehler (double free error) bezeichnet und ist einer der Speichersicherheitsfehler, die wir zuvor erwähnt haben. Das zweimalige Freigeben des Speichers kann zu einer Speicherverfälschung führen, was potenziell zu Sicherheitslücken führen kann.

Um Speichersicherheit zu gewährleisten, betrachtet Rust nach der Zeile let s2 = s1; die Variable s1 als nicht mehr gültig. Daher braucht Rust nichts freizugeben, wenn s1 den Gültigkeitsbereich verlässt. Schau dir an, was passiert, wenn du versuchst, s1 zu benutzen, nachdem s2 erstellt wurde; es wird nicht funktionieren:

#![allow(unused)]
fn main() {
let s1 = String::from("Hallo");
let s2 = s1;

println!("{s1} Welt!");
}

Du erhältst eine Fehlermeldung wie diese, wodurch Rust dich daran hindert, die ungültige Referenz zu verwenden:

   Compiling playground v0.0.1 (/playground)
error[E0382]: borrow of moved value: `s1`
 --> src/main.rs:6:11
  |
3 | let s1 = String::from("Hallo");
  |     -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
4 | let s2 = s1;
  |          -- value moved here
5 |
6 | println!("{s1} Welt!");
  |           ^^^^ value borrowed here after move
  |
  = note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)
help: consider cloning the value if the performance cost is acceptable
  |
4 | let s2 = s1.clone();
  |            ++++++++

For more information about this error, try `rustc --explain E0382`.
error: could not compile `playground` (bin "playground") due to 1 previous error

Wenn du beim Arbeiten mit anderen Sprachen schon mal die Begriffe flache Kopie (shallow copy) und tiefe Kopie (deep copy) gehört hast, hört sich das Konzept des Kopierens des Zeigers, der Länge und der Kapazität ohne Kopieren der Daten nach einer flachen Kopie an. Aber weil Rust auch die erste Variable ungültig macht, wird es nicht als flache Kopie, sondern als Verschieben (move) bezeichnet. In diesem Beispiel würden wir sagen, dass s1 in s2 verschoben wurde. Was tatsächlich geschieht, ist in Abbildung 4-4 dargestellt.

Drei Tabellen: Die Tabellen s1 und s2, die jeweils die Strings auf dem
Stack darstellen und beide auf dieselben String-Daten auf dem Heap
referenzieren. Die Tabelle s1 ist durchgestrichen, weil s1 nicht mehr gültig
ist; nur s2 kann für den Zugriff auf die Heap-Daten verwendet werden.

Abbildung 4-4: Speicherdarstellung, nachdem s1 ungültig gemacht wurde

Damit ist unser Problem gelöst! Da nur s2 gültig ist, wenn es den Gültigkeitsbereich verlässt, wird es allein den Speicher freigeben, und wir sind fertig.

Darüber hinaus gibt es eine Entwurfsentscheidung, die damit impliziert ist: Rust wird niemals automatisch „tiefe“ Kopien deiner Daten erstellen. Daher kann man davon ausgehen, dass jedes automatische Kopieren im Hinblick auf die Laufzeitperformanz kostengünstig ist.

Gültigkeitsbereich und Zuweisung

Umgekehrt gilt dies auch für die Beziehung zwischen Gültigkeitsbereich, Eigentümerschaft und Speicherfreigabe durch die Funktion drop. Wenn du einer bestehenden Variablen einen völlig neuen Wert zuweist, ruft Rust die Funktion drop auf und gibt den Speicher des ursprünglichen Wertes sofort frei. Betrachte zum Beispiel diesen Code:

fn main() {
    let mut s = String::from("Hallo");
    s = String::from("Ahoi");

    println!("{s} Welt!");
}

Wir deklarieren zunächst eine Variable s und binden sie an einen String mit dem Wert „Hallo“. Danach erstellen wir einen neuen String mit dem Wert "Ahoi" und weisen ihn der Variable s zu. Zu diesem Zeitpunkt referenziert nichts mehr auf den ursprünglichen Wert im Heap. Abbildung 4-5 zeigt die aktuellen Daten im Speicher:

Eine Tabelle stellt den String-Wert auf dem Heap dar und zeigt auf den
zweiten Teil der String-Daten (Ahoi) auf dem Heap, wobei die ursprünglichen
String-Daten (Hallo) durchgestrichen sind, weil auf sie nicht mehr zugegriffen
werden kann.

Abbildung 4-5: Darstellung im Speicher, nachdem der ursprüngliche Wert vollständig ersetzt worden ist.

Der ursprüngliche String verlässt damit den Gültigkeitsbereich. Rust führt die Funktion drop aus und ihr Speicher wird sofort freigegeben. Wenn wir den Wert am Ende ausgeben, lautet er „Ahoi Welt!“.

Variablen und Daten im Zusammenspiel mit Clone

Wenn wir die Daten von String im Heap tief kopieren wollen, nicht nur die Stack-Daten, können wir eine gängige Methode namens clone verwenden. Wir werden die Methodensyntax in Kapitel 5 besprechen, aber da Methoden eine gängige Funktionalität vieler Programmiersprachen sind, hast du sie wahrscheinlich schon einmal gesehen.

Hier ist ein Beispiel für die Methode clone:

#![allow(unused)]
fn main() {
let s1 = String::from("Hallo");
let s2 = s1.clone();

println!("s1 = {s1}, s2 = {s2}");
}

Das funktioniert sehr gut und erzeugt explizit das in Abbildung 4-3 gezeigte Verhalten, bei dem die Daten im Heap kopiert werden.

Wenn du einen Aufruf von clone siehst, weißt du, dass irgendein beliebiger Code ausgeführt wird und dass dieser Code teuer sein könnte. Es ist ein visueller Indikator dafür, dass etwas anderes vor sich geht.

Reine Stack-Daten: Copy

Es gibt noch einen weiteren Kniff, über den wir noch nicht gesprochen haben. Folgender Code mit ganzen Zahlen, der teilweise in Listing 4-2 gezeigt wurde, funktioniert und ist gültig:

#![allow(unused)]
fn main() {
let x = 5;
let y = x;

println!("x = {x}, y = {y}");
}

Aber dieser Code scheint dem zu widersprechen, was wir gerade gelernt haben: Wir haben keinen Aufruf von clone, aber x ist immer noch gültig und wurde nicht in y verschoben.

Der Grund dafür ist, dass Typen wie ganze Zahlen, die zur Kompilierzeit eine bekannte Größe haben, vollständig auf dem Stack gespeichert werden, so dass Kopien der tatsächlichen Werte schnell erstellt werden können. Das bedeutet, dass es keinen Grund gibt, warum wir verhindern wollen, dass x gültig ist, nachdem wir die Variable y erstellt haben. Mit anderen Worten, es gibt hier keinen Unterschied zwischen tiefen und flachen Kopien, also würde der Aufruf clone nichts anderes tun als das übliche flache Kopieren, und wir können es weglassen.

Rust hat eine spezielle Annotation, das Trait Copy, die wir an Typen hängen können, die auf dem Stack wie ganze Zahlen gespeichert sind (wir werden in Kapitel 10 mehr über Traits sprechen). Wenn ein Typ das Trait Copy implementiert, werden Variablen, die dieses Trait verwenden, nicht verschoben, sondern trivialerweise kopiert, sodass sie auch nach der Zuweisung an eine andere Variable noch gültig sind.

Rust lässt uns einen Typ nicht mit dem Trait Copy annotieren, wenn der Typ oder einer seiner Teile das Trait Drop implementiert. Wenn der Typ eine Sonderbehandlung benötigt, wenn der Wert den Gültigkeitsbereich verlässt und wir die Annotation Copy zu diesem Typ hinzufügen, erhalten wir einen Compilerfehler. Um zu erfahren, wie du die Copy-Annotation zu deinem Typ hinzufügen kannst, siehe „Ableitbare Traits“ in Anhang C.

Welche Typen unterstützen also Copy? Du kannst die Dokumentation für einen gegebenen Typ überprüfen, um sicherzugehen, aber als allgemeine Regel gilt: Jede Gruppierung von einfachen skalaren Werten unterstützt Copy, und nichts, was eine Allokation erfordert oder irgendeine Form von Ressource ist, kann Copy implementieren. Hier sind einige Typen, die Copy unterstützen:

  • Alle ganzzahligen Typen, z.B. u32.
  • Der boolesche Typ bool mit den Werten true und false.
  • Alle Fließkomma-Typen, z.B. f64.
  • Der Zeichentyp char.
  • Tupel, wenn sie nur Typen enthalten, die auch Copy unterstützen. Zum Beispiel unterstützt (i32, i32) Copy, nicht aber (i32, String).

Eigentümerschaft und Funktionen

Die Übergabe eines Wertes an eine Funktion funktioniert ähnlich wie die Zuweisung eines Wertes an eine Variable. Wenn eine Variable an eine Funktion übergeben wird, wird sie verschoben oder kopiert, genau wie bei der Zuweisung. Listing 4-3 enthält ein Beispiel mit einigen Anmerkungen, aus denen hervorgeht, wo Variablen in den Gültigkeitsbereich fallen und wo nicht.

Dateiname: src/main.rs

fn main() {
    let s = String::from("Hallo");  // s kommt in den Gültigkeitsbereich

    takes_ownership(s);             // Der Wert von s wird in die Funktion
                                    // verschoben und ist daher hier nicht
                                    // mehr gültig.

    let x = 5;                      // x kommt in den Gültigkeitsbereich

    makes_copy(x);                  // x würde in die Funktion verschoben werden,
                                    // aber i32 erlaubt Copy, also ist es in
                                    // Ordnung, danach immer noch x zu verwenden.

} // Hier verlassen s und x den Gültigkeitsbereich.
  // Aber weil der Wert von s verschoben wurde, passiert nichts Besonderes.

fn takes_ownership(some_string: String) { // some_string kommt in den
                                          // Gültigkeitsbereich
    println!("{some_string}");
} // Hier verlässt some_string den Gültigkeitsbereich und `drop` wird aufgerufen.
  // Der zugehörige Speicherplatz wird freigegeben.

fn makes_copy(some_integer: i32) { // some_integer kommt in den Gültigkeitsbereich
    println!("{some_integer}");
} // Hier verlässt some_integer den Gültigkeitsbereich.
  // Es passiert nichts Besonderes.

Listing 4-3: Funktionen mit kommentiertem Eigentum und Gültigkeitsbereich

Wenn wir versuchen würden, s nach dem Aufruf von takes_ownership zu verwenden, würde Rust einen Compilerfehler anzeigen. Diese statischen Prüfungen schützen uns vor Fehlern. Versuche, weiteren Code zu main hinzuzufügen, der s und x verwendet, um zu sehen, wo du sie verwenden kannst und wo die Eigentumsregeln dich daran hindern.

Rückgabewerte und Gültigkeitsbereich

Rückgabewerte können auch das Eigentum übertragen. Listing 4-4 ist ein Beispiel für eine Funktion mit einem Rückgabewert mit ähnlichen Anmerkungen wie die in Listing 4-3.

Dateiname: src/main.rs

fn main() {
    let s1 = gives_ownership();         // gives_ownership verschiebt seinen
                                        // Rückgabewert in s1

    let s2 = String::from("Hallo");     // s2 kommt in den Gültigkeitsbereich

    let s3 = takes_and_gives_back(s2);  // s2 wird in takes_and_gives_back
                                        // verschoben und der Rückgabewert
                                        // wird in s3 verschoben

} // Hier verlässt s3 den Gültigkeitsbereich und wird aufgeräumt.
  // s2 wurde verschoben, es passiert also nichts.
  // s1 verlässt den Gültigkeitsbereich und wird aufgeräumt.

fn gives_ownership() -> String {        // gives_ownership verschiebt seinen
                                        // Rückgabewert in die aufrufende Funktion

    let some_string = String::from("Hallo"); // some_string kommt in den
                                             // Gültigkeitsbereich

    some_string                         // some_string wird zurückgegeben und
                                        // wird an die aufrufende Funktion
                                        // verschoben
}

// Diese Funktion nimmt einen String entgegen und gibt einen zurück
fn takes_and_gives_back(a_string: String) -> String { // a_string kommt in den
                                                      // Gültigkeitsbereich

    a_string  // a_string wird zurückgegeben und
              // an die aufrufende Funktion verschoben
}

Listing 4-4: Übertragen des Eigentums an Rückgabewerten

Das Eigentum an einer Variable folgt jedes Mal dem gleichen Muster: Das Zuweisen eines Wertes an eine andere Variable verschiebt diese. Wenn eine Variable, die Daten im Heap enthält, den Gültigkeitsbereich verlässt, wird der Wert durch drop aufgeräumt, es sei denn, das Eigentum wurde auf eine andere Variable verschoben.

Dies funktioniert zwar, allerdings ist es etwas mühsam, das Eigentum zu übernehmen und in jeder Funktion zurückzugeben. Was ist, wenn wir eine Funktion einen Wert nutzen lassen wollen, aber nicht das Eigentum übergeben wollen? Es ist ziemlich lästig, dass alles, was wir übergeben, auch wieder zurückgegeben werden muss, wenn wir es wieder verwenden wollen, zusätzlich zu den Daten, die sich aus dem Funktionsrumpf ergeben, die wir vielleicht auch zurückgeben wollen.

Rust macht es möglich, mehrere Werte mit Hilfe eines Tupels zurückzugeben, wie in Listing 4-5 gezeigt.

Dateiname: src/main.rs

fn main() {
    let s1 = String::from("Hallo");

    let (s2, len) = calculate_length(s1);

    println!("Die Länge von '{s2}' ist {len}.");
}

fn calculate_length(s: String) -> (String, usize) {
    let length = s.len(); // len() gibt die Länge des Strings zurück

    (s, length)
}

Listing 4-5: Rückgeben des Eigentums an Parametern

Aber das ist zu viel Zeremonie und zu viel Arbeit für ein Konzept, das gebräuchlich sein sollte. Zum Glück gibt es in Rust eine Funktion, mit der man einen Wert verwenden kann, ohne das Eigentum zu übertragen: Referenzen