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

Есть два вида копирования: буквальное копирование (shallow copy) - то, которое обычно предлагается компилятором, если вы не определите эту операцию сами (т.е. автоматически сформированные компилятором конструктор копий и операция присваивания), и развернутое копирование (deep copy – или глубокое копирование).

Буквальное копирование

Буквальное копирование - это простое побитное (или поразрядное) копирование. Оно означает, что количество и состояние всех битов одного объекта абсолютно точно воспроизводится во втором. Например, точно копируются 32 разряда целого числа; точно так же буквально воспроизводятся и 32 бита указателя (например char *, хотя понятно, что в разных системах и в разных моделях памяти и размерность указателя  и размерность целого числа может быть совсем другой).

Что из себя представляет указатель любого типа? Он также состоит из последовательности битов. Значением указателя всегда является адрес (или местоположение в памяти). Обычно по этому адресу располагаются какие-то ресурсы (ну, например порт ввода-вывода или видео память), чаще всего это просто область памяти. Проблема состоит в том, что эта область как таковая не является элементом класса, элементом класса является только сам указатель. Поэтому при буквальном копировании дублируется только значение указателя, но, к сожалению,  вовсе не то, на что он указывает.

В некоторых случаях буквальное копирование указателя вполне допустимо. Предположим, что указатель задает, например, адрес порта ввода-вывода или адрес некоторой системной области. Например, по адресу 0х00000417 (если я не ошибаюсь) находится слово состояния служебных клавиш (<Shift>, <Caps Lock> и т. п.). Поразрядное копирование указателя такого вида вполне разумно, потому что вашим классам не придется динамически распределять эту область памяти, или как-то беспокоиться о ее состоянии – это системная область памяти и ей занимается BIOS.

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

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

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

Однако, именно с этой целью этот пример и приведен.
Код:
01  // SMARRAY2.H - интерфейс интеллектуального массива
02  #ifndef _SMARRAY2_H
03  #define _SMARRAY2_H
04  class INT_ARRAY
05  { 
06 public:
07   INT_ARRAY(unsigned int sz = 100);
08   ~INT_ARRAY();   
09   INT_ARRAY(const INT_ARRAY&); // Объявление конструктора копий
10   INT_ARRAY& operator=(const INT_ARRAY&);// Объявление операции присваивания
11 // Использование беззнаковых целых снимает необходимость
12 // проверки на отрицательные индексы
13   int& operator[]( unsigned int index);
14
15 private:
16   unsigned int max;
17   unsigned int dummy;
18   int *data;
19  };
20  #endif

Здесь был объявлен конструктор копий, а следом - операция присваивания класса INT_ARRAY. Объявления этих функций вполне правильны.  Но, как вы увидите ниже, для этого класса определения этих функций будут неправильны.

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

Код:
01  // SMARRAY2.CPP - реализация интеллектуального массива
02  #include <mem.h>
03  #include [i]
04  #include "smarray2.h"
05
06  // Конструктор - Добавляет единицу к размеру массива для хранения
07  // фиктивного значения на случай использования недопустимого индекса.
08  INT_ARRAY::INT_ARRAY(unsigned int sz)
09  {
10    max=sz;
11    dummy=sz+1;
12    data=new int[sz+1];
13     // Если  new возвращает допустимый блок памяти,
14     // тогда data не нуль. Функция memset инициализирует
15     // этот блок памяти значением 0.
16    if(data) memset( data, 0, dummy);
17    else max = -1;
18  }


19  // деструктор
20  INT_ARRAY::~INT_ARRAY()
21  {
22    delete [] data; // освобождение массива
23    data = 0; // установка указателя в 0 позволяет проверять
24              // его недопустимость
25  }

// - Предупреждение -
// Этот класс использует буквальное копирование.
// Не пользуйтесь им в реальных программах.
// Этот конструктор копий демонстрирует вариант
// "буквального" конструктора, генерируемого компилятором.
// Здесь не только два указателя ссылаются на один и тот же
// блок памяти, но и, кроме того, не освобождается
// старый блок.

//конструктор копий
26  INT_ARRAY::INT_ARRAY(const INT_ARRAY& rhs)
27  {
28    this->max = rhs.max;
29    this->dummy = rhs. dummy;
30    this->data = rhs.data;  // ОШИБКА: буквальное копирование
31  }

