13.16 – Поверхностное и глубокое копирование

Добавлено 23 июля 2021 в 04:15
Глава 13 – Перегрузка операторов  (содержание)

Поверхностное копирование

Поскольку C++ мало что знает о вашем классе, конструктор копирования по умолчанию и операторы присваивания по умолчанию, которые он предоставляет, используют метод копирования, известный как поэлементное копирование (также известная как поверхностное копирование). Это означает, что C++ копирует каждый член класса отдельно (используя оператор присваивания для перегруженного operator= и прямую инициализацию для конструктора копирования). Когда классы просты (например, не содержат динамически выделяемой памяти), это работает очень хорошо.

Например, давайте взглянем на наш класс Fraction:

#include <cassert>
#include <iostream>
 
class Fraction
{
private:
    int m_numerator{};
    int m_denominator{};
 
public:
    // конструктор по умолчанию
    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;
}

Конструктор копирования и оператор присваивания, предоставленные компилятором для этого класса по умолчанию, выглядят примерно так:

#include <cassert>
#include <iostream>
 
class Fraction
{
private:
    int m_numerator{};
    int m_denominator{};
 
public:
    // Конструктор по умолчанию
    Fraction(int numerator=0, int denominator=1) :
        m_numerator{ numerator },
        m_denominator{ denominator }
    {
        assert(denominator != 0);
    }
 
    // Конструктор копирования
    Fraction(const Fraction &f) :
        m_numerator{ f.m_numerator },
        m_denominator{ f.m_denominator }
    {
    }
 
    Fraction& operator= (const Fraction &fraction);
 
    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;
}
 
// Улучшенная реализация operator=
Fraction& Fraction::operator= (const Fraction &fraction)
{
    // защита от самоприсваивания
    if (this == &fraction)
        return *this;
 
    // делаем копию
    m_numerator = fraction.m_numerator;
    m_denominator = fraction.m_denominator;
 
    // возвращаем существующий объект, чтобы
    // можно было включить этот оператор в цепочку
    return *this;
}

Обратите внимание: поскольку эти версии по умолчанию отлично подходят для копирования данного класса, в этом случае действительно нет причин писать наши собственные версии данных функций.

Однако при разработке классов, которые обрабатывают динамически выделяемую память, поэлементное (поверхностное) копирование может доставить нам массу неприятностей! Это связано с тем, что поверхностное копирование указателя просто копируют адрес указателя – оно не выделяет память и не копирует содержимое, на которое указывает указатель!

Давайте рассмотрим это на примере:

#include <cstring> // для strlen()
#include <cassert> // для assert()
 
class MyString
{
private:
    char *m_data{};
    int m_length{};
 
public:
    MyString(const char *source="")
    {
        assert(source); // убеждаемся, что source не является пустой строкой
 
        // Находим длину строки
        // Плюс один символ для терминатора
        m_length = std::strlen(source) + 1;
        
        // Выделяем буфер, равный этой длине
        m_data = new char[m_length];
        
        // Копируем строку параметра в наш внутренний буфер
        for (int i{ 0 }; i < m_length; ++i)
            m_data[i] = source[i];
    
        // Убеждаемся, что строка завершается нулем
        m_data[m_length-1] = '\0';
    }
 
    ~MyString() // деструктор
    {
        // нужно удалить нашу строку
        delete[] m_data;
    }
 
    char* getString() { return m_data; }
    int getLength() { return m_length; }
};

Показанный выше простой строковый класс выделяет память для хранения передаваемой нами строки. Обратите внимание, что мы не определили конструктор копирования и перегруженный оператор присваивания. Следовательно, C++ предоставит конструктор копирования по умолчанию и оператор присваивания по умолчанию, которые выполняют поверхностное копирование. Конструктор копирования будет выглядеть примерно так:

MyString::MyString(const MyString &source) :
    m_length{ source.m_length },
    m_data{ source.m_data }
{
}

Обратите внимание, что m_data – это просто поверхностная копия указателя source.m_data, то есть теперь они оба указывают на одно и то же.

Теперь рассмотрим следующий фрагмент кода:

int main()
{
    MyString hello{ "Hello, world!" };
    {
        MyString copy{ hello }; // использовать конструктор копирования по умолчанию
    } // copy - это локальная переменная, поэтому здесь она уничтожается.
      // Деструктор удаляет строку copy, которая оставляет hello с висячим указателем
 
    // это будет иметь неопределенное поведение
    std::cout << hello.getString() << '\n'; 
 
    return 0;
}

Хотя этот код выглядит достаточно безобидным, он содержит коварную проблему, которая приведет к сбою программы! Можете ее заметить? Не волнуйтесь, если не можете, она довольно незаметна.

Давайте разберем этот пример построчно:

MyString hello{ "Hello, world!" };

Эта строка достаточно безобидна. Она вызывает конструктор MyString, который выделяет некоторую память, устанавливает hello.m_data, чтобы указать на нее, а затем копирует в нее строку "Hello, world!".

MyString copy{ hello }; // использовать конструктор копирования по умолчанию

Эта строка тоже кажется достаточно безобидной, но на самом деле она является источником нашей проблемы! Когда эта строка вычисляется, C++ будет использовать конструктор копирования по умолчанию (потому что мы не предоставили свой собственный). Этот конструктор копирования будет выполнять поверхностное копирование, инициализируя copy.m_data тем же адресом, что и hello.m_data. В результате copy.m_data и hello.m_data теперь указывают на один и тот же участок памяти!

} // copy здесь уничтожается

