На этом уроке мы рассмотрим поверхностное и глубокое копирование в языке C++.
Поверхностное копирование
Поскольку язык C++ не может знать наперед всё о вашем классе, то конструктор копирования и оператор присваивания, которые C++ предоставляет по умолчанию, используют почленный метод копирования — поверхностное копирование. Это означает, что C++ выполняет копирование для каждого члена класса индивидуально (используя оператор присваивания по умолчанию вместо перегрузки оператора присваивания и прямую инициализацию вместо конструктора копирования). Когда классы простые (например, в них нет членов с динамически выделенной памятью), то никаких проблем с этим не должно возникать.
Рассмотрим следующий класс Drob:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
#include <iostream> #include <cassert> class Drob { private: int m_numerator; int m_denominator; public: // Конструктор по умолчанию Drob(int numerator=0, int denominator=1) : m_numerator(numerator), m_denominator(denominator) { assert(denominator != 0); } friend std::ostream& operator<<(std::ostream& out, const Drob &d1); }; std::ostream& operator<<(std::ostream& out, const Drob &d1) { out << d1.m_numerator << "/" << d1.m_denominator; return out; } |
Конструктор копирования и оператор присваивания по умолчанию, предоставляемые компилятором автоматически, выглядят примерно следующим образом:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 |
#include <iostream> #include <cassert> class Drob { private: int m_numerator; int m_denominator; public: // Конструктор по умолчанию Drob(int numerator=0, int denominator=1) : m_numerator(numerator), m_denominator(denominator) { assert(denominator != 0); } // Конструктор копирования Drob(const Drob &d) : m_numerator(d.m_numerator), m_denominator(d.m_denominator) { } Drob& operator= (const Drob &drob); friend std::ostream& operator<<(std::ostream& out, const Drob &d1); }; std::ostream& operator<<(std::ostream& out, const Drob &d1) { out << d1.m_numerator << "/" << d1.m_denominator; return out; } // Перегрузка оператора присваивания Drob& Drob::operator= (const Drob &drob) { // Проверка на самоприсваивание if (this == &drob) return *this; // Выполняем копирование m_numerator = drob.m_numerator; m_denominator = drob.m_denominator; // Возвращаем текущий объект, чтобы иметь возможность выполнять цепочку операций присваивания return *this; } |
Поскольку эти конструктор копирования и оператор присваивания по умолчанию отлично подходят для выполнения копирования с объектами этого класса, то действительно нет никакого смысла писать здесь свои собственные версии конструктора копирования и перегрузки оператора.
Однако при работе с классами, в которых динамически выделяется память, почленное (поверхностное) копирование может вызывать проблемы! Это связано с тем, что при поверхностном копировании указателя копируется только адрес указателя — никаких действий по содержимому адреса указателя не предпринимается. Например:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
#include <cstring> // для strlen() #include <cassert> // для assert() class SomeString { private: char *m_data; int m_length; public: SomeString(const char *source="") { assert(source); // проверяем не является ли source нулевой строкой // Определяем длину source + еще один символ для нуль-терминатора (символ завершения строки) m_length = 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'; } ~SomeString() // деструктор { // Освобождаем память, выделенную для нашей строки delete[] m_data; } char* getString() { return m_data; } int getLength() { return m_length; } }; |
Вышеприведенный класс — это обычный строковый класс, в котором выделяется память для хранения передаваемой строки. Здесь мы не определяли конструктор копирования или перегрузку оператора присваивания. Следовательно, язык C++ предоставит конструктор копирования и оператор присваивания по умолчанию, которые будут выполнять поверхностное копирование. Конструктор копирования выглядит примерно следующим образом:
1 2 3 4 |
SomeString::SomeString(const SomeString &source) : m_length(source.m_length), m_data(source.m_data) { } |
Здесь m_data
— это всего лишь поверхностная копия указателя source.m_data
, поэтому теперь они оба указывают на один и тот же адрес памяти. Теперь рассмотрим следующий фрагмент кода:
1 2 3 4 5 6 7 8 9 10 11 |
int main() { SomeString hello("Hello, world!"); { SomeString copy = hello; // используется конструктор копирования по умолчанию } // объект copy является локальной переменной, которая уничтожается здесь. Деструктор удаляет значение-строку объекта copy, оставляя, таким образом, hello с висячим указателем std::cout << hello.getString() << '\n'; // здесь неопределенные результаты return 0; } |
Хотя этот код выглядит достаточно безвредным, но он имеет в себе коварную проблему, которая приведет к сбою программы! Можете найти эту проблему? Если нет, то ничего страшного.
Разберем этот код по строкам:
1 |
SomeString hello("Hello, world!"); |
Строка кода, приведенная выше, безвредна. Здесь вызывается конструктор класса SomeString, который выделяет память, заставляет hello.m_data
указывать на эту память, а затем копирует в выделенный адрес памяти значение — строку Hello, world!
.
1 |
SomeString copy = hello; // используется конструктор копирования по умолчанию |
Эта строка также кажется достаточно безвредной, но именно она и является источником нашей коварной проблемы! При обработке этой строки C++ будет использовать конструктор копирования по умолчанию (так как мы не предоставили своего). Выполнится поверхностное копирование, результатом чего будет инициализация copy.m_data
адресом, на который указывает hello.m_data
. И теперь copy.m_data
и hello.m_data
оба указывают на одну и ту же часть памяти!
1 |
} // объект copy уничтожается здесь |
Когда объект-копия выходит из области видимости, то вызывается деструктор SomeString для этой копии. Деструктор удаляет динамически выделенную память, на которую указывают как copy.m_data
, так и hello.m_data
! Следовательно, удаляя копию, мы также (случайно) удаляем и данные hello
. Объект copy
затем уничтожается, но hello.m_data
остается указывать на удаленную память!
1 |
std::cout << hello.getString() << '\n'; // здесь неопределенные результаты |
Теперь вы поняли, почему эта программа работает не совсем так, как нужно. Мы удалили значение-строку, на которую указывал hello
, а сейчас пытаемся вывести это значение.
Корнем этой проблемы является поверхностное копирование, выполняемое конструктором копирования по умолчанию. Такое копирование почти всегда приводит к проблемам.
Глубокое копирование
Одним из решений этой проблемы является выполнение глубокого копирования. При глубоком копировании память сначала выделяется для копирования адреса, который содержит исходный указатель, а затем для копирования фактического значения. Таким образом копия находится в отдельной, от исходного значения, памяти и они никак не влияют друг на друга. Для выполнения глубокого копирования нам необходимо написать свой собственный конструктор копирования и перегрузку оператора присваивания.
Рассмотрим это на примере с классом SomeString:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
// Конструктор копирования SomeString::SomeString(const SomeString& source) { // Поскольку 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 = 0; } |
Как вы видите, реализация здесь более углубленная, нежели при поверхностном копировании! Во-первых, мы должны проверить, имеет ли исходный объект ненулевое значение вообще (строка №8). Если имеет, то мы выделяем достаточно памяти для хранения копии этого значения (строка №11). Наконец, копируем значение-строку (строки №14-15).
Теперь рассмотрим перегрузку оператора присваивания:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
// Оператор присваивания SomeString& SomeString::operator=(const SomeString & source) { // Проверка на самоприсваивание if (this == &source) return *this; // Сначала нам нужно очистить предыдущее значение m_data (члена неявного объекта) 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 = 0; return *this; } |
Заметили, что код перегрузки очень похож на код конструктора копирования? Но здесь есть 3 основных отличия:
Мы добавили проверку на самоприсваивание.
Мы возвращаем текущий объект (с помощью указателя *this), чтобы иметь возможность выполнить цепочку операций присваивания.
Мы явно удаляем любое значение, которое объект уже хранит (чтобы не произошло утечки памяти).
При вызове перегруженного оператора присваивания, объект, которому присваивается другой объект, может содержать предыдущее значение, которое нам необходимо очистить/удалить, прежде чем мы выделим память для нового значения. С не динамически выделенными переменными (которые имеют фиксированный размер) нам не нужно беспокоиться, поскольку новое значение просто перезапишет старое. Однако с динамически выделенными переменными нам нужно явно освободить любую старую память до того, как мы выделим любую новую память. Если мы этого не сделаем, сбоя не будет, но произойдет утечка памяти, которая будет съедать нашу свободную память каждый раз, когда мы будем выполнять операцию присваивания!
Лучшее решение
В Стандартной библиотеке C++ классы, которые работают с динамически выделенной памятью, такие как std::string и std::vector, имеют свое собственное управление памятью и свои конструкторы копирования и перегрузку операторов присваивания, которые выполняют корректное глубокое копирование. Поэтому, вместо написания своих собственных конструкторов копирования и перегрузки оператора присваивания, вы можете выполнять инициализацию или присваивание строк, или векторов, как обычных переменных фундаментальных типов данных! Это гораздо проще, менее подвержено ошибкам, и вам не нужно тратить время на написание лишнего кода!
Заключение
Конструктор копирования и оператор присваивания, предоставляемые по умолчанию языком C++, выполняют поверхностное копирование, что отлично подходит для классов без динамически выделенных членов.
Классы с динамически выделенными членами должны иметь конструктор копирования и перегрузку оператора присваивания, которые выполняют глубокое копирование.
Используйте функциональность классов из Стандартной библиотеки C++, нежели самостоятельно выполняйте/реализовывайте управление памятью.
Вопрос по последней строчке — разве не должно быть m_data[m_length] = ‘\0’; Потому что в коде получается что мы последний элемент строки, которую копируем — заменяем на нуль терминатор? Возможно не прав, буду благодарен поснению.
Индексация в С++ начинается с нуля. То-есть если длина строки m_length = 10, то индекс посленего элемента будет m_length — 1 = 9
Почему тут используется конструктор копирования по умолчаиню, а не оператор присваивания по умолчанию?
Я думаю по тому что, конструктор копирования используется при инициализации новых объектов, как здесь:
оператор присваивания заменил бы содержимое уже существующего объекта.
Как вариант для улучшения скорости юзать memcpy или memmove (нет смысла юзать второй в данном случае) вместо цикла, передающего байты строки из оригинала в копию ;'-} Дело в том, что memcpy, как и malloc и free юзают менеджер памяти, что реализованы на assembler'овом коде в операционке (что в разы быстрее навороченного C++ на выхлопе компилятором), но некоторые компиляторы могут заменять на свою Си реализацию в libc этот memcpy, а Си всяко быстрее C++, где-то в 1.1 раза ;'-} Хотя в большенстве случаев это ассемблер
Спасибо! Очень хороший ресурс(чего-то в 2021 мало комментариев .C++ уже не интересен? Переходим на квантовый ассемблер?))))
Первый вопрос: SomeString(const char *source="") это тоже самое что SomeString(const char *source=nullptr) ?
Второй вопрос: Каким образом SomeString hello("Hello, world!"); инициализирует указатель *source? Ведь "Hello, world!" это же r-value?
Третий вопрос: В 84 уроке (Символьные константы строк C-style) не досконально изложена тема и мне не ясно имеют ли статическую продолжительность эти строки объявленные в параметрах функции/конструктора (const char *source="")??? То есть я хочу знать: будут ли аргументы переданные в такие параметры существовать где-то в памяти на протяжении всей жизни программы.
Вот что нашел в интернете:
В C++ "Обычный строковый литерал имеет тип" массив n const char "" (от 2.13.4/1 "Строковые литералы"). Но в стандарте C++ есть особый случай, когда указатель на строковые литералы легко преобразуется в указатели non-const-qualified (преобразование 4.2/2 "Array-to-pointer"):
Строковый литерал (2.13.4), который не является широким строковым литералом, может быть преобразован в rvalue типа “указатель на символ”; широкий строковый литерал может быть преобразован в rvalue типа “pointer to wchar_t”.
В качестве примечания — поскольку массивы в C/C++ так легко преобразуются в указатели, строковый литерал часто можно использовать в контексте указателя, как и любой массив в C/C++.
Строковые литералы имеют статическую продолжительность жизни
Где хранятся переменные?
GCC 4.8 x86-64 Ubuntu 14.04:
char s[] : стек
char *s :
.rodata раздел объектного файла
тот же сегмент, где сбрасывается раздел .text объектного файла, который имеет разрешения на чтение и выполнение, но не на запись
Сидишь такой, потеешь заучивая правильную методику написания класса для работы со строками типа char* , потом в конце узнаешь что все эти проблемы автоматически решаются ели использовать std::string.
Спасибо
А можно в конструкторе глубокого копирования тоже предварительно очищать память объекта, в который копируем?
Ведь это ничему не повредит? Тогда глубокое копирование в случае конструктора и в случае перегрузки оператора были бы ещё больше похожи.
Если так делать нельзя, то почему?
А как вы собираетесь очищать память m_data, если ее еще не существует, ведь это конструктор, который создает объект? Конструктор копирования — это такой же конструктор, как и другие конструкторы, он вызывается, чтобы создать объект, а вы уже решили его уничтожать 😉
В этом примере должны быть вторые фигурные скобки? Что-то не пойму, к чему они.
Эти скобки нужны для реализации ситуации, когда указатель выходит из области видимости, в следствии чего вызывается деконструктор класса SomeString и освобождает выделенную память. Т.е. это просто для примера, утрированно.
Без вторых скобок, деконструктор бы не вызвался, а мы по итогу получили бы копию copy объекта hello, которая бы указывала на ту же память, что и оригинал. И в дальнейшем изменения copy, будут менять значения hello.
Пишу так, как понял сам, поэтому прошу тапками не бить)
Совершенно верно, антиконструктор всегда вызывается и разрушает все что создавалось в области видимости
Ещё уроки будут?
Будут.
отлично!