Урок №153. Поверхневе і глибоке копіювання

  Юрій  | 

  Оновл. 18 Лют 2021  | 

 146

На цьому уроці ми розглянемо поверхневе і глибоке копіювання в мові 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 Зірок (3 оцінок, середня: 5,00 з 5)
Loading...

Залишити відповідь

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