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
sin 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
Stringfertig 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.
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.
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.
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.
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:
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
boolmit den Wertentrueundfalse. - Alle Fließkomma-Typen, z.B.
f64. - Der Zeichentyp
char. - Tupel, wenn sie nur Typen enthalten, die auch
Copyunterstü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