Was ist Besitz?

RustRustBeginner
Jetzt üben

This tutorial is from open-source community. Access the source code

💡 Dieser Artikel wurde von AI-Assistenten übersetzt. Um die englische Version anzuzeigen, können Sie hier klicken

Einführung

Willkommen zu Was ist Besitz?. Dieser Lab ist ein Teil des Rust Buches. Du kannst deine Rust-Fähigkeiten in LabEx üben.

In diesem Lab wirst du über Besitz in Rust lernen, einer Reihe von Regeln, die bestimmen, wie ein Programm den Arbeitsspeicher verwaltet und wie dies das Verhalten und die Leistung der Sprache beeinflusst.


Skills Graph

%%%%{init: {'theme':'neutral'}}%%%% flowchart RL rust(("Rust")) -.-> rust/BasicConceptsGroup(["Basic Concepts"]) rust(("Rust")) -.-> rust/DataTypesGroup(["Data Types"]) rust(("Rust")) -.-> rust/FunctionsandClosuresGroup(["Functions and Closures"]) rust(("Rust")) -.-> rust/DataStructuresandEnumsGroup(["Data Structures and Enums"]) rust/BasicConceptsGroup -.-> rust/variable_declarations("Variable Declarations") rust/BasicConceptsGroup -.-> rust/mutable_variables("Mutable Variables") rust/DataTypesGroup -.-> rust/string_type("String Type") rust/FunctionsandClosuresGroup -.-> rust/function_syntax("Function Syntax") rust/FunctionsandClosuresGroup -.-> rust/expressions_statements("Expressions and Statements") rust/DataStructuresandEnumsGroup -.-> rust/method_syntax("Method Syntax") subgraph Lab Skills rust/variable_declarations -.-> lab-100392{{"Was ist Besitz?"}} rust/mutable_variables -.-> lab-100392{{"Was ist Besitz?"}} rust/string_type -.-> lab-100392{{"Was ist Besitz?"}} rust/function_syntax -.-> lab-100392{{"Was ist Besitz?"}} rust/expressions_statements -.-> lab-100392{{"Was ist Besitz?"}} rust/method_syntax -.-> lab-100392{{"Was ist Besitz?"}} end

Was ist Besitz?

Besitz ist eine Reihe von Regeln, die bestimmen, wie ein Rust-Programm den Arbeitsspeicher verwaltet. Alle Programme müssen die Art und Weise steuern, wie sie den Arbeitsspeicher eines Computers während der Ausführung verwenden. Einige Sprachen haben einen Garbage Collector, der während der Ausführung regelmäßig nach nicht mehr verwendeten Arbeitsspeicher sucht; in anderen Sprachen muss der Programmierer den Arbeitsspeicher explizit zuweisen und freigeben. Rust verwendet einen dritten Ansatz: Der Arbeitsspeicher wird durch ein System des Besitzes mit einer Reihe von Regeln verwaltet, die der Compiler überprüft. Wenn eine der Regeln verletzt wird, kompiliert das Programm nicht. Keine der Eigenschaften des Besitzes verlangsamt dein Programm während der Ausführung.

Da der Besitz für viele Programmierer ein neues Konzept ist, dauert es einige Zeit, sich daran zu gewöhnen. Die gute Nachricht ist, dass du mit zunehmender Erfahrung mit Rust und den Regeln des Besitzsystems findest, dass es einfacher wird, Code zu entwickeln, der sicher und effizient ist. Halte durch!

Wenn du den Besitz verstehst, wirst du eine solide Grundlage für das Verständnis der Eigenschaften haben, die Rust einzigartig machen. In diesem Kapitel wirst du den Besitz durch einige Beispiele lernen, die sich auf eine sehr häufige Datenstruktur konzentrieren: Strings.

Der Stapel und der Heap

Viele Programmiersprachen erfordern es dich nicht, dich oft mit dem Stapel und dem Heap zu befassen. Aber in einer Systems-Programmiersprache wie Rust hat es Auswirkungen auf das Verhalten der Sprache und warum du bestimmte Entscheidungen treffen musst, ob ein Wert auf dem Stapel oder auf dem Heap liegt. Teile des Besitzes werden später in diesem Kapitel im Zusammenhang mit dem Stapel und dem Heap beschrieben, hier ist daher eine kurze Erklärung als Vorbereitung.

