🛠 Побитовое и почленное копирование в C++
Программистам на C++ приходится самостоятельно управлять ресурсами компьютера. В этой статье рассматриваются различные семантики копирования пользовательских объектов, а также способы их правильной реализации. Обсудить Под копированием в программировании обычно подразумевается создание идентичного существующему объекта или присваивание значения одного объекта другому. Примитивные типы данных встроены в язык: их количество ограничено, и компилятор в точности знает, как их копировать. Копирование объектов пользовательских типов не всегда является тривиальной задачей: программист должен сам указать компилятору, как копировать экземпляры созданных им классов. В C++ это делается с помощью двух специальных функций-членов: конструктора копирования и оператора присваивания копии. Если они не определены, компилятор неявно их генерирует. Поскольку компилятор не осведомлен о внутренних особенностях пользовательского класса, созданные им функции выполняют т.н. неглубокое или поверхностное копирование. Во время этого процесса все поля исходного объекта копируются в целевой одно за другим. Конструктор копирования по умолчанию копирует члены данных объекта, вызывая их конструкторы копирования. Этот метод отлично работает, когда ни один из членов класса не является сырым указателем. Поскольку конструктор копирует только содержимое указателей вместо данных, на которые те ссылаются, мы получаем два объекта, ссылающихся на один и тот же адрес в памяти. Эти объекты не являются независимыми копиями – если мы изменим один из них, изменение будет видно и в другом. Что хуже, когда один объект удален или находится вне области видимости, деструктор может освободить общую память, в то время как указатель внутри другого объекта до сих пор на нее ссылается. Ссылающийся на освобожденную память указатель называется висячим. Попытка доступа к освобожденной памяти может привести к неопределенному поведению и породить множество странных или опасных ошибок в программе. Поверхностное (неглубокое) копирование – простой и дешевый способ, который можно реализовать просто копируя каждый бит объекта. Такой способ известен и как побитовое копирование. Чтобы продемонстрировать, как работает неглубокое копирование, давайте взглянем на простой класс прямоугольника: Класс Rectangle Поскольку этот класс не содержит указателей, созданного компилятором конструктора копирования достаточно для получения независимых копий. В функции Функция main Результат main Внесенные в Измененный класс Rectangle Выполнение той же функции Новый результат main При изменении Диаграмма, иллюстрирующая поверхностное копирование В отличие от поверхностного, в глубоком копировании посещенные указатели разыменовываются и объекты, на которые они указывают, также копируются. В результате мы имеем две независимых друг от друга копии. Глубокое копирование обходится значительно дороже, поскольку приходится выделять динамическую память для нового объекта, а указатели могут образовывать сложный граф. Кроме того, глубокое копирование – рекурсивный процесс, так как требуется глубокая копия каждого поля. Глубокое копирование ещё называют почленным. Чтобы реализовать его для нашего класса, нужно более подробно изучить конструктор копирования и оператор присваивания. Конструктор копирования позволяет создать новый экземпляр класса, который является точной копией существующего. Объявление конструктора копирования выглядит следующим образом: Как и любой другой конструктор он не возвращает значения и обычно принимает в качестве аргумента ссылку (константу) на исходный объект. Строго говоря, внутри конструктора копирования мы можем делать все, что захотим, но чтобы избежать путаницы, рекомендуется реализовать ожидаемое поведение. Также возможно, что нам захочется предотвратить копирование экземпляров классов. В таком случае можно удалить конструктор копирования: Напомним, что автоматически созданный конструктор копирования выполняет неглубокое копирование. Допустим, класс называется Конструктор копирования вызывается многократно в разных ситуациях. Самый очевидный случай – когда мы явно создаем новый объект на основе другого экземпляра класса: Всякий раз, когда объект передается функции по значению, копия аргумента должна быть создана, поэтому конструктор копирования вызывается для инициализации локального аргумента. Именно поэтому нельзя передавать аргументы по значению конструктору копирования – это запустит бесконечную рекурсию. Стоит отметить, что после удаления конструктора копирования мы не можем больше передавать объекты по значению. Если объект возвращается из функции, конструктор копирования также может быть вызван, хотя компилятор может использовать оптимизацию возвращаемого значения (RVO), чтобы избежать ненужного копирования. Мы должны помнить, что конструктор копирования по-прежнему является конструктором и используется только для инициализации нового объекта. Но как быть, если мы хотим присвоить значение экземпляра существующему объекту? Оператор присваивания – это метод, который используется для выполнения присваивания. Как и в случае с конструктором копирования, C++ предоставляет оператор присваивания по умолчанию. Пример объявления оператора присваивания: Оператор присваивания и конструктор копирования реализованы аналогично, хотя есть некоторые заметные различия. Во-первых, мы видим, что оператор присваивания возвращает ссылку на экземпляр, потому что в C++ разрешены объединенные в цепочку присваивания: В приведённом выше примере оператор присваивания вызывается для Наконец, в отличие от конструктора копирования, оператор присваивания перезаписывает существующие объекты, ресурсы которых могут быть выделены в куче. Он должен освободить эти ресурсы, чтобы предотвратить утечку памяти. При определении этих методов нужно всегда помнить о правиле трех. Оно гласит, что если класс определяет один из следующих методов, он должен явно определить все три метода: Если мы определили деструктор, но не определен конструктор копирования, то деструктор будет вызван дважды для копий: один раз для содержащих копию объектов и во второй раз –для объектов, из которых копируются элементы данных. Поскольку копии не являются независимыми, деструктор дважды освобождает один и тот же участок памяти, что приводит к неопределенному поведению программы. Ознакомившись с конструктором копирования и оператором присваивания, мы готовы реализовать глубокое копирование для класса Конструктор копирования для класса Оператор присваивания: Добавив эти методы в класс, запускаем основную программу, чтобы убедиться в независимости копий: Новый результат main Диаграмма, иллюстрирующая глубокое копирование Предоставляемые C++ по умолчанию конструктор копирования и оператор присваивания выполняют поверхностное копирование, которое подходит для классов без указателей. В классах с динамически выделенными членами конструктор копирования и оператор присваивания должны быть определены таким образом, чтобы они выполняли глубокое копирование.Поверхностное копирование
#include <iostream> using namespace std; class Rectangle { public: Rectangle(int w=1, int h=1) { width=w; height=h; } void display() const { cout<<"Width: " << width << endl; cout<<"Height: " << height<< endl; } int setWidth(int w) {width=w;} int setHeight(int h) {height=h;} private: int width, height; };
main
мы создаем новый экземпляр на основе существующего объекта, затем вносим изменения в один объект и отображаем оба. Ниже показан код функции main
и результат её работы:
int main() { Rectangle rect1(5,7); Rectangle rect2=rect1; rect1.setHeight(10); cout<<"First Rectangle: "<<endl; rect1.display(); cout<<"Second Rectangle: "<<endl; rect2.display(); return 0; }
rect1
изменения не отражаются на rect2
. Чтобы увидеть проблемы неглубокого копирования, изменим класс Rectangle
так, чтобы он содержал указатели:
class Rectangle { public: Rectangle(int w=1, int h=1) { width = new int; height = new int; *width=w; *height=h; } ~Rectangle() { delete width; delete height; } void display() const { cout<<"Width: " << *width << endl; cout<<"Height: " << *height<< endl; } int setWidth(int w) {*width=w;} int setHeight(int h) {*height=h;} private: int *width, *height; };
main
выдает другой результат:rect1
изменилось и содержимое rect2
. Состояние переменных можно выразить с помощью следующей диаграммы:Конструктор копирования и оператор присваивания
class Rectangle { public: Rectangle(const Rectangle& src); … }
Rectangle(const Rectangle& src) = delete;
ClassName
и имеет поля m1
, m2
, m3
, …, mN
. Тогда определение созданного компилятором конструктора выглядит следующим образом:
ClassName::ClassName(const ClassName& src) : m1 { src.m1 }, m2 { src.m2 }, ... mN { src.mN } { }
Rectangle rectangle1 (5,7); Rectangle rectangle2 = rectangle1; // вызывается конструктор копирования
Rectangle rectangle1 (5,7), rectangle2; rectangle2 = rectangle1;
class Rectangle { public: Rectangle& operator=(const Rectangle& src); … }
rectangle3 = rectangle2 = rectangle1;
rectangle2
с rectangle3
в качестве аргумента. Затем оператор для rectangle1
вызывается со ссылкой, возвращенной из предыдущего вызова в качестве аргумента. Также необходимо учитывать возможность самоприсваивания:
rectangle1 = rectangle1;
Реализация глубокого копирования
Rectangle
. Rectangle
выглядит следующим образом:
Rectangle::Rectangle(const Rectangle& src) { // выделяем память под новый объект width = new int; height = new int; // разыменовываем указатели и копируем содержимое адреса памяти, на который они ссылаются *width=*(src.width); *height=*(src.height); }
Rectangle& Rectangle::operator=(const Rectangle& src) { // проверяем на самоприсваивание if(this==&src) { return *this; } // освобождаем занятую память delete width; delete height; // выделяем память width = new int; height = new int; // разыменовываем указатели и копируем содержимое адреса памяти, на который они ссылаются *width=*(src.width); *height=*(src.height); // возвращаем ссылку на перезаписанный объект return *this; }
Выводы
- 20 views
- 0 Comment