Добавил:
Опубликованный материал нарушает ваши авторские права? Сообщите нам.
Вуз: Предмет: Файл:
Скачиваний:
79
Добавлен:
02.05.2014
Размер:
94.21 Кб
Скачать

Копирование объектов. Поверхностное Глубокое

(побитовое) (полное)

Есть две семантики копирования : поверхностное (побитовое) и глубокое (полное). Если объект содержит в себе ссылки на другие объекты, как в случае с объектом vector,

Ссылка на сам объект

Длина объекта

При поверхностном (побитовом ) копирование получится следующее:

Ри таком копированиии у нас появляются два объекта vector, но они ссылаются на одно и то же. Это влечет за собой проблему «висячей ссылки»

работы с динамической памятью.

Проблема возникнет очень остро, когда эти объекты начнут уничтожаться. Если в конструкторе есть new, то в деструкторе обязательно должен быть delete. Значит первый деструктор очистит область памяти, на которую ссылается объект vector, а второму придется удалять, то что уже удалено до него. Это одна из очень неприятных ошибок, которую довольно трудно поймать в режиме проектирования и выполнения. Эту проблему должен осознавать сам проектировщик или программист. В языках с ссылочной семантикой (JAVA, C#, Delphi) таких проблем не возникает, потому что там идет просто копирование ссылок. В С++ копирование должно иметь другую семантику. От сюда идет возможность перекрытия операции присваивания, т. е. можно писать

X& operator = (X&x) {…}

X a,b;

а=b вызывается

Операция присваивания, если она сгенерирована неявно, то она поверхностная (побитовая).

В языке С у нас можно было делать

(1) int a, b; обычное объявление переменных

Можно еще было написать так

(2) int a=b;

И программисты никакой существенной разницы не видели в том, чтобы написать как в первой строчке, а потом дописать

  1. а=b

или сразу написать одну вторую строчку.

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

Т х=а; (1) Т х; (2)

х=а;

здесь уже есть разница

Операция присваивания радикально отличается от конструктора тем, что она сначала должна освободить старый ресурс, а потом записать уже новый. В примере (2) сначала вызывается конструктор умолчания, потом операция присваивания тут же уничтожает то, что сделал конструктор, и делается копия. Это очень не эффективно. А вот для класса vector, вообще, отсутствует конструктор умолчания, так какой же конструктор будет вызываться при объявлении? Конструкция (1) в языке С++ носит особенную семантику. Там должен вызываться конструктор, но какой. Вызовется конструктор копирования. Он имеет следующую семантику:

X ( X& ) {…}

Это похоже на прототип объявления операции присваивания.

Или же

X (const x&) {…}

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

На вскидку приходит то, что компилятор автоматически сгенерирует что-то типа memcpy(…); Но это не так. Ни один компилятор такого делать не будет. Почему? Вспомним семантику конструктора. Сначала вызывается конструктор для базового класса, а потом уже конструктор подобъектов. То же самое справедливо и для конструктора копирования. При копировании некоторые объекты могут быть уже проинициализированы. Семантика конструктора копирования отличается от семантики простого побитового копирования, а именно, часть объектов может просто копировать побитовым образом, а для части – могут вызываться пользовательские конструкторы копирования.

Теперь понятно, что ни конструкторы умолчания, ни конструкторы копирования, ни операция присваивания не копируются. Если бы они наследовались, создавались бы дополнительные проблемы.

Есть еще третий специальный вид конструктора (первый – конструктор умолчания, второй – конструктор копирования) – конструктор преобразования.

Вопрос: нужны ли ЯП преобразования. С этой точки зрения ЯП делятся на две группы: ЯП, в которых запрещены преобразования (например, язык Оберон) и в которых преобразования разрешены. Там единственное, что разрешено, так это преобразование между простыми классами, причем преобразование от простого типа к сложному, т.е.

BYTE INTEGER LONG

такого рода преобразования разрешают выполнять все ЯП, это преобразования по отношению включения). Но ведь можно делать и более сложные преобразования. Например, в язык С введена целая система неявных преобразований. Спрашивается, когда вводится новый тип данных, разрешено ли там делать неявные преобразования? Например, в языке Ада существуют понятия типа и подтипа. Так вот неявные преобразования между типами и подтипами разрешены (они фактически относятся к одному типу), а между разными типами любые неявные преобразования запрещены (явные разрешаются). Так вот возникает вопрос, разрешать или нет неявные преобразования в самом ЯП? Так вот языки С++ и C# разрешают неявные преобразования, причем они допускают, чтобы семантику этого преобразования описывал сам пользователь.