Sowohl der Stapel als auch der Heap sind Teile des Arbeitsspeichers, der deinem Code zur Laufzeit zur Verfügung steht, aber sie sind unterschiedlich strukturiert. Der Stapel speichert Werte in der Reihenfolge, in der er sie erhält, und entfernt die Werte in umgekehrter Reihenfolge. Dies wird als letzt dran, zuerst raus bezeichnet. Denke an einen Stapel mit Tellern: Wenn du mehr Teller hinzufügst, legst du sie auf den Stapel, und wenn du einen Teller brauchst, nimmst du einen von oben. Ein Teller aus der Mitte oder unten hinzufügen oder entfernen würde nicht so gut funktionieren! Das Hinzufügen von Daten wird als Auf den Stapel legen bezeichnet, und das Entfernen von Daten wird als Von dem Stapel nehmen bezeichnet. Alle auf dem Stapel gespeicherten Daten müssen eine bekannte, feste Größe haben. Daten mit einer unbekannten Größe zur Compile-Zeit oder einer Größe, die sich ändern kann, müssen stattdessen auf dem Heap gespeichert werden.

Der Heap ist weniger strukturiert: Wenn du Daten auf den Heap legst, beantragst du einen bestimmten Speicherplatz. Der Arbeitsspeicherzuweisungsdienst sucht einen leeren Platz im Heap, der groß genug ist, markiert ihn als in Gebrauch und gibt einen Zeiger zurück, der die Adresse dieses Ortes ist. Dieser Prozess wird als Auf dem Heap zuweisen bezeichnet und wird manchmal abgekürzt als einfach zuweisen (das Auf den Stapel legen von Werten wird nicht als zuweisen betrachtet). Da der Zeiger auf den Heap eine bekannte, feste Größe hat, kannst du den Zeiger auf dem Stapel speichern, aber wenn du das tatsächliche Daten benötigst, musst du den Zeiger folgen. Denke daran, in einem Restaurant Platz zu nehmen. Wenn du eintreibst, gibst du die Anzahl der Personen in deiner Gruppe an, und der Wirt findet einen leeren Tisch, der für alle passt, und führt dich dahin. Wenn jemand in deiner Gruppe später kommt, kann er fragen, wo ihr gesessen seid, um euch zu finden.

Das Auf den Stapel legen ist schneller als das Zuweisen auf dem Heap, weil der Arbeitsspeicherzuweisungsdienst nie suchen muss, einen Platz zum Speichern neuer Daten zu finden; dieser Ort ist immer oben auf dem Stapel. Im Vergleich dazu erfordert das Zuweisen von Speicherplatz auf dem Heap mehr Arbeit, weil der Arbeitsspeicherzuweisungsdienst zunächst einen groß genug Platz finden muss, um die Daten aufzunehmen, und dann Buchhaltung betreiben muss, um sich auf die nächste Zuweisung vorzubereiten.

Das Zugreifen auf Daten im Heap ist langsamer als das Zugreifen auf Daten auf dem Stapel, weil du einen Zeiger folgen musst, um dorthin zu gelangen. Moderne Prozessoren arbeiten schneller, wenn sie weniger im Arbeitsspeicher springen. Fortführend mit der Analogie, stell dir einen Kellner in einem Restaurant vor, der Bestellungen von vielen Tischen entgegennimmt. Es ist am effizientesten, alle Bestellungen von einem Tisch zu erhalten, bevor man zum nächsten Tisch geht. Eine Bestellung von Tisch A, dann eine von Tisch B, dann wieder eine von A und dann wieder eine von B wäre ein viel langsamerer Prozess. Aus dem gleichen Grund kann ein Prozessor seine Arbeit besser erledigen, wenn er an Daten arbeitet, die in der Nähe anderer Daten liegen (wie auf dem Stapel), als wenn sie weiter weg liegen (wie auf dem Heap).

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

