Historia: Moduł copy został wprowadzony na wczesnym etapie rozwoju Pythona, aby zapewnić ustandaryzowane powielanie obiektów ponad prostym przypisaniem referencji. Gdy programiści potrzebowali powielić złożone grafiki obiektów zawierające zagnieżdżone struktury, początkowe wdrożenia rekurencyjnego kopiowania napotykały na nieskończoną rekurencję, gdy obiekty odnosiły się do siebie bezpośrednio lub pośrednio, a także nie zachowywały tożsamości, gdy wiele ścieżek prowadziło do tego samego obiektu.
Problem: Bez rejestru już skopiowanych obiektów, deepcopy wchodziłby w nieskończoną rekurencję w przypadku napotkania odniesień cyklicznych (np. węzeł rodzica odnosi się do dziecka, które odnosi się z powrotem do rodzica). Dodatkowo, brak mapowania tożsamości skutkowałby tym, że wiele odniesień do tego samego obiektu w grafie prowadziłoby do różnych kopii, co naruszałoby semantykę tożsamości obiektów.
Rozwiązanie: Algorytm wykorzystuje słownik memo, który mapuje id(original_object) na nowo utworzoną kopię. Na początku operacji kopiowania dla dowolnego obiektu algorytm sprawdza, czy id(obj) istnieje w memo; jeśli zostanie znalezione, natychmiast zwraca istniejącą kopię. Jeśli nie, tworzy nową instancję, natychmiast przechowuje ją w memo pod ID oryginału (przed rekurencyjnym populowaniem) i następnie przystępuje do kopiowania atrybutów. To zapewnia, że odniesienia cykliczne rozwiązują się w tą samą skopiowaną instancję. Użytkownik może zdefiniować własne klasy, które zaimplementują __deepcopy__(self, memo), aby dostosować to zachowanie, otrzymując słownik memo do przekazania podczas rekurencyjnych wywołań.
Scenariusz: Narzędzie do zarządzania infrastrukturą chmurową modeluje topologię centrum danych jako graf obiektów Server. Każdy Server utrzymuje listę peers dla równoważenia obciążenia oraz odniesienie do swojego węzła primary dla zapasowej awarii. Te relacje tworzą dwukierunkowe odniesienia (Serwer A wymienia Serwer B jako równego partnera, Serwer B wymienia Serwer A), tworząc cykle w grafie obiektów. Zespół operacyjny potrzebuje sklonować tę topologię do testów symulacyjnych bez wpływu na stan konfiguracyjny produkcji.
Opis problemu: Początkowe próby duplikacji grafu serwerów przy użyciu ręcznego kopiowania rekurencyjnego kończyły się błędem RecursionError, gdy algorytm napotkał odniesienia cykliczne do równego partnera. Ponadto niektóre współdzielone obiekty konfiguracyjne (jak konteksty certyfikatów SSL) były powielane wielokrotnie, marnując pamięć i łamiąc kontrole tożsamości, które oczekiwały zachowań typu singleton.
Rozważane rozwiązania:
Ręczna weryfikacja z zestawem odwiedzonych: Wdrażanie niestandardowej metody clone() w klasie Server, która przyjmuje słownik visited. Ta metoda sprawdzałaby, czy serwer został już odwiedzony, zwracałaby istniejący klon, jeśli tak, lub tworzyła nowy i rekurencyjnie klonowała partnerów. Plusy: Pełna kontrola nad procesem klonowania, brak zależności zewnętrznych. Minusy: Wymaga wdrożenia złożonej logiki przeszukiwania dla każdej klasy w hierarchii, podatnej na błędy, gdy dodawane są nowe typy relacji, oraz narusza Zasadę Jednej Odpowiedzialności, mieszając logikę klonowania z logiką domeny.
Serializacja JSON w obie strony: Serializacja grafu serwera do JSON przy użyciu niestandardowych encoderów do obsługi cykli, a następnie deserializacja do nowych obiektów. Plusy: Prosta implementacja przy użyciu standardowych bibliotek. Minusy: Traci specyficzne dla Pythona typy (zbiory stają się listami, krotki stają się listami), traci metody i zachowanie, źle działa w przypadku dużych grafów oraz krytycznie nie zachowuje tożsamości obiektów dla współdzielonych odniesień niecyklicznych (dwa serwery dzielące ten sam obiekt konfiguracyjny otrzymałyby oddzielne kopie po deserializacji).
Standardowy copy.deepcopy z niestandardowymi hakami: Wykorzystanie copy.deepcopy Pythona z niestandardowymi implementacjami __deepcopy__ w klasie Server, aby obsłużyć zasoby, które nie mogą być kopiowane, takie jak gniazda sieciowe. Plusy: Automatycznie obsługuje odniesienia cykliczne przez wewnętrzny słownik memo, zachowuje typy Pythona i tożsamość dla współdzielonych obiektów, dobrze przetestowane i standardowe. Minusy: Nieco większe zużycie pamięci podczas kopiowania z powodu słownika memo, wymaga starannego wdrożenia __deepcopy__, aby poprawnie przekazać słownik memo, aby uniknąć złamania wykrywania cykli.
Wybrane rozwiązanie: Zespół wybrał copy.deepcopy (Opcja 3). Wdrożyli __deepcopy__ w klasie Server, aby stworzyć nową instancję przy użyciu self.__class__, natychmiast zarejestrowali ją w słowniku memo, a następnie głęboko skopiowali tylko atrybuty konfiguracyjne, które można serializować, podczas gdy ponownie inicjowali połączenia gniazdowe leniwie przy pierwszym użyciu w kopii.
Rezultat: System skutecznie powielił konfiguracje centrum danych zawierające tysiące serwerów z złożonymi cyklicznymi relacjami równorzędnymi. Słownik memo zapewniał, że współdzielone konteksty SSL, do których odnosiło się wiele serwerów, pozostały współdzielone w kopii, zachowując efektywność pamięci, podczas gdy cykliczne odniesienia do równego partnera były rozwiązywane bez błędów rekurencji.
Dlaczego copy.deepcopy nie zachowuje atrybutów specyficznych dla podklas przy kopiowaniu instancji niestandardowych podklas listy lub dict, mimo że prawidłowo kopiuje elementy?
Kiedy deepcopy napotyka wbudowane typy kontenerów takie jak list lub dict (w tym ich podklasy), używa zoptymalizowanej szybkiej ścieżki, która tworzy nową instancję dokładnego typu podklasy i kopiuje zawarte elementy. Jednak ta szybka ścieżka omija metodę __init__ podklasy i nie kopiuje atrybutów przechowywanych w __dict__ instancji. W konsekwencji atrybuty, takie jak metadane lub pamięci podręczne dodane do instancji class MyList(list), zostają utracone w kopi. Aby to zachować, podklasa musi jawnie zaimplementować __deepcopy__, aby obsłużyć dodatkowe atrybuty lub ewentualnie użyć copy.copy na instancji, a następnie ręcznie skopiować atrybuty, zapewniając, że dane specyficzne dla podklasy zostaną przeniesione do nowej instancji.
Jak mechanizm słownika memo zapobiega nieskończonej rekurencji w cyklicznych grafach obiektów i dlaczego ważne jest, aby przekazywać ten sam obiekt słownika do wszystkich rekurencyjnych wywołań deepcopy, zamiast tworzyć nowe?
Słownik memo utrzymuje mapowanie z id() każdego oryginalnego obiektu na jego odpowiadającą kopię. Przed przetworzeniem jakiegokolwiek obiektu deepcopy sprawdza, czy id(obj) istnieje w memo; jeśli zostanie znalezione, natychmiast zwraca istniejącą kopię, przerywając potencjalne cykle. Podczas tworzenia nowej kopii algorytm natychmiast przechowuje mapowanie memo[id(original)] = new_copy przed rekurencyjnym kopiowaniem zawartości obiektu. To zapewnia, że jeśli oryginał zostanie napotkany ponownie podczas rekurencyjnego przeszukiwania (odniesienie cykliczne), zostanie zwrócona częściowo skonstruowana kopia, co zapobiega nieskończonej rekurencji. Przekazywanie tego samego słownika memo do wszystkich rekurencyjnych wywołań jest niezbędne, ponieważ zapewnia globalny widok postępu kopiowania w całym grafie obiektów; tworzenie nowych słowników izolowałoby gałęzie grafu, co spowodowałoby, że cykle byłyby pomijane, a obiekty współdzielone byłyby duplikowane.
Jaki subtelny błąd może wystąpić, jeśli wyjątek zostanie zgłoszony wewnątrz niestandardowej implementacji __deepcopy__ po tym, jak metoda zarejestrowała nową instancję w słowniku memo, ale zanim skończy z populowaniem atrybutów obiektu?
Standardowy wzór wdrażania __deepcopy__ wymaga zarejestrowania nowej instancji w słowniku memo natychmiast po utworzeniu (używając memo[id(self)] = result) i przed rekurencyjnym kopiowaniem atrybutów. Jeśli wyjątek wystąpi podczas fazy kopiowania atrybutów, słownik memo zachowa odniesienie do częściowo skonstruowanego (i potencjalnie niespójnego) obiektu. Jeśli kod wywołujący przechwyci ten wyjątek i kontynuuje kopiowanie innych części grafu, lub jeśli ten sam obiekt jest odniesiony przez inną ścieżkę w grafie, kolejny przegląd w memo zwróci ten uszkodzony, w połowie zainicjowany obiekt. Może to prowadzić do cichej korupcji danych, gdzie niektóre odniesienia wskazują na w pełni skonstruowane kopie, podczas gdy inne wskazują na niekompletny obiekt ocalały po wyjątku. Aby to złagodzić, implementacje __deepcopy__ powinny zapewnić atomowe kopiowanie atrybutów lub starannie zarządzać obsługą wyjątków, aby oczyścić słownik memo w przypadku niepowodzenia, chociaż standardowa biblioteka Pythona nie zapewnia automatycznego wycofania w tym scenariuszu.