Класс хранит владеющий сырой указатель и использует неявно сгенерированный копирующий конструктор. Какие по...

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

Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

Неявно сгенерированный копирующий конструктор копирует только значение указателя, а не ресурс. В результате два объекта считают себя владельцами одного адреса: первый уничтоженный объект оставляет второй с висячим указателем, а повторное освобождение приводит к неопределённому поведению.

Исторический контекст

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

Идиома RAII перенесла освобождение ресурса в деструктор объекта. Однако RAII сам по себе не делает копирование безопасным: класс всё ещё должен корректно определить, копирует ли он ресурс, передаёт владение или запрещает копирование.

Постановка проблемы

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

Если один из объектов уничтожается первым, память освобождается. У второго объекта указатель остаётся прежним, но ресурс уже недействителен. Его использование — обращение к освобождённой памяти, а его последующее уничтожение может повторно вызвать delete для того же адреса.

Кроме копирующего конструктора, аналогичная проблема возникает у неявно сгенерированного копирующего присваивания: оно может потерять ранее принадлежавший объект и создать совместное владение без учёта его корректного освобождения.

Подробное решение

Минимальная демонстрация проблемы:

#include <utility> class Buffer { int* data_; public: explicit Buffer(int value) : data_(new int(value)) {} ~Buffer() { delete data_; } }; void use() { Buffer first(42); Buffer second = first; }

Для second компилятор создаёт копию значения data_. Когда use завершится, деструкторы уничтожат second и first; оба вызовут delete для одного адреса. Результат — неопределённое поведение. Порядок уничтожения здесь не устраняет ошибку, потому что второй объект всё равно сохраняет тот же адрес.

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

Если ресурс нельзя или не нужно копировать, копирующие операции следует удалить, а владение передавать перемещением. Для обычной динамической памяти предпочтительнее хранить std::unique_ptr, что обычно приводит к идиоме Rule of Zero: класс не реализует специальные функции вручную и получает корректное уничтожение и перемещение от поля.

std::shared_ptr решает другую задачу — совместное владение с подсчётом ссылок. Заменять им любой сырой указатель нельзя: совместное владение имеет стоимость управляющего блока и атомарных операций в многопоточных сценариях, а циклические связи могут препятствовать уничтожению объектов.

Ситуация из практики

В библиотеке есть класс Image, который хранит буфер пикселей, выделенный вручную. Объекты Image передаются по значению в контейнер и возвращаются из функций. После добавления такого копирования приложение периодически падало при очистке контейнера.

Рассматривались три варианта. Глубокое копирование сохраняло семантику независимых изображений, но увеличивало потребление памяти и время копирования. Запрет копирования устранял ошибку, но требовал менять интерфейс и хранить объекты через ссылки или указатели. Замена поля на std::unique_ptr с реализацией явного глубокого копирования в самом Image сохраняла безопасное владение и контролировала стоимость копии.

Выбрали третий вариант: копирующий конструктор выделяет новый буфер и копирует пиксели, а перемещающие операции передают unique_ptr. В результате копии стали независимыми, перемещение контейнером не требовало копирования буфера, а ручной delete исчез из класса.

Что кандидаты часто упускают

  1. Достаточно ли написать только пользовательский деструктор, чтобы исправить проблему?

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

  1. Чем опасно копирующее присваивание даже при наличии глубокого копирующего конструктора?

Оператор присваивания работает с уже существующим объектом, поэтому перед копированием нужно корректно освободить старый ресурс. Небезопасная реализация может привести к утечке, если старый адрес потерян, или к ошибке при самоприсваивании. Надёжная реализация должна учитывать самоприсваивание и исключения; часто удобнее использовать идиому copy-and-swap или поля-владельцы вроде std::unique_ptr.

  1. Почему наличие пользовательского деструктора влияет на перемещение?

Пользовательский деструктор является признаком того, что класс управляет ресурсом вручную. В таких условиях не следует рассчитывать на автоматически сгенерированные перемещающие операции: их наличие зависит от объявленных специальных функций, а при отсутствии перемещения может примениться копирование. Поэтому для ручного владения нужно явно определить или удалить копирующие и перемещающие операции, либо перейти к типу-владельцу и использовать Rule of Zero.