Płytkie kopiowanie
Ponieważ C++ nie wie zbyt wiele o twojej klasie, domyślny konstruktor kopiujący i domyślne operatory przypisania, które udostępnia, używają metody kopiowania znanej jako kopia członkowska (znana również jako płytka kopia). Oznacza to, że C++ kopiuje każdego członka klasy indywidualnie (używając operatora przypisania dla przeciążonego operatora= i bezpośredniej inicjalizacji konstruktora kopiującego). Kiedy klasy są proste (np. nie zawierają żadnej dynamicznie alokowanej pamięci), działa to bardzo dobrze.
Przyjrzyjmy się na przykład naszej klasie Fraction:
#include <cassert>
#include <iostream>
class Fraction
{
private:
int m_numerator { 0 };
int m_denominator { 1 };
public:
// Default constructor
Fraction(int numerator = 0, int denominator = 1)
: m_numerator{ numerator }
, m_denominator{ denominator }
{
assert(denominator != 0);
}
friend std::ostream& operator<<(std::ostream& out, const Fraction& f1);
};
std::ostream& operator<<(std::ostream& out, const Fraction& f1)
{
out << f1.m_numerator << '/' << f1.m_denominator;
return out;
}Domyślny konstruktor kopiujący i domyślny operator przypisania dostarczone przez kompilator dla tej klasy wyglądają mniej więcej tak:
#include <cassert>
#include <iostream>
class Fraction
{
private:
int m_numerator { 0 };
int m_denominator { 1 };
public:
// Default constructor
Fraction(int numerator = 0, int denominator = 1)
: m_numerator{ numerator }
, m_denominator{ denominator }
{
assert(denominator != 0);
}
// Possible implementation of implicit copy constructor
Fraction(const Fraction& f)
: m_numerator{ f.m_numerator }
, m_denominator{ f.m_denominator }
{
}
// Possible implementation of implicit assignment operator
Fraction& operator= (const Fraction& fraction)
{
// self-assignment guard
if (this == &fraction)
return *this;
// do the copy
m_numerator = fraction.m_numerator;
m_denominator = fraction.m_denominator;
// return the existing object so we can chain this operator
return *this;
}
friend std::ostream& operator<<(std::ostream& out, const Fraction& f1)
{
out << f1.m_numerator << '/' << f1.m_denominator;
return out;
}
};Zauważ, że ponieważ te wersje domyślne świetnie sprawdzają się przy kopiowaniu tej klasy, naprawdę nie ma powodu pisać w tym własnej wersji tych funkcji przypadek.
Jednak przy projektowaniu klas obsługujących dynamicznie alokowaną pamięć, kopiowanie składowe (płytkie) może przysporzyć nam sporo kłopotów! Dzieje się tak dlatego, że płytkie kopie wskaźnika po prostu kopiują adres wskaźnika - nie przydzielają one żadnej pamięci ani nie kopiują wskazywanej zawartości!
Przyjrzyjmy się temu przykładowi:
#include <cstring> // for strlen()
#include <cassert> // for assert()
class MyString
{
private:
char* m_data{};
int m_length{};
public:
MyString(const char* source = "" )
{
assert(source); // make sure source isn't a null string
// Find the length of the string
// Plus one character for a terminator
m_length = std::strlen(source) + 1;
// Allocate a buffer equal to this length
m_data = new char[m_length];
// Copy the parameter string into our internal buffer
for (int i{ 0 }; i < m_length; ++i)
m_data[i] = source[i];
}
~MyString() // destructor
{
// We need to deallocate our string
delete[] m_data;
}
char* getString() { return m_data; }
int getLength() { return m_length; }
};Powyższe jest prostą klasą łańcuchową, która przydziela pamięć do przechowywania przekazywanego przez nas ciągu. Zauważ, że nie zdefiniowaliśmy konstruktora kopiującego ani przeciążonego operatora przypisania. W rezultacie C++ zapewni domyślny konstruktor kopiujący i domyślny operator przypisania, które wykonują płytką kopię. Konstruktor kopiujący będzie wyglądał mniej więcej tak:
MyString::MyString(const MyString& source)
: m_length { source.m_length }
, m_data { source.m_data }
{
}Zauważ, że m_data jest tylko płytką kopią wskaźnika source.m_data, co oznacza, że teraz oba wskazują na to samo.
Teraz rozważ następujący fragment kodu:
#include <iostream>
int main()
{
MyString hello{ "Hello, world!" };
{
MyString copy{ hello }; // use default copy constructor
} // copy is a local variable, so it gets destroyed here. The destructor deletes copy's string, which leaves hello with a dangling pointer
std::cout << hello.getString() << '\n'; // this will have undefined behavior
return 0;
}Chociaż ten kod wygląda wystarczająco nieszkodliwie, zawiera podstępny problem, który spowoduje, że program wyświetli niezdefiniowany zachowanie!
Przeanalizujmy ten przykład wiersz po wierszu:
MyString hello{ "Hello, world!" };Ten wiersz jest wystarczająco nieszkodliwy. Wywołuje to konstruktor MyString, który przydziela część pamięci, ustawia hello.m_data tak, aby wskazywał na nią, a następnie kopiuje ciąg znaków „Hello, world!” w to.
MyString copy{ hello }; // use default copy constructorTa linia również wydaje się nieszkodliwa, ale w rzeczywistości jest źródłem naszego problemu! Kiedy ta linia zostanie oceniona, C++ użyje domyślnego konstruktora kopiującego (ponieważ nie udostępniliśmy własnego). Ten konstruktor kopiujący wykona płytką kopię, inicjując copy.m_data na ten sam adres hello.m_data. W rezultacie copy.m_data i hello.m_data wskazują teraz na ten sam fragment pamięci!
} // copy gets destroyed hereGdy kopiowanie wykracza poza zakres, podczas kopiowania wywoływany jest destruktor MyString. Destruktor usuwa dynamicznie przydzieloną pamięć, na którą wskazują zarówno copy.m_data, jak i hello.m_data! W rezultacie, usuwając kopię, wpłynęliśmy (nieumyślnie) na hello. Kopia zmiennej zostaje następnie zniszczona, ale plik hello.m_data pozostaje wskazujący na usuniętą (nieprawidłową) pamięć!
std::cout << hello.getString() << '\n'; // this will have undefined behaviorTeraz możesz zobaczyć, dlaczego ten program ma niezdefiniowane zachowanie. Usunęliśmy ciąg znaków, na który wskazywał hello, a teraz próbujemy wydrukować wartość pamięci, która nie jest już alokowana.
Źródłem tego problemu jest płytka kopia wykonywana przez konstruktor kopiujący — wykonanie płytkiej kopii wartości wskaźników w konstruktorze kopiującym lub przeciążonym operatorze przypisania prawie zawsze powoduje problemy.
Głębokie kopiowanie
Jedną z odpowiedzi na ten problem jest wykonanie głębokiej kopii dowolnego kopiowane są wskaźniki inne niż null. A głęboka kopia przydziela pamięć dla kopii, a następnie kopiuje rzeczywistą wartość, tak że kopia żyje w pamięci odrębnej od źródła. W ten sposób kopia i źródło są odrębne i nie wpływają na siebie w żaden sposób. Wykonywanie głębokich kopii wymaga napisania własnych konstruktorów kopiujących i przeciążonych operatorów przypisania.
Pójdźmy dalej i pokażmy, jak to się robi dla naszej klasy MyString:
// assumes m_data is initialized
void MyString::deepCopy(const MyString& source)
{
// first we need to deallocate any value that this string is holding!
delete[] m_data;
// because m_length is not a pointer, we can shallow copy it
m_length = source.m_length;
// m_data is a pointer, so we need to deep copy it if it is non-null
if (source.m_data)
{
// allocate memory for our copy
m_data = new char[m_length];
// do the copy
for (int i{ 0 }; i < m_length; ++i)
m_data[i] = source.m_data[i];
}
else
m_data = nullptr;
}
// Copy constructor
MyString::MyString(const MyString& source)
{
deepCopy(source);
}Jak widzisz, jest to trochę bardziej skomplikowane niż zwykła, płytka kopia! Najpierw musimy sprawdzić, czy źródło zawiera w ogóle ciąg znaków (linia 11). Jeśli tak, przydzielamy wystarczającą ilość pamięci, aby pomieścić kopię tego ciągu (linia 14). Na koniec musimy ręcznie skopiować ciąg znaków (linie 17 i 18).
Zajmijmy się teraz przeciążonym operatorem przypisania. Przeciążony operator przypisania jest nieco trudniejszy:
// Assignment operator
MyString& MyString::operator=(const MyString& source)
{
// check for self-assignment
if (this != &source)
{
// now do the deep copy
deepCopy(source);
}
return *this;
}Zauważ, że nasz operator przypisania jest bardzo podobny do naszego konstruktora kopiującego, ale są trzy główne różnice:
- Dodaliśmy kontrolę samodzielnego przypisania.
- Zwracamy *to, abyśmy mogli połączyć operator przypisania w łańcuch.
- Musimy jawnie zwolnić dowolną wartość, która już znajduje się w łańcuchu (abyśmy nie mieli wycieku pamięci, gdy m_data jest przeniesione później). Jest to obsługiwane przez funkcję deepCopy().
Gdy wywoływany jest przeciążony operator przypisania, przypisywany element może już zawierać poprzednią wartość, którą musimy wyczyścić przed przypisaniem pamięci dla nowych wartości. W przypadku zmiennych niealokowanych dynamicznie (które mają stały rozmiar) nie musimy się tym przejmować, ponieważ nowa wartość po prostu zastępuje starą. Jednakże w przypadku zmiennych alokowanych dynamicznie musimy jawnie zwolnić starą pamięć przed przydzieleniem nowej pamięci. Jeśli tego nie zrobimy, kod nie ulegnie awarii, ale nastąpi wyciek pamięci, który pożera naszą wolną pamięć za każdym razem, gdy wykonujemy zadanie!
Zasada trzech
Pamiętasz zasadę trzech? Jeśli klasa wymaga zdefiniowanego przez użytkownika destruktora, konstruktora kopiującego lub operatora przypisania kopiującego, to prawdopodobnie wymaga wszystkich trzech. Oto dlaczego. Jeśli definiujemy którąkolwiek z tych funkcji przez użytkownika, dzieje się tak prawdopodobnie dlatego, że mamy do czynienia z dynamiczną alokacją pamięci. Potrzebujemy konstruktora kopiującego i przypisania kopii do obsługi głębokich kopii oraz destruktora do zwolnienia pamięci.
Lepsze rozwiązanie
Klasy w bibliotece standardowej, które zajmują się pamięcią dynamiczną, takie jak std::string i std::vector, obsługują całe zarządzanie pamięcią i mają przeciążone konstruktory kopiujące i operatory przypisania, które wykonują prawidłowe głębokie kopiowanie. Zamiast więc samodzielnie zarządzać pamięcią, możesz po prostu je zainicjować lub przypisać jak zwykłe zmienne podstawowe! Dzięki temu te klasy są prostsze w użyciu, mniej podatne na błędy i nie musisz tracić czasu na pisanie własnych przeciążonych funkcji!
Streszczenie
- Domyślny konstruktor kopiujący i domyślne operatory przypisania wykonują płytkie kopie, co jest dobre w przypadku klas, które nie zawierają dynamicznie alokowanych zmiennych.
- Klasy z dynamicznie alokowanymi zmiennymi muszą mieć konstruktor kopiujący i operator przypisania, które wykonują głęboką kopię.
- Preferuj używanie klas w standardzie zamiast samodzielnego zarządzania pamięcią.