Преобразования между данными все-таки нужны. Другое дело, что они всегда должны быть явными.

У нас есть в С++ тип данных char *, который служит для описания так называемых ASCI строк. Работа с string, т.е. массивом символов, есть одна их двух причин ненадежности языка С++ (вторая причина – работа с указателями). Одна из самых распространенных ошибок, связанная с ненадежностью сетевого обслуживания, представляет из себя переполнение буфера. Мы запрашиваем памяти меньше, чем реально потом заполняем. Эта проблема переполнения буфера всегда появляется при работе с динамической памятью. Есть специальные строки в разработанных библиотеках, которые позволяют работать со строками произвольной длины, т.е. там операция конкатенации совершенно безопасные. Хотелось бы иметь преобразование из типа char * в тип string. Константы типа char * описываются через литералы.

Страуструп решил написать библиотеку для работы с комплексными числами (если посмотреть на область применения комплексных чисел, то мы увидим, что они перемешиваются с действительными), с точки зрения программирования это означает, что у нас одновременно работают типы данных int, float, double, complex и все это в одном ключе. Если бы у нас не было неявных преобразований, то для двуместной операции «+» надо было бы написать 16 вариантов операции «+». А подтипов еще больше (есть unsigned int, unsigned float и т. д.), если бы не было неявных преобразования, то даже простая библиотека разрослась бы до неимоверных пределов. Из тех языков, которые мы рассматриваем, С++ и С# разрешают неявные преобразования, а язык Java— нет. Язык Java из проблемы преобразования типа данных string выходит очень просто. Там есть класс , который описан в пакете Java.long, а мы говорили, что обо всех объектах, которые там находятся, компилятор знает больше, чем обо всех остальных. А знает он следующее. Т.к. все классы выводятся их класса object, а там обязательно есть объект tostring( ) и его мы можем применить для любых объектов, это и есть преобразование в тип данных string. Даже если int, long… не являются классами, для них есть так называемые классы-обертки Int, Long, а вот в этих классах уже соответствующий метод есть.

Если мы пишем

string s;

s+5; компилятор чувствует, что ему здесь нужно сделать преобразование, и он неявным образом вызывает метод tostring для класса Int, т.е. идет преобразование 5 к типу srting, а «+» расценивается как обычная операция конкатенации.

Если мы пишем

s+x, то это эквивалентно

s+x tostring();

Т. е. для большинства типов данных неявные преобразования уже вставлены в сам язык, а других, пользовательских, неявных преобразований компилятор вставлять не разрешает. Это сделано для повешения надежности языка. В Delphi то же самое: там есть встроенный тип данных string, который ведет себя точно так же, как тип stirng в языке Java. Страуструп не хотел расширять базис языка. Он решил оставить реализацию таких типов данных, как string, complex на усмотрение разработчиков стандартных библиотек(так получилась библиотека шаблонов). Именно для того, чтобы облегчить жизнь именно этим разработчикам, он вынужден был ввести в язык неявные преобразования. Как следствие у нас появился целый ряд проблем.

Х (Т);

Х (&Т);

Второй конструктор носит название «конструктор преобразования» и может вызывать не явно.

string (cons char *), если есть такой конструктор, то имеем право написать

string s = (“abc”);

Что в этом случае будет делать компилятор? В соответствии со стандартной семантикой разумно ожидать присваивания

