Лекция 18
Лекция 18
Часть II:
Объектно-ориентированные языки
программирования (ООЯП)
Идут очень интенсивные споры по поводу того, что составляет предмет ОО проектирования.
У нас концепция: какие черты у ЯП, чтобы он мог называться ООЯП?
3 составляющие:
1. Инкапсуляция (для построения больших систем)
2. Наследование
Рекомендуемые материалы
3. Динамический полиморфизм
Во многих ЯП существует статический полиморфизм: одной сущности (подпрограмма) может соответствовать несколько форм (несколько тел). Это overloading (перегрузка). Связывание (тела функции с её вызовом) происходит в момент компиляции. Хотя одной сущности может соответствовать несколько тел (одному имени- несколько функций). Динамический полиморфизм должен быть в ООЯП (без него наследование особого смысла не имеет).
[Книга Себесты] ЯП должны поддерживать концепции:
АТД
наследование
динамическое связывание
- то же самое, но другими словами
Про инкапсуляцию поговорили в первой части нашего курса.
Наследование уже появляется в традиционных ЯП. Понятие наследования есть в Аде
package M is
type T is /* набор операций */
procedure P(x: inout T);
...
end M;
Соответствующий пакет называется определяющим для ТД.
Где-то можем отдельно компилированную единицу определить:
with M; use M;
Написать свой модуль-пакет:
package MM is
type T1 is new T;
{можем ввести свой новый ТД. Здесь может стоять какое-то уточнение, если тип данных Т был описан в виде какой-то структуры, или, например, неограниченный массив; но чаще в Т идёт речь о каком-то приватном ТД (так что никакого уточнения структуры быть не может), но что известно, что ТД Т1 обладает тем же набором операций, что и Т. Единственное, что здесь мы можем добавлять свои новые операции:}
procedure PP(x : T1);
{можем написать что-то ещё или можем переопределить процедуру P}
procedure P(x: inout T);
То есть какое-то наследование есть. Но тут идёт речь именно о наследовании множества операций. Происходит полное наследование структуры и полное наследование множества операций. Операции при наследовании можем добавлять или переопределять, но структуру типа Т1, по сравнению с типом Т мы поменять не можем. Это вырожденный случай наследования.
ООЯП используют другую концепцию наследования. Обогащать и изменять можно не только набор операций, но и структуру.
Глава 1: наследование
Общая идея наследования: у нас есть некий базовый ТД, который называется базовым классом (суперкласс в терминологии языка SmallTalk). К тому же, у нас есть некий механизм наследования, который из базового типа Т выводит Т1; где Т- это базовый класс или суперкласс(в Java вызов метода суперкласса- ключевое слово super) , а Т1- это производный класс (такую терминологию ввёл Страуструп в языке С++) или подкласс. При этом Т1 содержит все члены (и члены-данные и члены-функции) Т, но добавляет ещё новые члены, поэтому Страуструпу не понравилось слово «подкласс», так как «под»- это что-то меньшее. А тут ситуация немножко наоборот: производный класс расширяет. При наследовании синтаксис может быть разный, но суть одна.
Оберон (самый простой ООЯП): базовым классом может быть любой класс- запись.
Две категории ЯП:
1)Есть специальные ТД, которые можно расширять
2)Единственный ТД можно расширять
TYPE T = RECORD
X: INTEGER
END;
T1 = RECORD (T)
Y: INTEGER
END;
A: T; B: T1;
Можно писать:
A.X
B.X, B.Y
Можно расширять любой ТД, типа запись.
Delphi:
class T;
type T1 =
class (T)
...
end;
В Delphi, с точки зрения расширения, подход несколько иной. Для обеспечения совместимости со старыми языками (Турбо Паскаль): расширять можно только класс (обычные записи расширять нельзя). То есть, просто со времён Турбо Паскаля 5.5 была добавлена новая совокупность типов данных (Турбо Паскаль- ключевое слово object, Delphi- class). В Delphi есть базовый выделенный класс (Тobject). Если мы пишем:
type X =
class
...
end;
Это аналогично:
type X =
class (T: Object)
...
end;
{из терминологии языка SmallTalk}
В Delphi - это TObject, в Java - Object, С# - object. Это специальный класс, про который компилятор кое-что знает: в частности, то, что любой другой класс, даже, если не указано наследование, он всегда, по умолчанию будет выводиться, из этого базисного класса. В Обероне отсутствует понятие базисного ТД, на зато можно расширять произвольный ТД типа запись. В Java и С# нет понятия запись, зато есть понятие структура в C#, которая кроме отсутствия ссылочной семантики отличается от класса, именно тем, что от неё ничего нельзя наследовать, но, опять же, по умолчанию, ТД структура наследуется от object и по этому обладает всеми свойствами. Базисный ТД обладает только функциями(интерфейсом), но не данными.
В языках, которые объектную ориентированность добавляли к уже существующим процедурным парадигмам,- C++ и Ада 95, которые тоже являются настоящими ООЯП, необходимы были дополнительные средства, сопряженные с существующей концепцией типа. Поэтому там не существует никакого супер базисного ТД, из которого выводятся все остальные.
С++: есть понятие класса, и из любого класса можно вывести другой. Структуры трактуются, как частный случай класса, то есть отсутствие класса. В С++, с точки зрения управления наследования, более хитрая схема:
сlass X {
...
};
- не является наследником, если не указано наследование, а от этого класса уже можно наследовать.
class Y: базовый класс { /* дополнительные члены */ };
При этом сложность в том, что описание базового класса выглядит так: [модификатор доступа] имя класса. И из стандартных ТД наследовать нельзя. Но во многих библиотеках существует базовый класс, принадлежащий библиотеке, а не языку, из которого всё и выводится (В борландовских библиотеках- это TObject), так как наличие базового супер класса является преимуществом. Модификатор доступа может отсутствовать, по умолчанию: если у нас ключевое слово class, то это private, если structure- public. При этом есть ещё и protected: он управляет доступом к членам базовых классов с точки зрения производных классов. Эти модификаторы в применении к наследованию- это модификация правил доступа в базовом классе:
class X {
public:
int i;
protected:
int j;
private
int k;
}
class Y: public X {
void f() {
i = 0;
j = 1;
k = 2; - нельзя
};
}
Если модификатор public, то какие права доступа объявлены в классе Х, такими они и сохраняются в классе Y. И если где-то есть функция g():
void g() {X a; Y: b;
можно писать:
a.i, b.i,
нельзя писать:
a.j, a.k, b.j, b.k
}
Если class Y: protected X, то публичные члены становятся защищёнными, защищённые остаются защищёнными, а приватные остаются приватными. Модификатор может только понизить права доступа к соответствующему классу. Следовательно теперь обращение b.i становится некорректным.
Если class Y: private X, то даже защищённые члены Х становятся приватными. То есть, хотя мы в классе Y имели право обращаться к j:
class Z: public Y
j = 1;
Аналогичным образом работает концепция друзей:
class X {
friend class T;
...
}
void T::g() {X x; Y y;
x.k- можно; y.k- нельзя (Y не разрешил дружбу с Т). То есть для продолжения отношений класс Y должен явно объявить дружбу с T. Дружба не транзитивна.
При наследовании в С++ очень гибкие правила доступа. Обычно наследование бывает публичным (хотя во многих сложных библиотеках по умолчанию наследования, которое не является публичным, и иногда это бывает полезно).
Другие ЯП отказались от таких изощрённых средств управления доступом.
Ада: есть понятие тегированной (помеченной) записи:
type T is tagged record
...
end;
- из помеченной записи можно наследовать:
type T1 is new T with record
...
end;
Так как в Аде, как и в Обероне, все функции и процедуры ещё не считаются членами класса, то Т1 полностью наследует все операции (операции доступа к самой записи, плюс операции, описанные в том же пакете, что и тип данных Т).
Приватные ТД:
type T is tagged private;
следовательно внизу должно быть:
private
type T is record ... end;
end;
Можно расширять(в том числе и приватныеТД) с помощью конструктора new, после которого стоит ТД. А если хотим изменить только набор операций- специальная конструкция:
type T1 is new T
with null record;
Почему так надо писать? Что никаких членов не добавляем.
type T1 is new T;
Но в чём принципиальная разница этой и предыдущей конструкции? Нарушается первая, самая главная, аксиома концепции уникальности типов (каждый объект данных имеет тип и этот ТД единственный). ООЯП нарушают её тем, что если есть наследование и из Т наследуем Т1, то объекты Т1 одновременно являются и объектами типа Т: Т Т1 (появляется отношение наследования, которое уместно обозначать, как отношение включения). Но аксиома №4: различные ТД не совместимы по операциям. Следствие:
procedure P(x: T);
её фактический параметр может быть типа объект Т1. И это относится к любой операции, в том числе, и операции присваивания.
Итак, если Т => Т1 (из Т выводим Т1)
1) a b
a = b; - корректно
P(a), P(b) /* тут Т := Т1 */ - можно
но нельзя:
b = a;
procedure P1(x: T1);
P1(b);
P1(a); { T1 := T }
Можно было бы придумать интерпретацию, где это было бы корректно. Как вообще интерпретировать соответствующее присваивание в этом случае?
b = a;- b не является полностью определённой переменной, следовательно это присваивание (и такая передача параметров) некорректно.
2)В языках, где есть указатели и ссылки, которые являются базовыми ТД, и, вроде бы никакого наследования относительной базовых ТД вводится не может, а наследуются у нас в языках либо классы, либо их аналогии (структуры или записи). Но для ссылок и указателей приняты те же правила. Если P => P1, то мы считаем, что у нас для ссылок и указателей имеет место точно такое же наследование(хотя с точки зрения реализации и указатели и ссылки- это просто адреса памяти) в смысле, что к ним применимы те же самые правила в смысле присваивания:
PT => PT1
pa pb
pa = pb; - разрешено
pb = pa; - запрещено
Ещё замечание о распределении памяти: то есть каким образом выглядит объект. Речь всегда идёт о некой структуре данных, то есть о последовательность полей. То с точки зрения распределения памяти, у нас, в принципе, есть два способа реализации наследования:
1)Линейное распределение памяти
2)Цепное распределение
С точки зрения копирования, не важно какое распределение памяти применяется. Но с точки зрения эффективности реализации линейное представление эффективнее цепного. Большинство ЯП: линейное распределение памяти, с некоторыми исключениями: SmallTalk- цепное.
Множественное наследование
Его нет в SmallTalk, поэтому его нет и во многих других ЯП. Настоящее множественное наследование есть только в языке С++.
Общий случай наследования в языке С++:
class X: список баз { ... };
Где в элемент списка баз:
[модификатор] имя_типа
Перед соответствующим именем типа может стоять ключевое слово virtual.
Что означает множественное наследование?
class X: public Y; public Z { ...};
Это простое и очевидное расширение понятия единичного наследования: Так будет выглядеть структура класса Х(часть, относящаяся к классу Y; часть, относящаяся к классу Z; и часть, относящаяся к классу Х):
Если в языке есть только единичное наследование, то структура будет выглядет как дерево:
Множественное наследование:
void f() {i = 0;}
void g() {k = 1;}
О каком i и о каком k идёт речь (из Y или Z)? Тут может возникнуть конфликт имён. Так как язык С++ единственный язык реализующий множественное наследование по данным, то как в нём решаются эти проблемы?
С точки зрения функций класса W нет никакого правила, которое говорит, что лучше, и надо явно указать из какого класса это к нам пришло:
Y::i; X::i; Y::k; Z::k;
- есть механизм уточнения имён, с помощью него разрешаем конфликт имён. Но он не будет работать в следующем случае:
В этом случае механизм уточнения имён никак не работает, и поэтому, этот случай запрещён. Нельзя, чтобы в списке баз появлялись одинаковые классы (на разных уровнях могут).
Виртуальное наследование
Одиночное наследование позволяет моделировать иерархию. А такое наследование позволяет нам моделировать некоторые сети, но не все сети можно смоделировать с помощью такого механизма. Например, бриллиантовое (ромбовидное) наследование:
Разница с точки зрения распределения памяти. В первом случае у нас есть только один экземпляр объекта класса Х, а во втором- два. Так же очевидно, что при схеме 1) распределение памяти для Z нелинейное.
А зачем нам такое может понадобиться. Например, потоки ввода-вывода из стандартной библиотеки STL:
Это классический случай ромбовидного наследования, и он реализуется следующим образом:
class W: virtual public Y; virtual public Z {}
Неприятная особенность состоит в том, что ключевое слово virtual надо писать не при определении класса, а при наследовании.
Множественно наследование в ограниченном виде поддерживается в ЯП С# и Java. Там есть специальное языковое понятие «interface»- класс без данных: есть только функции члены и статические члены константы (он содержит только описание функций). Допускается наследование одного класса и множественное наследование нескольких интерфейсов.
Java:
public class X extends Y implements I1,I2,I3 { ... }
/* implements = реализует */
C#:
public class X: список имён { ... };
- список имён ::= одно имя класса + имена интерфейсов. Это расширение концепции единичного наследования.
В С++ можно моделировать произвольные схемы наследования. С матиматической точки зрения там у нас есть некая математическая абстракция «решетка классов».
Во всех остальных ЯП данные можно наследовать только единичным образом.
Наследование и области действия
Во всех ЯП, за исключением Оберона классы рассматриваются как вложенные области действия, то есть каждый новый класс- это новая область действия.
X int i;
Y double i;
Аналогично:
{ int i;
{ double i; - статически вложенный блок
i // double
}
i // int
}
Это статические области действия. Но из вложенного блока нельзя сослаться на i внешнего блока. А в классах можно, используя возможность уточнения: Y -> X::i;. Классы образуют вложенные области действия.
В Аде есть управление видимости, а в языке С++ мы говорм об управлении доступом. В чём отличие?
class X {
...
private:
void f()
...
};
Y f();
В классе Y видимость есть, а доступа нет (для функции f();).
Если бы речь шла об управлении видимостью, то всё было бы «ОК». А тут логически (здравый смысл)- это обращение к функции f(); из класса Z, но компилятору обе функции из X и Z видимы независимо от модификаторв (private, public). Тут компилятор не знает о какой функции идёт речь. Нет ошибки, только если напишем Z::f();. То же самое распространяется и на замещение имён.
Замещение имён
Компилятор сообщит об ощибке, так как он сначала пытается разрешить имя, а потом смотрит доступ, не доступ или что ещё, то есть компилятор начинает разбираться с семантикой только после разрешения имени. Общая схема: сначала компилятор должен догадаться, что это за имя, а потом- правомерно или нет обращение к нему. Тут верно только a = X::g();.
В Обероне закрыть имя запрещено: даже производные классы рассматриваются как единая область действия имён..
А зачем нужно наследование? Его легко промоделировать, что и делается при ОО программировании, в языках, где нет наследования (XTool- надстройка над Xlib: она более ОО, чем какая-нибудь библиотека классов MFC, хотя написана на С}.
struct X { ... };
struct Y {
struct X x;
...
};
-это менее удобно, так как обращаемся через имя переменной «х». То есть чисто одно понятие наследования с точки зрения программирования ничего не даёт. Но, когда есть наследование, появляется концепция динамического связывания.
Глава 2: динамическое связывание
(методов)
С++:
class X {
public:
void f();
...
};
class Y: public X {
public:
void f(); /* у них разные области действия -> нет противоречия */
...
};
X x;
Y y;
x.f(); // X::f();
y.f(); // Y::f();
Аналогично, когда у нас указатели:
X* px;
Y* py;
px->f(); // X::f();
py->f(); // Y::f();
Тут вложенные области действия. Это статическое связывание. Оно может быть разрешено во время компиляции.
Другая ситуация, если у нас опять появляется ключевое слово virtual только в другом смысле (Страуструб в 86 году боялся вводить новые ключевые слова):
class X {
virtual void f();
};
Если перед «f» появилось ключевое слово virtual- это означает то, что это функция будет вызываться в зависимости от динамического типа. Для ООЯП появляется ещё понятие «динамического типа». Как мы уже говорили: «каждый объект данных обладает некоторым типом». В традиционных ЯП этот тип только один, а в ООЯП объект данных может обладать, вообще говоря, совокупностью, но, разумеется, конечной совокупностью типов. Тут если объект данных принадлежит типу Т, то он принадлежит и всем типам из которых наследует Т, тоже относиться и к операциям. Как только объект данным появился в памяти, он своего типа не меняет (так сделано в большинстве ЯП). Тот тип, о котором мы говорили раньше, это так называемый «статический тип»- тип, который приписан объекту в момент его объявления, он должен быть единственным. Коль скоро объект размещён в памяти, то он принадлежит одному статическому типу и этот тип не может меняться. Примеры статических типов:
X x; // статический тип X
Y y; // статический тип Y
Динамический тип: им обладают не все ТД, а именно, указатели и ссылки (у классов его нет):
X* px;
Y* py;
Статический тип px- Х, py- Y, но при присваивании, мы уже начинаем различать статический и динамический типы. Допустимо (так как объектам производных типов можно присваивать объекты базового типа):
px = py;
-теперь px указывает на объектт класса Y. Статический тип px остаётся X. В С указатели должны ссылаться только на объекты соответсвующего типа, следовательно, там нет разницы между динамическим и статическим типом. Как ттолько появилось наследование, то у нас получается, что px может указывать не только на объекты класса Х, но и на объекты любого производного класса. Вот это и есть динамический тип указателя, то есть динамический тип указателя- это тип объекта, на который в данным момент ссылается указатель. Очевидно, он динамический, поскольку при присваивании этот тип может меняться. Тоже самое обобщается и на ссылки, поскольку с точки зрения реализации- это тоже самое.
X &c = x;
c: статический тип- Х
динамический тип- Х
Х &с = y;
c: статический тип- Х
динамический тип- Y
Чем отличаются виртуальные функции от невиртуальных? Строго говоря, сами функции- ничем. Отличаются механизмы их вызова, механизмы их связывания. То есть виртуальные функции с точки зрения реализации и то как они компилируются ничем не отличаются от обычных, невиртуальных функций. Если вызываем функции по объектам:
X x;
Y y;
... <что бы здесь ни было написано>
x.f(); // X::f
y.f(); // Y::f
- то виртуальные и невиртуальные функции неотличимы. А в случае указателей ситуация совершенно другая (для них вызов функции «f» произойдёт в зависимости от динамического типа:
X* px;
Y* py;
...
px->f();
py->f();
то всё зависит (вызов какой функции будет произведён) от того, что написано тут (какие значения имеют px и py).
1)Если тут
px = new X;
py = new Y;
, то динамический и статический типы не отличаються, и будут вызваны:
X::f
Y::f
2)Если тут
py = new Y;
px = py;
, то будет вызвана функция в зависимости от динамического типа:
Y::f
Y::f
3)Если тут
class Z: public Y {
void f() { ... }
...
};
px = new Y;
py = new Z;
, то
Y::f , так как динамический тип px- это Y
Z::f , так как динамический тип py- это Z
Следовательно вызов виртуальной функции происходит в зависимости от динамического типа. Тут речь идёт именно о механизме вызывов. И это (виртуальные функции) применимо только для ссылок и указателей. А почему бы и не сделать, чтобы при «x = y;» динамически менялся тип х? Это можно сделать, но тут придётся динамически перераспределять память.
Delphi, C#, Java: имя объекта- это ссылка, следовательно, нет разницы, вызываем мы через имя объекта или как-то ещё- у нас речь идёт всегда о вызове через ссылку (в Java вообще нет понятия указателя). Любая видная функция рассматривается как виртуальная. Единственное отличие Delphi от C# и Java состоит в том, что в Delphi есть деление функций на виртуальные и невиртуальные. В C# и Java все функции считаются виртуальными (то есть динамически связываемыми).
С++ отличается от Delphi. Если на каком-то уровне иерархии определили функцию как virtual, то все её наследники тоже являются virtual, автоматически (и в наследниках можно явно не указывать). А в Delphi допускается, чтобы функция была невиртуальной.
Механизм виртуального замещения отличается от механизма перегрузки операций: во-первых виртуальные функции должны иметь один и тот же прототип (а в старом варианте С++ даже один и тот же тип возвращаемого значения, а в новом стандарте оно может быть производным типом от типа которым обладает самый первый прототип). Это уже не перекрытие операций (которое как раз и основано на различие прототипов, где компилятор разрешает какой вызов производить), а замещение:
overridding - динамическое замещение
overloading - статическое перекрытие
Delphi:
T = class
procedure P: virtual;
end;
T1 = class (T)
procedure P; override - виртуальная, как в С++
end;
Она не является виртуальной, а подменяет собой виртуальную функцию, если нет override, и, более того, тоже будет если мы просто напишем у неё ключевое слово virtual.
Тоже самое и в языке С#:
public class X {
public virtual void f() { ... };
public void f(int i) { ... };
Лекция "Лекция 18 - Анализ вынужденных процессов" также может быть Вам полезна.
};
отличаются по прототипам
public class Y: X {
public override void f();
};
И если в классе Y просто напишем void f, то никакого замещения производиться не будет.