лекции (2003) (Глазкова) (1160821), страница 25
Текст из файла (страница 25)
Языки чётко делятся на два класса:
-
неявные преобразования запрещены (строгие языки),
-
неявные преобразования разрешены.
Самый яркий пример строгого языка – это язык Аda. Неявные преобразования (причём с контролем) там разрешены только между подтипами одного и того же типа. Преобразования же между разными типами (с разными именами) как явные, так и неявные в языке Аdа запрещены. В то же время во многих языках можно осуществлять явные преобразования типа. Например, в языке Оберон есть явные преобразования типа; неявные же преобразования существуют только при расширении типа (BYTE < SHORT < INTEGER) – здесь потери информации произойти не может.
В любых ОО языках существуют неявные преобразования между базовым классом и производным, потому что с точки зрения операций наследования производный класс является частью родительского. Только такие неявные преобразования и разрешены в строгих языках.
Вопрос о неявных преобразованиях сразу же встаёт перед любым автором языка программирования. В своё время ответ на этот вопрос искал и Страуструп.
Мы уже обсуждали проблему неявных преобразований, когда обсуждали проблему знаковых и без знаковых типов. Попытка запретить неявные преобразования из знакового в без знаковый тип и наоборот у Страуструпа провалилась (с точки зрения практического программирования). Первоначально в языке Си была сделана попытка запретить все неявные преобразования с классами, потому что неявные преобразования в принципе провоцируют появление ошибок.
Почему же неявные преобразования в языке С++ всё-таки появились? В чём идеология введения механизма классов в языке С++? Основная прагматика (цель) появления классов в языке С++ следующая: класс – это механизм определения нового типа. Новый тип по своим функциональным возможностям, и синтаксически, и семантически ничем не отличается от типов, встроенных в язык. В прошлый раз мы рассмотрели канонический пример – определение класса Vector. Вместо того, чтобы вводить в базис языка сложные понятия, можно делать очень мощные средства развития - классы. И синтаксически, и семантически, и с точки зрения эффективности Vector ведёт себя точно так же, как и надёжный вектор, встроенный в такой язык, как стандартный Pasсal.
Какие классы обязательно должны быть добавлены в библиотеку универсального языка? Надёжный ввод/вывод (iostream). Следующее расширение (одно из первых) - это понятие комплексного числа (Соmрlех). Тип данных Соmрlех – хорошая проверка для нового языка. Так как концепция классов должна поддерживать элегантное расширение языка (т.е. добавление новых ТД, которые ничем не отличаются от стандартных), класс Соmрlех должен обеспечивать такие же возможности как и язык Fortran для комплексных чисел. Выяснилось следующее: так как в математике всюду смешение типов, мы должны писать несколько вариантов перекрытых операций для работы с новым типом данных.
Например, Complex operator+ (Complex C1,Complex C2);
Кроме этого для 10 стандартных числовых типов надо перекрыть оператор +. И кроме этого затем перекрыть все другие операторы.
Т.о., получилось, что первая попытка реализовать в языке Си с классами комплексный тип данных привела к огромной библиотеке, в которой многократно переписывается один и тот же код. И это для такого очевидного ТД как Соmрlех. Заметим, что именно поэтому в язык были добавлены неявные преобразования типов (для удобства программирования библиотек). Причём, если в большинстве ЯП неявные преобразования только стандартные и только безопасные, то в С++ допускается, чтобы неявные преобразования определял сам программист. Возникает вопрос, как интегрировать неявные преобразования, задаваемые пользователем, с самим языком. В языке С++ существуют понятия
-
конструктора преобразования и
-
оператора преобразования.
Конструктор используется при инициализации созданного объекта на базе уже существующего. Конструктор преобразования имеет вид:
X(T), X(T&), X(const T&),
где T - произвольный тип данных.
Пример 1
X a;
T t;
a=t;
Формально ТД левой и правой части различны. Но если компилятор увидит преобразование Х(Т), то он неявно вставит вызов а=Х(t). Т.е. создаётся временный объект типа Х, и он используется как аргумент операции присваивания.
Пример 2
X a=t;
Формально, это ошибка.
Но компилятор генерирует вызов Х а=Х(t). Это вроде бы требует вызова двух конструкторов: преобразования и копирования. Совершенно очевидно, что временный объект типа Х должен удаляться, как это происходит в операции присваивания. Здесь конструктор копирования с точки зрения оптимизации является лишним. Современные компиляторы применяют следующий приём: они вызывают только конструктор преобразования, но не для временного объекта, а передавая ему ссылку на вновь создаваемый объект а. Это даёт существенную оптимизацию.
Пример 3 (аналогичный)
void f(X x);
Передача параметра – по значению. Мы говорили, что это один из вариантов вызова конструктора копирования, потому что в записи активации отводится место для формального параметра, и вызов по значению состоит в том, что перед началом работы процедуры происходит инициализация записи активации, в процессе которой копируется значение фактического параметра.
Пусть Х х; тогда вызов f(x) приводит к вызову конструктора копирования; после выхода из функции f работает деструктор объекта.
Пусть Т t; тогда вызов f(t) приводит к вызову f(X(t)). Из тех же соображений, что и в предыдущем примере здесь реально вызывается один конструктор – конструктор преобразования. Заметим, что если void f(X& x); то никакого конструктора копирования не вызывается по определению.
Неявные преобразования в языке С++ появились достаточно давно. В первых версиях языка был известен неприятный факт.
Пример
Конструктор класса Vector:
Vector (int size){...}
Чисто синтаксически такой конструктор является конструктором преобразования. В то же время семантически рассматривать конструктор Vector как механизм, который из целого делает вектор, бессмысленно. Поэтому, если мы пишем
Vector (10);
А после этого пишем v=5 (вообще говоря, это чушь). Что на самом деле происходит: компилятор находит конструктор преобразования, и поэтому будет сгенерирован текст v=Vector(5); вместо того, чтобы просигнализировать об ошибке. Эта общая проблема любых неявных преобразований. Решение этой проблемы было на поверхности, но по каким-то причинам его приняли только позже. Решение простое: в С++ вводится дополнительное ключевое слово explicit (явный), и если перед конструктором преобразования стоит ключевое слово explicit - это означает, что компилятор не будет рассматривать этот конструктор как конструктор неявного преобразования.
Пример
Если написать
explicit Vector (int size) {...}
и после написать v=5; то компилятор выдаст ошибку, потому что он не имеет право сам вставлять этот код. В чём недостаток этого решения? Если ключевое слово explicit отсутствует, это означает, что конструктор неявный.
В языке С# есть два ключевых слова: explicit и implicit.
По умолчанию (если опущены оба ключевых слова), все преобразования явные.
В языке С++ из соображений совместимости с предыдущими версиями, по умолчанию все преобразования неявные.
Иногда просто конструктора преобразования не хватает. Конструктор преобразования по существующему типу даёт новый (Т =>X).
Иногда требуется написать обратное преобразование (например, из нового типа данных получить существующий тип данных (Х => T)). Если пользовать конструктор, единственная возможность в классе Т описать конструктор Т(X&). А если Т – класс из библиотеки, то возможности создать такой конструктор нет.
Кроме того, нет возможности создать такой конструктор и если Т не является классом (например, Т – встроенный тип данных). А иногда необходимость в таких преобразованиях есть.
Оператор преобразования
Оператор преобразования – еще один вид специальных функций.
Например, если мы хотим написать преобразование от базового класса к производному, надо описать в производном классе оператор преобразования. Оператор преобразования – это функция-член класса.
Пример.
class X {
operator T( ) {….}
………….
};
Пример. Если для нашего класса определено преобразование к типу char * и мы пишем Ха; то мы можем написать cout << а;
Почему это работает?
Это функция от двух параметров ostream& и Х&. Прямой функции компилятор не находит, поэтому он начинает искать неявные преобразования: он ставит cout << (char*)а;
Пример. У типа данных string есть совершенно очевидный вариант преобразования в char*. String реализован на базе char*, т.е. возвращается указатель на динамически размещенную в куче строку. Кажется совершенно очевидным, что оператор неявного преобразования должен возвращать этот указатель. И в чем проблема? В том, что этот указатель в общем случае является перемещаемым, т.е. следующая же операции с соответствующей структурой данных может привести к тому, что этот указатель указывает на уже переразмещенную функцию.
Пример.
string s;
// есть неявное преобразование string =>char*
char*x=s; // это допустимо, т.к. существуют неявные преобразования
St = “String”;
В этот момент может произойти такое переразмещение динамической памяти, что этот указатель х указывает не туда, куда надо. Это типичная ошибка. Заметим, что ничто с точки зрения языка не подсказывает нам об этом.
Существует ли в классе string из SТL неявное преобразование к char*? Нет, именно, чтобы все-таки программист, когда выписывал явные преобразования, задумался.
К char* даже явного преобразования нет, потому что есть функция-член класса
string c_str (), которая дает cоnst chаr*.
Т.о., неявные преобразования – это очень мощный метод, который повышает гибкость программирования, но надежность может пострадать.
Проблема именно в том, что в самом языке средств анализа семантики операций не хватает для того, чтобы предупредить пользователя-программиста о потенциально небезопасном использовании соответствующих конструкций. Именно по этой причине во многих языках неявные преобразования запрещены вообще (как пользовательские, так и стандартные). Переопределение семантики стандартных операций тоже запрещено по этим же самым причинам.
Поговорим теперь про другие языки программирования, в которых есть понятие класса.
Рассмотрим язык Java.
В Java есть понятие конструктора, а понятие деструктора отсутствует. Вместо этого у каждого класса есть защищенный метод
void finalize () {…}
Его можно переопределять. Он вызывается тогда, когда сборщик мусора убирает объект из памяти. Мы обсуждали, что в языке С++ вариантов вызова конструктора может быть очень много.
Как только в языке появляется ссылочная структура, объект размещается в память только в одном случае Х а= new Х();// конструктор вызывается явно.
Только в одном случае возможен неявный вызов конструктора. Вспомним стандартную семантику конструктора языка С++. Она состоит из вызова конструкторов базовых классов, вызова конструкторов подобъектов и, наконец, вызов тела конструктора.
Синтаксис языка Java несколько расширен: можно инициализировать член прямо при его объявлении.
Пример.
class X {
int a [ ] =new int [10];
…………………..
}
В языке Java неявным остался конструктор умолчании, который неявно вызывается только как конструктор базового класса.
Обратим внимание, что языке С++ конструктор умолчания генерируется неявно, только если мы не задаем никакого другого конструктора. Если мы задали хотя бы один другой конструктор, конструктор умолчания уже не генерируется.
Если базовый класс не обладает конструктором умолчания или в наследуемом классе Y мы не хотим вызывать конструктор умолчания базового класса Х, тогда в Java есть мощное ключевое слово Super (аргументы);
Это вызов конструктора базового класса (по умолчанию Super (); ).
Ключевое слово Super надо, чтобы можно было обращаться к методам базового класса: Super – ссылка на базовый класс.
Понятия деструктора в Java нет. Вообще деструктор – это функция, которая должна вызываться в тот момент, когда объект уходит из памяти.
Поскольку все объекты размещены в динамической памяти, мы не знаем, в какой именно момент объект уйдет из памяти. Система автоматической сборки мусора имеет право мусор вообще не собирать. Это означает, что объект будет ликвидирован, когда программа закончит свою работу. В принципе в Java (и вообще во всех системах с автоматической сборкой мусора) обязательно есть специальный вызов, который вызывает явно процедуру сборки мусора, но пользоваться этим надо крайне осторожно, потому, что это ведет к накладным расходам. Возникает проблема: в системах с автоматической сборкой мусора (например, в Java) гарантируется корректная работа только с одним видом системного ресурса, а именно, с оперативной динамической памятью.
Деструктор нужен для освобождения системных ресурсов. Если мы не захватываем системные ресурсы, деструктор не нужен.
Автоматический сборщик мусора обеспечивает утилизацию только динамической памяти. Но у нас кроме памяти есть много других системных ресурсов. Например, если мы открыли коммуникационный порт, большинство ОС позволяют использовать его только в монопольном режиме. Поэтому, как только порт нам не нужен, надо его закрыть. То же самое относится ко многим другим системным ресурсам.
Т.о., возникает делема: понятие деструктора исчезает потому, что отложен момент разрушения объекта, и, в то же время, нам необходима конструкция, которая работала бы как деструктор.