лекции (1998) (Буров) (1161123), страница 15
Текст из файла (страница 15)
Вообще говоря, тут можно было бы схитрить и перекрыть операцию «,», но это ящик Пандоры, так как запятые используются не только для связывания комплексных констант. Можно поступить по-другому и описать константу следующим образом:
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(); - деструктор
}
Также как вызов конструктора автоматически связывается с размещением объекта в памяти, вызов деструктора связывается с уничтожением объекта.
Деструкторы статических объектов вызываются после завершения работы программы, в так называемом «стандартном эпилоге», схема выполнения программы выглядит следующим образом:
Пролог – здесь инициализируются статические объекты
Main - выполнение программы (плюс создание/уничтожение динамических объектов)
Эпилог - уничтожение статических объектов
Для квазистатических объектов конструкторы выполняются при входе в блок, деструкторы – при выходе. Для динамических – конструкторы вызываются при вызове new, деструкторы – delete.
Для класса Complex имеет смысл писать конструкторы (причем не один), а вот деструктор – не имеет, так как класс Complex никаких ресурсов не захватывает.
Для класса же MyString деструктор писать надо - он будет удалять строку из динамической памяти, точно также для класса Stack.
В принципе, деструкторов может быть много, хотя сложно придумать параметрический деструктор (но возможность такая есть). Как правило существует деструктор умолчания. Также как и у конструктора, деструктор имеет две семантики: стандартная и пользовательская. Но у дестркутора в начале будет вызвана пользовательская деструкция (то что прописано в теле деструктора пользователем), затем деструкторы подобъектов и деструкторы базовых классов (деструкторы, описанные в родительских классах). То есть строим мы с фундамента до крыши, а ломаем, соответственно, с крыши до фундамента.
Это и есть деструкторы умолчания, они вызываются неявно. В 99% случаев их бывает достаточно. Деструкторы можно вызывать явно, но редко это бывает нужно (в отличие от конструкторов), так как явный вызов деструктора может означать только то, что мы хотим разрушить объект, а потом сразу на его месте создать новый.
Таким образом мы практически закончили раздел специальных функций. Мы не затронули функции new и delete. Но это тема для самостоятельного изучения. Напоследок стоит сказать, как можно вызывать конкретные функции классов:
имя класса :: имя функции
если функция глобальная, и для нее класс не определен, то ее вызывают:
:: имя функции
То есть, написав:
:: new
мы укажем на базовую функцию new, которую можно, например, переопределить.
Посмотрим, что же у нас получилось из концепции классов C++. А то, что фактически ничего в базисе C++ по отношению к C не прибавилось (кроме ссылочного типа), даже не был введен популярный в других языках строковый тип. Почему нет? Мы определили – потому что с помощью концепции классов можно получить тип string, гораздо более мощный, чем если бы мы ввели его в базис.
Заметим, что ни Java, ни в Delphi нельзя создать класс, аналогичный sstring, так как в этих языках нельзя, например, перекрыть операцию «+». Поэтому в Delphi появился встроенный тип string и почти то же самое было сделано в Java (в Java не разрешено перекрывать стандартные операции). Например, в Java можно написать:
S+5;
и число 5 будет переведено в строку «5», так как в Java есть функция ToString для всех объектов.
Это единственный выход, если не делать таких же средств развития, как в C++, а эти средства иногда «кусаются», так как черезчур мощны.
Но в то же время заметим, что и в Java и в C++ отсутствует понятие диапазона. Иногда оно полезно, иногда нет (это понятие есть в Pascal, Modula-2, Ada). Можем ли мы придумать какой-нибудь класс, полностью эквивалентный диапазону? Конечно, да.
class Diap {