Historia pytania: Wczesne wersje Rust wymagały ręcznych wywołań destruktorów. Wprowadzenie traitu Drop zautomatyzowało czyszczenie zasobów, ale wprowadziło złożoność w połączeniu z semantyką ruchu Rust. Problem częściowych ruchów—gdzie niektóre pola są usuwane ze struktury, podczas gdy inne pozostają—wymagał ostrożnej definicji kolejności usuwania, aby zapobiec błędom użycia po zwolnieniu pamięci lub podwójnym usunięciu. Projektanci języka musieli określić, czy niestandardowa implementacja Drop jest wywoływana w tym scenariuszu.
Problem: Gdy struktura implementuje Drop, kompilator zakłada, że destruktor potrzebuje dostępu do wszystkich pól, aby utrzymać bezpieczeństwo invariansów (takich jak odblokowywanie Mutex lub zwalnianie pamięci). Jeśli dopasowanie wzorca przesuwa tylko niektóre pola (let Foo { a, .. } = foo), pozostałe pola będą musiały zostać usunięte, ale niestandardowa implementacja Drop może uzyskać dostęp do przesuniętych pól, co prowadzi do nieokreślonego zachowania. Tworzy to konflikt między zamiarem programisty wyodrębnienia danych a gwarancją typu, że jego destruktor będzie działał z pełnym dostępem do swojego stanu wewnętrznego.
Rozwiązanie: Kompilator zabrania częściowych ruchów pól z struktury, która implementuje Drop, chyba że struktura jest całkowicie zdekonstrukowana w wzorcu (wiązanie wszystkich pól). Gdy jest całkowicie zdekonstrukrowana, struktura jest uznawana za przesuniętą, a Drop nie jest wywoływane; zamiast tego, poszczególne pola są usuwane w odwrotnej kolejności deklaracji. Dla typów bez Drop, dozwolone są częściowe ruchy, ponieważ kod usuwania generowany przez kompilator dotyka tylko pozostałych pól.
struct NoDrop(String, i32); struct WithDrop(String, i32); impl Drop for WithDrop { fn drop(&mut self) { println!("Usuwanie: {}", self.0); } } fn main() { let no_drop = NoDrop("a".into(), 1); let NoDrop(s, _) = no_drop; // OK: częściowy ruch dozwolony // println!("{}", no_drop.0); // Błąd: wartość została przesunięta println!("Pozostałe: {}", no_drop.1); // OK: pole 1 wciąż ważne drop(s); let with_drop = WithDrop("b".into(), 2); // let WithDrop(s, _) = with_drop; // Błąd: nie można częściowo przesunąć z typu implementującego Drop let WithDrop(s, n) = with_drop; // OK: całkowite zniszczenie, Drop NIE jest wywoływane println!("Przesunięte: {} i {}", s, n); // Pola są usuwane indywidualnie na końcu zakresu }
Zespół programistyczny zajmujący się systemami zbudował analizator pakietów sieciowych Zero-Copy. Zdefiniowali strukturę Packet, która przechowuje odniesienie do surowego bufora i kilka pól metadanych (znacznik czasu, długość). Struktura Packet zaimplementowała Drop, aby zwrócić bufor do puli. Próbowali wydobyć tylko znacznik czasu do logowania podczas przetwarzania pakietu później, używając częściowego ruchu w ramieniu dopasowania.
Rozwiązanie 1: Usunięcie implementacji Drop i użycie osobnego wrappera PacketHandle, który zarządza pulą, podczas gdy Packet staje się zwykłym widokiem bez logiki usuwania. Zalety: To pozwala na częściowe ruchy pól Packet i czysto oddziela zarządzanie zasobami od dostępu do danych. Wady: Wprowadza dodatkową warstwę pośrednią i wymaga starannego zarządzania cyklem życia, aby zapewnić, że widok nie przeżyje bufora, co potencjalnie złamałoby bezpieczeństwo w przypadku złego zarządzania.
Rozwiązanie 2: Skopiowanie pola znacznika czasu przed przesunięciem, aby uniknąć częściowego ruchu. Zalety: To prosta zmiana, która utrzymuje istniejącą strukturę z minimalnymi zmianami w kodzie. Wady: Wiąże się z kosztem wykonania związanym z klonowaniem; podczas gdy jest znikomy dla liczb całkowitych, staje się znaczący dla złożonych metadanych i nie rozwiązuje podstawowego ograniczenia architektonicznego systemu typów.
Rozwiązanie 3: Przebudowanie funkcji przetwarzającej, aby przejąć własność całego Packet, wydobyć pola za pomocą całkowitego zniszczenia i odtworzyć nowy Packet, jeśli to konieczne, do zwrotu do puli. Zalety: Działa to ściśle w ramach gwarancji bezpieczeństwa Rust i czyni transfer własności explicity. Wady: Jest to rozwlekłe i wymaga starannego obchodzenia się, aby zapewnić prawidłowy zwrot bufora; błąd w odtworzeniu może prowadzić do wycieków zasobów.
Zespół wybrał Rozwiązanie 1, ponieważ zasadniczo pasowało do modelu własności Rust, oddzielając zasoby (bufor) od widoku (metadane). To natychmiast usunęło błędy kompilacji, poprawiło przejrzystość kodu, rozróżniając zarządzanie zasobami i przeglądanie danych oraz utrzymując wymagania dotyczące zerowego kosztu abstrahowania projektu.
Dlaczego kompilator zabrania częściowych ruchów w typach implementujących Drop?
Gdy typ implementuje Drop, kompilator generuje wywołanie do drop() na końcu zakresu. Metoda drop() otrzymuje &mut self, co implikuje, że potrzebuje dostępu do całej struktury, aby utrzymać bezpieczeństwo invariansów, takich jak zwalnianie blokad lub pamięci. Gdyby pole zostało przesunięte wcześniej za pomocą częściowego ruchu, drop() próbowałoby uzyskać dostęp do zwolnionej pamięci lub nieprawidłowych zasobów, co prowadziłoby do nieokreślonego zachowania. Wymuszając całkowite zniszczenie (wiązanie wszystkich pól), Rust zapewnia, że kod destruktora nigdy nie jest wykonywany; zamiast tego pola są usuwane indywidualnie, omijając potencjalnie niebezpieczną niestandardową logikę.
Jaka jest dokładna kolejność usuwania, gdy struktura jest całkowicie zdekonstrukrowana za pomocą dopasowania wzorca?
Gdy struktura jest całkowicie zdekonstrukrowana (np. let MyStruct { field1, field2 } = my_struct;), implementacja Drop struktury jest całkowicie tłumiona. Pola są następnie usuwane w odwrotnej kolejności ich deklaracji w definicji struktury (field2, a następnie field1 w tym przypadku). To zachowanie odpowiada standardowej kolejności usuwania dla pól struktury, ale krytycznie omija niestandardowy destruktor kontenera, zapobiegając mu obserwowaniu stanu po usunięciu i naruszaniu gwarancji bezpieczeństwa.
Czy typ z Drop może być Copy, jeśli zapewnimy, że destruktor jest idempotentny?
Nie, kompilator Rust egzekwuje, że Copy i Drop są nawzajem wykluczające się, zgodnie z zasadami spójności traitów, niezależnie od rzeczywistej implementacji destruktora. To jest celowy konserwatywny wybór projektowy: nawet jeśli drop() jest obecnie pusty lub idempotentny, pozwolenie na Copy umożliwiłoby niejawne kopiowanie bitowe. Przyszłe modyfikacje mogłyby sprawić, że drop() stanie się nie-idempotentny, cicho łamiąc gwarancje bezpieczeństwa, a ponieważ kompilator nie może zweryfikować idempotencji w ogólnym przypadku w czasie kompilacji, po prostu zabrania tego połączenia, aby zapobiec niespójnościom.