Die Aufzeichnung davon, welche Teile des Codes welche Daten auf dem Heap verwenden, die Minimierung der Anzahl von Duplikaten auf dem Heap und das Bereinigen von nicht mehr verwendeten Daten auf dem Heap, damit du nicht aus Platz kommst, sind alle Probleme, die der Besitz löst. Wenn du den Besitz verstehst, musst du dich nicht oft mit dem Stapel und dem Heap befassen, aber das Wissen, dass der Hauptzweck des Besitzes darin besteht, Heap-Daten zu verwalten, kann helfen, zu erklären, warum er so funktioniert.

Besitzregeln

Zunächst schauen wir uns die Besitzregeln an. Halten Sie diese Regeln im Kopf, während wir durch die Beispiele gehen, die sie veranschaulichen:

  • Jeder Wert in Rust hat einen Besitzer.
  • Es kann zu einem bestimmten Zeitpunkt nur einen Besitzer geben.
  • Wenn der Besitzer außerhalb seines Gültigkeitsbereichs fällt, wird der Wert gelöscht.

Variablenbereich

Jetzt, nachdem wir die grundlegende Rust-Syntax hinter uns haben, werden wir in den Beispielen nicht mehr den gesamten fn main() {-Code angeben. Wenn du mitmachst, musst du daher die folgenden Beispiele manuell in eine main-Funktion einfügen. Dadurch werden unsere Beispiele etwas kürzer und wir können uns auf die tatsächlichen Details konzentrieren, anstatt auf Boilerplate-Code.

Als erstes Beispiel für Besitz betrachten wir den Bereich von Variablen. Ein Bereich ist der Bereich innerhalb eines Programms, für den ein Element gültig ist. Nehmen wir die folgende Variable:

let s = "hello";

Die Variable s bezieht sich auf einen String-Literal, wobei der Wert des Strings in den Text unseres Programms hartenkodiert ist. Die Variable ist von dem Zeitpunkt an gültig, zu dem sie deklariert wird, bis zum Ende des aktuellen Bereichs. Listing 4-1 zeigt ein Programm mit Kommentaren, die angeben, wo die Variable s gültig wäre.

{                      // s ist hier nicht gültig, da sie noch nicht deklariert ist
    let s = "hello";   // s ist ab diesem Zeitpunkt gültig

    // mache etwas mit s
}                      // dieser Bereich ist jetzt 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 zwei wichtige Zeitpunkte hier:

  • Wenn s in den Bereich kommt, ist sie gültig.
  • Sie bleibt gültig, bis sie außerhalb des Bereichs fällt.

An diesem Punkt ist die Beziehung zwischen Bereichen und der Gültigkeit von Variablen ähnlich wie in anderen Programmiersprachen. Jetzt bauen wir auf diesem Verständnis auf, indem wir den String-Typ einführen.

Der String-Typ

Um die Besitzregeln zu veranschaulichen, benötigen wir einen Datentyp, der komplexer ist als diejenigen, die wir in "Datentypen" behandelt haben. Die zuvor behandelten Typen haben eine bekannte Größe, können auf dem Stapel gespeichert und beim Verlassen ihres Bereichs vom Stapel entfernt werden und können schnell und einfach kopiert werden, um eine neue, unabhängige Instanz zu erstellen, wenn ein anderer Teil des Codes denselben Wert in einem anderen Bereich verwenden muss. Wir möchten jedoch Daten betrachten, die auf dem Heap gespeichert sind, und untersuchen, wie Rust weiß, wann diese Daten bereinigt werden sollen, und der String-Typ ist ein ausgezeichnetes Beispiel.

Wir werden uns auf die Teile von String konzentrieren, die mit dem Besitz zusammenhängen. Diese Aspekte gelten auch für andere komplexe Datentypen, ob sie von der Standardbibliothek bereitgestellt werden oder von Ihnen erstellt werden. Wir werden String im Kapitel 8 im Detail diskutieren.

Wir haben bereits String-Literale gesehen, bei denen ein String-Wert in unserem Programm hartenkodiert ist. String-Literale sind praktisch, aber sie eignen sich nicht für jede Situation, in der wir möglicherweise Text verwenden möchten. Ein Grund dafür ist, dass sie unveränderlich sind. Ein weiterer Grund ist, dass nicht jeder String-Wert bekannt sein kann, wenn wir unseren Code schreiben: Was ist beispielsweise, wenn wir Benutzereingaben entgegennehmen und speichern möchten? Für diese Situationen hat Rust einen zweiten String-Typ, String. Dieser Typ verwaltet Daten, die auf dem Heap zugewiesen werden, und kann daher eine unbekannte Anzahl von Zeichen speichern, die uns zur Compile-Zeit unbekannt sind. Du kannst einen String aus einem String-Literal mit der from-Funktion erstellen, wie folgt:

let s = String::from("hello");

Der Doppelpunkt ::-Operator ermöglicht es uns, diese bestimmte from-Funktion im Namensraum des String-Typs zu platzieren, anstatt einen Namen wie string_from zu verwenden. Wir werden diese Syntax im Abschnitt "Methodensyntax" und wenn wir über Namensräume mit Modulen im Abschnitt "Pfade zur Referenz auf ein Element im Modultree" diskutieren.

Dieser Art von String kann verändert werden:

let mut s = String::from("hello");

s.push_str(", world!"); // push_str() fügt ein Literal an einen String an

println!("{s}"); // Dies wird `hello, world!` ausgeben

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

Arbeitsspeicher und Zuweisung

Im Fall eines String-Literals kennen wir den Inhalt zur Compile-Zeit, sodass der Text direkt in das endgültige ausführbare Programm hartenkodiert ist. Deshalb sind String-Literale schnell und effizient. Aber diese Eigenschaften resultieren nur aus der Unveränderlichkeit des String-Literals. Leider können wir nicht für jedes Stück Text, dessen Größe zur Compile-Zeit unbekannt ist und die sich während der Programmausführung ändern kann, einen Arbeitsspeicherblock in das Binärprogramm einfügen.

Mit dem String-Typ müssen wir, um einen veränderlichen, wachsenden Text zu unterstützen, einen Arbeitsspeicherplatz auf dem Heap reservieren, der zur Compile-Zeit unbekannt ist, um den Inhalt zu speichern. Dies bedeutet:

  • Der Arbeitsspeicher muss zur Laufzeit vom Arbeitsspeicherzuweisungsdienst angefordert werden.
  • Wir brauchen eine Möglichkeit, diesen Arbeitsspeicherplatz 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 benötigten Arbeitsspeicherplatz an. Dies ist in den meisten Programmiersprachen weit verbreitet.

Der zweite Teil ist jedoch unterschiedlich. In Sprachen mit einem Garbage Collector (GC) überwacht der GC den Arbeitsspeicher und bereinigt nicht mehr verwendeten Speicher, und wir müssen uns nicht darum kümmern. In den meisten Sprachen ohne GC ist es unsere Verantwortung, zu identifizieren, wann der Arbeitsspeicher nicht mehr verwendet wird, und Code aufzurufen, um ihn explizit freizugeben, genauso wie wir ihn angefordert haben. Dies hat sich historisch als schwieriges Programmierproblem erwiesen. Wenn wir vergessen, verschwenden wir Arbeitsspeicher. Wenn wir es zu früh tun, haben wir eine ungültige Variable. Wenn wir es zweimal tun, ist das ebenfalls ein Fehler. Wir müssen genau eine allocate mit genau einer free verknüpfen.

Rust wählt einen anderen Weg: Der Arbeitsspeicher wird automatisch zurückgegeben, sobald die Variable, die ihn besitzt, außerhalb ihres Gültigkeitsbereichs fällt. Hier ist eine Version unseres Bereichsbeispiels aus Listing 4-1, bei der wir einen String anstelle eines String-Literals verwenden:

{
    let s = String::from("hello"); // s ist ab diesem Zeitpunkt gültig

    // mache etwas mit s
}                                  // dieser Bereich ist jetzt vorbei, und s ist nicht mehr gültig

Es gibt einen natürlichen Zeitpunkt, zu dem wir den Arbeitsspeicherplatz zurückgeben können, den unser String benötigt, dem Arbeitsspeicherzuweisungsdienst: wenn s außerhalb seines Gültigkeitsbereichs fällt. Wenn eine Variable außerhalb ihres Gültigkeitsbereichs fällt, ruft Rust eine spezielle Funktion für uns auf. Diese Funktion heißt drop, und hier kann der Autor von String den Code einfügen, um den Arbeitsspeicherplatz zurückzugeben. Rust ruft drop automatisch bei der schließenden geschweiften Klammer auf.

Hinweis: In C++ wird dieses Muster des Freigebens von Ressourcen am Ende der Lebensdauer eines Elements manchmal als Resource Acquisition Is Initialization (RAII) bezeichnet. Die drop-Funktion in Rust sollte Ihnen vertraut sein, wenn Sie RAII-Muster verwendet haben.

Dieses Muster hat einen tiefgreifenden Einfluss auf die Art und Weise, wie Rust-Code geschrieben wird. Es mag momentan einfach erscheinen, aber das Verhalten des Codes kann in komplexeren Situationen unerwartet sein, wenn wir mehrere Variablen verwenden möchten, die auf dem Heap zugewiesenen Daten nutzen. Lassen Sie uns einige dieser Situationen jetzt untersuchen.

Variablen und Daten, die mit Move interagieren

In Rust können mehrere Variablen auf die gleichen Daten auf verschiedene Weise zugreifen. Schauen wir uns ein Beispiel mit einem Integer in Listing 4-2 an.

let x = 5;
let y = x;

Listing 4-2: Zuweisen des ganzzahligen Werts von Variable x an y

Wir können wahrscheinlich raten, was hier passiert: "Binde den Wert 5 an x; kopiere dann den Wert in x und binde ihn an y." Wir haben jetzt zwei Variablen, x und y, und beide sind gleich 5. Tatsächlich passiert genau das, weil Integer einfache Werte mit einer bekannten, festen Größe sind, und diese beiden 5-Werte werden auf den Stapel gelegt.

Schauen wir uns jetzt die String-Version an:

let s1 = String::from("hello");
let s2 = s1;

Dies sieht sehr ähnlich aus, also könnten wir annehmen, dass es auf die gleiche Weise funktioniert: dass heißt, die zweite Zeile würde einen Kopie des Werts in s1 erstellen und ihn an s2 binden. Dies ist jedoch nicht ganz, was passiert.

Schauen Sie sich Abbildung 4-1 an, um zu sehen, was mit String im Hintergrund passiert. Ein String besteht aus drei Teilen, wie auf der linken Seite gezeigt: Ein Zeiger auf den Arbeitsspeicher, der den Inhalt des Strings enthält, eine Länge und eine Kapazität. Diese Datengruppe wird auf dem Stapel gespeichert. Rechts ist der Arbeitsspeicher auf dem Heap, der den Inhalt enthält.

Abbildung 4-1: Darstellung im Arbeitsspeicher eines Strings, der den Wert "hello" an s1 bindet

Die Länge ist die Anzahl der Bytes, die der Inhalt des Strings derzeit verwendet. Die Kapazität ist die Gesamtanzahl der Bytes, die der String vom Arbeitsspeicherzuweisungsdienst erhalten hat. Der Unterschied zwischen Länge und Kapazität ist wichtig, aber in diesem Zusammenhang nicht relevant, also können wir die Kapazität vorerst ignorieren.

Wenn wir s1 an s2 zuweisen, wird die String-Daten kopiert, was bedeutet, dass wir den Zeiger, die Länge und die Kapazität, die auf dem Stapel sind, kopieren. Wir kopieren jedoch nicht die Daten auf dem Heap, auf die der Zeiger verweist. Mit anderen Worten, die Datenrepräsentation im Arbeitsspeicher sieht wie in Abbildung 4-2 aus.

Abbildung 4-2: Darstellung im Arbeitsspeicher der Variable s2, die eine Kopie des Zeigers, der Längen und der Kapazität von s1 hat

Die Darstellung sieht nicht wie in Abbildung 4-3 aus, was der Arbeitsspeicher so aussehen würde, wenn Rust auch die Heap-Daten kopierte. Wenn Rust dies tun würde, könnte die Operation s2 = s1 in Bezug auf die Laufzeitleistung sehr aufwendig sein, wenn die Daten auf dem Heap groß wären.

Abbildung 4-3: Eine andere Möglichkeit, was s2 = s1 tun könnte, wenn Rust auch die Heap-Daten kopierte

Früher haben wir gesagt, dass wenn eine Variable außerhalb ihres Gültigkeitsbereichs fällt, Rust automatisch die drop-Funktion aufruft und den Heap-Arbeitsspeicher für diese Variable bereinigt. Aber Abbildung 4-2 zeigt, dass beide Datenzeiger auf die gleiche Stelle zeigen. Dies ist ein Problem: wenn s2 und s1 außerhalb ihres Gültigkeitsbereichs fallen, werden beide versuchen, den gleichen Arbeitsspeicher freizugeben. Dies wird als double free -Fehler bezeichnet und ist ein von den zuvor genannten Arbeitsspeichersicherheitsfehlern. Das Doppelte Freigeben von Arbeitsspeicher kann zu einer Arbeitsspeicherfehler führen, was möglicherweise zu Sicherheitslücken führen kann.

Um die Arbeitsspeichersicherheit zu gewährleisten, betrachtet Rust nach der Zeile let s2 = s1; s1 als nicht mehr gültig. Daher muss Rust nichts freigeben, wenn s1 außerhalb seines Gültigkeitsbereichs fällt. Schauen Sie sich an, was passiert, wenn Sie versuchen, s1 nach der Erstellung von s2 zu verwenden; es wird nicht funktionieren:

let s1 = String::from("hello");
let s2 = s1;

println!("{s1}, world!");

Sie erhalten einen Fehler wie diesen, weil Rust Sie daran hindert, auf die ungültige Referenz zuzugreifen:

error[E0382]: borrow of moved value: `s1`
 --> src/main.rs:5:28
  |
2 |     let s1 = String::from("hello");
  |         -- move occurs because `s1` has type `String`, which
 does not implement the `Copy` trait
3 |     let s2 = s1;
  |              -- value moved here
4 |
5 |     println!("{s1}, world!");
  |                ^^ value borrowed here after move

Wenn Sie die Begriffe shallow copy und deep copy in anderen Sprachen gehört haben, klingt das Konzept des Kopierens des Zeigers, der Längen und der Kapazität ohne das Kopieren der Daten wahrscheinlich wie das Erstellen einer shallow copy. Aber weil Rust auch die erste Variable ungültig macht, wird es nicht als shallow copy bezeichnet, sondern als move. In diesem Beispiel würden wir sagen, dass s1 in s2 verschoben wurde. Was tatsächlich passiert, ist in Abbildung 4-4 gezeigt.

Abbildung 4-4: Darstellung im Arbeitsspeicher nach der Ungültigkeit von s1

Das löst unser Problem! Mit nur s2 gültig, wird es, wenn es außerhalb seines Gültigkeitsbereichs fällt, allein den Arbeitsspeicher freigeben, und wir sind fertig.

Zusätzlich gibt es eine Designentscheidung, die hier impliziert ist: Rust wird niemals automatisch "tiefe" Kopien Ihrer Daten erstellen. Daher kann angenommen werden, dass jede automatische Kopie in Bezug auf die Laufzeitleistung kostengünstig ist.

Variablen und Daten, die mit Clone interagieren

Wenn wir tatsächlich die Heap-Daten des Strings, nicht nur die Stapel-Daten, tief kopieren möchten, können wir eine häufige Methode namens clone verwenden. Wir werden die Methodensyntax im Kapitel 5 diskutieren, aber da Methoden ein häufiges Merkmal in vielen Programmiersprachen sind, haben Sie sie wahrscheinlich schon einmal gesehen.

Hier ist ein Beispiel für die Verwendung der clone-Methode:

let s1 = String::from("hello");
let s2 = s1.clone();

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

Dies funktioniert einwandfrei und erzeugt explizit das Verhalten, das in Abbildung 4-3 gezeigt wird, bei dem die Heap-Daten tatsächlich kopiert werden.

Wenn Sie einen Aufruf von clone sehen, wissen Sie, dass irgendein beliebiger Code ausgeführt wird und dass dieser Code möglicherweise aufwendig sein kann. Es ist ein visueller Hinweis darauf, dass etwas anderes geschieht.

Nur auf dem Stapel gespeicherte Daten: Copy

Es gibt noch einen Aspekt, über den wir bisher nicht gesprochen haben. Dieser Code mit ganzen Zahlen - einen Teil davon wurde in Listing 4-2 gezeigt - funktioniert und ist gültig:

let x = 5;
let y = x;

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

Aber dieser Code scheint im Widerspruch zu stehen zu dem, 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 ist, dass Typen wie ganze Zahlen, die zur Compile-Zeit eine bekannte Größe haben, vollständig auf dem Stapel gespeichert werden, sodass Kopien der tatsächlichen Werte schnell erstellt werden können. Das bedeutet, dass es keinen Grund gibt, x nach der Erstellung der Variable y ungültig zu machen. Mit anderen Worten, es gibt keinen Unterschied zwischen einer tiefen und einer flachen Kopie hier, sodass ein Aufruf von clone nichts anderes tun würde als die übliche flache Kopie, und wir können ihn weglassen.

Rust hat eine spezielle Annotation namens Copy-Eigenschaft, die wir auf Typen platzieren können, die auf dem Stapel gespeichert werden, wie dies bei ganzen Zahlen der Fall ist (wir werden in Kapitel 10 mehr über Eigenschaften sprechen). Wenn ein Typ die Copy-Eigenschaft implementiert, bewegen sich Variablen, die ihn verwenden, nicht, sondern werden einfach kopiert, sodass sie nach der Zuweisung an eine andere Variable immer noch gültig sind.

Rust lässt uns keinen Typ mit Copy annotieren, wenn der Typ oder irgendein Teil davon die Drop-Eigenschaft implementiert hat. Wenn der Typ etwas besonderes erfordert, wenn der Wert außerhalb seines Gültigkeitsbereichs fällt, und wir der Copy-Annotation hinzufügen, erhalten wir einen Compile-Fehler. Um zu erfahren, wie Sie der Copy-Annotation zu Ihrem Typ hinzufügen, um die Eigenschaft zu implementieren, siehe "Ableitbare Eigenschaften".

Welche Typen implementieren die Copy-Eigenschaft? Sie können die Dokumentation für den jeweiligen Typ überprüfen, um sicher zu sein, aber als allgemeine Regel können alle einfachen skalaren Wertegruppen Copy implementieren, und nichts, was eine Zuweisung erfordert oder eine Art von Ressource ist, kann Copy implementieren. Hier sind einige der Typen, die Copy implementieren:

  • Alle ganzzahligen Typen, wie u32.
  • Der boolesche Typ, bool, mit den Werten true und false.
  • Alle Gleitkomma-Typen, wie f64.
  • Der Zeichensatztyp, char.
  • Tupel, wenn sie nur aus Typen bestehen, die ebenfalls Copy implementieren. Beispielsweise implementiert (i32, i32) Copy, aber (i32, String) nicht.

Besitz und Funktionen

Die Mechanismen beim Übergabe eines Werts an eine Funktion ähneln denen beim Zuweisen eines Werts an eine Variable. Das Übergeben einer Variable an eine Funktion wird entweder verschieben oder kopieren, genau wie die Zuweisung. Listing 4-3 zeigt ein Beispiel mit einigen Anmerkungen, die anzeigen, wann Variablen in und außerhalb ihres Gültigkeitsbereichs sind.

// src/main.rs
fn main() {
    let s = String::from("hello");  // s tritt in den Gültigkeitsbereich

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

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

    makes_copy(x);                  // x würde in die Funktion verschoben werden,
                                    // aber i32 implementiert Copy, so dass es
                                    // danach noch in Ordnung ist, x zu verwenden

} // Hier tritt x außerhalb seines Gültigkeitsbereichs, dann s. Allerdings
  // da der Wert von s verschoben wurde, passiert nichts Besonderes

fn takes_ownership(some_string: String) { // some_string tritt in den Gültigkeitsbereich
    println!("{some_string}");
} // Hier tritt some_string außerhalb seines Gültigkeitsbereichs und `drop`
  // wird aufgerufen. Der zugrunde liegende Arbeitsspeicher wird freigegeben

fn makes_copy(some_integer: i32) { // some_integer tritt in den Gültigkeitsbereich
    println!("{some_integer}");
} // Hier tritt some_integer außerhalb seines Gültigkeitsbereichs.
  // Passiert nichts Besonderes

Listing 4-3: Funktionen mit Besitz und Gültigkeitsbereich annotiert

Wenn wir versuchen, s nach dem Aufruf von takes_ownership zu verwenden, würde Rust einen Compile-Fehler werfen. Diese statischen Prüfungen schützen uns vor Fehlern. Versuchen Sie, Code in main hinzuzufügen, der s und x verwendet, um zu sehen, wo Sie sie verwenden können und wo die Besitzregeln Sie davon abhalten.

Rückgabewerte und Gültigkeitsbereich

Das Zurückgeben von Werten kann ebenfalls die Besitzübertragung bewirken. Listing 4-4 zeigt ein Beispiel einer Funktion, die einen Wert zurückgibt, mit ähnlichen Anmerkungen wie in Listing 4-3.

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

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

    let s3 = takes_and_gives_back(s2);  // s2 wird in
                                        // takes_and_gives_back verschoben,
                                        // das auch seinen Rückgabewert in s3
                                        // verschiebt
} // Hier tritt s3 außerhalb seines Gültigkeitsbereichs und wird freigegeben.
  // s2 wurde verschoben, also passiert nichts. s1 tritt außerhalb seines
  // Gültigkeitsbereichs und wird freigegeben

