Лекции (1129116), страница 27
Текст из файла (страница 27)
Тот код, который мы написали для процедуры DrawAll несомненно нехорошим стилем ООП. В частности, то, что мы написали, очень похоже на то, что пишется не в ОО языках за исключением наследования. Нехорошесть заключается в том, что для введения нового типа данных (прямоугольника или отрезка) нужно было бы перелопачивать весь код. Мало того, в нем содержалась ошибка. Если бы иерархия была такая:
Shape | ||
Point | Circle | Line |
то все было бы нормально, но в написанном варианте код не работал. Основная идея была в переключателе:
IF List is Point THEN DrawPoint(List(Point))
ELSEIF List is Circle THEN DrawCircle(List(Circle))
это есть страж типа. Для указанной выше иерархии код сработал бы нормально, но вот там-то иерархия была другая, а именно:
Shape | |
Point | |
Circle | Line |
очевидно, что в этом случае будет ошибка – всегда будет рисоваться точка.
Вирт предлагает другую методологию реализации подобных вещей. Он предлагает использовать handler’ы (обработчики). Обработчик – это некое поле данных, тип которого – процедурный. Чтобы не писать переключатель, который в зависимости от типа вызывает нужную процедуру, мы вставляем некоторое поле процедурного типа (чем-то это напоминает событийно-ориентированное программирование). Что общего имеют все объекты, выведенные из Shape (неважно как – непосредственно или через промежуточные типы)? Во-первых, они будут обладать указателями на следующий объект списка, потом, они умеют себя отрисовывать и передвигать. Мы пишем:
Nale = RECORD
Next: Shape;
DrawProc = PROCEDURE(Shape); // вводим типы обработчиков
MoveProc = PROCEDURE(Shape; DX,DY: INTEGER);
Draw: DrawProc; // вводим сами обработчики
Move: MoveProc;
END;
Как тогда мы будем расширять иерархию? Вот так:
Point_Obj = RECORD(Shape)
x,y: integer;
END;
Теперь мы должны написать методы Move и Draw.
Процедура для отрисовк должна по профилю совпадать с DrawProc:
PROCEDURE DrawPoint(this: Shape);
BEGIN
...
END DrawPoint;
После чего мы обязательно должны написать процедуру (фактически, это конструктор):
PROCEDURE InitPoint(this: Point; x,y: integer);
begin
this.x:=x;
this.y:=y;
this.Draw:=DrawPoint; //инициализация соответствующих обработчиков
this.Move:=MovePoint;
...
END InitPoint;
Теперь, как только мы создаем новый экземпляр объекта:
VAR P: Point;
Мы делаем:
new (P); //выделяем память
InitPoint(P,1,2); //инициализируем
List.Add(P); //добавляем в список
И так мы должны делать для каждой, в данном случае, графической фигуры, которую хотелось бы использовать. Это можно оформить в виде отдельного модуля и по-тихоньку добавлять.
Как же будет выглядеть теперь процедура отрисовки всех объектов? Следующим образом:
PROCEDURE DrawAll(List;Shape);
BEGIN
WHILE List # NIL DO
List.Draw(List);
List:=List.Next;
END;
END DrawAll;
Эта процедура будет работать для любых типов данных, производных от Shape. Соответственно, нам следует всего лишь добавлять новые объекты и определять в них процедуры Init, Draw и Move. При этом DrawAll никак не изменится, даже в двоичном коде. Это и есть нормальное наследование.
Почему DrawPoint работала с типом Shape? Ведь, возможно, правильнее было бы написать:
PROCEDURE DrawPoint(this: Point);
Но дело в том, что если мы сделаем подобное ослабление в системе типов, то есть не будем требовать точного соответствия handler’ов, то придем к очень плохой ситуации. А именно, когда мы пишем некую процедуру, например, DrawPoint, у которой параметр “this: Point”, то для чего введены статические типы данных помимо динамических? Статические типы гарантируют, что фактический параметр обязательно будет обладать свойствами объекта Point. Причем правила согласования в компиляторе требуют, что фактический параметр был либо типа Point, либо производным от него. Если мы разрешим
DrawPoint (this:Point)
вставать на место DrawProc,то получится беда, а именно, в тот момент, когда будет передан объект типа Point в процедуру
DrawCircle (this: Circle);
а Point, это не Circle. В результате, мы получим ошибку защиты памяти.
Поэтому профиль обработчиков должен совпадать точно. Очевидно, что и DrawPoint и любая другая процедура должны начинаться, как:
PROCEDURE DrawPoint(this: Shape);
BEGIN
WITH this(Point) DO // этот страж типа должен быть обязательно
...
END DrawPoint;
Если тип будет передан неправильно, то программа выдаст ошибку.
Вот такой стиль программирования можно уже назвать объектно-ориентированным. Для этого мы ввели динамическое связывание методов (handler’ы).
Очередной раз стоит заметить, что ООП к ОО языкам отношение имеет не самое большое. Как уже говорилось, можно с успехом объектно программировать и на обычных языках. Например, наследование можно реализовать, как вложенность структуры:
Core=>Primitive
struct {
Core core_part;
Primitive_part prim_part;
}
А методы реализуются в виде таблицы. где стоят указатели на соответствующие функции. То есть для ООП достаточно иметь отношение наследования и динамическую привязку методов (она достигается через процедурный тип данных). Но, конечно, это все ненадежно.
Естественно, что по-настоящему объектным язык становится лишь тогда, когда появляется возможность надежной динамической привязки методов. Это то, что называется в терминологии C++ виртуальными методами доступа. Интересно, что в Oberon-2 появилось тоже самое.
Но о виртуальных функциях мы еще будем говорить, а сейчас еще немножко побеседуем про наследование и про то, как оно реализуется в различных языках.
C++
Пусть у нас есть класс T. Тогда класс наследник описывается как:
class T1: <спецификатор доступа> T {
...
}
И сразу нам приходится начать обсуждать проблемы доступа в C++. Поле <спецификатор> может принимать значения
-
private (он же по умолчанию, если ничего не указано)
-
public
-
protected
Также эти спецификаторы могут употребляться и внутри описания класса:
class T {
public:
int a;
private int b;
protected int c;
...
}
Стоит повторить, что
public:
объект доступен там же, где и экземпляр класса
private:
объект доступен только изнутри для функций-членов класса с учетом «друзей»
так как b объявлена как private, то функции-члены класса T1 уже не могут обращаться к b. Приватность – самое жесткое ограничение доступа
protected:
доступ к таким объектам разрешен только из функций-членов своего класса и наследников
Когда мы указываем спецификатор перед родительским классом, мы указываем усилитель доступа. Пусть класс T1 выглядит как:
class T1: public T {
int d, e;
...
}
Если мы укажем «public T», как в примере, то это означает, что права доступа для членов родительского класса не изменяются. Если написать “protected T”, это будет означать, что все public объекты в T являются protected в T1. Если же написать “private T”, то все члены T (public и protected) станут private в T1.
Заметим, что эти спецификаторы только усиливают защищенность, но не уменьшают.
Следует обратить внимание, что “private T” запрещает доступ из T1 к объектам T.
Также, если мы объявим:
class T2: public T1 {
g() {a=1; b=2;c=3}; // здесь возникнет ошибка, если “class T1: private T”
}
Еще такое замечание. Мы говорим о правах доступа и управлении доступом. А есть альтернативный подход: можно управлять доступом, а можно управлять видимостью. Чем отличаются эти два понятия? Если в языке реализовано управление доступом, то это означает, что если мы объявили где-то какой-то элемент, то он всегда виден, а вот доступа к нему может не быть. И здесь есть некий ньюанс, он достаточно тонкий, и мы еще будем о нем говорить.
Заметим, что наследование допускает такие вещи, как, например, перекрытие полей, операций. Например, в T1 можно объявить свое поле a, в отличе от T, где тоже есть a. Непосредственный доступ можно получить путем точного указания класса, например, T1::a.
Java
В Java синтаксис немного другой, но суть такая же:
class T1 extends T {
...
}
Заметим, что в Java при наследовании нельзя усилить защищенность объектов класса-родителя, создатели языка посчитали это избыточностью.
В Java есть некоторые отличия от C++. В частности, спецификатор доступа надо указывать перед каждым полем, тогда как в C++ они работают как переключатели – один спецификатор покрывает все объекты до следующего спецификатора.
Кроме этого есть еще пакетный доступ. Отсутсвие спецификатора перед именем члена означает публичность с точки зрения пакета и приватность с точки зрения других пакетов:
package имя_пакета
use имя_пакета
Спецификаторы public и private в Java означают то же , что и в C++. А вот с protected есть некоторая тонкость – protected означает, что нельзя использовать извне класса, но можно использовать объект из унаследованного класса. Рассмотрим ситуацию
class T{
protected int a;
}
Пусть T1 и T2 – наследники T.
Можно ли писать:
f( T2 t) {t.a}; ?
Нет. А в C++ можно.
Delphi
В Delphi все очень похоже на C++ и Java, только там есть еще один спецификатор - published. Это связано исключительно с самим языком Delphi, так как в нем есть такая отличительная черта, как свойства (Property). Property ничего нового не добавляет в язык, но делает его удобнее. Все пошло с языка Visual Basic 3.0, там были стандартные компоненты, у которых были три атрибута:
-
методы
-
свойства
-
события
Например, если компонента визуальная, то у нее можно было написать:
Comp.Width=10;
Comp.Show=true;
События – это те события (нажатие мышкой, например), на которые реагирует данный компонент. Понято, что метод – это то, как он реагирует. А вот что есть Свойство? Просто переменной это назвать нельзя, так как присваивание
Comp.Width=10;
приводит к тому, что компонента меняет свой размер. Следовательно, вызывается перерисовка и еще куча всего.
Просто оказалось, что есть наборы операций, которые с точки зрения программиста и визуализации (например) выглядят, как изменение одного числа или параметра. В C++ так просто сделать нельзя. Но с точки зрения прикладного программиста подход Delphi весьма удобен. Property определяется следующим образом:
type MyComp= class(TWinControl)
FMyprop: integer;
property MyProp: read FMyProp; //выполнится при чтении Property
write FMyProp; // выполнится при записи в Property
FMyProp2: integer;
procedure SetMyProp2(...); //определяется процедура для работы с Property
property MyProp2: read FMyProp2;
write SetMyProp2;
end;
C:MyComp;
C.MyProp:=3;
C.MyProp2:=10;
Очевидно, что это очень удобно, особенно при визуальной разработке приложений.
Интересно, что события в Delphi тоже реализованы, как Property, значения которых – процедурный тип, правда, немного необычный:
type TNotify.Event = procedure (Sender: TObject; Ev: TEvent) of object;
И теперь мы можем работать с событием, как со свойством, например:
OnResize:=MyResizeHandler;
а если мы не хотим ничего делать на событии Resize, то можно написать:
OnResize:=nil;
Спецификатор published относится как раз к свойствам и означает его публикуемость. В Delphi есть целая иерархия типов, например для объекта ListView:
TWinControl
...
TCustomListView
TListView
<здесь следует описать назначение published и ключевого слова final, запись стала невыносимо быстройи неразборчивой>
final
class T{
final int f();
}
final int i=0;
Лекция 23
Множественное наследование.
Множественное наследование до сих пор вызывает споры. Наиболее строгие апологеты объектно-ориентированного подхода считают, что множественное наследование – несколько искусственная форма, и что от нее нужно отказаться. Поклонники языка Smalltalk считают, что если чего-то нет в Smalltalk, то это не имеет никакого отношения к объектно-ориентированному (в Smalltalk нет множественного наследования). Наиболее максимальные языки, такие как CLOS (Command Lisp Object System) и С++, используют множественное наследование, хотя именно критика множественного наследования в С++ и послужила катализатором мнений по этому поводу. Сейчас ситуация с множественным наследованием прояснилась окончательно, и тот вариант множественного наследования, который реализован в языке Java в настоящий момент признан оптимальным.
Такие языки, как Delphi, Оберон, Smaltalk множественного наследования не поддерживают.
П
ри множественном наследовании появляется возможность моделировать более сложные отношения между классами, которые в математике называются решетками. Можно создавать структуры разных типов.
Подобного рода структуры реализуются следующим образом:
class X {
int i;