Урок №145. Поверхностное и глубокое копирование

  Юрий  | 

  |

  Обновл. 13 Сен 2021  | 

 52434

 ǀ   18 

На этом уроке мы рассмотрим поверхностное и глубокое копирование в языке C++.

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

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

Рассмотрим следующий класс Drob:

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

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

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

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

Здесь m_data — это всего лишь поверхностная копия указателя source.m_data, поэтому теперь они оба указывают на один и тот же адрес памяти. Теперь рассмотрим следующий фрагмент кода:

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

Разберем этот код по строкам:

Строка кода, приведенная выше, безвредна. Здесь вызывается конструктор класса SomeString, который выделяет память, заставляет hello.m_data указывать на эту память, а затем копирует в выделенный адрес памяти значение — строку Hello, world!.

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

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

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

Корнем этой проблемы является поверхностное копирование, выполняемое конструктором копирования по умолчанию. Такое копирование почти всегда приводит к проблемам.

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


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

Рассмотрим это на примере с классом SomeString:

Как вы видите, реализация здесь более углубленная, нежели при поверхностном копировании! Во-первых, мы должны проверить, имеет ли исходный объект ненулевое значение вообще (строка №8). Если имеет, то мы выделяем достаточно памяти для хранения копии этого значения (строка №11). Наконец, копируем значение-строку (строки №14-15).

Теперь рассмотрим перегрузку оператора присваивания:

Заметили, что код перегрузки очень похож на код конструктора копирования? Но здесь есть 3 основных отличия:

   Мы добавили проверку на самоприсваивание.

   Мы возвращаем текущий объект (с помощью указателя *this), чтобы иметь возможность выполнить цепочку операций присваивания.

   Мы явно удаляем любое значение, которое объект уже хранит (чтобы не произошло утечки памяти).

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

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

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

Заключение


   Конструктор копирования и оператор присваивания, предоставляемые по умолчанию языком C++, выполняют поверхностное копирование, что отлично подходит для классов без динамически выделенных членов.

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

   Используйте функциональность классов из Стандартной библиотеки C++, нежели самостоятельно выполняйте/реализовывайте управление памятью.

Оценить статью:

Звёзд: 1Звёзд: 2Звёзд: 3Звёзд: 4Звёзд: 5 (206 оценок, среднее: 4,88 из 5)
Загрузка...

Комментариев: 18

  1. Yauheni:

    Вопрос по последней строчке — разве не должно быть m_data[m_length] = ‘\0’; Потому что в коде получается что мы последний элемент строки, которую копируем — заменяем на нуль терминатор? Возможно не прав, буду благодарен поснению.

    1. Олександр:

      Индексация в С++ начинается с нуля. То-есть если длина строки m_length = 10, то индекс посленего элемента будет m_length — 1 = 9

  2. Дима:

    Почему тут используется конструктор копирования по умолчаиню, а не оператор присваивания по умолчанию?

    1. Юрий:

      Я думаю по тому что, конструктор копирования используется при инициализации новых объектов, как здесь:

      оператор присваивания заменил бы содержимое уже существующего объекта.

  3. VectorASD:

    Как вариант для улучшения скорости юзать memcpy или memmove (нет смысла юзать второй в данном случае) вместо цикла, передающего байты строки из оригинала в копию ;'-} Дело в том, что memcpy, как и malloc и free юзают менеджер памяти, что реализованы на assembler'овом коде в операционке (что в разы быстрее навороченного C++ на выхлопе компилятором), но некоторые компиляторы могут заменять на свою Си реализацию в libc этот memcpy, а Си всяко быстрее C++, где-то в 1.1 раза ;'-} Хотя в большенстве случаев это ассемблер

  4. Дэн:

    Спасибо! Очень хороший ресурс(чего-то в 2021 мало комментариев .C++ уже не интересен? Переходим на квантовый ассемблер?))))

  5. Tony_B:

    Первый вопрос: SomeString(const char *source="") это тоже самое что SomeString(const char *source=nullptr) ?

    Второй вопрос: Каким образом SomeString hello("Hello, world!"); инициализирует указатель *source? Ведь "Hello, world!" это же r-value?

    Третий вопрос: В 84 уроке (Символьные константы строк C-style) не досконально изложена тема и мне не ясно имеют ли статическую продолжительность эти строки объявленные в параметрах функции/конструктора (const char *source="")??? То есть я хочу знать: будут ли аргументы переданные в такие параметры существовать где-то в памяти на протяжении всей жизни программы.

    1. Grave18:

      Вот что нашел в интернете:
      В 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++.

    2. Grave18:

      Строковые литералы имеют статическую продолжительность жизни
      Где хранятся переменные?

      GCC 4.8 x86-64 Ubuntu 14.04:

      char s[] : стек
      char *s :
      .rodata раздел объектного файла
      тот же сегмент, где сбрасывается раздел .text объектного файла, который имеет разрешения на чтение и выполнение, но не на запись

  6. Снова я:

    Сидишь такой, потеешь заучивая правильную методику написания класса для работы со строками типа char* , потом в конце узнаешь что все эти проблемы автоматически решаются ели использовать std::string.
    Спасибо

  7. Анастасия:

    А можно в конструкторе глубокого копирования тоже предварительно очищать память объекта, в который копируем?

    Ведь это ничему не повредит? Тогда глубокое копирование в случае конструктора и в случае перегрузки оператора были бы ещё больше похожи.
    Если так делать нельзя, то почему?

    1. Kris:

      А как вы собираетесь очищать память m_data, если ее еще не существует, ведь это конструктор, который создает объект? Конструктор копирования — это такой же конструктор, как и другие конструкторы, он вызывается, чтобы создать объект, а вы уже решили его уничтожать 😉

  8. somebox:

    В этом примере должны быть вторые фигурные скобки? Что-то не пойму, к чему они.

    1. teezbeGood:

      Эти скобки нужны для реализации ситуации, когда указатель выходит из области видимости, в следствии чего вызывается деконструктор класса SomeString и освобождает выделенную память. Т.е. это просто для примера, утрированно.
      Без вторых скобок, деконструктор бы не вызвался, а мы по итогу получили бы копию copy объекта hello, которая бы указывала на ту же память, что и оригинал. И в дальнейшем изменения copy, будут менять значения hello.
      Пишу так, как понял сам, поэтому прошу тапками не бить)

      1. Снова я:

        Совершенно верно, антиконструктор всегда вызывается и разрушает все что создавалось в области видимости

  9. korvell:

    Ещё уроки будут?

      1. korvell:

        отлично!

Добавить комментарий

Ваш E-mail не будет опубликован. Обязательные поля помечены *