//оператор присваивания
// Предупреждение - И здесь та же ошибка
32  INT_ARRAY& INT_ARRAY::operator=(const INT_ARRAY& rhs)
33  {
34    if(this == &rhs) return *this;
35    this->max = rhs.max;
36    this->dummy = rhs.dummy;
37    this->data = rhs.data;
38    return *this;
39  }

// Очень кратко, но допустимость индекса проверяется.
// Этот вид проблем - превосходный кандидат для
// обработки исключительных ситуаций.
40  int& INT_ARRAY::operator[](unsigned int index)
41  {
42    return index < max ? data[index] : data[dummy];
43  }


44  void main(void)
45  {
46    INT_ARRAY ouch;

  // Создается искусственная область видимости,
  // чтобы деструктор startMeUp был вызван первым
47    {
48      INT_ARRAY StartMeUp(10);
49      for(unsigned int k = 0; k<10; k++)
50      StartMeUp[k] = k;  // здесь работает operator[]
51      ouch = StartMeUp;  // Вызов "ПЛОХОЙ" операции присваивания
52      // Демострация того факта, что 'ouch' и
53      // 'startMeUp' указывают на один и тот же блок памяти
54      for(unsigned int k = 0; k<10; k++) cout << ouch[k] << endl;
55    }
56  }

Итак, здесь (строки 26-31) мы определили конструктор копий, выполняющий буквальное копирование. Именно так функционирует конструктор копий, который компилятор будет генерировать по умолчанию, если вы не заблокируете копирование (как это сделать, мы рассмотрим позднее) или не определите правильно реализованный конструктор копий.

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

Наконец в функции main()  для объекта StartMeUp создается искусственная область видимости (строка 47 и 55) так, что его деструктор вызывается раньше, чем деструктор объекта ouch. Элементам объекта StartMeUp присваиваются значения от 0 до 9. Далее происходит вызов операции присваивания, следовательно в обоих объектах элементы будут иметь значения от 0 до 9.

Деструктор объекта StartMeUp вызывается первым и освобождает память, на которую указывает член класса int *data. В правильно определенном классе это не должно затрагивать другие объекты того же типа. Этот класс, однако, использует буквальное копирование там, где требуется развернутое. В результате получается, что указатель data объекта ouch после этого ссылается неизвестно куда. Или, известно куда, но там уже неизвестно
что…

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

Развернутое копирование

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

Если очень кратко – то сделать надо следующее. Освободить память объекта-копии, выделить ему новую область памяти в количестве, достаточном для хранения копии данных объекта-источника (то есть копируемых данных), и собственно скопировать эти данные. Идея развернутого копирования состоит в грамотном воспроизведении блоков памяти.

Должным образом определенное развернутое копирование помимо дублирования стековых элементов управляет перераспределением ресурсов динамических членов.

Приведенный ниже пример содержит модифицированные версии определений конструктора копий и операции присваивания класса INT_ARRAY, демонстрирующие примеры развернутого копирования.
Код:
35 // Конструктор копий с развернутым копированием
36 INT_ARRAY::INT_ARRAY(const INT_ARRAY& rhs)
37 {
38   delete [] data; //освобождаем память
39   max = rhs.max;
40   dummy = rhs.dummy;
41   data = new int [dummy]; //выделяем новый блок
42   for( unsigned int j = 0; j<duinmy; j++)
43     data[j] = rhs.data[j]; //копируем данные
44 }
45 // Операция присваивания с развернутым копированием
46 INT_ARRAY& INT_ARRAY::operator=( const INT_ARRAY&rhs)
47 {
48   if(this == &rhs) return *this;
49 // Обратите внимание, что код идентичен тому,
     // что используется в конструкторе копий
50    delete [] data;
51    max = rhs.max;
52    dummy = rhs.dummy;
53    data = new int [dummy];
54    for( unsigned int j = 0; j<dummy; j++)
55      data[j] = rhs.data [j];
56    return *this;
57  }

Обратите внимание, что строки 38—43 конструктора копий и строки 50—55 операции присваивания идентичны: освобождается область памяти, адресуемая указателем data вызывающего объекта, затем выделяется новая область памяти и в нее копируются значения из памяти объекта аргумента.
Конструктор копий и операция присваивания переработанного класса не станут теперь причиной потерь памяти.

На этом, пожалуй, сделаем паузу до следующего раза. Небольшое резюме напоследок.

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

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

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

При написании статьи использованы материалы из книг
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

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