Когда copy выходит за пределы области видимости, для нее вызывается деструктор MyString. Деструктор удаляет динамически выделенную память, на которую указывают copy.m_data и hello.m_data! Следовательно, удалив copy, мы также (непреднамеренно) повлияли на hello. Переменная copy затем уничтожается, но hello.m_data остается, указывая на удаленную (недействительную) память!

// это будет иметь неопределенное поведение
std::cout << hello.getString() << '\n'; 

Теперь вы можете понять, почему эта программа имеет неопределенное поведение. Мы удалили строку, на которую указывал hello, и теперь пытаемся вывести значение в памяти, которая больше не выделена.

Корень этой проблемы – поверхностное копирование, выполняемое конструктором копирования: выполнение поверхностного копирования значений указателя в конструкторе копирования или перегруженном операторе присваивания почти всегда вызывает проблемы.

Глубокое копирование

Одно из решений этой проблемы – выполнить глубокое копирование любых копируемых ненулевых указателей. Глубокое копирование выделяет память для копии, а затем копирует фактическое значение, поэтому копия живет в отдельной памяти от источника. Таким образом, копия и источник различаются и никоим образом не влияют друг на друга. Выполнение глубокого копирования требует, чтобы мы написали наши собственные конструкторы копирования и перегруженные операторы присваивания.

Давайте продолжим и покажем, как это делается для нашего класса MyString:

// предполагается, что m_data инициализирована
void MyString::deepCopy(const MyString& source)
{
    // сначала нам нужно освободить любое значение,
    // которое содержит эта строка!
    delete[] m_data;
 
    // поскольку m_length не является указателем,
    // на нем можем выполнить поверхностное копирование
    m_length = source.m_length;
 
    // m_data - это указатель, поэтому, если он не равен нулю,
    // для него нужно выполнить глубокое копирование
    if (source.m_data)
    {
        // выделяем память для нашей копии
        m_data = new char[m_length];
 
        // делаем копию
        for (int i{ 0 }; i < m_length; ++i)
            m_data[i] = source.m_data[i];
    }
    else
        m_data = nullptr;
}
 
// Конструктор копирования
MyString::MyString(const MyString& source)
{
    deepCopy(source);
}

Как видите, это немного сложнее, чем простое поверхностное копирование! Во-первых, мы даже должны проверить, есть ли в источнике строка (строка 14). Если это так, то мы выделяем достаточно памяти для хранения копии этой строки (строка 17). И в конце нам нужно вручную скопировать строку (строки 20 и 21).

Теперь займемся перегруженным оператором присваивания. Перегруженный оператор присваивания немного сложнее:

// Оператор присваивания
MyString& MyString::operator=(const MyString & source)
{
    // проверяем на самоприсваивание
    if (this != &source)
    {
        // теперь выполняем глубокое копирование
        deepCopy(source);
    }
 
    return *this;
}

Обратите внимание, что наш оператор присваивания очень похож на наш конструктор копирования, но есть три основных отличия:

  • мы добавили проверку на самоприсваивание;
  • мы возвращаем *this, чтобы можно было добавить оператор присваивания в цепочку;
  • нам нужно явно освободить любое значение, которое уже содержится в строке (чтобы не было утечки памяти, когда m_data заново размещается позже).

Когда вызывается перегруженный оператор присваивания, элемент, которому выполняется присваивание, может уже содержать предыдущее значение, которое необходимо очистить перед присваиванием памяти для новых значений. Для нединамически назначаемых переменных (которые имеют фиксированный размер) нам не нужно об этом беспокоиться, потому что новое значение просто перезаписывает старое. Однако для динамически размещаемых переменных нам необходимо явно освободить любую старую память, прежде чем выделять новую. Если мы этого не сделаем, код не завершится со сбоем, но у нас будет утечка памяти, которая будет съедать нашу свободную память каждый раз, когда мы выполняем присваивание!

Лучшее решение


Классы в стандартной библиотеке, которые имеют дело с динамической памятью, например std::string и std::vector, обрабатывают всё управление своей памятью и имеют перегруженные конструкторы копирования и операторы присваивания, которые выполняют правильное глубокое копирование. Поэтому вместо того, чтобы самостоятельно управлять памятью, вы можете просто инициализировать их и выполнять им присваивание, как обычным переменным базовых типов! Это делает эти классы более простыми в использовании, менее подверженными ошибкам, и вам не нужно тратить время на написание собственных перегруженных функций!

Резюме

  • Конструктор копирования по умолчанию и операторы присваивания по умолчанию выполняют поверхностное копирование, что подходит для классов, не содержащих динамически размещаемых переменных.
  • Классы с динамически размещаемыми переменными должны иметь конструктор копирования и оператор присваивания, выполняющие глубокое копирование.
  • Предпочитайте использование классов из стандартной библиотеки, вместо реализации собственного управления памятью.

Теги

C++ / CppLearnCppГлубокое копированиеДля начинающихКонструктор копированияОбучениеОператор присваиванияПрограммирование

На сайте работает сервис комментирования DISQUS, который позволяет вам оставлять комментарии на множестве сайтов, имея лишь один аккаунт на Disqus.com.

В случае комментирования в качестве гостя (без регистрации на disqus.com) для публикации комментария требуется время на премодерацию.