языки программирования 21

advertisement
Языки программирования.
Лекция 21.
Глава 3: Абстрактные классы и интерфейсы (продолжение)
Абстрактный класс - это класс, в котором есть хоть одна чисто виртуальная функция (ЧВФ):
class X{
...
virtual void f() = 0;//ЧВФ; иногда говорят, что это абстрактные функции – они служат «заглушками» для конкретных функций, которые появятся в конкретных классах
}
При этом для класса, в котором есть хотя бы одна виртуальная функция, программист не имеет право заводить
объекты (они не имеют смысла – у них не определена хотя бы одна виртуальная функция). Мы имеем право не
указывать тело ЧВФ, в то же время в стандарте сказано, что мы можем заводить тело чисто виртуальной
функции. Но зачем, как мы сможем её вызвать? Ведь виртуальная функция всегда вызывается в зависимости от
динамического типа, а динамический тип объекта никогда не может быть абстрактным классом:
X* px;
px не может указывать на объект класса Х, потому что мы не можем объявлять объект Х и создавать его в
динамической памяти.
Как в такой ситуации мы можем вызвать чисто виртуальную функцию? Если через разрешение имени, то
компилятор нас схватит за руку. Получается, что ЧВФ вообще вызывать не надо, зачем указывать тело?
Есть возможность когда мы вызываем тело ЧВФ (это нехорошо и неправильно, но можно). У абстрактного
класса может быть конструктор, а есть такая вещь, что в конструкторах виртуальность вызова снимается:
classs Y{
virtual void g();
Y() {g();}//конструктор, в котором вызывается виртуальная функция
}
В конструкторах виртуальность вызова снимается, и в этом есть глубокий смысл. Пусть у нас из класса У есть
производный класс Z. Как начинают работать конструкторы класса Z: в начале вызываются конструкторы баз,
после этого вызываются конструкторы подобъектов, и после этого собственно конструктор класса Z. В начале
вызывается конструктор базового класса (класса У), при этом части Z еще нет, и поэтому вызывать
виртуальную функцию из класса Z нельзя (объекта Z еще нет). Когда работает конструктор класса Z, мы можем
быть уверены, что ничего кроме класса У нет и поэтому вызывать виртуальную функцию из наследников не
имеет смысла (пока мы еще не рождены, наследников у нас быть не может) – виртуальность вызова снимается.,
и если мы вызовем ЧВФ, то вызовется именно она (тело, которое генерируется по умолчанию, выдает
сообщение об ошибке и заканчивается).
Абстрактный класс в общем случае обязан иметь наследников. При этом класс, который его наследует, не
обязан переопределять ЧВФ, но при этом он тоже будет абстрактным.
Тесно связано с понятием абстрактного класса понятие интерфейса. Это абстрактный класс, который доведен до
абсолюта. Представим, что все соответствующие методы в классе являются ЧВФ, и никаких других методов у
нас нет. На языковом уровне понятия интерфейса в Си++ нет, но появляется на технологическом. Интерфейс на
Си++ - это класс, в котором нет никаких нестатических данных, все операции являются публичными и чисто
виртуальными. Современные языки, в отличие от Си++, уже явно поддерживают интерфейсы на языковом
уровне. Интерфейс это в чистом виде одни методы, хотя могу присутствовать статические члены (поскольку
они к экземпляру класса никакого отношения не имеют).
Зачем нужны интерфейсы? Страуструп приводит пример с множествами. Должны присутствовать операции:
объединение, пересечение, разность, добавление элемента, исключение, проверка на вхождение. Как сделать
так, чтобы реализация множества не зависел от формы работы с множествами? Один из вариантов - реализовать
интерфейс с 6 чисто виртуальными функциями.
Реализующий класс может выглядеть следующим образом (через однонаправленный список):
class SlistSet: public Set, public Slist {
virtual bool IsIn(T &x);
virtual void Excl(T &x);
......
}
Кроме этого SlistSet унаследует класс Slist (получается, что SlistSet у наследует класс реализацию и интерфейс
Set). Операции над множество реализуем с помощью операций из SlistSet. Если нам не нравится класс SlistSet,
то делаем другую реализацию Set (например, через Scale).
Можно сделать и так:
class SlistSet: public Set, private Slist {
virtual bool IsIn(T &x);
virtual void Excl(T &x);
......
}
Это означает, что работаем только с элементами из Set, а элементы из Slist нам недоступны. Чтобы программы
не зависели от реализации, они должны работать через Set:
Set *pSet = set; // если set это указатель на объект класса SlistSet, то указатель на производный класс приводиться
к указателю на базовый класс.
Все программы, работающие с Set, должны работать через указатель или ссылку на него. Нам нужна некая
производящая функция, которая возвращает нам ссылку или указатель на Set, но использует при этом в качестве
конструктора именно класс SlistSet. В результате если нам не нравится реализация в виде однонаправленного
списка, то мы можем вместо реализующего класса Slist взять другой. Придется переделать только
производящую функцию, которая генерирует объекты этого типа, а все остальное работает через интерфейс Set
(нам даже не придется перетранслировать соответствующий модуль).
О минимизации расходов на перетрансляцию.
 В Модуле 2 все четко – есть модуль определений и модуль реализации (это различные физические
модули). Если я меняю модуль реализации мне нужно перетранслировать только модуль реализации,
все клиентские модули не нужно перетранслировать. Надо пересобрать всю программу из объектных
модулей.
 В Аде надо перетранслировать клиентские модули, если меняем структуру типа данных. Вспомним,