string s(“abc); т. е.

string s = string (“abc”);

Точно так же, если у нас есть

string s;

(работает конструктор умолчания, который просто присвоит s пустую строку)

s = “abc”;

(компилятор сюда вставляет s = string (“abc”);)

Работает конструктор преобразования, а потом уже конструктор копирования или операция присваивания. Здесь вместо одного конструктора будут работать два. Аналогичная ситуация возникает, когда у нас есть просто класс Х и функция f, которая принимает объект типа Х по значению, а это означает, что х будет работа с копией х. Здесь будет работа не с операцией присваивания, а работать будет конструктор копирования, потому что при операции присваивания и левая, и правая части должны быть проинициализированы. А при копировании один их объектов не определен.

Копирование х происходит куда-то там в локальную память, следовательно работает именно конструктор копирования.

  1. void f (X x)

  1. X f( ) {X x; … return x;}

Здесь тоже х буде скопирован куда-то, а следовательно будет работать конструктор копирования, тук как операция return это есть копирование значения х куда-то.

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

int a;

x = a;

x = 5; ~ x=X(5) (конструктор преобразования вставляется)

Тогда f(5); корректный вызов

В вызове функции f (1) будет аналогично вставлен конструктор преобразования f(X(5)), который создает временный объект, а потом уже работает конструктор копирования. Т.е. два конструктора на одном месте, когда достаточно одного. Компиляторы имеют право вставить следующую оптимизацию: т. к. самая важная здесь работа конструктора преобразования, то запись типа

string s = “abc”;

компилятор заменит на запись типа

strings(“abc”);

тем самым будет сэкономлен один конструктор. Это есть некоторое усложнения языка.

Вернемся к нашему классу Vector, в котором нет конструктора умолчания, но можно описать конструктор преобразования. Если мы напишем что-то типа

int a;

Vector v;

v=a; утверждается, что с вероятностью близкой к 1 эта запись ошибочна, т. е. программист случайно перепутал имена переменных. А на самом деле будет выполнено : V = Vector (a); и старое значение V будет потеряно. Эта ошибка, причем как любая динамическая ошибка, она то ли найдется, то ли нет. В таком случае программа, скорее всего, будет работать, но она станет выдавать разные данные, если у Вас возникает такая проблема, то у Вас явно есть не инициализированное значение. Эта возможная ошибка была замечена довольно рано, и Страуструп в 90-е годы (когда влияние С++ стало ничуть не меньше языка С, и как следствие Страуструп перестал бояться вводить новые слова) появляется ключевое слово explisit, которое ставится перед конструктором преобразования. Ключевое слово explisit означает, что конструктор может быть вызван только явно.

explisit Vector(int);

Vector v(10); корректный вызов,

V = 5; эквивалентно Vector (5); это неявный вызов конструктора, следовательно, ошибка т.к. неявные вызовы запрещены. Хорошо бы было, если б ввели еще слово implisit, которое если мы хотим действительно неявный конструктор преобразования (как char * к string), то мы его объявляем как implisit. Полное решение было бы ввести два слова: explisit и implisit, по умолчанию ставить implisit. Но сделали по-другому: ввели всего одно ключевое слово, и если не указано слово ехplisit, то данный конструктор может вызываться как по умолчанию, так и явно. Это было сделано исключительно из-за совместимости со старыми программами на С++, иначе пришлось бы все перекомпилировать, в том числе и стандартную библиотеку(естественно, что конструкторы преобразования встречались там очень часто).

