|
|
|||||||||||||||||||||||||||||
|
Классы: копирование и присваивание. Часть вторая (исходники)Источник: Весельчак У Сергей Малышев (aka Михалыч)
Есть два вида копирования: буквальное копирование (shallow copy) - то, которое обычно предлагается компилятором, если вы не определите эту операцию сами (т.е. автоматически сформированные компилятором конструктор копий и операция присваивания), и развернутое копирование (deep copy - или глубокое копирование). Буквальное копирование Буквальное копирование - это простое побитное (или поразрядное) копирование. Оно означает, что количество и состояние всех битов одного объекта абсолютно точно воспроизводится во втором. Например, точно копируются 32 разряда целого числа; точно так же буквально воспроизводятся и 32 бита указателя (например char *, хотя понятно, что в разных системах и в разных моделях памяти и размерность указателя и размерность целого числа может быть совсем другой). Что из себя представляет указатель любого типа? Он также состоит из последовательности битов. Значением указателя всегда является адрес (или местоположение в памяти). Обычно по этому адресу располагаются какие-то ресурсы (ну, например порт ввода-вывода или видео память), чаще всего это просто область памяти. Проблема состоит в том, что эта область как таковая не является элементом класса, элементом класса является только сам указатель. Поэтому при буквальном копировании дублируется только значение указателя, но, к сожалению, вовсе не то, на что он указывает. В некоторых случаях буквальное копирование указателя вполне допустимо. Предположим, что указатель задает, например, адрес порта ввода-вывода или адрес некоторой системной области. Например, по адресу 0х00000417 (если я не ошибаюсь) находится слово состояния служебных клавиш (<Shift>, <Caps Lock> и т. п.). Поразрядное копирование указателя такого вида вполне разумно, потому что вашим классам не придется динамически распределять эту область памяти, или как-то беспокоиться о ее состоянии - это системная область памяти и ей занимается BIOS. Чаще всего указатели адресуют участки памяти, выделенные с помощью операции new в вашей программе. В таком случае буквальное копирование может привести к утечкам (или потерям) памяти. Как ранее уже упоминалось, утечки памяти возникают тогда, когда два или более объекта ссылаются на один и тот же фрагмент памяти и один из объектов освобождает этот фрагмент, не ставя в известность об этом остальных. Легко догадаться, что утечки памяти ни к чему хорошему не приводят. Когда один объект освобождает память (например при своем уничтожении), которую также использует и другой объект (или несколько объектов), то во втором объекте остаются ненулевые указатели (они-то все еще указывают на ту область памяти!). Когда дело доходит до удаления этого второго объекта, к его членам-указателям обычно применяется операция delete. Вызов delete для указателя «в никуда» приводит к непредсказуемым, но обычно к весьма разрушительным последствиям. Например, память, которую в этом случае якобы «освободил» объект, уже могла быть распределена вновь для потребностей совсем другого объекта. В качестве примера рассмотрим класс INT_ARRAY, представляющий интеллектуальный массив целых чисел. Класс целочисленного массива написан исключительно в демонстрационных целях и для практического применения интереса не представляет. Ниже приведены интерфейс и реализация этого класса, дополненная буквальным конструктором копий. Несложно убедиться, что это приводит к ошибке, так как один из членов класса является указателем, ссылающимся на блок памяти, выделяемый операцией new. Однако, именно с этой целью этот пример и приведен. Код:
Здесь был объявлен конструктор копий, а следом - операция присваивания класса INT_ARRAY. Объявления этих функций вполне правильны. Но, как вы увидите ниже, для этого класса определения этих функций будут неправильны. Фактически в определении конструктора копий и операции присваивания намеренно используется буквальное копирование, чтобы продемонстрировать ту версию этих функций, которую сгенерировал бы компилятор (в случае, если их не создадите вы).
Код:
Итак, здесь (строки 26-31) мы определили конструктор копий, выполняющий буквальное копирование. Именно так функционирует конструктор копий, который компилятор будет генерировать по умолчанию, если вы не заблокируете копирование (как это сделать, мы рассмотрим позднее) или не определите правильно реализованный конструктор копий. Здесь же определена и столь же плохая оператор-функция присваивания (строки 32-39). Как и конструктор копий, она не освобождает память, выделенную указателю вызывающего объекта, не перераспределяет память и не копирует все элементы массива. Все, что она делает - это присваивает указателям ссылки на один и тот же фрагмент памяти. Наконец в функции main() для объекта StartMeUp создается искусственная область видимости (строка 47 и 55) так, что его деструктор вызывается раньше, чем деструктор объекта ouch. Элементам объекта StartMeUp присваиваются значения от 0 до 9. Далее происходит вызов операции присваивания, следовательно в обоих объектах элементы будут иметь значения от 0 до 9. Деструктор объекта StartMeUp вызывается первым и освобождает память, на которую указывает член класса int *data. В правильно определенном классе это не должно затрагивать другие объекты того же типа. Этот класс, однако, использует буквальное копирование там, где требуется развернутое. В результате получается, что указатель data объекта ouch после этого ссылается неизвестно куда. Или, известно куда, но там уже неизвестно что… Так что для класса, подобного INT_ARRAY, придется выполнять развернутое копирование или создать специальный механизм для подсчета числа ссылок data, чтобы знать, когда его можно безболезненно удалить. А теперь давайте посмотрим, что такое развернутое копирование и как его следует определять. Развернутое копирование Забота о реализации процедуры развернутого копирования полностью лежит на разработчике класса. Развернутое копирование делает все то же самое, что и буквальное, но плюс к этому обеспечивает еще и копирование ресурсов, связанных с членами класса - указателями. Если очень кратко - то сделать надо следующее. Освободить память объекта-копии, выделить ему новую область памяти в количестве, достаточном для хранения копии данных объекта-источника (то есть копируемых данных), и собственно скопировать эти данные. Идея развернутого копирования состоит в грамотном воспроизведении блоков памяти. Должным образом определенное развернутое копирование помимо дублирования стековых элементов управляет перераспределением ресурсов динамических членов. Приведенный ниже пример содержит модифицированные версии определений конструктора копий и операции присваивания класса INT_ARRAY, демонстрирующие примеры развернутого копирования. Код:
Обратите внимание, что строки 38-43 конструктора копий и строки 50-55 операции присваивания идентичны: освобождается область памяти, адресуемая указателем data вызывающего объекта, затем выделяется новая область памяти и в нее копируются значения из памяти объекта аргумента. Конструктор копий и операция присваивания переработанного класса не станут теперь причиной потерь памяти. На этом, пожалуй, сделаем паузу до следующего раза. Небольшое резюме напоследок. Для разработки конструкторов копий и операций присваивания можно сформулировать следующие рекомендации:
Запомните: если достаточно буквального копирования, то удобно использовать копирование и присваивание, генерируемые компилятором. На самом деле конструктор копий и операция присваивания способны не только к манипуляциям с памятью. C++ создан для широкой области приложений. Помимо правильного перераспределения ресурсов, копирование и присваивание могут быть использованы, например, для инициализации физического состояния аппаратных устройств или внешних процессов. Не ограничивайте свое воображение тем, что тут написано. В конечном счете, цель этого материала достаточно узка - помочь вам избежать одной из наиболее частых ошибок - утечек памяти.
|
|