приватные типы данных (прямо в спецификации пакета должна быть приватная часть для приватных
тд):
package P is
type T is private;
private
type T is ... //структура типа Т здесь меняется
В результате, если мы меняем эту структуру, то клиентские модули должны перетранслироваться.
 К какому типу отнести языки, основанные на понятии класса. Кажется, что к языку типа Ада – даже
если мы меняем приватные члены, то мы все равно их меняем, и клиентские модули надо
перетранслировать. С физической точки зрения: объявление класса в header файле, мы меняем
объявление внутри header файла, мы вынуждены перетранслировать. Для некоторых проектов снижение
затрат на перетрансляцию весьма серьезная проблема. А что делать, если мы часто меняем реализацию?
Страуструп рекомендует работать в терминах интерфейсов.
Для нашего примера: программы, которые работают с header файлом, должны включать только спецификацию
типа Set и прототип производящей функции, которая генерирует объекты конкретного класса Set. При смене
реализации мы меняем только cpp-модуль, который и содержит в себе описание класса SlistSet, оно не
содержится ни в каком header файле (либо содержится в приватном header файле, которые пользователи
включать не должны). При смене типа Set c Slist на Map, то меняется соответствующий cpp-файл и текст
производящей функции. Header не меняется – ничего перетранслировать не надо.
Заметим, что в случаи реальной иерархии классов нам необходимо использовать виртуальные функции в
большинстве случаев, поэтому никаких накладных расходов не добавляется.
Интерфейсы хороши ещё тогда, когда они характеризуют некоторые свойства различных классов, например,
свойство Serializable (возможность сохранения и загрузки из внешней памяти – реализация зависит от
конкретного типа, поэтому такие функции изначально виртуальные и определяются для каждого конкретного
класса). Имеет смысл оформлять все это не в виде класса, в виде интерфейса. Аналогично – Runnable(объекты,
которые могут запускаться, содержат в себе некоторую исполняемую сущность).
Использование интерфейсов, даже в тривиальных примерах, подразумевает множественное наследование: класс
использует интерфейс (Set) и должен реализовать унаследованные функции на базе некоторого конкретного
которого (наследует класс Slist). Отметим, что в полном виде множественное наследование реализовано только
в Си++ (из тех языков, которые мы рассматриваем).
В современных ООЯ понятия множественного наследования нет, наследование единичное. В самом чистом
объектном языке SmallTalk множественного наследования нет. Однако, реализующий класс наследовать
необязательно, его можно включать как некоторую переменную, мы говорили отношение наследования можно
всегда заменять отношением включения.
В языках, где нет множественного наследования, должно появиться особое понятие интерфейса. Так в Си++
интерфейсов нет, потому, что они легко модулируются на базе множественного наследования.
Например, в Java:
class X entends Y //единичное наследование
Вводится понятие интерфейса. Проблемы с множественным наследованием появляются только тогда, когда мы
множественным образом наследуем данные, а если наследуем только операции (включая и виртуальные), то
проблем не бывает.
class X entends Y implements I1, I2, I3 {...} //общая форма наследования
I1, I2, I3 - произвольный список интерфейсов; если часть extends Y отсутствует, то подразумевается extends
Object.
Аналогично в Cи#: class X: Y, I1, I2, I3 {...}//общая форма наследования
Y, I1, I2, I3 // только одно из этих имен может быть именем класса, и находится оно может в любом месте
Интерфейсы в Java и Cи# немного отличаются. Синтаксически все выглядит очень похоже:
interface имя {
прототипы методов
....
final int red = 1;
}
1. Модификаторов доступа нет, так как подразумевается, что все они по умолчанию public, virtual и не
static.
2. Ещё могут объявляться константы.
В Cи# внутри допускаются вложенные перечисления.
В Java внутри интерфейса допускается вложенное определение классов. В Java вложенные классы могут быть
статическими (никакого отношения к внешнему классу не имеет, не считая локализации имён) и
нестатическими (объекты и функции-члены этих классов являются продолжением внешнего класса). Любой
объект вложенного нестатического класса имеет доступ к своему объемлющему супер-объекту, никаких
разграничений с точки зрения доступа на внутренний класс не накладывается. В некоторых случаях эти
внутренние классы могут быть полезными.
 Обычный синтаксис создания нового класса:
new имя(...)
 Анонимные классы:
1. new имя_базового_класса{определения} // вводится новый анонимный класс, который
производный от базового, с новыми методами и свойствами
2. аналогично можно написать:
class Ano extends имя {...};
new Ano;
Анонимные классы удобно использовать, если нужно ввести один новый метод и использовать новый класс в
одном месте один раз (например, если в Java нужно написать реакцию на нажатие кнопки, а понятия события у
нас нет).
В наших интерфейсах даже если допускаются вложенные классы, то они обычные статические.
Если класс поддерживает интерфейс:
class X implements IFoo {
...//здесь мы должны реализовать все функции, описанные в IFoo
//интерфейсы служат, как заглушки
}
IFoo f = (IFoo)x; (если объект х поддерживает этот интерфейс).
С точки зрения реализации, что из себя представляет интерфейс? Интерфейс представляет из себя пару: this и
указатель на ТВМ IFoo.
Вначале ТВМ Х это:
Y//виртуальные методы для класса У
I1//вм
I2//вм
I3//вм
Х//виртуальные методы, которые добавлены в классе X
В интерфейсе мы запоминаем ссылку на this (для того, чтобы получить ссылку на данные) и указатель на ТВМ
IFoo. Интерфейсы нужны для того, чтобы не заботиться о конкретной сущности класса Х.
Например, если нам нужно реализовать загрузку и выгрузку объекта – мы делаем это используя одни и те же
процедурами и функциями, не важно какой это объект.
Рассмотрим:
interface IFoo{
void Foo();
void Fа();
...
} //такое определение и для Java и для Cи# верно
Обратим внимание, что интерфейсы сами могут наследовать другие интерфейсы:
interface IFoo: IFoo1, IFoo2 {...
Java: interface IFoo extends
Cи#: IFoo1, IFoo2 {...
Проблемы при множественном наследовании
1. При множественном наследовании возникает проблема конфликта имён.
Есть два подхода.
А) Пусть два интерфейса экспонируют метод Foo() (или более наглядный пример с функцией draw()). Идеология
Java такая, что мы должны написать в реализации свою функцию с таким же именем, которая правильно
поддерживала бы обе функции классов-родителей. Говорят, что интерфейс определяет некоторый контракт, и
класс, который будет реализовывать этот интерфейс, говорит, что подчиняется некоторому контракту
(соглашению). Интерфейс – набор соглашений, что мы должны поддерживать такие-то методы (если профили
функций не совпадают, используется механизм перекрытий.)
Но не всегда удаётся так сделать. Некоторые функции могут иметь двоякий смысл (например, draw - в переводе
это рисовать и тянуть, и например, для реализации пасьянса не обходимы обе функции).
Б)В Cи# есть явная реализация метода. Пусть один интерфейс реализует Execute и другой реализует Execute.
Пусть у нас есть класс Х:
class X: IFoo1, IFoo2 {
void IFoo1.Execute() {...} //такое нужно при конфликте имен
void IFoo2.Execute() {...}
};
X x = new X;
((IFoo1)x).Execute();//вызываем первый метод Execute
А если просто написать x.Execute? Вот тогда будет выдана ошибка.
В классе Х мы можем написать свой Execute:
void Execute() {...} //мы должны явным образом что-то перенаправить
И в нём, возможно, вызывать какой-то из родительских Execute или написать наш. Итак, в случае если хочешь
реализовать общий Execute, то надо реализовать 1-ый, 2-ой и явно сослаться, какой выбираешь. Но оба Execute
должны быть реализованы! Это сделано для того, чтобы при явном приведении к одному из двух интерфейсов
мы знали какой из 2-х Execute выбрать. Поскольку мы используем 2 интерфейса, мы должны выполнять 2
контракта.
//В Java все с точностью до наоборот – там мы обязаны реализовать только один Execute(который удовлетворяет
требованиям сразу двух контрактов).
При реализации методов Execute не указывается никаких модификаторов, и в этом есть глобальный смысл. По
умолчанию эти идентификаторы считаются как public.
Последние замечания к интерфейсам.
Интерфейсы очень полезны, например, для реализации рефлексии (в момент выполнения определять некоторые
свойства, определённые в исходном тексте, динамически подключать классы, определять их сигнатуры,
осуществлять все проверки, которые можно осуществлять при проверке текста).
Пример1.
using (X x = new X()) //объект х обязан реализовывать интерфейс IDisposable
{
...
}//здесь из IDisposable вызывается метод dispose, который явным образом удаляет объект из динамической
памяти
Для того, чтобы использовать наш объект в данной конструкции необходимо, чтобы он явно или косвенно
поддерживал интерфейс IDisposable.
Пример2.
foreach (X x in S) {...} //S – некоторая коллекция
Объекты х по очереди присваивают объекты из коллекции. Нужно чтобы класс объекта х поддерживал
некоторый интерфейс, который позволяет перебирать соответствующие коллекции.
В Си# и Java введены много интерфейсов, которые позволяют связать объекты с языковыми конструкциями и
позволяют использовать свойства рефлексии.
Дополнительные вопросы
Множественное наследование (МН)
Первая проблема МН - проблема имён.
Си++
В языке Си++ МН выглядит следующим образом:
class X: public B1, public B2, ... //произвольный список классов, модификатор доступа произвольный
{
...
}
Очевидно, что все классы В должны быть различными (из-за проблемы именования).
В Си++ это решается через явную классификацию имени класса (если профили одинаковые):
B1::f(), B2::f()
При единичном наследовании у нас получается некая иерархия. Но при множественном наследовании у нас
могут возникать различные структуры, напоминающие графы.
Виртуальные наследование - такое наследование, при котором при наследовании одинаковые родительские
классы сливаются друг с другом.
Пример1.
--- схема класса Link (реализует однонаправленный список) ---
Такое наследование не возможно, возникнет конфликт одинаковых имен.
Так сделать можно. Как мы доберемся, например, до функции GetNext?
Если просто GetNext – то из Link, а если х.GetNext – из класса Х. Кроме того здесь есть доминирование –
выбирается ближайший класс, который реализует соответствующую функцию. Решаются проблемы с
конфликтом имён полным путем по дереву: X::Link::getLink();
Объект класса Y: // два подобъекта класса Link
Link next
X
Link next
Y
Получается, что объект типа Link находится в двух однонаправленных списках. Поля next в двух Link – разные,
и никакой речи о виртуальности не идет. Рассмотрим другой пример.
Пример2.
Другой пример иерархии (примерная иерархия класса iostream):
ios <=> int fd //целочисленный файловый дескриптор
1)
Заметим, что ios – не абстрактный класс (функциональность – открывать и закрывать).
Такое наследование называется бриллиантовым или ромбовидным.
Наследование:
ios
istream
ostream
iostream
Сколько в iostream объектов ios? Тут может быть две схемы: одна – указанная выше, и другая.
2)
В этой ситуации у нас два файловых дескриптора: один на чтение, другой на запись.
Но у нас файлы устроены по-другому, у нас в один и тот же дескриптор можно и читать
и писать.
Наследование:
ios
istream
ios
ostream
iostream
--- для ромбовидного наследования --Мы должны учитывать возможность множественного наследования еще на стадии ответвления istream и
ostream; и написать так:
class istream: virtual public ios {...}
class ostream: virtual public ios {...}
Если опустить одно из двух virtual – никакого виртуального наследования не будет.
class iostream: public istream, public ostream //(тут о ключевом слове virtual мы по магическим причинам можем
забыть)
При ромбовидном множественном наследовании мы можем получить структуру классов, называемую решёткой
(частичный порядок – не каждые два класса могут находиться в отношении родства)1.
1
Но за всё в этой жизни надо платить. Страуструп писал, что он совершил только единственную дизайнерскую ошибку. Он начал
реализацию эффективного мн противовес шаблоном. В итоге шаблоны появились в языке много позже. И по многих коммерческих
компиляторах механизм шаблонов до сих пор не реализован до конца. Библиотека STL появилась только в 90-ых годах, одна и та же
реализация STL не может одинаково компилироваться. Говорят не о библиотеки STL, а о платформе STL – это собственно реализация STL +
компилятор.
Проблемы в МН возникают только если есть виртуальные методы, но зачем нужно наследование без
виртуальных методов?! В 90% случаев наследование нужно вместе с динамическим связыванием.
Пример проблемы:
При вызове p->f(); Какой this мы передаём? Тут уже не всё так просто.
Пусть есть:
class Х { // в котором есть вм
}
class У // в котором есть вм
class Z{
}
Ситуация 1. Пусть в Х определена f() и она же переопределяется в Z. Неважно динамический тип р есть Х или Z.
То ли функция f() для класса Х будет вызываться, то ли для класса Z – все равно.
Ситуация 2. в У определена f(), а в классе Z она не переопределена. Значит, функция должна вызываться для
какого класса? Для У. А какой указатель будет передаваться? На Х.
Если f() передать указатель р, то предастся -> X. Потому как функция f() в классе У не
знает что ее унаследовали (Y считает, что this указывает на него, а не на Х). Нужно
р+дельта и предать все это в f() в качестве this. Компилятор – этого не знает (т.к. метод
динамически связан), и в общем случае компилятор может не знать на что указывает р.
Если р указывает на Х или на У – это одно. Пусть у нас У* р – тогда компилятор не знает
то ли р указывает на объект класса Z, то ли он указывает на отдельно стоящий объект
класса У.
Тут возникает специальная схема реализации множественного наследования, которая основана на том, что у нас
должна быть сдвоенная ТВМ.
ссылка на ТВМ XZ
Х
ссылка на ТВМ YZ
У
Z
Кроме этого в специальных ТВМ содержится специальная дельта*, которую нужно прибавить к указателю this,
чтобы получить нужный указатель. Для XZ дельта* будет нулевая, а для YZ дельта* будет содержать ту самую
дельту, о которой говорилось ранее. Получается не самая красивая схема.
Все проблемы связаны только с наследованием данных (возникают большие накладные расходы даже при
эффективной реализации). Если речь идет только о наследовании интерфейсов, то мы можем просто построить
ВМ для Х, ВМ для У, ВМ для Z. Если У и последующие базы у нас не содержит никаких данных, то мы можем
построить ТВМ именно таким образом:
Х
У
Z
Объединенные таблицы нужны для дельта (прибавлять ее или нет). Если в Z не переопределяется функция из У,
тогда дельта должна стоять, а если переопределяется то дельта = 0. А если никаких данных нет, то и дельты нет.
Итак для МН:
1) трудности реализации
2) накладные расходы
3) усложняются программы
Поэтому МН в современных яп реализовывается для интерфейсов, где никаких проблем нет.
Download