Geschichte der Frage: Frühere Versionen von Rust erforderten explizite Aufruf von Destruktoren. Die Einführung des Drop-Traits automatisierte die Ressourcenbereinigung, führte jedoch zu Komplexität in Kombination mit Rusts Bewegungsemantik. Das Problem der teilweisen Bewegungen – bei denen einige Felder aus einer Struktur verschoben werden, während andere bleiben – erforderte eine sorgfältige Definition der Zerstörungsreihenfolge, um Probleme wie Use-after-Free oder Double-Drop-Bugs zu vermeiden. Die Spieldesigner mussten festlegen, ob die benutzerdefinierte Drop-Implementierung in diesem Szenario ausgeführt wird.
Das Problem: Wenn eine Struktur Drop implementiert, geht der Compiler davon aus, dass der Destruktor Zugriff auf alle Felder benötigt, um Sicherheitsinvarianten (wie das Entsperren eines Mutex oder das Freigeben von Speicher) aufrechtzuerhalten. Wenn ein Musterabgleich nur einige Felder bewegt (let Foo { a, .. } = foo), müssen die verbleibenden Felder fallen gelassen werden, aber die benutzerdefinierte Drop-Implementierung könnte auf die verschobenen Felder zugreifen, was zu undefiniertem Verhalten führen könnte. Dies schafft einen Konflikt zwischen der Absicht des Programmierers, Daten zu extrahieren, und der Garantie des Typs, dass sein Destruktor mit vollem Zugriff auf seinen internen Zustand ausgeführt wird.
Die Lösung: Der Compiler verbietet teilweise Bewegungen von Feldern aus einer Struktur, die Drop implementiert, es sei denn, die Struktur wird im Muster vollständig dekonstruiert (alle Felder werden gebunden). Bei totaler Zerstörung wird die Struktur als verschoben betrachtet, und Drop wird nicht aufgerufen; stattdessen werden die einzelnen Felder in umgekehrter Deklarationsreihenfolge gelöscht. Für Typen ohne Drop sind teilweise Bewegungen erlaubt, da der vom Compiler generierte Zerstörungscode nur die verbleibenden Felder berührt.
struct NoDrop(String, i32); struct WithDrop(String, i32); impl Drop für WithDrop { fn drop(&mut self) { println!("Dropping: {}", self.0); } } fn main() { let no_drop = NoDrop("a".into(), 1); let NoDrop(s, _) = no_drop; // OK: partielle Bewegung erlaubt // println!("{}", no_drop.0); // Fehler: Wert bewegt println!("Verbleibend: {}", no_drop.1); // OK: Feld 1 ist noch gültig drop(s); let with_drop = WithDrop("b".into(), 2); // let WithDrop(s, _) = with_drop; // Fehler: kann nicht teilweise von einem Typ bewegen, der Drop implementiert let WithDrop(s, n) = with_drop; // OK: totale Zerstörung, Drop wird NICHT aufgerufen println!("Verschoben: {} und {}", s, n); // Felder werden einzeln am Ende des Gültigkeitsbereichs gelöscht }
Ein Systemprogrammierungsteam baute einen Zero-Copy Netzwerkpaketparser. Sie definierten eine Packet-Struktur, die eine Referenz auf einen rohen Puffer und mehrere Metadatenfelder (Zeitstempel, Länge) hielt. Die Packet implementierte Drop, um den Puffer an einen Pool zurückzugeben. Sie versuchten, nur den Zeitstempel für Protokollierungszwecke zu extrahieren, während sie das Paket später verarbeiteten, indem sie eine partielle Bewegung in einem Musterarm verwendeten.
Lösung 1: Entfernen Sie die Drop-Implementierung und verwenden Sie einen separaten PacketHandle-Wrapper, der den Pool verwaltet, während Packet zu einer einfachen Ansicht ohne Drop-Logik wird. Vorteile: Dies ermöglicht partielle Bewegungen der Packet-Felder und trennt die Ressourcenverwaltung sauber vom Datenzugriff. Nachteile: Es führt eine zusätzliche Indirektionsschicht ein und erfordert sorgfältiges Lebenszeitmanagement, um sicherzustellen, dass die Ansicht den Puffer nicht überlebt, was möglicherweise die Sicherheit gefährdet, wenn es schlecht verwaltet wird.
Lösung 2: Klonen Sie das Zeitstempelfeld vor der Bewegung, um eine partielle Bewegung zu vermeiden. Vorteile: Dies ist eine einfache Änderung, die die bestehende Struktur mit minimaler Codeveränderung aufrechterhält. Nachteile: Es verursacht zur Laufzeit Kosten für das Klonen; während dies für Ganzzahlen vernachlässigbar ist, wird es für komplexe Metadaten signifikant und adressiert nicht das zugrunde liegende architektonische Einschränkungen des Typsystems.
Lösung 3: Strukturieren Sie die Verarbeitungsfunktion um, um das gesamte Packet zu übernehmen, extrahieren Sie Felder durch totale Zerstörung und rekonstruieren Sie ein neues Packet, falls dies für die Rückgabe an den Pool erforderlich ist. Vorteile: Das funktioniert strikt innerhalb der Sicherheitsgarantien von Rust und macht den Eigentumsübergang explizit. Nachteile: Es ist umständlich und erfordert sorgfältige Handhabung, um sicherzustellen, dass der Puffer ordnungsgemäß zurückgegeben wird; ein fehlerhaftes Rekonstruieren könnte zu Ressourcenlecks führen.
Das Team wählte Lösung 1, da sie grundsätzlich mit Rusts Eigentumsmodell übereinstimmte, indem sie die Ressource (den Puffer) von der Ansicht (den Metadaten) entkoppelte. Dadurch wurden die Kompilierungsfehler sofort beseitigt, die Codeklarheit verbessert, indem zwischen Ressourcenverwaltung und Datenansicht unterschieden wurde, und die Anforderungen an die Nullkostenabstraktion des Projekts aufrechterhalten wurden.
Warum verbietet der Compiler partielle Bewegungen bei Typen, die Drop implementieren?
Wenn ein Typ Drop implementiert, generiert der Compiler einen Aufruf von drop() am Ende des Gültigkeitsbereichs. Die drop()-Methode erhält &mut self, was impliziert, dass sie Zugriff auf die gesamte Struktur benötigt, um Sicherheitsinvarianten wie das Freigeben von Sperren oder das Freigeben von Speicher aufrechtzuerhalten. Wenn ein Feld vorher über eine partielle Bewegung verschoben würde, würde drop() versuchen, auf freien Speicher oder ungültige Ressourcen zuzugreifen, was zu undefiniertem Verhalten führen würde. Durch das Erfordernis der totalen Zerstörung (alleseinbindend) stellt Rust sicher, dass der Destruktorkode niemals ausgeführt wird; stattdessen werden die Felder einzeln gelöscht, wodurch die potenziell unsichere benutzerdefinierte Logik umgangen wird.
Was ist die genaue Reihenfolge der Zerstörung, wenn eine Struktur über Musterabgleich vollständig dekonstruiert wird?
Wenn eine Struktur vollständig dekonstruiert wird (z. B. let MyStruct { field1, field2 } = my_struct;), wird die Drop-Implementierung der Struktur vollständig unterdrückt. Die Felder werden dann in umgekehrter Reihenfolge ihrer Deklaration in der Strukturdefinition gelöscht (field2 dann field1 in diesem Fall). Dieses Verhalten entspricht der standardmäßigen Zerstörungsreihenfolge für Strukturfelder, überspringt jedoch kritisch den benutzerdefinierten Destruktor des Containers, sodass er den verschobenen Zustand nicht beobachten kann und die Sicherheitsgarantien verletzt.
Kann ein Typ mit Drop Copy sein, wenn wir sicherstellen, dass der Destruktor idempotent ist?
Nein, der Rust-Compiler stellt durch Trait-Kohärenzregeln sicher, dass Copy und Drop gegenseitig ausschließend sind, unabhängig von der tatsächlichen Implementierung des Destruktors. Dies ist eine bewusste konservative Designwahl: Selbst wenn drop() derzeit leer oder idempotent ist, würde die Zulassung von Copy eine implizite bitweise Duplikation erlauben. Zukünftige Änderungen könnten drop() nicht-idempotent machen, was die Sicherheitsgarantien stillschweigend verletzen würde, und da der Compiler die Idempotenz im allgemeinen Fall zur Kompilierzeit nicht überprüfen kann, verbietet er die Kombination einfach, um Unsoundness zu verhindern.