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);
}
// Możliwa implementacja ukrytego konstruktora kopiującego
Fraction(const Fraction& f)
: m_numerator{ f.m_numerator }
, m_denominator{ f.m_denominator }
{
}
// Możliwa implementacja ukrytego operatora przypisania
Fraction& operator= (const Fraction& fraction)
{
// ochroniarz z własnym przydziałem
if (this == &fraction)
return *this;
// zrób kopię
m_numerator = fraction.m_numerator;
m_denominator = fraction.m_denominator;
// zwróć istniejący obiekt, abyśmy mogli połączyć ten 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> // dla strlen()
#include <cassert> // dla Assert()
class MyString
{
private:
char* m_data{};
int m_length{};
public:
MyString(const char* source = "" )
{
assert(source); // upewnij się upewnij się, że źródło nie jest ciągiem pustym
// Znajdź długość sznurka
// Plus jeden znak dla terminatora
m_length = std::strlen(source) + 1;
// Przydziel bufor równy tej długości
m_data = new char[m_length];
// Skopiuj ciąg parametrów do naszego wewnętrznego bufora
for (int i{ 0 }; i < m_length; ++i)
m_data[i] = source[i];
}
~MyString() // destructor
{
// Musimy zwolnić nasz ciąg znaków
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 }; // użyj domyślny konstruktor kopiujący
} // copy jest zmienną lokalną, więc tutaj zostaje zniszczona. Destruktor usuwa ciąg kopii, co pozostawia hello ze zwisającym wskaźnikiem
std::cout << hello.getString() << '\n'; // będzie miała niezdefiniowane zachowanie
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 }; // użyj domyślny konstruktor kopiującyTa 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!
} // kopia zostanie zniszczona tutajGdy 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'; // będzie miała niezdefiniowane zachowanieTeraz 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. 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:
// zakłada, że zainicjowano m_data
void MyString::deepCopy(const MyString& source)
{
// najpierw musimy zwolnić dowolną wartość przechowywaną w tym ciągu!
delete[] m_data;
// ponieważ m_length nie jest wskaźnikiem, możemy go płytkie skopiować
m_length = source.m_length;
// m_data jest wskaźnikiem, więc musimy go głęboko skopiować, jeśli nie ma wartości null
if (source.m_data)
{
// przydziel pamięć dla naszej kopii
m_data = new char[m_length];
// zrób kopię
for (int i{ 0 }; i < m_length; ++i)
m_data[i] = source.m_data[i];
}
else
m_data = nullptr;
}
// Kopiuj konstruktor
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)
{
// sprawdź samodzielne przypisanie
if (this != &source)
{
// teraz wykonaj głęboką kopię
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ą.

