Спонсорские ссылки
-
Статья
Версия для печати
Обсудить на форуме (4)
Классы: копирование и присваивание. Часть 3.


Автор: Сергей Малышев (aka Михалыч).


Продолжим начатое в статьях "Классы: копирование и присваивание. Часть 1 и Часть 2" подробное рассмотрение проблемы копирования и присваивания в классах.
В этой части мы рассмотрим разницу между копированием и присваиванием, посмотрим, когда выполняется копирование, и обсудим положение конструктора копий и операции присваивания в классах.

Когда выполняется копирование?

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

Код: (C++)
void Pixel(POINT arg); //передача объекта по значению
POINT Draw(void); //возврат объекта по значению

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

Код: (C++)
POINT& PointMaker(void)
{
  POINT *data = new POINT; // локально размещенный объект
  return *data;
}

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

Код: (C++)
POINT& PointMaker(void)
{
  POINT data;
  return data;
}

С этой функцией проблема состоит в том, что при завершении ее работы локальный стек функции очищается и при выходе из функции автоматически вызывается деструктор для объекта data. Таким образом ссылка, которую мы вернули, ссылается уже неизвестно куда (или неизвестно на что).
Итак, для объекта, порожденного внутри функции, возврат должен происходить по значению, и следует представлять, во что это обходится. Копирование также может быть реализовано компилятором. Компилятор Borland C++ при необходимости может производить оптимизацию кода, в процессе которой также возможно создание временных объектов, и соответственно их копирование.

Разница между копированием и присваиванием

А теперь давайте рассмотрим, когда же используется конструктор копий, а когда операция присваивания. Конструктор копий или операция присваивания вызывается при создании или копировании уже существующих объектов, а также при создании временных объектов. Ниже мы рассмотрим все (ну или почти все) варианты вызова на примере объектов Y и Z класса POINT. Для начала возьмем следующую форму вызова:

Код: (C++)
POINT Y(Z);

Это прямой вызов конструктора копий класса POINT. Объект Y - вызывающий, а объект Z выступает в роли аргумента. Поскольку синтаксис конструктора копий выглядит как

Код: (C++)
POINT:: POINT(const POINT& rhs);

то в нашем случае указатель this в конструкторе копий ссылается на Y, а псевдоним rhs является адресом Z. В данном примере создается новый объект Y, так что совершенно несомненно вызывается конструктор копий.
В следующем примере также происходит вызов конструктора копий, но уже не столь очевидный: POINT Y = Z; Присутствие в этой строке знака равенства заставляет предположить, что здесь происходит присваивание. Однако, помимо этого, налицо факт создания нового объекта класса POINT. Если бы в этом операторе использовалась операция присваивания,то в развернутом виде он выглядел бы так:

Код: (C++)
Y.operator = (Z);   // Что недопустимо

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

Код: (C++)
POINT Y, Z;
Y = Z;     // вызов операции присваивания

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

Код: (C++)
// COPIES.CPP - Демонстрация форм операторов
// конструктора копий и операции присваивания
#include <iostream.h>
#include <fstream.h>

ofstream of("output.dat"); //тут можно посмотреть результаты

class X
{
  public:
    X();
    ~X () ;
    X(const X&) ;
    X& operator=(const X&);
    operator int() { return num; }
  private:
    int num;
};

// Конструктор по умолчанию

X::X()
{
  of << "конструктор" << endl;
  num = 5;
}

// Деструктор
X::~X()
{
  of << "деструктор" << endl;
}

// Конструктор копий
X::X(const X& rhs)
{
  of << "конструктор копий" << endl;
  num = rhs.num;
}

// Операция присваивания
X& X::operator=(const X& rhs)
{
  if (this == &rhs) return *this;
  of << "операция присваивания" << endl;
  num = rhs. num;
  return *this;
}

// Возврат по значению
X Foo(void)
{
  return X();
}

void main(void)
{
  {
    X Z;   // конструктор
    X Y = Z; // конструктор копий
  }        // вызов двух деструкторов
  {
    X A;    // конструктор
    X B(A); // конструктор копий
  }         // вызов двух деструкторов
  {
    X C, D; // два конструктора
    C = D;    // операция присваивания
  }         // вызов двух деструкторов
  {
    X E = Foo() ; // конструктор
  }             // деструктор
}

Парные скобки {} использованы для вызова деструкторов в порядке создания объектов. Обычно так не делается, так что этот прием применен только для того, чтобы вызовы конструкторов и деструкторов для удобства интерпретации результатов следовали парами. Примерный вид результатов воспроизводят комментарии к функции main(), а что получилось на самом деле, можно просмотреть в файле Output.dat.

Положение в классах

Местоположение конструктора копий и операции присваивания в классе очень важно. Эти функции-члены обычно проводят свою деятельность по дублированию объектов вне класса. А если функции-члены вызываются, явно или неявно, извне класса, и вызываются они не экземплярами дочерних классов или друзьями, то эти функции должны располагаться в открытой (public) части интерфейса. Хотя чаще всего они именно там и находятся, это не единственно возможное местоположение.
При некоторых обстоятельствах может потребоваться, чтобы дублировать объекты могли только друзья и (или) классы-потомки. Это достигается объявлением функций в защищенной (protected) или закрытой (private) частях интерфейса.
Третья возможность - блокировать копирование вообще. Такой подход иногда оправдан и мы рассмотрим его в следующий раз.

При написании статьи использованы материалы из книг:
  • P.Kimmel, Using Borland C++ 5, Special Edition, перевод  BHV - С.Петербург 1997.
  • C++. Бархатный путь, Марченко А.Л., Центр Информационных Технологий, www.citmgu.ru.
  • Thinking in C++, 2nd ed. Volume 1, 2000 by Bruce Eckel.

Если вам интересно, или возникают вопросы – пишите, разберемся.
Версия для печати
Обсудить на форуме (4)