You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Ownership, Borrowing und Lifetimes in Rust im Vergleich mit Garbage Collection
Rust
Programmiersprache dessen Design mit besonderem Fokus auf folgenden drei Eigenschaften basiert:
Zuverlässigkeit wird besonders durch Typ-/Speichersicherheit erzielt. Z.B. beim Zugriff Variablen wird überprüft ob die Art des Zugriffs mit dem Datentyp kompatibel ist. Typkonvertierungen müssen explizit angegeben werden. Zugriff auf ungültige Speicherbereiche (z.B. durch dangling pointer, oder mehrmaliges freigeben desselben Speichers) werden verhindert. Memory leaks die dadurch entstehen, dass aufgrund von Programmierfehlern Speicher nicht wieder freigegeben wird, werden ebenfalls durch Sprachdesign / statischer Analyse vermieden.
Geschwindigkeit wird besonders durch zero-cost abstractions optimiert. Komplexe Sprachfeatures werden - wenn möglich - zur Compilezeit aufgelöst und verlangsamen damit nicht die Ausführung.
Nebenläufigkeit: Mögliche data races werden verhindert indem z.B. immer nur eine Referenz auf eine Ressource mutable sein darf. Mehrere Referenzen auf die selbe Ressource sind nur erlaubt wenn alle immutable sind.
Es gibt kein garbage collection in Rust. Das Freigeben von Speicher wird explizit vom Programmcode definiert und ist Teil des normalen Kontrollflusses.
Garbage Collection
Je nach Implementierung haben Garbage Collection Mechanismen, im Vergleich zu expliziter Speicherfreigabe, oft folgende Eigenschaften (ohne Anspruch auf Vollständigkeit):
Rechenaufwändig: Der gesamte allozierte Speicher des Programms muss regelmäßig analysiert werden, um Speicherbereiche, die nicht mehr referenziert werden, zu detektieren. Die Auswirkungen (ob positiv oder negativ) auf die Performance der Software sind von vielen Faktoren abhängig und nicht pauschal zu benennen.
Speicherverbrauch-Overhead: Benötigt Overhead an Speicher was den Speicherverbrauch der Software erhöht.
Defragmentierung des Heap: Der Speicher wird durch den Garbage Collector defragmentiert, was sich unter anderem positiv auf die Geschwindigkeit des Allozierens von Speicher auswirken kann.
Nicht-Deterministisches Verhalten: Da der Garbage Collection Mechanismus außerhalb des normalen Kontrollflusses operiert, hat der Programmcode keinen direkten Einfluss darauf wann Ressourcen tatsächlich freigegeben werden. Das kann unerwartet Einfluss auf die Performance der Software haben.
Explizites Speichermanagement
Das Freigeben von Speicher ist Teil des normalen Programmablaufs und passiert daher weder parallel noch muss der Programmablauf dafür angehalten werden.
Programmcode hat die Kontrolle über das Freigeben von Speicher.
Höherer Programmieraufwand:
Programmierer muss sich Gedanken darum machen wie lange eine Ressource benötigt wird und welcher Code-Abschnitt auf welche Weise darauf zugreifen können muss. Beispiel: Sollte eine Funktion einen Parameter als Owner oder Referenz übergeben bekommen?
Programmierer benötigt Kenntnis über die verschiedenen Hilfsmittel / Konzepte die die Programmiersprache bietet.
Ressourcen Management
Das Management (also Erstellen/Anfordern, gefolgt vom wieder Freigeben/Finalisieren) von "Ressourcen" bezieht sich nicht zwingend nur Speicherbereiche im Heap, sondern kann auch auf File-Handles, Netzwerk-Sockets, Threads, Mutex-Locks, etc. angewendet werden.
Ressourcen sind an die Lifetime von Variablen/Objekten gebunden.
Das Freigeben von Ressourcen wird von der Sprache bzw. Laufzeitumgebung sichergestellt.
Konstruktor initialisiert Resource.
Destruktor gibt Resource wieder frei.
In folgendem Beispiel wird eine Liste (vector) von drei Integern angelegt welches Speicher auf dem Heap zum Speichern der drei Werte nutzt. Beim Aufruf des Konstruktors wird Speicher auf dem Heap angefordert. Beim Aufruf des Destruktors wird der Speicher wieder freigegeben werden. Rust stellt sicher, dass der Destruktor aufgerufen wird, sobald der Gültigkeitsbereich der Variablen x verlassen wird.
fn func() {
let x = vec![1, 2, 3];
}
Mit Garbage Collection wäre der Code wohl kaum kürzer! Der Typ vec ist Teil der Standardbibliothek von Rust und definiert bereits Methoden für Konstruktor und Destruktor.
"Ist das schon alles?"
Das zuverlässige Freigeben von Ressourcen ist durch das Anwenden von RAII bzw. OBRM quasi erledigt. Allerdings ist das letzte Beispiel minimalistisch und die Realität oft komplexer. Zum Beispiel möchte man den Wert von Variable x vielleicht an eine Funktion übergeben (wer kümmert sich dann um das Freigeben?), oder als Return-Wert zurückgegeben, sodass diese erst irgendwann vom Aufrufer freigegeben wird...
Ownership
Eine Ressource hat immer einen Besitzer, welcher verantwortlich für das finale Freigeben ist. Dadurch wird ausgeschlossen, dass eine Ressource ausversehen mehrmals versucht wird freizugeben. Speicher der bereits freigegeben wurde, darf nicht "nochmal" freigegeben werden, da dieser Speicherbereich möglicherweise bereits wieder alloziert wurde.
Wenn jede Ressource nur einen Besitzer haben kann, wie kann man diese dann von einer Variablen einer anderen zuweisen an eine Funktion übergeben oder sie als Rückgabewert nutzen?
Antwort:
Eine Möglichkeit wäre es die Ressource zu kopieren, was allerdings langsam und in vielen Fällen unnötig wäre. Die Alternative: move! Die Ressource wechselt also den Besitzer. Das ist in Rust das default Verhalten. Objekte werden bei Zuweisungen generell (kommt auf den Datentyp an) verschoben und nicht kopiert.
Beispiel 1:
let x = vec![1, 2, 3]; // x ist der Owner
println!("x: {:?}", x); // Liste ausgeben
let y = x; // y ist jetzt der Owner
// x kann hier nicht mehr verwendet werden
println!("y: {:?}", y);
In Beispiel 1 wird die Variable x mit einem neuen Vector initialisiert und wird zum Owner der Ressource. Wie zu erwarten kann der Wert nun über x referenziert und z.B. auf die Konsole geprinted werden. Danach weisen wir den Wert von x an die neue Variable y zu, welches nun der alleinige Besitzer der Ressource ist, da hier nicht kopiert sondern implizit verschoben wurde. x ist kann danach nicht mehr verwendet werden.
Beispiel 2:
fn func(y: Vec<i32>) {}
...
let x = vec![1, 2, 3];
func(x);
// x kann hier nicht mehr verwendet werden
Im zweiten Beispiel wird die Liste an eine Funktion übergeben und kann nach dem Aufruf kann die Variable nicht mehr verwendet werden. Der Parameter y wird zum Besitzer der Ressource. Daher wird die Ressource freigegeben sobald der Scope vom Aufruf von func endet.
Borrowing
Eine Ressource von Owner zu Owner zu verschieben mag in einigen Fällen ein geeigneter Ansatz sein. In anderen Fällen jedoch möchte man die Ressource zwar einer Funktion übergeben, muss sie danach aber weiterhin nutzen. Auch soll die Ressource weder kopiert, noch zu früh (in der Funktion) gelöscht werden.
Frage:
Wenn mir die Ressource gehört, kann ich sie nicht auch einfach mal kurz verleihen?
Antwort:
Ja! Siehe folgendes Beispiel...
Beispiel 3:
fn func(y: &Vec<i32>) {
println!("y: {:?}", y);
}
...
let x = vec![1, 2, 3];
func(&x);
println!("x: {:?}", x);
Die Ressource bleibt in diesem Beispiel immer im Besitz von x. In func wird jetzt wegen des &-Zeichens in der Funktionssignatur ein borrow erwartet, was eine Referenz auf ein anderen Objekt ist und auf dieses Zugreifen kann ohne verantwortlich für das Freigeben der Ressource zu sein. Auch der Aufrufer muss explizit ein & voranstellen um die Variable zu verleihen.
Borrow Checker
Referenzen sind praktisch, aber je komplexer der Code wird desto einfacher schleichen sich subtile Fehler ein. Auf eine Referenz sollte niemals zugegriffen werden, wenn der Besitzer die referenzierte Ressource schon wieder freigegeben hat. Ansonsten würden wir auf Speicher zugreifen der vielleicht schon wieder neu alloziert wurde. Rust überprüft daher zur Compile-Zeit alle Referenzen darauf ob sie definitiv immer gültig sind.
Beispiel 4:
let y: &i32;
{
let x = 5;
y = &x; // Compiler Error: borrowed value does not live long enough
}
println!("y: {}", y);
Im Beispiel 4 wird versucht y ein Borrow von der Variablen x zuzuweisen. Der Gültigkeitsbereich (oder die lifetime) von x ist jedoch kleiner als der von y. Wenn println erreicht wird, ist die Ressource von x bereits freigegeben und dürfte nicht mehr genutzt werden. Der Borrow Checker von Rust erkennt das und verbietet es uns.
Der Borrow Checker überwacht und vergleicht Lifetimes von Referenzen um deren Gültigkeit zu validieren. Dafür müssen dem Compiler manchmal zusätzliche Informationen gegeben werden um ihm zu "beweisen", dass die Referenzen zu jedem Zeitpunk gültig sein werden. Dafür können explizite Lifetimes deklariert und Referenzen zugewiesen werden. Dadurch kann der Borrow Checker erkennen wie die Lifetimes unterschiedlicher Referenzen zueinander in Relation stehen.
Beispiel 5:
fn longest(x: &str, y: &str) -> &str { // Compiler Error: missing lifetime specifier
if x.len() > y.len() { x }
else { y }
}
...
let s1 = String::from("abcd");
let result = longest(s1.as_str(), "xyz");
Die Funktionssignatur in diesem Beispiel ist nicht valide. Es werden zwei Parameter vom Typ String als Borrow erwartet und wiederum einer von beiden als String-Referenz zurückgegeben. Der Compiler kann jedoch nicht wissen welcher Parameter zurückgegeben wird, da dies zur Ausführungszeit bestimmt wird. Daher kann die Gültigkeit der zurückgegebenen Referenz nicht bestimmt und überprüft werden.
Lösung:
Es müssen explizite Lifetimes angegeben werden die dem Compiler sagen wie die Lifetimes der beiden Parameter und der Return-Wert in Relation stehen, damit er diese überprüfen kann. Siehe nächstes Beispiel.
Beispiel 6:
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x }
else { y }
}
...
let s1 = String::from("abcd");
let result = longest(s1.as_str(), "xyz");
Beim Aufruf erkennt der Borrow Checker, dass beide Ressourcen-Owner länger bzw. "mehr oder weniger gleichlang" leben als die zurückgegebene Referenz. Damit kann die Referenz niemals ungültig sein (e.g. egal welcher Pfad in longest ausgeführt wird).
Beispiel 7:
let s1 = String::from("abcd");
{
let s2 = String::from("xyz");
let result = longest(s1.as_str(), s2.as_str());
}
In Beispiel 7 wird die longest-Funktion nochmal aufgerufen. Diesmal haben beide Referenzen unterschiedliche Lifetimes. Beide leben aber länger als result. Daher ist auch das valide und der Compiler kann das auch erkennen.
Beispiel 8:
let s1 = String::from("abcd");
let result;
{
let s2 = String::from("xyz");
result = longest(s1.as_str(), s2.as_str()); // Compiler-Error: `s2` does not live long enough
}
println!("{}", result);
Beispiel 8 zeigt eine ähnliche Situation wie Beispiel 4 und auch hier kann der Compiler ein (möglicherweise) ungültiges Borrow erkennen. Die Referenz result könnte den Wert von s2 borrowen, lebt aber länger und wäre daher ungültig.
Anmerkung: Tatsächlich würde in diesem konkreten Beispiel (abcd ist länger als xyz) immer eine gültige Referenz in result stehen - aber das weiß der Borrow Checker nicht.
In Beispiel 4 wurden keine expliziten Lifetimes definiert und trotzdem funktioniert es. Der Borrow Checker benötigt immer Lifetime-Informationen zum Überprüfen der Gültigkeit von Referenzen. Um lästige Tipparbeit zu ersparen und um Code einfacher lesbar zu machen, werden Lifetimes jedoch in manchen Fällen automatisch hinzugefügt. Der Mechanismus nennt sich Lifetime Elision und erkennt einige sehr häufig auftretende Muster. Dabei geht der Algorithmus allerdings vorsichtig vor - im Zweifel gibt es einen Fehler und man muss die Lifetimes explizit angeben.
reacted with thumbs up emoji reacted with thumbs down emoji reacted with laugh emoji reacted with hooray emoji reacted with confused emoji reacted with heart emoji reacted with rocket emoji reacted with eyes emoji
-
Ownership, Borrowing und Lifetimes in Rust im Vergleich mit Garbage Collection
Rust
Programmiersprache dessen Design mit besonderem Fokus auf folgenden drei Eigenschaften basiert:
dangling pointer
, oder mehrmaliges freigeben desselben Speichers) werden verhindert.Memory leaks
die dadurch entstehen, dass aufgrund von Programmierfehlern Speicher nicht wieder freigegeben wird, werden ebenfalls durch Sprachdesign / statischer Analyse vermieden.zero-cost abstractions
optimiert. Komplexe Sprachfeatures werden - wenn möglich - zur Compilezeit aufgelöst und verlangsamen damit nicht die Ausführung.data races
werden verhindert indem z.B. immer nur eine Referenz auf eine Ressourcemutable
sein darf. Mehrere Referenzen auf die selbe Ressource sind nur erlaubt wenn alleimmutable
sind.Es gibt kein
garbage collection
in Rust. Das Freigeben von Speicher wird explizit vom Programmcode definiert und ist Teil des normalen Kontrollflusses.Garbage Collection
Je nach Implementierung haben Garbage Collection Mechanismen, im Vergleich zu expliziter Speicherfreigabe, oft folgende Eigenschaften (ohne Anspruch auf Vollständigkeit):
Explizites Speichermanagement
Ressourcen Management
Das Management (also Erstellen/Anfordern, gefolgt vom wieder Freigeben/Finalisieren) von "Ressourcen" bezieht sich nicht zwingend nur Speicherbereiche im Heap, sondern kann auch auf File-Handles, Netzwerk-Sockets, Threads, Mutex-Locks, etc. angewendet werden.
Rust nutzt dafür als grundlegendes Konzept
RAII
(Resource Aquisition is Initialization) im Rust-Kontext auchOBRM
(Ownership Based Resource Management) genannt.In folgendem Beispiel wird eine Liste (
vector
) von drei Integern angelegt welches Speicher auf dem Heap zum Speichern der drei Werte nutzt. Beim Aufruf des Konstruktors wird Speicher auf dem Heap angefordert. Beim Aufruf des Destruktors wird der Speicher wieder freigegeben werden. Rust stellt sicher, dass der Destruktor aufgerufen wird, sobald der Gültigkeitsbereich der Variablenx
verlassen wird.Mit Garbage Collection wäre der Code wohl kaum kürzer! Der Typ
vec
ist Teil der Standardbibliothek von Rust und definiert bereits Methoden für Konstruktor und Destruktor."Ist das schon alles?"
Das zuverlässige Freigeben von Ressourcen ist durch das Anwenden von RAII bzw. OBRM quasi erledigt. Allerdings ist das letzte Beispiel minimalistisch und die Realität oft komplexer. Zum Beispiel möchte man den Wert von Variable
x
vielleicht an eine Funktion übergeben (wer kümmert sich dann um das Freigeben?), oder als Return-Wert zurückgegeben, sodass diese erst irgendwann vom Aufrufer freigegeben wird...Ownership
Eine Ressource hat immer einen Besitzer, welcher verantwortlich für das finale Freigeben ist. Dadurch wird ausgeschlossen, dass eine Ressource ausversehen mehrmals versucht wird freizugeben. Speicher der bereits freigegeben wurde, darf nicht "nochmal" freigegeben werden, da dieser Speicherbereich möglicherweise bereits wieder alloziert wurde.
Dokumentation hier (rust-by-example) und hier (buch first edition)
Move
Frage:
Wenn jede Ressource nur einen Besitzer haben kann, wie kann man diese dann von einer Variablen einer anderen zuweisen an eine Funktion übergeben oder sie als Rückgabewert nutzen?
Antwort:
Eine Möglichkeit wäre es die Ressource zu kopieren, was allerdings langsam und in vielen Fällen unnötig wäre. Die Alternative:
move
! Die Ressource wechselt also den Besitzer. Das ist in Rust das default Verhalten. Objekte werden bei Zuweisungen generell (kommt auf den Datentyp an) verschoben und nicht kopiert.Beispiel 1:
In Beispiel 1 wird die Variable
x
mit einem neuen Vector initialisiert und wird zum Owner der Ressource. Wie zu erwarten kann der Wert nun überx
referenziert und z.B. auf die Konsole geprinted werden. Danach weisen wir den Wert vonx
an die neue Variabley
zu, welches nun der alleinige Besitzer der Ressource ist, da hier nicht kopiert sondern implizit verschoben wurde.x
ist kann danach nicht mehr verwendet werden.Beispiel 2:
Im zweiten Beispiel wird die Liste an eine Funktion übergeben und kann nach dem Aufruf kann die Variable nicht mehr verwendet werden. Der Parameter
y
wird zum Besitzer der Ressource. Daher wird die Ressource freigegeben sobald der Scope vom Aufruf vonfunc
endet.Borrowing
Eine Ressource von Owner zu Owner zu verschieben mag in einigen Fällen ein geeigneter Ansatz sein. In anderen Fällen jedoch möchte man die Ressource zwar einer Funktion übergeben, muss sie danach aber weiterhin nutzen. Auch soll die Ressource weder kopiert, noch zu früh (in der Funktion) gelöscht werden.
Frage:
Wenn mir die Ressource gehört, kann ich sie nicht auch einfach mal kurz verleihen?
Antwort:
Ja! Siehe folgendes Beispiel...
Beispiel 3:
Die Ressource bleibt in diesem Beispiel immer im Besitz von
x
. Infunc
wird jetzt wegen des&
-Zeichens in der Funktionssignatur einborrow
erwartet, was eine Referenz auf ein anderen Objekt ist und auf dieses Zugreifen kann ohne verantwortlich für das Freigeben der Ressource zu sein. Auch der Aufrufer muss explizit ein&
voranstellen um die Variable zu verleihen.Borrow Checker
Referenzen sind praktisch, aber je komplexer der Code wird desto einfacher schleichen sich subtile Fehler ein. Auf eine Referenz sollte niemals zugegriffen werden, wenn der Besitzer die referenzierte Ressource schon wieder freigegeben hat. Ansonsten würden wir auf Speicher zugreifen der vielleicht schon wieder neu alloziert wurde. Rust überprüft daher zur Compile-Zeit alle Referenzen darauf ob sie definitiv immer gültig sind.
Beispiel 4:
Im Beispiel 4 wird versucht
y
ein Borrow von der Variablenx
zuzuweisen. Der Gültigkeitsbereich (oder dielifetime
) vonx
ist jedoch kleiner als der vony
. Wennprintln
erreicht wird, ist die Ressource vonx
bereits freigegeben und dürfte nicht mehr genutzt werden. Der Borrow Checker von Rust erkennt das und verbietet es uns.Dokumentation hier (rust-by-example) und hier (buch first edition)
Lifetimes
Der Borrow Checker überwacht und vergleicht Lifetimes von Referenzen um deren Gültigkeit zu validieren. Dafür müssen dem Compiler manchmal zusätzliche Informationen gegeben werden um ihm zu "beweisen", dass die Referenzen zu jedem Zeitpunk gültig sein werden. Dafür können explizite Lifetimes deklariert und Referenzen zugewiesen werden. Dadurch kann der Borrow Checker erkennen wie die Lifetimes unterschiedlicher Referenzen zueinander in Relation stehen.
Beispiel 5:
Die Funktionssignatur in diesem Beispiel ist nicht valide. Es werden zwei Parameter vom Typ String als Borrow erwartet und wiederum einer von beiden als String-Referenz zurückgegeben. Der Compiler kann jedoch nicht wissen welcher Parameter zurückgegeben wird, da dies zur Ausführungszeit bestimmt wird. Daher kann die Gültigkeit der zurückgegebenen Referenz nicht bestimmt und überprüft werden.
Lösung:
Es müssen explizite Lifetimes angegeben werden die dem Compiler sagen wie die Lifetimes der beiden Parameter und der Return-Wert in Relation stehen, damit er diese überprüfen kann. Siehe nächstes Beispiel.
Beispiel 6:
Beim Aufruf erkennt der Borrow Checker, dass beide Ressourcen-Owner länger bzw. "mehr oder weniger gleichlang" leben als die zurückgegebene Referenz. Damit kann die Referenz niemals ungültig sein (e.g. egal welcher Pfad in
longest
ausgeführt wird).Beispiel 7:
In Beispiel 7 wird die
longest
-Funktion nochmal aufgerufen. Diesmal haben beide Referenzen unterschiedliche Lifetimes. Beide leben aber länger alsresult
. Daher ist auch das valide und der Compiler kann das auch erkennen.Beispiel 8:
Beispiel 8 zeigt eine ähnliche Situation wie Beispiel 4 und auch hier kann der Compiler ein (möglicherweise) ungültiges Borrow erkennen. Die Referenz
result
könnte den Wert vons2
borrowen, lebt aber länger und wäre daher ungültig.Anmerkung: Tatsächlich würde in diesem konkreten Beispiel (
abcd
ist länger alsxyz
) immer eine gültige Referenz inresult
stehen - aber das weiß der Borrow Checker nicht.Code-Beispiele wurden abgewandelt übernommen aus The Rust Programming Language
Dokumentation hier (nomicon) und hier (rust-by-example) und hier (buch first edition)
Lifetime Elision
In Beispiel 4 wurden keine expliziten Lifetimes definiert und trotzdem funktioniert es. Der Borrow Checker benötigt immer Lifetime-Informationen zum Überprüfen der Gültigkeit von Referenzen. Um lästige Tipparbeit zu ersparen und um Code einfacher lesbar zu machen, werden Lifetimes jedoch in manchen Fällen automatisch hinzugefügt. Der Mechanismus nennt sich
Lifetime Elision
und erkennt einige sehr häufig auftretende Muster. Dabei geht der Algorithmus allerdings vorsichtig vor - im Zweifel gibt es einen Fehler und man muss die Lifetimes explizit angeben.Beispiel 9:
wird zu
Dokumentation hier (rust-by-example)
Links
Beta Was this translation helpful? Give feedback.
All reactions