С конструктором преобразования тесно связано понятие еще одной специальной функции, а именно, «оператор преобразования». Для нового класса конструктор дает способ преобразования в объекты нового класса их любого типа данных, в том числе и стандартных, встроенных и базис языка. Но тогда получается, из старых классов в новый можно делать преобразование, а из новых в старый нельзя. Получается некоторая несимметричность. Может случиться так, что мы не хотим расширять старый класс Т, добавляя в него конструктор преобразования Т(Х). Может Т вообще не класс, а стандартный тип данных типа int или char *. Мы говорили, что есть преобразование string -> char *, и это хорошо : мы указываем литерал, и он у нас автоматически преобразовывается в строку, но обратное преобразование, char * -> string, тоже было бы полезно, причем неявное. Например, у нас есть операция cout в стандартной библиотеке, для которой перекрыта операция “<<”, так вот, если есть неявное преобразование, то не надо делать перекрытие для char *. Появляется оператор преобразования, который может работать только, как функция-член класса, имеет только один параметр – указатель this. Например, для типа char * это будет выглядеть так:

Class X {

Operator char * ( ) {…};

}

Обычно в char * никто не преобразует, а все преобразуют в const char *.

Итак, у нас есть конструкторы Х( ); – умолчания,

Х (Х&); – копирования,

Х(Т); – преобразования,

все остальные.

Правила, как мы видели, для соответствующих конструкторов не самые простые. Что с этой точки зрения делается в других ЯП, которые мы рассматриваем? Конструкторы есть и в Delphi, и в Java, и в C#. Синтаксис конструкторов в Java и C# похожи. В Delphi нет зарезервированных имен, и мы должны пивать явно

Type T = class

Constructor Create(…);

Destructor Free;

Имя конструктора может быть любое, но как правило оно все-таки Create. Из-за совместимости с базовым типом object , из которого наследуются все классы, имеет конструктор Creatе. Деструкторы обычно без параметров, хотя параметризованные деструкторы разрешены как здесь, так и в С++.

Var X: T;

Это просто указание на то, что такой объект буден.

X:= T. Create(…);

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

X.Free;

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

Конструкторы и деструкторы не генерируются самостоятельно, но зато они наследуются. Они не вызываются без явного указания на то. Т.е никто не будет вызывать за нас конструкторы базовых класса или конструктор подобъектов.

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

T=class

X:Y; подобъект другого класса

Место всегда отводится только под ссылку.

Y.Create;

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

Inherited Create;

Это если мы хотим вызвать метод из базового класса.

В С++ если у нас есть класс

Т1 и Т2 и мы хотим вызвать метод класса Т1, то пишем Т1::f( );

Если есть и функция Void g ( ) – член класса Т2, то правильно будет следующее.

T1 T2

Void g ( ) {T1 :: f ( )}

Обратите внимания на то, что т. к. языки со ссылочной семантикой, то никаких вызовов по умолчанию ни конструктора, ни деструктора быть не может ни в Java, ни в C#, там надо проводить инициализацию явно.

X x = new X( );

В Java конструктор умолчания называется безаргументным конструктором. Безаргументный конструктор или конструктор умолчания автоматически вызывается для конструкторов базовых классов. Конструкторы подобъектов программист вставляет сам явно, потому что все они инициализируются с помощью операции new. В языке Java, если нам надо вызвать аргументный конструктор, вставляется ключевое слово super (…);

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

Static operator int (X x){…}

Если есть два класса Х и Y, то мы можем задуматься, куда вставлять преобразование? Можно, куда больше нравиться.

В C#, как и в С++ есть неявные преобразования (преобразования, которые вставляет компилятор). Семантику этих неявных преобразований может задавать сам пользователь. Если есть стандартный класс Т1 и Т2, то нельзя делать оператор преобразования одного к другому( эти преобразования уже есть свои у компилятора). Наши базовые типы это не просто базовые типы языка С#, а базовые типы среды CLR или .NET. Правила преобразования из одного стандартного класса в другой одинаковые для языков C# и BASIC, и всех языков, входящих в среду CLR. Так как разрешено неявное преобразование, то может возникать проблема, описанная выше в С++, В C# есть ключевые слова explisit и implisit, по умолчанию вставляется implisit и, если стоит explisit, это означает, что оператор преобразования вставляется только явно. Если есть explisit , и мы пишем

int a; X x;

a = x нельзя

a = int (x); верно

13

Соседние файлы в папке Лекции по программированию на ЯВУ