Моменты копирования объектов. Поведение по умолчанию. Конструктор копий и оператор копирующего присвоения

На протяжении жизни объекта возникает множество ситуаций, при которых его содержимое может копироваться в другой объект. К моментам копирования объекта относятся:

- инициализация нового объекта из уже существующего:

Date today;

Date d = today; // Объект d будет идентичен объекту today

- передача объекта по значению в функцию:

voidtakesDate ( Date _d ) { ... }

// Объект в функции takesDate будет копией объекта today

Date today;

takesDate ( today );

- возврат объекта по значению из функции:

Date returnsDate () { returnDate(); }

// Объект d будет идентичен объекту, возвращенному из returnsDate

Date d = returnsDate();

Обычно в подобном стиле используют классы, представляющие собой некие сложные ЗНАЧЕНИЯ (values), которыми программист манипулирует подобно переменным встроенных типов. Копирование редко применяется для классов, представляющих собой СУЩНОСТИ (entities) реального мира. Для классов-сущностей чаще свойственна передача через функции по ссылке/указателю, а операцию копирования часто в явном виде запрещают.

Логику копирования объектов обеспечивают КОНСТРУКТОРЫ КОПИЙ (Copy Constructors).

Тривиальный конструктор копий, осуществляющий ПОЧЛЕННОЕ КОПИРОВАНИЕ (memberwise copy) полей объектов генерируется автоматически для всех классов по умолчанию, даже если классы определяют собственные конструкторы другого типа. Элементы встроенных типов, включая указатели и ссылки, при почленном копировании просто присваиваются. Для дочерних объектов структур и классов вызываются соответствующие им конструкторы копий. Данные массивов переносятся поэлементно.

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

 

// Создаем стек, конструктор по умолчанию выделяет блок для хранения данных

Stack s;

s.Push( 3 );

s.Push( 5 );

 

// Создаем копию стека, почленное копирование, новый блок не выделяется

Stack s2 = s;

 

// Неявные вызовы деструкторов в обратном созданию порядке:

// Stack::~Stack( & s2 );

// Stack::~Stack( & s1 );

// Завершается крахом, попытка повторного удаления памяти через скопированный адрес

 

Сначала первый стек выделяет в конструкторе память для хранения элементов, которая частично заполняется значениями после вызовов метода Push:

 

 

Затем, создается второй объект-копия на основе почленного копирования, в результате которого второй объект ссылается на тот же самый блок памяти, что и первый объект, за счет копирования указателей m_pDataStart и m_pDataTop:

 

 

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

 

 

Наконец, вызывается деструктор для первого стека. Происходит попытка освобождения блока памяти через указатель, который в данный момент продолжает указывать на уже освобожденный вторым стеком блок памяти. Удаление через такой “висячий” указатель (dangling pointer) приводит к краху программы.

 

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

classStack

{

// ...
public:

// Объявление конструктора копий

Stack ( constStack & _s );

 

// ...

 

};

 

Корректным способом копирования для рассматриваемого случая является создание вторым стеком идентичного блока памяти для хранения данных и копирования всех хранящихся в первом стеке значений в этот новый блок. Разумеется, с точки зрения производительности нет смысла копировать блок памяти из стека-оригинала целиком, лишь ту часть, которая фактически используется стеком-оригиналом:

 

// Реализация конструктора копий

Stack::Stack ( constStack & _s )

: m_size( _s.m_size )

{

// Выделяем такой же блок данных

m_pDataStart = new int[ m_size ];

 

// Выясняем сколько данных фактически имеется в объекте-оригинале

int nActuallyStored = _s.m_pDataTop - _s.m_pDataStart;

 

// Смещаем вершину нового стека относительно начала нового блока как в оригинале

m_pDataTop = m_pDataStart + nActuallyStored;

 

// Копируем только фактически занятую часть памяти из оригинала в новый стек

memcpy( m_pDataStart, _s.m_pDataStart, nActuallyStored * sizeof( int) );
}

 

 

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

 

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

 

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

 

classDate

{

// ...
public:

 

// Объявление конструктора копий

Date( constDate & _d );

 

// ...

 

};

 

// Реализация конструктора копий

Date::Date ( constDate & _d )

: m_Year( _d.m_Year ),

m_Month( _d.m_Month ),

m_Day( _d.m_Day )

{

}

 

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

 

К слову, сравнивать объекты-сущности на равенство, как и копировать такие объекты, не имеет смысла. Двух равных объектов-сущностей в программе одновременно существовать не должно.

 

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

 

Date d( 2013, 2, 19 );

 

// ...

Date today;

 

// После присвоения объект d будет идентичен объекту today

d = today;

 

Логику работы в данном случае обеспечивает ОПЕРАТОР КОПИРУЮЩЕГО ПРИСВОЕНИЯ (copy assignment operator), или просто, оператор присвоения.

 

