Cechą Copy pojawiła się we wczesnym projektowaniu Rustu jako znacznik dla typów, które mogą być duplikowane za pomocą prostego kopiowania bitowego bez obaw o zarządzanie zasobami. Drop został wprowadzony, aby zająć się deterministycznym sprzątaniem zasobów dla typów zarządzających zewnętrznymi zasobami, takimi jak deskryptory plików czy pamięć na stercie. Konflikt między implicitną duplikacją a unikalnym posiadaniem stał się oczywisty, gdy projektanci zdali sobie sprawę, że bitowe kopie dzielą niezbywalne uchwyty zasobów. W konsekwencji, kompilator został zaprojektowany tak, aby odrzucał wszelkie typy, które próbują implementować oba cechy jednocześnie.
Jeśli typ implementujący Drop (np. zarządzający deskryptorem pliku) byłby także Copy, przypisanie wartości do nowej zmiennej stworzyłoby dwa identyczne bitowo kopie. Gdy obie kopie wychodzą z zakresu, niestandardowa implementacja Drop wykonuje się dwa razy na tym samym podstawowym zasobie. Prowadzi to do wady podwójnego zwolnienia (double-free) lub użycia po zwolnieniu (use-after-free), jeśli zasób został unieważniony przez pierwszy drop, ale jest dostępny przez drugi, naruszając bezpieczeństwo pamięci.
Kompilator Rustu zawiera kontrolę spójności w systemie cech, która wyraźnie zabrania typowi implementowania zarówno Copy, jak i Drop. To ograniczenie zmusza programistów do używania Clone (jawna duplikacja) dla typów wymagających niestandardowej destrukcji, co pozwala na odpowiednie zwiększenie liczby odniesień lub wykonanie głębokich kopii. Dzięki zapewnieniu, że każdy logiczny byt ma odpowiadający unikalny drop, system typów utrzymuje zero-kosztowe abstrakcje bez rezygnacji z gwarancji bezpieczeństwa.
Rozważmy strukturę DatabaseHandle, owiniętą wokół wskaźnika surowego do obiektu połączenia w zewnętrznej bibliotece C. Aplikacja wymaga przekazywania uchwytów przez wartość do wielu zamknięć (closures) w celu logowania, a każdy uchwyt musi zamknąć swoje unikalne połączenie poprzez wywołanie FFI, gdy jest zwalniany. Jeśli uchwyt byłby Copy, implicitna duplikacja stworzyłaby wiele uchwytów, które twierdziłyby, że są właścicielami tego samego podstawowego zasobu C, co nieuchronnie prowadziłoby do podwójnego zamknięcia lub użycia po zwolnieniu, gdy zakres się kończy.
Jednym z podejść było pozwolenie na Copy i implementację Drop z wewnętrznym liczeniem odniesień przy użyciu Arc. To dodałoby narzut synchronizacji dla każdego uchwytu, zwiększając rozmiar binarny i koszt czasu wykonania we wszystkich operacjach. Utrudniałoby to także granicę FFI, gdzie surowy wskaźnik musiałby być atomowo wydobyty z Arc, wprowadzając potencjalne zakleszczenia, jeśli logika zwolnienia sama wywołuje kod Rustu.
Innym podejściem było użycie Copy, ale udokumentowanie, że użytkownicy muszą ręcznie wywołać metodę close przed zwolnieniem wartości. To całkowicie przenosi odpowiedzialność za bezpieczeństwo pamięci na programistę, łamiąc podstawową zasadę Rustu polegającą na zapobieganiu błędom na etapie kompilacji. Niezawodnie prowadzi to do wycieków zasobów, gdy programiści zapomnieli wywołać close, lub do podwójnych zamknięć, gdy niechcący kopiują uchwyt i próbują zamknąć oba kopie.
Wybrane rozwiązanie polegało na usunięciu Copy i ręcznej implementacji Clone, razem z Drop. Clone wykonuje głęboką kopię, otwierając nowe połączenie z bazą danych, zapewniając, że każda instancja posiada swój unikalny zasób i zapobiega aliasingowi podstawowego wskaźnika C. Drop zamyka tylko swoje własne połączenie, podczas gdy kompilator zapobiega przypadkowemu kopiowaniu bitowemu, utrzymując bezpieczeństwo bez narzutu czasu wykonania.
System typów teraz zapobiega przypadkowemu kopiowaniu na etapie kompilacji, zmuszając programistów do explicite wywoływania clone, co czyni nabywanie zasobów widocznym w kodzie źródłowym. Program unika błędów podwójnego zwolnienia, gdy uchwyty są przekazywane do wątków lub zamknięć, a gwarancje deterministycznej destrukcji pozostają nienaruszone bez potrzeby operacji atomowych czy ręcznego zarządzania pamięcią.
Dlaczego nie mogę wyprowadzić Copy dla struktury zawierającej Vec?
Vec posiada pamięć alokowaną na stercie i implementuje Drop, aby zwolnić tę pamięć, gdy wektor wychodzi z zakresu. Gdyby struktura zawierająca Vec była Copy, bitowa duplikacja stworzyłaby dwie struktury wskazujące na ten sam bufor na stercie, ale obie zawierałyby ten sam wskaźnik do sterty. Gdy pierwsza struktura zostanie zwolniona, pamięć zostaje zwolniona; gdy druga zostanie zwolniona, próbuje ponownie zwolnić tę samą pamięć, co powoduje nieokreślone zachowanie. Rust zapobiega temu, wymagając, aby wszystkie pola typu Copy były również Copy, zapewniając rekurencyjnie, że nie istnieją zagnieżdżone implementacje Drop.
Czy mem::forget zapobiega problemom z Copy i Drop?
std::mem::forget konsumuje wartość, nie wywołując jej destruktora, ale działa tylko na jedną konkretną wartość, a nie na wszystkie jej kopie. Gdyby Copy i Drop były dozwolone, zapomnienie jednej kopii nie uniemożliwiłoby innym bitowym kopiom wykonania swoich implementacji Drop, gdy wychodzą z zakresu. Pozostałe wywołania Drop nadal próbowałyby zwolnić ten sam podstawowy zasób, prowadząc do użycia po zwolnieniu lub podwójnego zwolnienia, niezależnie od zapomnianej instancji.
Czy mogę użyć ManuallyDrop, aby bezpiecznie zaimplementować Copy?
Owinięcie pola w ManuallyDrop zapobiega automatycznemu wywołaniu Drop, co technicznie pozwala na pochodzenie Copy zewnętrznej struktury. Jednak, to przenosi odpowiedzialność za wywołanie ManuallyDrop::drop na użytkownika dla każdej pojedynczej kopii, co skutkuje efektywnym stworzeniem scenariusza ręcznego zarządzania pamięcią. Jeśli użytkownik zapomni zwolnić chociaż jedną kopię, zasób w cieknie na stałe; Rust zabrania tego wzorca dla typów posiadających zasoby, ponieważ osłabia to gwarancję bezpieczeństwa deterministycznego, automatycznego oczyszczania.