programowanieC++ Middle developer

Wyjaśnij różnice między shallow a deep copy w C++ na przykładzie kontenera z dynamiczną pamięcią. Jak ręcznie zrealizować głębokie kopiowanie?

Zdaj rozmowy kwalifikacyjne z asystentem AI Hintsage

Odpowiedź.

Mechanizm kopiowania obiektów w C++ dzieli się na powierzchowne kopiowanie (shallow copy) i głębokie kopiowanie (deep copy). Różnica ma szczególne znaczenie dla klas z dynamicznie przydzielaną pamięcią.

Historia pytania

W C++ wiele struktur danych działa z dynamiczną pamięcią (new/delete). Domyślnie kompilator generuje konstruktor kopiowania i operator przypisania, który dokonuje kopiowania bajtowego (shallow copy). To szybkie, ale niebezpieczne, jeśli obiekt zarządza zewnętrznymi zasobami.

Problem

Shallow copy kopiuje tylko adresy dynamicznie przydzielonych zasobów. Po usunięciu jednego obiektu pamięć zostanie zwolniona, a inny egzemplarz pozostanie z "zwisającym" (dangling) wskaźnikiem. W rezultacie występują podwójne usunięcia, wycieki pamięci i awarie.

Rozwiązanie

Głębokie kopiowanie wymaga jawnego utworzenia kopii wszystkich dynamicznych zasobów. W tym celu w klasie należy samodzielnie zaimplementować konstruktor kopiowania i operator przypisania, aby zapewnić kopię każdego elementu.

Przykład kodu dla klasy z tablicą:

class DynArray { int* data; size_t size; public: DynArray(size_t n) : size(n), data(new int[n]) {} ~DynArray() { delete[] data; } // Konstruktor głębokiego kopiowania DynArray(const DynArray& other) : size(other.size), data(new int[other.size]) { for (size_t i = 0; i < size; ++i) data[i] = other.data[i]; } // Operator głębokiego kopiowania DynArray& operator=(const DynArray& other) { if (this != &other) { delete[] data; size = other.size; data = new int[size]; for (size_t i = 0; i < size; ++i) data[i] = other.data[i]; } return *this; } };

Kluczowe cechy:

  • Shallow copy kopiuje wskaźniki, deep copy tworzy nowe egzemplarze dynamicznej pamięci.
  • Dla deep copy konieczne jest zaimplementowanie własnej logiki kopiowania w konstruktorze i operatorze=
  • Ignorowanie potrzeby deep copy prowadzi do trudnych do wytropienia błędów.

Pytania podchwytliwe.

Kompilator zawsze poprawnie generuje copy constructor i operator przypisania, prawda?

Odpowiedź:

Nieprawda. Dla klas z dynamicznymi zasobami kopiowanie domyślnie jest niepoprawne: oba obiekty będą posiadały jeden i ten sam zasób. Głębokie kopiowanie należy zaimplementować jawnie przy posiadaniu zewnętrznych zasobów.

Czy należy implementować destruktor, jeśli napisano tylko konstruktor/operador głębokiego kopiowania?

Odpowiedź:

Tak, w przeciwnym razie wystąpi wyciek pamięci: jeśli zwolnisz pamięć w niestandardowym konstruktorze kopiowania, ale nie zaimplementujesz destruktora, pamięć nie będzie zwalniana przy zniszczeniu obiektów.

Czy std::vector może przechowywać wskaźniki i dlaczego przy jego kopiowaniu mogą wystąpić wycieki?

Odpowiedź:

Tak, std::vector spokojnie przechowuje wskaźniki. Podczas kopiowania takiego std::vector kopiowane są same wskaźniki, a nie obiekty, na które wskazują. To jest shallow copy: jeśli potrzebna jest głęboka kopia całej zawartości, potrzeba ręcznie skopiować każdy obiekt i umieścić je w pamięci niezależnie.

Przykład:

std::vector<int*> v1; v1.push_back(new int(42)); std::vector<int*> v2 = v1; // Kopiowane są wskaźniki, a nie *int

Typowe błędy i antywzorce

  • Ignorowanie potrzeby zaimplementowania zasady trzech.
  • Kopiowanie wskaźników, sądząc, że to kopia obiektu.
  • Nie zwalnianie dynamicznych zasobów w destruktorze.
  • Używanie shallow copy dla klas z posiadanymi zasobami.

Przykład z życia

Negatywny przypadek

Programista implementuje klasę-opakowanie dla tablicy, nie nadpisując konstruktora kopiowania/operatora przypisania. W rezultacie oba obiekty posiadają jedną pamięć, zniszczenie jednego prowadzi do awarii przy dostępie do drugiego.

Zalety:

  • Szybko działa (brak kopii).

Wady:

  • Bardzo trudne do wyśledzenia błędy w czasie działania; wystąpienie podwójnego zwolnienia/segfault.

Pozytywny przypadek

Programista implementuje głębokie kopiowanie: kopiowane są zawartości tablicy, jest własny destruktor oraz operator przypisania z ochroną przed self-assignment.

Zalety:

  • Bezpieczne kopiowanie i zwalnianie pamięci.
  • Kod jest łatwy w utrzymaniu i rozszerzaniu.

Wady:

  • Trochę więcej kodu i kosztów pamięci.
  • Bardziej skomplikowane dla klas z wieloma dynamicznymi zasobami.