Farididdin Rahimov 17 июня 2021
C++

🛠 Побитовое и почленное копирование в C++

Программистам на C++ приходится самостоятельно управлять ресурсами компьютера. В этой статье рассматриваются различные семантики копирования пользовательских объектов, а также способы их правильной реализации.
🛠 Побитовое и почленное копирование в C++
Под копированием в программировании обычно подразумевается создание идентичного существующему объекта или присваивание значения одного объекта другому. Примитивные типы данных встроены в язык: их количество ограничено, и компилятор в точности знает, как их копировать. Копирование объектов пользовательских типов не всегда является тривиальной задачей: программист должен сам указать компилятору, как копировать экземпляры созданных им классов.

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

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

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

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

Поверхностное (неглубокое) копирование – простой и дешевый способ, который можно реализовать просто копируя каждый бит объекта. Такой способ известен и как побитовое копирование.

Чтобы продемонстрировать, как работает неглубокое копирование, давайте взглянем на простой класс прямоугольника:

Класс Rectangle
        #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 и результат её работы:

Функция 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;
}
    
Результат main
Результат main

Внесенные в rect1 изменения не отражаются на rect2. Чтобы увидеть проблемы неглубокого копирования, изменим класс Rectangle так, чтобы он содержал указатели:

Измененный класс 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 выдает другой результат:

Новый результат main
Новый результат main

При изменении rect1 изменилось и содержимое rect2. Состояние переменных можно выразить с помощью следующей диаграммы:

<span><span><span>Диаграмма, иллюстрирующая поверхностное копирование</span></span></span>
Диаграмма, иллюстрирующая поверхностное копирование
В отличие от поверхностного, в глубоком копировании посещенные указатели разыменовываются и объекты, на которые они указывают, также копируются. В результате мы имеем две независимых друг от друга копии. Глубокое копирование обходится значительно дороже, поскольку приходится выделять динамическую память для нового объекта, а указатели могут образовывать сложный граф. Кроме того, глубокое копирование – рекурсивный процесс, так как требуется глубокая копия каждого поля.

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

Конструктор копирования и оператор присваивания

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

        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; // вызывается конструктор копирования 

    

Всякий раз, когда объект передается функции по значению, копия аргумента должна быть создана, поэтому конструктор копирования вызывается для инициализации локального аргумента. Именно поэтому нельзя передавать аргументы по значению конструктору копирования – это запустит бесконечную рекурсию.

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

Мы должны помнить, что конструктор копирования по-прежнему является конструктором и используется только для инициализации нового объекта. Но как быть, если мы хотим присвоить значение экземпляра существующему объекту?

        Rectangle rectangle1 (5,7), rectangle2;
rectangle2 = rectangle1;

    

Оператор присваивания – это метод, который используется для выполнения присваивания. Как и в случае с конструктором копирования, C++ предоставляет оператор присваивания по умолчанию.

Пример объявления оператора присваивания:

        class Rectangle
{
public:
    Rectangle& operator=(const Rectangle& src);
    …
}

    

Оператор присваивания и конструктор копирования реализованы аналогично, хотя есть некоторые заметные различия. Во-первых, мы видим, что оператор присваивания возвращает ссылку на экземпляр, потому что в C++ разрешены объединенные в цепочку присваивания:

        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;
}

    

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

Новый результат main
Новый результат main
<span><span><span>Диаграмма, иллюстрирующая глубокое копирование</span></span></span>
Диаграмма, иллюстрирующая глубокое копирование

Выводы

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

МЕРОПРИЯТИЯ

Комментарии

ВАКАНСИИ

Добавить вакансию
Ведущий разработчик C#
по итогам собеседования

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