fn gives_ownership() -> String {             // gives_ownership wird seinen
                                             // Rückgabewert in die Funktion
                                             // verschieben, die es aufruft

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

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

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

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

Listing 4-4: Übertragung der Besitzhaftung von Rückgabewerten

Die Besitzhaftung einer Variable folgt jedes Mal dem gleichen Muster: Das Zuweisen eines Werts an eine andere Variable verschiebt es. Wenn eine Variable, die Daten auf dem Heap enthält, außerhalb ihres Gültigkeitsbereichs fällt, wird der Wert durch drop aufgeräumt, es sei denn, die Besitzhaftung der Daten wurde an eine andere Variable übertragen.

Während dies funktioniert, ist es etwas lästig, bei jeder Funktion die Besitzhaftung zu übernehmen und dann wieder zurückzugeben. Was ist, wenn wir einer Funktion einen Wert zur Verfügung stellen möchten, aber die Besitzhaftung nicht übernehmen? Es ist ziemlich ärgerlich, dass alles, was wir übergeben, auch zurückgegeben werden muss, wenn wir es erneut verwenden möchten, zusätzlich zu allen Daten, die aus dem Funktionskörper resultieren und die wir möglicherweise auch zurückgeben möchten.

Rust erlaubt uns tatsächlich, mehrere Werte als Tupel zurückzugeben, wie in Listing 4-5 gezeigt.

Dateiname: src/main.rs

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

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

    println!("The length of '{s2}' is {len}.");
}

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

    (s, length)
}

Listing 4-5: Rückgabe der Besitzhaftung von Parametern

Aber das ist zu aufwendig und erfordert viel Arbeit für einen Begriff, der eigentlich üblich sein sollte. Glücklicherweise hat Rust eine Funktion, um einen Wert zu verwenden, ohne die Besitzhaftung zu übertragen, nämlich Referenzen.

Zusammenfassung

Herzlichen Glückwunsch! Sie haben das Lab "Was ist Besitz?" abgeschlossen. Sie können in LabEx weitere Labs absolvieren, um Ihre Fähigkeiten zu verbessern.