Аналогично созданию объекта-копии при конструировании, по умолчанию при присвоении применяется сгенерированная компилятором функция почленного копирования.

 

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

 

classMyClass

{

public:

MyClass ( Stack & _stack )

: m_stack( _stack )

{

}

 

private:

Stack & m_stack; // Переменная-член ссылочного типа

};

 

intmain ()
{

Stack s1, s2;

MyClass myObject1( s1 ), myObject2( s2 );

 

myObject1 = myObject2; // ОШИБКА КОМПИЛЯЦИИ!
}

 

error C2582: 'operator =' function is unavailable in 'MyClass'

 

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

 

// Создаем первый объект

Stack s1;

s1.Push( 2 );

 

// Создаем второй объект

Stack s2;

s2.Push( 3 );

s2.Push( 5 );

 

// Присваиваем содержимое первого объекта второму. Утечка памяти - старый блок

s1 = s2;

 

// Неявные вызовы деструкторов в обратном порядке:

// Stack::~Stack( & s2 );

// Stack::~Stack( & s1 );

// Завершается крахом из-за попытки повторного удаления памяти

// через скопированный при присвоении адрес

 

Для решения проблемы следует определить собственный вариант оператора присвоения:

 

classStack

{

// ...
public:

 

// Объявление оператора присвоения

Stack& operator= ( constStack & _s );

 

// ...

 

};

 

// Реализация оператора присвоения

Stack & Stack::operator= ( constStack & _s )

{

// Защита от присвоения самому себе

if( & _s == this)

return* this;

 

// Освобождение ранее занятых ресурсов

delete[] m_pDataStart;

 

// Так будет сделать нельзя, если поле константно

m_size = _s.m_size;

 

// Выделяем новый блок данных

m_pDataStart = new int[ m_size ];

 

// Выясняем сколько данных фактически имеется в объекте-оригинале

intnActuallyStored = _s.m_pDataTop - _s.m_pDataStart;

 

// Смещаем вершину стека относительно начала нового блока как в оригинале

m_pDataTop = m_pDataStart + nActuallyStored;

 

// Копируем только фактически занятую часть памяти из оригинала в новый стек

memcpy( m_pDataStart, _s.m_pDataStart, nActuallyStored * sizeof( int) );

 

// Возвращаем ссылку на себя

return* this;
}

 

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

 

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

 

Stack s;

s = s; // Объект присваивают самому себе

 

Вполне корректным поведением для данного граничного случая является игнорирование присвоения. Для этого первым действием в перегруженном оператор является проверка на равенство адресов текущего объекта и объекта-оригинала:

 

if( & _s == this) // Защита от присвоения объекта самому себе СЕБЕ

return* this;

 

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

 

Stack s1;

s1.Push( 2 );

 

Stack s2;

if( ( s2 = s1 ).IsEmpty() )

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

 

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

 

classStack

{

 

public:

 

// ...

 

// Объявление конструктора копий

Stack ( constStack & _s );

 

// Объявление оператора присвоения

Stack & operator= ( constStack & _s );

 

// ...

 

private:

 

// Объявление вспомогательной функции

voidCopyDataFrom ( constStack & _s );

 

// ...

};

 

// Реализация конструктора копий

Stack::Stack ( constStack & _s )

{

CopyDataFrom( _s ); // Все необходимое делает вспомогательная функция

}

 

// Реализация оператора присвоения

Stack & Stack::operator= ( constStack & _s )

{

// Защита от присвоения объекта самому себе

if( this== & _s )

return* this;

 

// Освобождение ранее занятых ресурсов

delete[] m_pDataStart;

 

// Выделение нового блока и копирование данных - в теле вспомогательной функции

CopyDataFrom( _s );

 

return* this;

}

 

// Реализация вспомогательной функции

voidStack::CopyDataFrom ( constStack & _s )

{

// Так будет сделать нельзя, если поле константно

m_size = _s.m_size;

 

// Выделяем новый блок данных

m_pDataStart = new int[ m_size ];

 

// Выясняем сколько данных фактически имеется в объекте-оригинале

intnActuallyStored = _s.m_pDataTop - _s.m_pDataStart;

 

// Смещаем вершину стека относительно начала нового блока как в оригинале

m_pDataTop = m_pDataStart + nActuallyStored;

 

// Копируем только фактически занятую часть памяти из оригинала в новый стек

memcpy( m_pDataStart, _s.m_pDataStart, nActuallyStored * sizeof( int) );

}

 

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

 

9. Временные объекты. Явные и неявные конструкторы. Оптимизации RVO/NRVO.

Временные объекты

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

Когда временный объект создается для инициализации переменной-ссылки, время его жизни определяется выходом потока управления из области видимости, в которой определяется ссылка.

Когда временный объект создается во время вычисления выражений, время его жизни определяется завершением выполнения ПОЛНОГО ВЫРАЖЕНИЯ - выражения, не являющегося частью другого выражения.