Лекции (1129116), страница 15
Текст из файла (страница 15)
Complex operator + (Complex C1&, Complex C2&) { return Complex(C1.Re+C2.Re, C1.Im+C2.Im); };
Такие же перекрытия надо написать для других операций. Возникает проблема: если переменная С в выражении будет вещественной, то как поступить в этом случае? Можно написать, конечно, конструктор преобразования Complex(double,double), но тогда его придется вписывать явно в выражение. Можно для каждого из базисных типов данных написать свой оператор "+" и все прочие операторы, но это приводит к значительному "раздуванию" библиотек.
Лекция 12
Обсудим подробнее конструкторы преобразования. Почему они появились? Изначальной идеей Страуструпа было создать классы так, чтобы с их помощью можно было определять любые типы с произвольной семантикой. При этом новые типы ничем не отличались от базисных с точки зрения эксплуатации. Хорошим примером в данном случае будет тип комплексных чисел
A=B*C+D*(0,1)
Это выражение на языке Fortran, в нем подразумевается, что все переменные имеют тип комплексного числа. Написать подобное выражение на языке, не обладающем гибкостью C++, вообще говоря, сложно. С другой стороны можно добавлять типы данных в базис и интегрировать их с языком, но это не самое лучшее решение.
С этой точки зрения Страуструп обеспечил следующие средства развития. Во-первых, сам механизм классов, во-вторых, перекрытие операций. То есть можно перекрывать произвольные функции и знаки операций, причем, функции операции могут быть, как функциями членами, так и глобальными.
Мы писали уже перекрытие оператора «+» для комплексных чисел, он у нас выглядел, как функция-член. Это не совсем хорошо, но об этом мы еще поговорим.
С точки зрения языка Fortran, такое решение выглядит далеко не самым лучшим, так как в этом случае, если A,B,C,D - комплексные
Complex A,B,C,D;
(для определения константы следует воспользоваться конструктором)
Выражение на C++ примет вид:
A=B*C+D*Complex(0,1);
Вообще говоря, тут можно было бы схитрить и перекрыть операцию «,», но это ящик Пандоры, так как запятые используются не только для связывания комплексных констант. Можно поступить по-другому и описать константу следующим образом:
const Complex Im1(0,1);
Однако, что еще есть в Fortran? Там есть еще неявное преобразование типов (которое есть практически в любом языке). Естественно, в случае, если A и D – комплексные, а B и C – float, то наше выражение будет компилироваться с ошибкой. Получается, что нам следует определять операции сложения, умножения и т.д. для всех базисных типов данных. Библиотеки, таким образом будут неимоверно разрастаться. В результате Страуструп разрешил пользователю управлять неявными преобразованиями типов, так как если вспомнить философию языка C++, то основной ее тезис – сделать язык удобным для программирования, а удобство означает прежде всего – большие мощность и гибкость, чтобы человек мог сделать все то, что он хочет.
Итак, неявные преобразования программист может разрешать и составлять сам. Для этого ему и служит конструктор преобразования.
Пусть у нас есть A+B, где A и B – double числа.
+(Complex, Complex)
Если наш конструктор находит Complex(double) (а он его, конечно, найдет, правда, по хитрым правилам), то в этом случае компилятор проведет замену:
A+B => Complex(A)+Complex(B)
проблема решена. Конечно, недостаточно написать Complex(double), следует еще написать Complex(float), Complex(int) – и т.д. Тогда у нас полностью обеспечивается гибкость Fortran.
Возникает вопрос, почему бы не разрешить
1+C ~ Complex(double(1)) + C?
Дело в том, что здесь мы имеем цепочку преобразований, сначала базисное, затем определяемое пользователем, а в больших проектах может быть не одно преобразование, а в общем случае некоторый граф преобразований, возможно, с циклами, что, конечно же, позволяет совершить массу ошибок. Поэтому разрешаются только одношаговые преобразования.
Операторы преобразования.
Естественно, коль скоро мы говорим о преобразованиях, связанных с конструктором, когда у нас есть объект какого-то типа и компилятор находит соответствующие неявные преобразования, то имеет смысл говорить и об операторах преобразования, т.к. в некоторых случаях совершенно не нужно вызывать конструкторы.
Оба методы вполне допустимы. Что делает конструктор преобразования? Он создает временный объект ( в нашем примере – Complex(A) ), имея
C=A;
где C – комплексное, а A – целое, мы получим:
C=Complex(A);
то есть некоторый объект, который будет удален неизвестно когда (на усмотрение компилятора). Конечно, для комплексных чисел проблемы большой не видно. А если объект имеет достаточно сложную структуру? Тогда речь идет о том, что на базе одного объекта создается другой, и это может оказаться накладно. Поэтому есть так называемые операторы преобразования. Это некая функция-член, которая выглядит следующим образом:
operator T();
T – это тип, к которому происходит преобразование. Параметров нет. Явных. Есть один неявный – указатель на самого себя this.
Например:
class MyString {
MyString( char * );
MyString( const MyString *);
operator char*();
};
В данном случае оператор преобразования – char* (). Чем он удобнее в данном контексте? Пусть нам надо выдать строку (будем выдавать ее через безопасный вывод, через класс ostream с перекрытой операцией вывода):
ostream S;
ostream& operator << (const char *)
так как у нас используется ссылка, то мы можем сделать такой вывод:
S << “Hello!” << ‘\n’ <<”World?”;
Это альтернатива стандартного ввода вывода. И вот проблема: мы не можем изменить класс ostream, но очень хотим уметь выводить наши строки MyString (вполне невинное желание). Значит, нам нужно из класса ostream выводить новый класс (только для того, чтобы переопределить операцию вывода). Если у нас встречается конструкция:
MyString Str;
S << Str;
то можем ли мы воспользоваться конструктором преобразования, определенном в классе MyString? Нет. Ведь нам надо сделать преобразование из MyString в (char *), а конструктор делает наоборот. В данном случае помочь может только оператор преобразования. Если он есть, то мы получим то, что хотели:
(char *) Str;
То есть оператор и конструктор преобразования – обратные операции. Конструктор делает из «чужого» объекта «свой», а оператор – наоборот.
Уместно поговорить о константах. Так как свойство константности имеет место и при преобразованиях, то есть, когда компилятор вставляет код к неявному преобразованию константы, то получившийся объект продолжает оставаться константой. С этой точки зрения, если мы описываем функцию:
f(MyString & S);
а так как у нас есть операторы преобразования, то напишем:
f(“Hello!”);
в результате… сообщение об ошибке. В чем ошибка? В данной ситуации происходит преобразование (char *) => MyString, то есть работает конструктор. Что здесь получилось:
f( MyString(“Hello!”));
а так как свойство константности остается, то фактически мы создали константу типа MyString, компилятор смотрит на соответствующий профиль класса ( MyString( char *) ), но там стоит не константа. Получаем ошибку. Каким образом это лечится? Добавлением const в описание функции:
f(const MyString & S);
Дело в том, что прежнее описание f говорит о том, что мы собираемся изменять переменную S. А второе описание говорит, что мы передаем по ссылке, не собираясь ничего изменять, а только лишь для избежания накладных расходов, которые неизбежно возникают при передаче параметра по значению.
Теперь давайте поговорим о том – хорошая или плохая штука эти стандартные преобразования. Заметим, что в Modula-2 и Oberon неявные преобразования вообще запрещены. Разрешены только преобразования числовых типов, при которых не теряется точность (int -> longint ->real -> long real). Что же говорить о преобразованиях, определенных пользователем? Пусть у нас есть:
Stack(25);
у класс Stack есть конструктор, который оказывается конструктором преобразования:
Stack(int);
хотя сложно было бы подумать, что процесс порождения стека – перевод некоторого числа (длины стека) в некий объект. Но по правилам языка это можно трактовать именно так.
Посмотрим на следующее:
S=11;
Что хотелось сделать? Кажется – описка. Компилятор же преобразует данную строку в:
S = Stack(11);
Почему это произойдет? А дело в том, что при присваивании объекту типа Stack числа типа int произойдет автоматический вызов конструктора преобразования, который в нашем случае еще и создает стек заданной длины. Таким образом наш старый стек пойдет ко дну. С содержательной стороны, конечно же, хотелось бы видеть сообщение об ошибке. В последних версиях C++ эта проблема решается с помощью ключевого слова explicit:
explicit Stack( int );
В этом случае будет разрешено только явное использование данного конструктора при преобразованиях, и выражение:
S=11;
где подразумевается неявный вызов, повлечет за собой ошибку, что не найдет соответствующий оператор преобразования.
Отнюдь не случайно вопрос о неявных преобразованиях дискутируется в кругах разработчиков языков программирования. Заметим, что в языке Java, который можно рассматривать, как наследника C++. Однако его создатели ставили себе целью не просто создать язык, который был бы удобен некоему классу пользователей, а более жесткую задачу – ЯП, который можно было использовать в интернет с концепцией: «написал один раз, выполняешь – везде». К тому же создаетли Java не ориентировались на совместимость с C++, в то время как C++ был создан, как надстройка С и должен был быть совместим с ним.
В языке Java неявных преобразований нет, слишком уж это опасная штука.
Когда еще возникают неявные преобразования? Пусть у нас есть некий конструктор
X (int).
и функция:
f( x&);
Тогда
f(5) ~ f(X(5));
и будет произведена генерация некотрого временного объекта. Опять же – тут следует быть осторожным, если мы не хотим вызова X(), то надо использовать explicit. Однако и это не решает всех проблем. Вернемся к классу MyString:
class MyString {
char * body;
…
operator char* ( ) {return body; }
…
}
Естественно, написать функцию “+” для конкатенации двух строк:
MyString S(“Hello, ”);
S+”World!”
Если у нас есть функция “+” перекрытая с аргументами (string, string), то будет найден конструктор преобразования, который сгенерирует:
S+MyString(“World!”);
Пусть теперь у нас есть некая функция :
char * f( char *) {…; return t;}
где t – параметр, переданный функции (то есть функция возвращает то, что в нее передали).
Тогда будет выполнено следующее преобразование:
P = f(S+”Hello”) ~ f( (char*) tmp),
где
tmp=S+MyString(“Hello”); - временный объект типа MyString.
Таким образом мы в f передаем указатель на временный объект (char *) tmp и сама функция вернет нам значение именно этого указателя. То есть P будет указывать на (char *) tmp, временный объект в куче! В какой момент P прекратит существование? Никто не знает. Например, исходя из практики, компилятор Visual C сразу выполняет деструктор временного объекта по выходу из функции, компилятор Borland ждет конца блока.
Вобщем, не исключено, что после такого вызова P будет ссылаться черт знает куда. Хотя вроде бы мы не обязаны знать внутреннего устройства: у нас есть функция f, строки – а P ссылается неизвестно куда, и мы не можем им пользоваться. Эта беда случилась исключительно из-за неявных преобразований. Как только мы введем переменную tmp сами, то будем знать, что P живет столько же, сколько и P.
Какого рода еще есть проблемы?
Вернемся к нашему конструктору преобразования X:
X (int)
X (const X&)
Рассмотрим присваивание:
X x=5;
Здесь генерируется следующий код:
X tmp(5);
X x=tmp; - конструктор копирования
~tmp; - деструктор tmp
Что здесь можно оптимизировать? В данном случае оптимизация будет следующая:
X x(5);
то есть конструктор преобразования будет работать сразу для переменной x, минуя создание и удаление временной переменной tmp, что является хорошей экономией.
Эти все вещи специфичны только для языка C++. Заметим, что даже такая красивая концепция, как классы, способна испортить программисту жизнь.
Деструкторы
Деструкторы – чуть более простая вещь, нежели конструкторы (ломать – не строить). Деструктор – это функция, двойственная к конструктору. Он имеет следующий вид:
class X{
X(); - конструктор
~X(); - деструктор
}
Также как вызов конструктора автоматически связывается с размещением объекта в памяти, вызов деструктора связывается с уничтожением объекта.
Деструкторы статических объектов вызываются после завершения работы программы, в так называемом «стандартном эпилоге», схема выполнения программы выглядит следующим образом: