Даже если вы не согласны
Даже если вы не согласны с нашим проектным решением создать объект Заказчик, вы должны согласиться, что объектно-ориентированное проектирование — хороший общий подход для обсуждения проблем разработки и достоинств одного проекта перед другим.
В следующих разделах языковая поддержка ООП будет обсуждаться на примере двух языков: C++ и Ada 95. Сначала мы рассмотрим язык C++, который был разработан как добавление одной интегрированной конструкции для ООП к языку С, в котором нет поддержки даже для модулей. Затем мы увидим, как полное объектно-ориентированное программирование определено в языке Ada 95 путем добавления нескольких небольших конструкций к языку Ada 83, который уже имел много свойств, частично поддерживающих ООП.
14.2. Объектно-ориентированное программирование на языке C++
Говорят, что язык программирования поддерживает ООП, если он включает конструкции для:
• инкапсуляции и абстракции данных,
• наследования,
• динамического полиморфизма.
Позвольте нам вернуться к обсуждению инкапсуляции и абстракции данных из предыдущей главы.
Такие модули, как пакеты в языке Ada, инкапсулируют вычислительные ресурсы, выставляя только спецификацию интерфейса. Абстракция данных может быть достигнута через определение представления данных в закрытой части, к которой нельзя обращаться из других единиц. Единица инкапсуляции и абстракции в языке C++ — это класс (class), который содержит объявления подпрограмм и типов данных. Из класса создаются фактические объекты, называемые экземлярами(instances). Пример класса в языке C++:
class Airplane_Data {
public:
char *get_id(char *s) const {return id;}
void set_id(char *s) {strcpy(id, s);}
int get_speed() const {return speed;}
void set_speed(int i) {speed=i;}
Этот пример расширяет пример из
int get_altitude() const {return altitude;}
void set_altitude(int i) {altitude = i;}
private:
char id[80];
int speed;
int altitude;
};
Этот пример расширяет пример из предыдущей главы, создавая отдельный класс для данных о каждом самолете. Этот класс может теперь использоваться другим классом, например тем, который определяет структуру для хранения данных о многих самолетах:
class Airplanes {
public:
void New_Airplane(Airplane_Data, int &);
void Get_Airplane(int, Airplane_Data &) const;
private:
Airplane_Data database[100];
int current_airplanes;
int find_empty_entry();
};
Каждый класс разрабатывается для того, чтобы инкапсулировать набор объявлений данных. Объявления данных в закрытой части могут быть изменены без изменения программ, использующих этот класс и называющихся клиентами (clients) класса, хотя их и придется перекомпилировать. Класс имеет набор интерфейсных функций, которые извлекают и обновляют значения данных, внутренних по отношению к классу.
Вы можете задать вопрос, почему Airplane_Data лучше сделать отдельным классом, а не просто объявить обычной общей (public) записью. Это спорное проектное решение: данные должны быть скрыты в классе, если вы полагаете, что внутреннее представление может измениться. Например, вы можете знать, что один заказчик предпочитает измерять высоту в английских футах, тогда как другой предпочитает метры. Определяя отдельный класс для
Airplane_Data, вы можете использовать то же самое программное обеспечение для обоих заказчиков и изменить только реализацию функций доступа.
За эту гибкость приходится платить определенную цену; каждый доступ к значению данных требует вызова подпрограммы:
Aircraft_Data a; // Экземпляр класса
int alt;
alt = a.get_altitud(e); // Получить значение, скрытое в экземпляре
Программирование может стать очень утомительным,
alt = (alt* 2)+ 1000;
a.set_altitude(alt); // Вернуть значение в экземпляр
вместо простого оператора присваивания в случае, когда а общая (public) запись:
a.alt = (a.alt*2) + 1000;
Программирование может стать очень утомительным, а получающийся в результате код трудно читаемым, потому что функции доступа затеняют содержательные операции обработки. Таким образом, классы должны вводиться только тогда, когда можно получить явное преимущество от скрытия деталей реализации абстрактного типа данных.
Однако инкапсуляция вовсе не обязана сопровождаться значительными затратами времени выполнения. Как показано в примере, тело интерфейсной функции может быть написано внутри объявления класса; в этом случае функция является подставляемой (встраиваемой, inline) функцией, т.е. не используется механизм вызова подпрограммы и возврата из нее (см. гл. 7). Вместо этого код тела подпрограммы вставляется непосредственно внутрь последовательности кода в точке вызова. Поскольку при подстановке функции мы расплачиваемся пространством за время, подпрограммы должны быть очень маленькими (не более двух или трех команд). Другой фактор, который следует рассмотреть перед подстановкой подпрограммы, это то, что она вводит дополнительные условия для компиляции. Если вы изменяете подставляемую подпрограмму, все клиенты должна быть перекомпилированы.
14.3. Наследование
В разделе 4.6 мы показали, как в языке Ada один тип может быть получен из другого так, что производный тип получает копии значений и операций, которые были определены для порождающего типа. Задав порождающий тип:
package Airplane_Package is
type Airplane_Data is
record
Ada |
Speed: Integer range 0.. 1000;
Altitude: Integer range 0..100;
end record;
procedure New_Airplane(Data: in Airplane_Data: I; out Integer);
procedure Get_Airplane(l: in Integer; Data: out Airplane_Data);
Можно объявлять новые подпрограммы, которые
end Airplane_Package;
производный тип можно объявить в другом пакете:
Ada |
new Airplane_Package.Airplane_Data;
Можно объявлять новые подпрограммы, которые выполняют операции на производном типе, и заменять подпрограммы родительского типа новыми:
procedure Display_Airplane(Data: in New_Airplane_Data);
Ada |
procedure Get_Airplane(Data: in New_Airplane_Data; I: out Integer);
-- Замененная подпрограмма
-- Подпрограмма New_Airplane скопирована из Airplane_Data
Производные типы образуют семейство типов, и значение любого типа из семейства может быть преобразовано в значение другого типа из этого семейства:
Ada |
А2: New_Airplane_Data := New_Airplane_Data(A1);
A3: Airplane_Data := Airplane_Data(A2);
Более того, можно даже получить производный тип от приватного типа, хотя, конечно, все подпрограммы для производного типа должны быть определены в терминах общих подпрограмм родительского типа.
Проблема, связанная с производными типами в языке Ada, заключается в том, что могут быть расширены только операции, но не компоненты данных, которые образуют тип. Например, предположим, что система управления воздушным движением должна измениться так, чтобы для сверхзвукового самолета в дополнение к существующим данным хранилось число Маха. Одна из возможностей состоит в том, чтобы просто включить дополнительное поле в существующую запись. Это приемлемо, если изменение делается при первоначальной разработке программы. Однако, если система уже была протестирована и установлена у заказчика, лучше будет найти решение, которое не требует перекомпиляции и проверки всего существующего исходного кода.
В таком случае лучше использовать наследование (inheritance), которое является способом расширения существующего типа, не только путем добавления и изменения операции, но и добавления данных к типу.
это реализовано через порождение одного
В языке C++ это реализовано через порождение одного класса из другого:
class SST_Data: public Airplane_Data {
private:
float mach;
C++ |
float get_mach() const {return mach;};
void set_mach(float m) {mach = m;};
};
Производный класс SST_Data получен из существующего класса Airplane_Data. Это означает, что каждый элемент данных и каждая подпрограмма, которые определены для базового класса (base class), доступны и в производном классе. Кроме того, каждое значение производного класса SST_Data будет иметь дополнительный компонент данных mach, и есть две новые подпрограммы, которые могут применяться к значениям производного типа.
Производный класс — это обычный класс в том смысле, что могут быть объявлены экземпляры и вызваны подпрограммы:
C++ |
s.set_speed(1400); //Унаследованная подпрограмма
s.set_mach(2.4); // Новая подпрограмма
Подпрограмма, вызванная для set_mach, — это подпрограмма, которая объявлена внутри класса SST_ Data, а подпрограмма, вызванная для set_speed, — это подпрограмма, которая унаследована от базового класса. Обратите внимание, что производный класс может быть откомпилирован и скомпонован без изменения и перекомпиляции базового класса; таким образом, расширение на существующий код воздействовать не должно.
14.4. Динамический полиморфизм в языке C++
Когда один класс порожден из другого класса, вы можете замещать (override) унаследованные подпрограммы в производном классе, переопределяя их:
class SST_Data: public Airplane_Data {
public:
int get_spaed() const; // Заместить
void set_speed(int): // Заместить
};
Если задан вызов:
obj.set_speed(100);
то решение, какую именно из подпрограмм вызвать — подпрограмму, унаследованную из Airplane_Data, или новую в SST_ Data, — принимается во время компиляции на основе класса объекта оbj.Это называется статическим связыванием (static binding), или ранним связыванием (early binding), так как решение принимается до выполнения программы, и при выполнении всегда вызывается одна и та же подпрограмма.
Однако вся суть наследования состоит
Однако вся суть наследования состоит в том, чтобы создать группу классов с аналогичными свойствами, и резонно ожидать, что должна иметься возможность присвоить переменной значение, принадлежащее любому из этих классов. Что должно произойти, когда вызывается подпрограмма для такой переменной? Решение, какую подпрограмму вызывать, должно быть принято во время выполнения, потому что значение, содержащееся в переменной, до этого неизвестно; фактически, переменная может содержать значения разных классов в разное время выполнения программы. Термины, используемые для обозначения способности выбирать подпрограммы во время выполнения, — динамический полиморфизм, динамическое связывание, позднее связывание и диспетчеризация во время выполнения (dynamic polymorphism, dynamic binding, late binding и run-time dispatching).
В языке C++ используются виртуальные функции (virtual functions) для обозначения тех подпрограмм, для которых выполняется динамическое связывание:
class Airplane_Data {
private:
…
public:
virtual int get_speed() const;
virtual void set_speed(int);
….
};
Подпрограмма в производном классе с тем же самым именем и сигнатурой параметров, что и виртуальная подпрограмма в порождающем классе, также считается виртуальной. Повторять спецификатор virtual необязательно, но это лучше сделать для ясности:
class SST_Data : public Airplane_Data {
private:
float mach;
public:
float get_mach() const; // Новая подпрограмма
void set_mach(float m); // Новая подпрограмма
virtual int get_speed() const; // Заместить виртуальную подпрограмму
virtual void set_speed(int); // Заместить виртуальную подпрограмму
…
};
Рассмотрим теперь процедуру update со ссылочным параметром на базовый класс:
void update(Airplane_Data & d, int spd, int alt)
}
d.set_speed(spd); // На какой тип указывает d??
в том, что производное значение
d.set altitude(alt); //На какой тип указывает d??
}
Airplane_Data a;
SST_Data s;
void proc()
{
update(a, 500, 5000); // Вызвать с AirplaneJData
update(s, 800,6000); // Вызвать с SST_Data
}
Идея производных классов состоит в том, что производное значение является базовым значением (возможно, с дополнительными полями), поэтому update может вызываться с параметром s производного класса SST_Data. При компиляции update компилятор не может знать, на что указывает d: на значение Airplane_Data или на SST_Data. Поэтому он не может однозначно скомпилировать вызов set_speed, поскольку эта подпрограмма по-разному определена в двух классах. Следовательно, компилятор должен сгенерировать код для переключения (диспетчеризации) вызова на правильную подпрограмму во время выполнения в зависимости от того, на что указывает d. В первом вызове ргос указатель d указывает на Airplane_Data, и вызов будет диспет-черизован на подпрограмму, определенную в классе Airplane_Data, тогда как второй — на подпрограмму, определенную в SST_ Data.
Позвольте нам подчеркнуть преимущества динамического полиморфизма: вы можете писать большие блоки программы полностью в общем виде, используя вызовы виртуальных подпрограмм. Специализация обработки конкретного класса в семействе производных классов делается только во время выполнения за счет диспетчеризации виртуальных подпрограмм. Кроме тогo если вам когда-либо понадобится добавить производные классы в семействе не нужно будет изменять или перекомпилировать ни один из существующиx кодов, потому что любое изменение в существующей программе ограниченo исключительно новыми реализациями виртуальных подпрограмм. Например если мы порождаем еще один класс:
class Space_Plane_Data : public SST_Data {
virtual void set_speed(int); // Заместить виртуальную подпрограмм private:
содержащий определение для update, не
int reentry_speed;
};
Space_Plane_Data sp;
update(sp, 2000,30000);
файл, содержащий определение для update, не нужно перекомпилировать, даже если а) новая подпрограмма заместила set_speed и б) значение формального параметра d в update содержит дополнительное поле reentry_speed.
Когда используется динамический полиморфизм?
Давайте объявим базовый класс с виртуальной подпрограммой и обычной невиртуальной подпрограммой и породим класс, который добавляет дополнительное поле и дает новые объявления для обеих подпрограмм:
class Base_Class {
private:
int Base_Field;
public:
virtual void virtual_proc();
void ordinary_proc();
};
class Derived_Class : public Base_Class {
private:
int Derived_Field;
public:
virtual void virtual_proc();
void ordnary_proc(); };
Затем объявим экземпляры классов в качестве переменных. Присваивание значения производного класса переменной из базового класса разрешено:
Base_Class Base_0bject;
Derived_Class Derived_Object;
if (...) Base_0bject = Derived_Object;
потому что производный объект является базовым объектом (плюс дополнительная информация), и при присваивании дополнительная информация может игнорироваться (см. рис. 14.3).
Более того, вызов подпрограммы (виртуальной или не виртуальной) однозначный, и компилятор может использовать статическое связывание:
Base_0bject .virtual_proc();
Base_Object.ordinary_proc();
Derived_0bject.virtual_proc();
Derived_0bject.ordinary_proc();
Предположим, однако, что используется косвенность, и указатель на производный класс присвоен указателю на базовый класс:
Base_Class* Base_Ptr = new Base_Class;
Derived_Class* Derived_Ptr = new Derived_Class;
if (...) Base_Ptr = Derived_Ptr;
В этом случае семантика другая, так как базовый указатель ссылается на полный производный объект без каких-либо усечений (см.
зации не возникает никаких проблем,
рис. 14.4). При реали зации не возникает никаких проблем, потому что мы принимаем, что все указатели представляются одинаково независимо от указуемого типа.
Важно обратить внимание на то, что после присваивания указателя компилятор больше не имеет никакой информации относительно типа указуемого объекта. Таким образом, у него нет возможности привязать вызов
Base_Ptr- >virtual_proc();
к правильной подпрограмме, и следует выполнить динамическую диспетчеризацию. Аналогичная ситуация возникает, когда используется ссылочный параметр, как было показано выше.
Эта ситуация может внести путаницу, так как программисты обычно не делают различия между переменной и указуемым объектом. После следующих операторов:
inti1 = 1;
int i2 = 2;
int *p1 = &i1; // p1 ссылается на i1
int *p2 = &i2; // p2 ссылается на i2
p1 = p2; // p1 также ссылается на i2
i1 = i2; // i1 имеет то же самое значение, что и i2
вы ожидаете, что i1 == i2 и *р1 ==*р2; это, конечно, правильно, пока типы в точности совпадают, но это неверно для присваивания производного класса базовому классу из-за усечения. При использовании наследования вы должны помнить, что указуемый объект может иметь тип, отличный от типа указуемого объекта в объявлении указателя.
Есть одна западня в семантике динамического полиморфизма языка C++: если вы посмотрите внимательно, то заметите, что обсуждение касалось диспетчеризации, относящейся к замещенной виртуальной подпрограмме. Но в классе могут также быть и обычные подпрограммы, которые замещаются:
Base_Ptr = Derived_Ptr;
Base_Ptr->virtual_proc(); // Диспетчеризуется по указанному типу
Base_Ptr->ordinary_proc(); // Статическое связывание с базовым типом!!
Существует различие в семантике между двумя вызовами: вызов виртуальной подпрограммы диспетчеризуется во время выполнения в соответствии с типом указуемого объекта, в данном случае Derived_Class; вызов обычной подпрограммы связывается статически во время компиляции в соответствии с типом указателя, ъ данном случае Base_Class.
потому что изменение, которое состоит
Это различие весьма существенно, потому что изменение, которое состоит в замене невиртуальной подпрограммы на виртуальную подпрограмму или обратно, может вызвать ошибки во всем семействе классов, полученных из базового.
Динамическая диспетчеризация в языке C++ рассчитана на вызовы виртуальных подпрограмм, осуществляемые через указатель или ссылку.
Реализация
Ранее мы отмечали, что если подпрограмма не найдена в производном классе, то поиск делается в предшествующих классах, пока не будет найдено определение подпрограммы. В случае статического связывания поиск можно делать во время компиляции: компилятор просматривает базовый класс производного класса, затем его базовый класс, и так далее, пока не будет найдено соответствующее связывание подпрограммы. Затем для этой подпрограммы может компилироваться обычный вызов процедуры.
Если используются виртуальные подпрограммы, ситуация усложняется, потому что фактическая подпрограмма, которая должна быть вызвана, не известна до времени выполнения. Обратите внимание, что, если виртуальная подпрограмма вызывается с объектом конкретного типа, в противоположность ссылке или указателю, то все еще может использоваться статическое связывание. С другой стороны, решение, какую именно подпрограмму следует вызвать, основано на 1) имени подпрограммы и 2) классе объекта. Но первое известно во время компиляции, поэтому нам остается только смоделировать case-оператор по классам.
Обычно реализация выглядит немного иначе; для каждого класса с виртуальными подпрограммами поддерживается таблица диспетчеризации (см. рис. 14.5). Каждое значение класса должно «иметь при себе» свой индекс для входа в таблицу диспетчеризации для порождающего семейства, в котором оно определено. Элементы таблицы диспетчеризации являются указателями на таблицы переходов; в каждой таблице переходов содержатся адреса входов в виртуальные подпрограммы. Обратите внимание, что два элемента таблицы переходов могут указывать на одну и ту же процедуру; это произойдет, когда класс не замещает виртуальную подпрограмму.
в свою очередь произведен из
На рисунке cls3 произведен из
cls2, который в свою очередь произведен из базового класса cls1. Здесь cls2 заместил р2, но не р1, в то время как cls3 заместил обе подпрограммы.
Когда встречается вызов диспетчеризуемой подпрограммы ptr->p1(), выполняется код наподобие приведенного ниже, где мы подразумеваем, что неявный индекс — это первое поле указуемого объекта:
load RO.ptr Получить адрес объекта
load R1 ,(RO) Получить индекс указуемого объекта
load R2,&dispatch Получить адрес таблицы отправлений
add R2.R1 Вычислить адрес таблицы переходов
load R3,(R2) Получить адрес таблицы переходов
load R4,p1(R3) Получить адрес процедуры
call (R4) Вызвать процедуру, адрес которой находится в R4
Даже без последующей оптимизации затраты на время выполнения относительно малы, и, что более важно, фиксированы, поэтому в большинстве приложений нет необходимости воздерживаться от использования динамического полиморфизма. Но все же издержки существуют и применять динамический полиморфизм следует только после тщательного анализа. Лучше избегать обеих крайностей: и чрезмерного использования динамического полиморфизма только потому, что это «хорошая идея», и отказа от него, потому что это «неэффективно».
Обратите внимание, что фиксированные затраты получаются благодаря тому, что динамический полиморфизм ограничен фиксированным набором классов, порожденных из базового класса (поэтому может использоваться таблица диспетчеризации фиксированного размера), и фиксированным набором виртуальных функций, которые могут быть переопределены (поэтому размер каждой таблицы переходов также фиксирован). Значительным достижением языка C++ была демонстрация того, что динамический полиморфизм может быть реализован без неограниченного поиска во время выполнения.
ориентированное программирование на языке Ada
14.5. Объектно- ориентированное программирование на языке Ada 95
В языке Ada 83 наличие пакетной конструкции обеспечивает полную поддержку инкапсуляции, а наличие производных типов частично обеспечивает наследование. Полного наследования нет, потому что, когда вы производите новый тип, то можете добавлять только новые операции, но не новые компоненты данных. Кроме того, единственный полиморфизм — это статический полиморфизм вариантных записей. В языке Ada 95 поддерживается полное наследование за счет того, что программисту дается возможность расширить запись производного типа. Чтобы обозначить, что родительский тип записи пригоден для наследования, его нужно объявить как теговый (tagged) тип записи:
package Airplane_Package is
type Airplane_Data is tagged
record
ID:String(1..80);
Speed: Integer range 0..1000;
Altitude: Integer range 0..100;
end record;
end Airplane_Package;
Этот тег аналогичен тегу в языке Pascal и дискриминанту в вариантных записях языка Ada, где он используется для того, чтобы различать разные типы, производные друг из друга. В отличие от этих конструкций, тег теговой записи неявный, и программист не должен явно к нему обращаться. Заглядывая вперед, скажем, что этот неявный тег будет использоваться, чтобы диспетчери-зовать вызовы подпрограмм для динамического полиморфизма.
Чтобы создать абстрактный тип данных, тип должен быть объявлен как приватный и полное объявление типа дано в закрытой части:
package Airplane_Package is
type Airplane_Data is tagged private;
procedure Set_ID(A: in out Airplane_Data; S: in String);
function Get_ID(A: Airplane_Data) return String;
procedure Set_Speed(A: in out Airplane_Data; I: in Integer);
function Get_Speed(A: Airplane_Data) return Integer;
procedure Set_Altitude(A: in out Airplane_Data; I: in Integer);
определенные внутри спецификации пакета, содержащей
function Get_Altitude(A: Airplane_Data) return Integer;
private
type Airplane_Data is tagged
record
ID:String(1..80);
Speed: Integer range 0..1000;
Altitude: Integer range 0.. 100;
end record;
end Airplane_Package;
Подпрограммы, определенные внутри спецификации пакета, содержащей объявление тегового типа (наряду со стандартными операциями на типе), называются примитивными операциями, или операциями-примитивами (primitive operations) и являются подпрограммами, которые наследуются. Наследование выполняется за счет расширения (extending) тегового типа:
with Airplane_Package; use Airplane_Package;
package SST_Package is
type SST_Data is new Airplane_Data with
record
Mach: Float;
end record;
procedure Set_Speed(A: in out SST_Data; I: iri Integer);
function Get_Speed(A: SST_Data) return Integer;
end SST_Package;
Значения этого производного типа являются копиями значений родительского типа Airplane_Data вместе с (with) дополнительным полем записи Mach. Операции, определенные для этого типа, являются копиями элементарных подпрограмм; эти операции могут быть замещены. Конечно, для производного типа могут быть объявлены другие самостоятельные подпрограммы.
В языке Ada нет специального синтаксиса для вызова подпрограмм-примитивов:
A: Airplane_Data;
Set_Speed(A, 100);
С точки зрения синтаксиса объект А — это обычный параметр; И по его типу компилятор может решить, какую именно подпрограмму вызвать. Параметр называется управляющим, Потому что он управляет тем, какую подпрограмму выбрать. Управляющий параметр не обязан быть первым параметром, и их может быть несколько (при условии, что все они того же типа). Сравните это с языком C++, который использует специальный синтаксис, чтобы вы-звать подпрограмму, объявленную в классе:
C++ |
distinguished receiver) сообщения set_speed. Отличимый
a.set_speed(100);
Объект а является отличимым получателем ( distinguished receiver) сообщения set_speed. Отличимый получатель является неявным параметром, в данном случае обозначающим, что скорость (speed) будет установлена (set) для объекта а.
Динамический полиморфизм
Перед обсуждением динамического полиморфизма в языке Ada 95 мы должны коснуться различий в терминологии языка Ada и других объектно-ориентированных языков.
В языке C++ термин класс обозначает тип данных, который используется для создания экземпляров объектов этого типа. Язык Ada 95 продолжает использовать термины типы и объекты даже для теговых типов и объектов, которые известны в других языках как классы и экземпляры. Слово класс ис-| пользуется для обозначения набора всех типов, которые порождаются от об-|щего предка, в языке C++ мы их назвали семейством классов. Нижеследующее обсуждение лучше всего провести в правильной терминологии языка Ada 95; будьте внимательны и не перепутайте новое применение слова класс с его использованием в языке C++.
С каждым теговым типом Т связан тип, который обозначается как T'Class
и называется типом класса (class-wide type)". T'Class покрывает (covered) все
типы, производные от Т. Тип класса — это неограниченный тип, и объявить
объект этого типа, не задав ограничений, нельзя, подобно объявлению
неограниченного массива:
type Vector is array(lnteger range <>) of Float;
V1: Vector; -- Запрещено, нет ограничений
type Airplane_Data is tagged record . . . end record;
A1: Airplane_Data'Class: -- Запрещено, нет ограничений
Объект типа класса может быть объявлен, если задать начальное значение:
V2: Vector := (1 ..20=>0.0); -- Правильно, ограничен
Х2: Airplane_Data; -- Правильно, конкретный тип
в случае массива, коль скоро
ХЗ: SST_Data; -- Правильно, конкретный тип
А2: Airplane_Data'Class := Х2; -- Правильно, ограничен
A3: Airplane_Data'Class := ХЗ; --Правильно, ограничен
Как и в случае массива, коль скоро CW-объект ограничен, его ограничения изменить нельзя. CW-тип можно использовать в декларации локальных переменных подпрограммы, которая получает параметр CW-типа. Здесь снова полная аналогия с массивами:
procedure P(S: String; С: in Airplane_Data'Class) is
Local_String: String := S;
Local_Airplane: Airplane_Data'Class := C;
Begin
…
end P;
Динамический полиморфизм имеет место, когда фактический параметр имеет тип класса, в то время как формальный параметр — конкретного типа, принадлежащего классу:
with Airplane_Package; use Airplane_Package;
with SST_Package; use SST_Package;
procedure Main is
procedure Proc(C: in out Airplane_Data'Class; I: in Integer) is
begin
Set_Speed(C, I); -- Какого типа С ??
end Proc;
A: Airplane_Data;
S: SST_Data;
begin -- Main
Proc(A, 500); -- Вызвать с Airplane_Data
Proc(S, 1000); -- Вызвать с SST_Data end Main:
Фактический параметр С в вызове Set_Speed имеет тип класса, но имеются две версии Set_Speed с формальным параметром либо родительского типа, либо производного типа. Во время выполнения тип С будет изменяться от вызова к вызову, поэтому динамическая диспетчеризация необходима, чтобы снять неоднозначность вызова.
Рисунок 14.6 поможет вам понять роль формальных и фактических параметров в диспетчеризации. Вызов Set_Speed вверху рисунка делается с фактическим параметром типа класса. Это означает, что только при вызове подпрограммы мы знаем, имеет ли фактический параметр тип Airplane_Data или SST_Data.
Однако каждое обтъявление процедуры, показанное
Однако каждое обтъявление процедуры, показанное внизу рисунка, имеет формальный параметр конкретного типа. Как показано стрелками, вызов должен быть отправлен в соответствии с типом фактического параметра.
Обратите внимание, что диспетчеризация выполняется только в случае необходимости; если компилятор может разрешить вызов статически, он так и сделает. Следующие вызовы не нуждаются ни в какой диспетчеризации, потому что вызов делается с фактическим параметром конкретного типа, а не типа класса:
Set_Speed(A, 500);
Set_Speed(S, 1000);
Точно так же, если формальный параметр имеет тип класса, то никакая диспетчеризация не нужна. Вызовы Ргос — это вызовы отдельной однозначной про-
цедуры; формальный параметр имеет тип класса, который соответствует фактическому параметру любого типа, относящегося к классу. Что касается рис. 14.7, то, если бы объявление Set_Speed было задано как:
procedure Set_Speed(A: in out Airplane'Class: I: in Integer);
то любой фактический параметр класса «вписался» бы в формальный параметр класса. Никакая диспетчеризация не нужна, потому что каждый раз вызывается одна и та же подпрограмма.
При ссылочном доступе указуемый объект так же может иметь CW-тип. Указатель при этом может указывать на любой объект, тип которого покрывается CW-типом, и диспетчеризация осуществляется просто раскрытием указателя:
type Class_Ptr is access Airplane_Data'Class;
Ptr: Class_Ptr := new Airplane_Data;
if (...) then Ptr := new SST_Data; end if;
Set_Speed(Ptr.all); -- На какой именно тип указывает Ptr??
Динамический полиморфизм в языке Ada 95 имеет место, когда фактический параметр относится к CW-типу, а формальный параметр относится к конкретному типу.
Реализации диспетчеризации во время выполнения в языках Ada 95 и C++ похожи, тогда как условия для диспетчеризации совершенно разные:
подпрограмма должна быть объявлена виртуальной,
• В C++ подпрограмма должна быть объявлена виртуальной, чтобы можно было выполнить диспетчеризацию. Все косвенные вызовы виртуальной подпрограммы диспетчеризуются.
• В языке Ada 95 любая унаследованная подпрограмма может быть замещена и неявно становится диспетчеризуемой. Диспетчеризация выполняется только в случае необходимости, если этого требует конкретный вызов.
Основное преимущество подхода, принятого в языке Ada, состоит в том, что не нужно заранее определять, должен ли использоваться динамический полиморфизм. Это означает, что не существует различий в семантике между вызовом виртуальной и невиртуальной подпрограммы. Предположим, что Airplane_Data был определен как теговый, но никакие порождения сделаны не были. В этом случае вся система построена так, что в ней все вызовы разрешены статически. Позже, если будут объявлены производные типы, они смогут использовать диспетчеризацию без изменения или перекомпиляции существующего кода.
14.6. Упражнения
1. Метод разработки программного обеспечения, называемый нисходящим программированием, пропагандирует написание программы в терминах операций высокого уровня абстракции и последующей постепенной детализации операций, пока не будет достигнут уровень операторов языка программирования. Сравните этот метод с объектно-ориентированным программированием.
2. Объявили бы вы Aircraft_Data абстрактным типом данных или сделали поля класса открытыми?
3. Проверьте, что можно наследовать из класса в языке C++ или из тегового пакета в языке Ada 95 без перекомпиляции существующего кода.
4. Опишите неоднородную очередь на языке Ada 95: объявите теговый тип Item, определите очередь в терминах Item, а затем породите из Item производные типы — булев, целочисленный и символьный.
5. Опишите неоднородную очередь на языке C++.
6. Проверьте, что в языке C++ диспетчеризация имеет место для ссылочного, но не для обычного параметра.
В языке Ada 95 теговый
7. В языке Ada 95 теговый тип может быть расширен приватными добавлениями:
with Airplane_Package; use Airplane_Package;
package SST_Package is
type SST_Data is new Airplane_Data with private;
procedure Set_Speed(A: in out SST_Data; I: in Integer);
function Get_Speed(A: SST_Data) return Integer;
private
…
end SST_Package;
Каковы преимущества и недостатки такого расширения?
8. Изучите машинные команды, сгенерированные компилятором Ada 95 или C++ для динамического полиморфизма.
Глава 15
Еще об
объектно-ориентированном
программировании
В этой главе мы рассмотрим еще несколько конструкций, которые существуют в объектно-ориентированных языках. Это не просто дополнительные удобства — это существенные конструкции, которые необходимо освоить, если вы хотите стать компетентными в объектно-ориентированных методах программирования. Данный обзор не является исчерпывающим; детали можно уточнить в учебниках по языкам программирования. Глава разделена на шесть разделов:
1. Структурированные классы.
• Абстрактные классы используются для создания абстрактного интерфейса, который можно реализовать с помощью одного или нескольких наследуемых классов.
• Родовые подпрограммы (Ada) и шаблоны (C++) можно комбинировать с наследованием для параметризации классов другими классами.
• Множественное наследование: класс может быть производным от двух или нескольких родительских классов и наследовать данные и операции каждого из них.
2. Доступ к приватным компонентам: Являются компоненты в закрытой части пакета или класса всегда приватными, или их можно экспортировать производным классам или клиентам?
3. Данные класса. В этом разделе обсуждаются создание и использование компонентов данных в классе.
Язык Eiffel был разработан для
4. Eiffel. Язык Eiffel был разработан для поддержки ООП как единственного метода структурирования программ; поучительно сравнить конструкции языка Eiffel с конструкциями языков Ada 95 и C++, где поддержка ООП была добавлена к уже существующим языкам.
5. Проектные соображения. Каковы компромиссы между использованием класса и наследованием из класса? Для чего может использоваться наследование? Каковы взаимоотношения между перегрузкой и замещением?
6. В заключение приводится сводка методов динамического полиморфизма.
15.1. Структурированные классы
Абстрактные классы
Когда класс порождается из базового класса, предполагается, что базовый класс содержит большую часть требуемых данных и операций, тогда как производный класс всего лишь добавляет дополнительные данные, а также добавляет или изменяет некоторые операции. Во многих проектах лучше рассматривать базовый класс как некий каркас, определяющий общие операции для всего семейства производных классов. Например, семейство классов операций ввода/вывода или графики может определять такие общие операции, как get и display, которые будут определены для каждого производного класса. И Ada 95, и C++ поддерживают такие абстрактные классы.
Мы продемонстрируем абстрактные классы, описывая несколько реализаций одной и той же абстракции; абстрактный класс будет определять структуру данных Set, и производные классы — реализовывать множества двумя различными способами. В языке Ada 95 слово abstract обозначает абстрактный тип и абстрактные подпрограммы, связанные с этим типом:
Ada |
type Set is abstract tagged null record;
function Union(S1, S2: Set) return Set is abstract;
function Intersection(S1, S2: Set) return Set is abstract;
end Set_Package;
Вы не можете объявить объект
Вы не можете объявить объект абстрактного типа и не можете вызвать абстрактную подпрограмму. Тип служит только каркасом для порождения конкретных типов, а подпрограммы должны замещаться конкретными подпрограммами.
Сначала мы рассмотрим производный тип, в котором множество представлено булевым массивом:
with Set_Package;
package Bit_Set_Package is
type Set is new Set_Package.Set with private;
function Union(S1, S2: Set) return Set;
function lntersection(S1, S2: Set) return Set;
Ada |
type Bit_Array is array(1..100) of Boolean;
type Set is new Set_Package.Set with
record
Data: Bit_Array;
end record;
end Bit_Set_Package;
Конечно, необходимо тело пакета, чтобы реализовать операции.
Производный тип — это конкретный тип с конкретными компонентами данных и операциями, и он может использоваться как любой другой тип:
with Bit_Set_Package; use Bit_Set_Package;
procedure Main is
S1.S2, S3: Set;
Ada |
S1 := Union(S2, S3);
end Main;
Предположим теперь, что в другой части программы требуется другая реализация множеств, которая использует связанные списки вместо массивов. Вы можете породить дополнительный конкретный тип из абстрактного типа и использовать его вместо или в дополнение к предыдущей реализации:
with Set_Package;
package Linked_Set_Package is
type Set is new Set_Package.Set with private;
function Union(S1, S2: Set) return Set;
Ada |
private
type Node;
type Pointer is access Node;
type Set is new Set_Package.Set with
record
Head: Pointer;
end record;
end Linked_Set_Package;
Новая реализация может использоваться другим модулем; фактически, вы можете изменить реализацию, используемую в существующих модулях, просто заменяя контекстные указания:
Ada with Linked_Set_Package; use Linked_Set_Package;
Ada with Linked_Set_Package; use Linked_Set_Package;
Ada |
S1.S2, S3: Set;
begin
S1 := Union(S2, S3);
end Main;
В C++ абстрактный класс создается с помощью объявления чистой виртуальной функции, обозначенной «начальным значением» 0 для функции.
Абстрактный класс для множеств в языке C++ выглядит следующим образом:
class Set {
C++ |
virtual void Union(Set&, Set&) = 0;
virtual void lntersection(Set&, Set&) = 0;
};
У абстрактных классов не бывает экземпляров; абстрактный класс может только быть базовым для производных классов:
class Bit_Set: public Set {
public:
virtual void Union(Set&, Set&);
virtual void lntersection(Set&, Set&);
C++ |
int data[100];
};
class Linked_Set: public Set {
public:
virtual void Union(Set&, Set&);
virtual void lntersection(Set&, Set&);
private:
int data;
Set *next;
};
Конкретные производные классы можно использовать как любой другой класс: __
void proc()
{
C++ |
Linked_Set 11,12,l3;
b1.Union(b2,b3);
H.Union(l2,I3);
}
Обратите внимание на разницу в синтаксисе двух языков, которая вызвана разными подходами к ООП. В языке Ada 95 определяется обычная функция, которая получает два множества и возвращает третье. В языке C++ одно из множеств — отличимый получатель сообщения. Для
b1.Union(b2,b3);
подразумевается, что экземпляр b1, отличимый получатель операции Union, получит результат операции от двух параметров — Ь2 и bЗ — и использует его, • чтобы заменить текущее значение внутренних данных.
Возможно, вы предпочтете перегрузить предопределенные операции, например «+» и «*», вместо того чтобы использовать имена Union и Intersection. Это можно сделать как в C++, так и в Ada 95.
Все реализации абстрактного класса покрываются типом класса (CW-типом) Set'Class.
к правильному конкретному типу, т.
Величины абстрактного CW-типа будут диспетчеризованы к правильному конкретному типу, т. е. к правильной реализации. Таким образом, абстрактные типы и операции дают возможность программисту писать программное обеспечение, не зависящее от реализации.
Родовые возможности
В разделе 10.3 мы обсуждали родовые подпрограммы в языке Ada, которые позволяют программисту создавать шаблоны подпрограмм и затем конкретизировать их для различных типов. Родовые возможности чаще всего находят приложение в пакетах Ada; например, пакет работы со списком может быть родовым в отношении типа элементов списка. Кроме того, он может быть родовым в отношении функций, сравнивающих элементы, с тем
чтобы элементы списка можно было сортировать:
generic
type Item is private;
with function "<"(X, Y: in Item) return Boolean;
Ada |
type List is private;
procedure Put(l: in Item; L: in out List);
procedure Get(l: out Item; L: in out List);
private
type List is array( 1.. 100) of Item;
end List_Package;
Этот пакет теперь может быть конкретизирован для любого типа элемента:
Ada |
Конкретизация создает новый тип, и можно объявлять и использовать объекты этого типа:
lnt_List_1, lnt_List_2: lnteger_List.List;
lnteger_List.Put(42, lnt_List_1 );
lnteger_List.Put(59, lnt_List_2);
В языке Ada есть богатый набор нотаций для написания родовых формальных параметров, которые используются в модели контракта, чтобы ограничить фактические параметры некоторыми классами типов, такими как дискретные типы или типы с плавающей точкой. В языке Ada 95 эти средства обобщены до возможности специфицировать в родовом формальном параметре классы типов, задаваемые программистом:
with Set_Package;
Ada |
type Set_Class is new Set_Package.Set; package Set_IO is
Эта спецификация означает, что родовой
…
end Set_IO;
Эта спецификация означает, что родовой пакет может быть конкретизирован с любым типом, производным от тегового типа Set, такого как Bit_Set и Linked_Set. Все операции из Set, такие как Union, могут использоваться внутри родового пакета, потому что из модели контракта мы знаем, что любая конкретизация будет с типом, производным от Set, и, следовательно, она наследует или замещает эти операции.
Шаблоны
В языке C++ можно определять шаблоны классов:
Ada |
class List {
void put(const Item &);
};
Как только шаблон класса определен, вы можете определять объекты этого класса, задавая параметр шаблона:
C++ |
// lnt_List1 является экземпляром класса List с параметром int
Так же как и язык Ada, C++ позволяет программисту для объектов-экземпляров класса задать свои программы (процесс называется специализацией, specialization) или воспользоваться по умолчанию подпрограммами, которые существуют для класса. Есть важное различие родовых пакетов Ada и шаблонов C++. В языке Ada конкретизация родового пакета, который определяет тип, даст вам конкретный пакет, содержащий конкретный тип. Чтобы получить объект, потребуется еще один шаг. В C++ конкретизация дает объект сразу, не определяя конкретного класса. Чтобы определить другой объект, нужно просто конкретизировать шаблон снова:
C++ |
Компилятор и компоновщик отвечают за то, чтобы отследить пути всех конкретизации одного и того же типа и гарантировать, что код для операций шаблона класса не тиражируется для каждого объекта.
Следующее различие между языками состоит в том, что C++ не использует модель контракта, поэтому не исключено, что конкретизация вызовет ошибку компиляции в самом шаблоне (см. раздел 10.3).
Множественное наследование
Ранее обсуждалось порождение классов от
Ранее обсуждалось порождение классов от одного базового класса, так что семейство классов образовывало дерево. При объектно-ориентированном проектировании, вероятно, класс будет иметь характеристики двух или нескольких существующих классов, и кажется допустимым порождать класс из нескольких базовых классов. Это называется множественным наследованием (multiple inheritance). На рисунке 15.1 показано, что Airplane (самолет) может
быть многократно порожден из Winged_Vehicle (летательный аппарат с крыльями) и Motorized_Vehicle (летательный аппарат с мотором), в то время как Winged_Vehicle также является (единственным) базовым классом для Glider (планер). Задав два класса:
class Winged_Vehicle {
public:
void display(int);
C++ |
int Wing_Length; // Размах крыла
int Weight; // Bec
};
class Motorized_Vehicle {
public:
void display(int);
protected:
int Power; // Мощность
int Weight; // Bec
};
можно породить класс с помощью множественного наследования:
class Airplane:
C++ |
public:
void display_all();
};
Чтобы использовать множественное наследование, необходимо решить, что делать с данными и операциями, такими как Weight и display, которые наследуются из нескольких базовых классов. В языке C++ неоднозначность, вызванная многократно определенными компонентами, должна быть явно разрешена с помощью операции уточнения области действия:
void Airplane: :display_all()
{
C++ |
Winged_Vehicle::display(Winged_ Vehicle:: Weight);
Motorized_ Vehicle:: display(Power);
Motorized_ Vehicle:: display(Motorized_ Vehicle:: Weight);
Это нельзя считать удачным решением,
};
Это нельзя считать удачным решением, так как вся идея наследования в том, чтобы допускался прямой доступ к данным и операциям базы, если не требуется их модификации. Реализовать множественное наследование намного труднее, чем простое наследование, которое мы описали в разделе 14.4. Более подробно см. разделы с 10.1с по 10.1с упомянутого ранее справочного руководства по языку C++.
Значение множественного наследования в ООП является предметом для дискуссии. Некоторые языки программирования, такие как Eiffel, поддерживают использование множественного наследования, в то время как языки, подобные Ada 95 и Smalltalk, не имеют таких средств. При этом утверждается, что проблемы, которые можно решить с помощью множественного наследования, изящно решаются с использованием других средств языка. Например, выше мы отмечали, что родовые параметры теговых типов в языке Ada 95 можно использовать для создания новых абстракций, комбинируя уже существующие абстракции. Очевидно, что наличие возможности множественного наследования оказывает глубокое влияние на проектирование и программирование объектно-ориентированной системы. Таким образом, трудно говорить об объектно-ориентированном проекте, не зависящем от языка; даже на самых ранних стадиях проектирования вам следует ориентироваться на конкретный язык программирования.
5.2. Доступ к приватным компонентам
<<Друзья>> в языке C++
Внутри объявления класса в языке C++ можно включать объявление «друже-ственных» (friend) подпрограмм или классов, представляющих собой под-программы или классы, которые имеют полный доступ к приватным данным операциям класса:
class Airplane_Data {
private:
int speed;
friend void proc(const Airplane_Data &, int &);
friend class CL;
};
Подпрограмма ргос и подпрограммы класса CL могут обращаться к приватным компонентам Airplane_Data:
void proc(const Airplane_Data & a, int & i)
Подпрограмма ргос может затем передавать
{
i = a.speed; // Правильно, мы — друзья
}
Подпрограмма ргос может затем передавать внутренние компоненты класса, используя ссылочные параметры, или указатели, как показано выше. Таким образом, «друг» выставил на всеобщее обозрение все секреты абстракции.
Мотив для предоставления такого доступа к приватным элементам взят из операционных систем, в которых были предусмотрены механизмы явного предоставления привилегий, называемых возможностями (capabilities). Это понятие меньше соответствует языкам программирования, потому что одна из целей ООП состоит в том, чтобы создавать закрытые, пригодные для повторного использования компоненты. Идея «друзей» проблематична с проектной точки зрения, поскольку предполагает, что компонент располагает знанием о том, кто им воспользуется, а это определенно несовместимо с идеей многократного использования компонентов, которые вы покупаете или заимствуете из других проектов. Другая серьезная проблема, связанная с конструкцией friend, состоит в слишком частом использовании ее для «заплат» в программе, вместо переосмысления абстракции. Чрезмерное употребление конструкции friend, очевидно, разрушит абстракции, которые были так тщательно разработаны.
Допустимо применение «друзей», когда абстракция составлена из двух самостоятельных элементов. В этом случае могут быть объявлены два класса, которые являются «друзьями» друг друга. Например, предположим, что классу Keyboard (клавиатура) необходим прямой доступ к классу Display (дисплей), чтобы воспроизвести эхо-символ; и наоборот, класс Display должен быть в состоянии поместить символ, полученный из интерфейса сенсорного экрана, во внутренний буфер класса Keyboard:
class Display {
private:
void echo(char с);
friend class Keyboard; // Разрешить классу Keyboard вызывать echo
};
class Keyboard {
private:
void put_key(char c);
Использование механизма friend позволяет избежать
friend class Display; // Разрешить классу Display вызывать put_key
};
Использование механизма friend позволяет избежать как создания неоправданно большого числа открытых (public) подпрограмм, так и объединения двух классов в один большой класс только потому, что они имеют одну-един-ственную общую операцию.
С помощью friend можно также решить проблему синтаксиса, связанную с тем фактом, что подпрограмма в классе C++ имеет отличимый получатель, такой как obj1 при вызове obj1.proc(obj2). Это привносит в подпрограммы асимметрию, в противном случае они были бы симметричны по параметрам. Стандартный пример — перегрузка арифметических операций. Предположим, что мы хотим перегрузить «+» для комплексных чисел и в то же время позволить операции неявно преобразовать параметр с плавающей точкой в комплексное значение:
complex operator + (float);
complex operator + (complex);
Рассмотрим выражение х + у, где одна из переменных (х или у) может быть с плавающей точкой, а другая комплексной. Первое объявление правильно для комплексного х и плавающего у, потому что х+у эквивалентно x.operator+(y), и, стало быть, будет диспетчеризованно отличимому получателю комплексного типа. Однако второе объявление для х+у, где х имеет тип с плавающей точкой, приведет к попытке диспетчеризоваться к операции с плавающей точкой, но операция была объявлена в комплексном классе.
Решение состоит в том, чтобы объявить эти операции как «друзей» класса, а не как операции класса:
friend complex operator + (complex, complex);
friend complex operator + (complex, float);
friend complex operator + (float, complex);
Хотя эта конструкция популярна в языке C++, на самом деле существует лучшее решение, при котором не требуется friend.
Оператор «+=» можно определить как функцию-член (см. справочное руководство, стр. 249), а затем «+» можно определить как обычную функцию за пределами класса:
Когда один класс порождается из
complex operator + (float left, complex right)
{
complex result = complex(left);
result + = right; // Результат является отличимым получателем
return result;
}
Спецификаторы доступа в языке C++
Когда один класс порождается из другого, мы вправе спросить, имеет ли производный класс доступ к компонентам базового класса. В следующем примере database (база данных) объявлена как приватная, поэтому она недоступна в производном классе:
class Airplanes {
private:
Airplane_Data database [100];
};
class Jets : public Airplanes {
void process Jet(int);
};
void Jets::process_jet(int i)
{
Airplane_Data d = database[i] ; // Ошибка, нет доступа!
};
Если объявлен экземпляр класса Jets, он будет содержать память для database, но этот компонент недоступен для любой подпрограммы в производном классе.
Есть три спецификатора доступа в языке C++:
• Общий (public) компонент доступен для любого пользователя класса.
• Защищенный (protected) компонент доступен внутри данного класса и внутри производного класса.
• Приватный компонент доступен только внутри класса.
В примере, если database просто защищенный, а не приватный член класса, к нему можно обращаться из производного класса Jets:
class Airplanes {
protected:
Airplane_Data database[100];
};
class Jets : public Airplanes {
void process_jet(int);
};
void Jets::process_jet(int i)
{
Airplane_Data d = database[i]; // Правильно, в производном классе
};
Однако это не очень хорошая идея, потому что она ставит под удар абстракцию. Вероятно, было бы лучше даже для производного класса манипулировать унаследованными компонентами, используя общие или защищенные подпрограммы. Тогда, если внутреннее представление изменяется, нужно изменить только несколько подпрограмм.
допускает изменение доступности компонентов класса
Язык C++ допускает изменение доступности компонентов класса при объявлении производного класса. Обычно порождение бывает общим (public). Так было во всех наших примерах, и при этом сохранялась доступность, заданная в базовом класс. Однако вы также можете задать приватное порождение, тогда и общие, и защищенные компоненты становятся приватными:
class Airplanes {
protected:
Airplane_Data database [100];
};
class Jets : private Airplanes { // Приватное порождение
void process_jet(int);
};
void Jets::process_jet(int i)
{
Airplane_Data d = database[i]; // Ошибка, нет доступа
};
Пакеты-дети в языке Ada
В языке Ada только тело пакета имеет доступ к приватным объявлениям. Это делает невозможным непосредственное совместное использование пакетами приватных объявлений так, как это можно делать в языке C++ с защищенными объявлениями. В языке Ada 95 для совместного использования приватных объявлений доставлено специальное средство структурирования, так называемые пакеты-дети (child packages). Здесь мы ограничим обсуждение пакетов-детей только для этой цели, хотя они чрезвычайно полезны в любой ситуации, когда вы хотите расширить существующий пакет без его изменения или перекомпиляции.
Зададим приватный тип Airplane_Data, определенный в пакете:
package Airptane_Package is
type Airplane_Data is tagged private;
private
type Airplane_Data is tagged
record
ID:String(1..80);
Speed: Integer range 0.. 1000;
Altitude: Integer 0.. 100;
end record;
end Airplane_Package;
Этот тип может быть расширен в пакете-ребенке:
package Airplane_Package.SST_Package is
type SST_Data is tagged private;
procedure Set_Speed(A: in out SST_Data; I: in Integer);
private
type SST.Data is new Airplane_Data with
то Р2 принадлежит области родителя
record
Mach: Float;
end record;
end Airplane_Package.SST_Package;
Если задан пакет P1 и его ребенок Р1 .Р2, то Р2 принадлежит области родителя Р1, как если бы он был объявлен сразу после спецификации родителя. Внутри закрытой части и теле ребенка видимы приватные объявления родителя:
package body Airplane_Package.SST_Package is
procedure Set_Speed(A: in out SST_Data; I: in Integer) is
begin
A.Speed := I; -- Правильно, приватное поле в родителе
end Set_Speed;
end Airplane_Package.SST_Package;
Конечно, общая часть ребенка не может обращаться к закрытой части родителя, иначе ребенок мог бы раскрыть секреты родительского пакета.
15.3. Данные класса
Конструкторы и деструкторы
Конструктор (constructor) — это подпрограмма, которая вызывается, когда создается объект класса; когда объект уничтожается, вызывается деструктор (destructor). Фактически, каждый объект (переменная), определенный в каком-либо языке, требует выполнения некоторой обработки при создании и уничтожении переменной хотя бы для выделения и освобождения памяти. В объектно-ориентированных языках программист может задать такую обработку.
Конструкторы и деструкторы в языке C++ могут быть определены для любого класса; фактически, если вы не определяете их сами, компилятор обеспечит предусмотренные по умолчанию. Синтаксически конструктор — это подпрограмма с именем класса, а деструктор — то же имя с префиксным символом «~»:
class Airplanes {
private:
C++ |
int current_airplanes;
public:
Airplanes(int i = 0): current_airplanes(i) {};
~Airplanes();
};
После создания базы данных Airplanes число самолетов получает значение параметра i, который по умолчанию имеет значение ноль:
Airplanes а1 (15); // current_airplanes =15
Когда база данных удаляется, будет
Airplanes a2; //current_airplanes = О
Когда база данных удаляется, будет выполнен код деструктора (не показанный). Можно определить несколько конструкторов, которые перегружаются на сигнатурах параметров:
class Airplanes {
public:
Airplanes(int i = 0): current_airplanes(i) {};
C++ |
~Airptartes();
};
Airplanes a3(5,6); // current_airplanes = 11
В языке C++ также есть конструктор копирования (copy constructor), который дает возможность программисту задать свою обработку для случая, когда объект инициализируется значением существующего объекта или, в более общем случае, когда один объект присваивается другому. Полное определение конструкторов и деструкторов в языке C++ довольно сложное; более подробно см. гл. 12 справочного руководства по языку C++.
В языке Ada 95 явные конструкторы и деструкторы обычно не объявляются. Для простой инициализации переменных достаточно использовать значения по умолчанию для полей записи:
type Airplanes is tagged
record
Current_Airplanes: Integer := 0;
end record;
Ada |
type Airplanes(lnitial: Integer) is tagged
record
Current_Airplanes: Integer := Initial;
end record;
Программист может определить свои обработчики, порождая тип из абстрактного типа, называемого управляемым (Controlled). Этот тип обеспечивает абстрактные подпрограммы для Инициализации (Initialization), Завершения (Finalization) и Корректировки (Adjust) для присваивания, которые вы можете заместить нужными вам программами. За деталями нужно обратиться к пакету Ada. Finalization, описанному в разделе 7.6 справочного руководства по языку Ada.
Class-wide-объекты
Память распределяется для каждого экземпляра класса:
по 100 символов для с1
C++ |
chars[100];
};
С с1,с2; // по 100 символов для с1 и с2
Иногда полезно иметь переменную, которая является общей для всех экземпляров класса. Например, чтобы присвоить порядковый номер каждому экземпляру, можно было бы завести переменную last для записи последнего присвоенного номера. В языке Ada это явно делается с помощью включения обычного объявления переменной в теле пакета:
package body P is
Last: Integer := 0;
Ada |
в то время как в языке'C++ нужно воспользоваться другим синтаксисом:
class С {
C++ |
chars[100];
};
int C::last = 0; // Определение, доступное за пределами файла
Спецификатор static в данном случае означает, что будет заведен один CW-объект*. Вы должны явно определить компонент static за пределами определения класса. Обратите внимание, что статический (static) компонент класса имеет внешнее связывание и может быть доступен из других файлов, в отличие от статического объявления в области файла.
Преобразование вверх и вниз
В разделе 14.4 мы описали, как в языке C++ значение порожденного класса может быть неявно преобразовано в значение базового класса. Это называется преобразованием вверх (up-conversion), потому что преобразование делается вверх от потомка к любому из его предков. Это также называется сужением (narrowing), Потому что производный тип «широкий» (так как он имеет дополнительные поля), в то время как базовый тип «узкий», он имеет только поля, которые являются общими для всех типов в производном семействе. Запомните, что преобразование вверх происходит только, когда значение производного типа непосредственно присваивается переменной базового типа, а не когда указатель присваивается от одной переменной другой.
нию производного типа не допускается,
Преобразование вниз (down-conversion) от значения базового типа к значе нию производного типа не допускается, поскольку мы не знаем, какие значения включить в дополнительные поля. Рассмотрим, однако, указатель на базовый тип:
Base_Class* Base_Ptr = new Base_Class;
C++ |
if (...) Base_Ptr = Derived_Ptr;
Derived_Ptr = Base_Ptr; // На какой тип указывает Base_Ptr?
Конечно, возможно, что Base_Ptr фактически укажет на объект производного типа; в этом случае нет никакой причины отклонить присваивание. С другой стороны, если указуемый объект фактически имеет базовый тип, мы делаем попытку преобразования вниз, и присваивание должно быть отвергнуто. Чтобы предусмотреть этот случай, в языке C++ определено динамическое преобразование типов (dynamic cast), которое является условным в зависимости от типа указуемого объекта:
C++ |
Если указуемый объект фактически имеет производный тип, преобразование завершается успешно. В противном случае указателю присваивается 0, и программист может это проверить.
Уже в языке Ada 83 допускалось явное преобразование между любыми двумя типами, порожденными друг из друга. Это не вызывало никаких проблем, потому что производные типы имеют в точности те же самые компоненты. Для них допустимо иметь различные представления (см. раздел 5.8), но преобразование типов совершенно четко определено, потому что оба представления имеют одинаковые число и типы компонентов.
Расширение преобразования производного типа до теговых типов не вызывает проблем в случае преобразования вверх от производного типа к базовому. Ненужные поля усекаются:
Ada |
A: Airplane_Data := Airplane_Data(S);
В другом направлении используются агрегаты расширения (extention aggregates), чтобы обеспечить значения для полей, которые были добавлены при расширении:
и подобные берутся из соответствующих
Ada |
Поля Speed и подобные берутся из соответствующих полей в значении А, а дополнительное поле Mach задано явно.
При попытке преобразования вниз CW-типа к конкретному типу делается проверка во время выполнения, и, если CW-объект не производного типа, произойдет исключительная ситуация:
Ada |
S:SST_Data;
begin
S := SST_Data(C); - Какой тип у С ??
exception
when Constraint_Error => .. .
end P;
15.4. Язык программирования Eiffel
Основные характеристики языка программирования Eiffel:
• Язык Eiffel изначально создавался как объектно-ориентированный, а не как дополнительная пристройка для поддержки ООП в существующем языке.
• В языке Eiffel программу можно построить единственным способом — как систему классов, которые являются клиентами друга друга или наследуются один из другого.
• Поскольку наследование — это основная конструкция структурирования, центральное место в языке занимает стандартная библиотека классов (связанных наследованием).
• Не будучи частью «языка», развитая среда программирования была создана группой разработчиков языка Eiffel. Среда включает ориентированную на язык поддержку для отображения и изменения классов, для инкрементной компиляции и для тестирования и отладки.
В отличие от языка Smalltalk (который имеет аналогичные характеристики), язык Eiffel жестко придерживается статического контроля соответствия типов наряду с динамическим полиморфизмом, как в языках Ada 95 и C++. Eiffel идет дальше в попытках поддерживать надежное программирование, интегрируя утверждения в язык, как обсуждалось
в разделе 11.5.
Единственная программная единица в Eiffel — это класс: никаких файлов, как в языках С и C++, и никаких пакетов, как в языке Ada.
Терминология языка Eiffel отличается от других языков: подпрограммы (процедуры и функции) называются рутинами (routine), объекты (переменные и константы) называются атрибутами (attribute), а рутины и атрибуты, которые входят в состав класса, называются свойствами (feature) класса.
По существу, нет никакого различия
По существу, нет никакого различия между функциями и константами: подобно литералу перечисления языка Ada, константа рассматривается просто как функция без параметров. Язык Eiffel статически типизирован, подобно языку C++, в том смысле, что при присваиваниях и при передаче параметров типы должны соответствовать друг другу, и это соответствие может быть проверено во время компиляции. Однако язык не имеет таких богатых конструкций для управления соответствием типов, как подтипы и числовые типы (numerics) языка Ada.
Когда объявляется класс, задается список свойств:
class Airplanes
feature -- "public"
New_Airplane(Airplane_Data): Integer is
Do
….
end; -- New_Airplane Get_Airplane(lnteger): Airplane_Data is
do
….
end; -- Get_Airplane
feature {} --"private"
database: ARRAY[Airplane_Data];
current_airpianes: Integer;
find_empty_entry: Integer is
do
…
end; -- find_empty_entry
end; -- class Airplanes
Как и в языке C++, набор свойств может быть сгруппирован, и для каждой такой feature-группы может быть определена своя доступность, feature-группа со спецификатором, который изображает пустое множество «{}», не экспортируется ни в какой другой класс, подобно private-спецификатору, feature-группа без спецификатора экспортируется в любой другой класс в системе; однако это отличается от public-спецификатора в языке C++ и от открытой части спецификации пакета в языке Ada, потому что экспортируется только доступ для чтения. Кроме того, вы можете явно написать список классов в feature-спецификаторе; этим классам будет разрешен доступ к свойствам внутри группы, подобно «друзьям» в языке C++.
В языке Eiffel нет реального различия между предопределенными типами и типами, определенными программистом, database — это объект класса ARRAY, который является предопределенным в библиотеке языка Eiffel.
очень общее понятие; как мы
Конечно, «массив» — очень общее понятие; как мы должны указать тип элементов массива? Нужно применить тот же самый метод, который использовал бы программист для параметризации любого типа данных: обобщения (genetics). Встроенный класс ARRAY имеет один родовой параметр, который используется, чтобы определить тип элементов:
class ARRAY[G]
Когда объявляется объект типа ARRAY, должен быть задан фактический параметр, в данном случае Airplane_Data. В отличие от языков Ada и C++, которые имеют специальный синтаксис для объявления встроенных составных типов, в языке Eiffel все создается из родовых классов с помощью единого набора синтаксических и семантических правил.
Обобщения широко используются в языке Eiffel, потому что библиотека содержит определения многих родовых классов, которые вы можете специализировать для своих конкретных требований. Родовые классы также могут быть ограниченными (constrained), чтобы работала модель контракта между родовым классом и его конкретизацией, как это делается в языке Ada (см. раздел 10.3). Ограничения задаются не сопоставлением с образцом, а указанием имени класса, для которого фактический родовой параметр должен быть производным. Например, следующий родовой класс может быть конкретизирован только типами, производными от REAL:
class Trigonometry[R -> REAL]
Вы уже заметили, что в классе на языке Eiffel не разделены спецификации свойств и их реализация в виде выполнимых подпрограмм. Все должно находиться в одном и том же объявлении класса, в отличие от языка Ada, который делит пакеты на отдельно компилируемые спецификации и тела. Таким образом, язык Eiffel платит за свою простоту, требуя большего объема работы от среды программирования. В частности, язык определяет усеченную (short) форму, по сути интерфейс, и среда отвечает за отображение усеченной формы по запросу.
Наследование
Каждый класс определяет тип, а все классы в системе организованы в одну иерархию.
Наверху иерархии находится класс, называющийся
Наверху иерархии находится класс, называющийся ANY. Присваивание и равенство определены внутри ANY, но могут быть замещены внутри класса. Синтаксис для наследования такой же, как в языке C++: унаследованные классы перечисляются после имени класса. Если задан класс Airplane_Data:
class Airplane_Data
feature
Set_Speed(l: Integer) is...
Get_Speed: Integer is....
feature {}
ID: STRING;
Speed: Integer;
Altitude: Integer;
end; -- class Airplane_Data
его можно наследовать следующим образом:
class SSTJData inherit
Airplane_Data
redefine
Set_Speed, Get_Speed
end
feature
Set_Speed(l: Integer) is...
Get_Speed: Integer is...
feature {}
Mach: Real;
end; — class SST_Data
Все свойства в базовом классе наследуются с их экспортируемыми атрибутами в неизменном виде. Однако для производного класса программист может переопределить некоторые или все унаследованные свойства. Переопределяемые свойства должны быть явно перечислены в redefine-конструкции, которая следует за inherit-конструкцией. Кроме переопределения, свойство можно просто переименовать. Обратите внимание, что унаследованное свойство может быть реэкспортировано из класса, даже если оно было приватным в базовом классе (в отличие от языков C++ и Ada 95, которые не разрешают вторгаться в ранее скрытую реализацию).
Среда языка Eiffel может отображать плоскую (flat) версию класса, которая показывает все действующие на данный момент свойства, даже если они были унаследованы и повторно объявлены где-то еще в иерархии. Таким образом, интерфейс класса отчетливо отображается, и программисту не нужно «раскапывать» иерархию, чтобы точно увидеть, что было переобъявлено, а что не было.
Eiffel, аналогично языку C++, но, в отличие от языка Ada 95, использует подход отличимого получателя, поэтому нет необходимости задавать явный параметр для объекта, подпрограмма которого должна быть вызвана:
В языке EifFel нет никаких
A: Airplane_Data;
A.Set_Speed(250);
Распределение памяти
В языке EifFel нет никаких явных указателей. Все объекты неявно распределяются динамически и доступны через указатели. Однако программист может по выбору объявить объект как расширенный (expanded), в этом случае он будет размещен и доступен без использования указателя:
database: expanded ARRAY[Airplane_Data];
Кроме того, класс может быть объявлен как расширенный, и все его объекты будут доступны непосредственно. Само собой разумеется, что встроенные типы Integer, Character и т.д. являются расширенными.
Обратите внимание, что оператор присваивания или проверки равенства
X :=Y;
дает четыре варианта, в зависимости от того, являются объекты X и Y расширенными оба, либо только один из них, либо ни тот ни другой. В языках Ada и C++ программист отвечает за то, чтобы различать, когда подразумевается присваивание указателя, а когда — присваивание обозначенных объектов. В языке EifFel присваивание прозрачно для программиста, а значение каждого варианта в языке тщательно определено.
Преимущество косвенного распределения состоит в том, что обычные объекты, чей тип есть тип базового класса, могут иметь значения любого типа, чей класс порожден из базового типа:
A: Airplane_Data;
S: SST_Data;
A:=S;
Если распределение было статическим, в объекте А не будет «места» для дополнительного поля Mach из S. Когда используется косвенное распределение, присваивание — это, по сути, просто копирование указателя. Сравните это с языками Ada 95 и C++, в которых требуются дополнительные понятия: CW-типы и указатели для присваивания, которые поддерживают конкретный тип.
Кроме того, язык Eiffel делает различие между мелким (shallow)и глубоким (deep) копированием в операторах присваивания. При мелком копировании копируются только указатели (или данные, в случае расширенных объектов), в то время как при глубоком копировании копируются структуры данных целиком.
Замещая унаследованное определение присваивания, вы
Замещая унаследованное определение присваивания, вы можете выбрать любой вариант для любого класса.
Динамический полиморфизм получаем как непосредственное следствие. Возьмем
A.Set_Speed(250);
Компилятор не имеет никакой возможности узнать, является конкретный тип значения, находящегося в данный момент в А, базовым типом Air-plane_Data для А или некоторым типом, порожденным из Airplane_Data. Так как подпрограмма Set_Speed была переопределена, по крайней мере, в одном порожденном классе, должна выполняться диспетчеризация во время выполнения. Обратите внимание, что не требуется никакого специального синтаксиса или семантики: все вызовы потенциально динамические, хотя компилятор проведет оптимизацию и использует статическое связывание, где это возможно.
Абстрактные классы
Абстрактные классы в языке Eiffel такие же, как в языках C++ и Ada 95. Класс или свойство в классе может быть объявлено как отсроченное (deferred). Отсроченный класс должен быть сделан конкретным при помощи эффективизации (effecting) всех отсроченных свойств, т. е. предоставления реализации. Обратите внимание, что, в отличие от языков C++ и Ada 95, вы можете объявить объект, чей тип отсрочен; вы получаете null-указатель, который не может использоваться до тех пор, пока ему не будет присвоено значение имеющего силу производного типа:
deferred class Set... -- Абстрактный класс
class Bit_Set inherit Set... -- Конкретный класс
S: Set; -- Абстрактный объект!
В: Bit_Set; - Конкретный объект
!!B; --Создать экземпляр В
S := В; -- Правильно,S получает конкретный объект,
S.Union(...); --который теперь можно использовать
как разрешить неоднозначности, если имя
Множественное наследование
Язык Eiffel поддерживает множественное наследование:
class Winged_Vehicle
feature
Weight: Integer;
display is . .. end;
end;
class Motorized_Vehicle
feature
Weight: Integer;
display is ... end;
end;
class Airplane inherit
Winged_Vehicle, Motorized_Vehicle
…
end;
Поскольку допускается множественное наследование, в языке должно определяться, как разрешить неоднозначности, если имя унаследовано от нескольких предков. Правило языка Eiffel в основе своей очень простое (хотя его формальное определение сложное, поскольку оно должно принимать во внимание все возможности иерархии наследования):
Если свойство унаследовано от класса предка более чем одним путем, оно используется совместно; в противном случае свойства реплицируются.
rename- и redef ine-конструкции могут использоваться для изменения имен по мере необходимости. В примере класс Airplane наследует только одно поле Weight. Очевидно, по замыслу предлагалось для класса иметь два поля Weight, одно для корпуса летательного аппарата и одно для двигателя. Этого можно достичь за счет переименования двух унаследованных объектов:
class Airplane inherit
Winged_Vehicle
rename Weight as Airframe_Weight;
Motorized_Vehicle
rename Weight as Engine_Weight;
…
end;
Предположим теперь, что мы хотим заместить подпрограмму display. Мы не можем использовать redefine, потому что при этом возникла бы неоднозначность указания подпрограммы, которую мы переопределяем. Решение состоит в том, чтобы использовать undefine для отмены определений обеих унаследованных подпрограмм и написать новую:
class Airplane inherit
Winged_Vehicle
undefine display end;
Motorized_Vehicle
undefine display end;
В справочном руководстве по языку
feature
display is... end;
end;
В справочном руководстве по языку Eiftel подробно обсуждается использование rename, redefine и undefine для разрешения неоднозначности при множественном наследовании.
15.5. Проектные соображения
Наследование и композиция
Наследование — это только один метод структурирования, который может использоваться в объектно-ориентированном проектировании. Более простым методом является композиция, которая представляет собой вложение одной абстракции внутрь другой. Вы уже знакомы с композицией, поскольку вам известно, что одна запись может быть включена внутрь другой:
with Airplane_Package;
package SS"f.Package is
type SST_Data is private;
private
type SST_Data is
record
A: Airplane. Data;
Mach: Float;
end record;
end SST_Package;
и в языке C++ класс может включать экземпляр другого класса как элемент:
class SST_Data {
private:
Airplane_Data a;
float mach;
};
Композиция — более простая операция, чем наследование, потому что для ее поддержки не требуется никаких новых конструкций языка; любая поддержка инкапсуляции модуля автоматически дает вам возможности для композиции абстракций. Родовые единицы, которые в любом случае необходимы в языке с проверкой соответствия типов, также могут использоваться для формирования абстракций. Наследование, однако, требует сложной поддержки языка (теговых записей в языке Ada и виртуальных функций в языке C++) и дополнительных затрат при выполнении на динамическую диспетчеризацию.
Если вам нужна динамическая диспетчеризация, то вы должны, конечно, выбрать наследование, а не композицию. Однако, если динамической диспетчеризации нет, выбор зависит только от решения вопроса, какой метод дает «лучший» проект. Вспомните, что язык C++ требует, чтобы при создании базового класса вы решили, должна ли выполняться динамическая диспетчеризация, объявляя одну или несколько подпрограмм как виртуальные; эти и только эти подпрограммы будут участвовать в диспетчеризации.
В языке Ada 95 динамическая
В языке Ada 95 динамическая диспетчеризация потенциально произойдет в любой подпрограмме, объявленной с управляющим параметром тегового типа:
type T is tagged ...;
procedure Proc(Parm: T);
Фактически решение, является связывание статическим или динамическим, принимается отдельно для каждого вызова. Не используйте наследование, когда подошла бы простая запись.
Основное различие между двумя методами состоит в том, что композиция просто использует существующую закрытую абстракцию, в то время как наследование знает о реализации абстракции. Пользователи закрытой абстракции защищены от изменения реализации. При использовании наследования базовые классы не могут изменяться без учета того, какие изменения это вызовет в производных классах.
С другой стороны, при каждом доступе к закрытой абстракции должна выполняться подпрограмма интерфейса, в то время как наследование разрешает эффективный прямой доступ производным классам. Кроме того, вы можете изменить реализацию в производном классе, в то время как в композиции ограничены использованием существующей реализации. Говоря кратко: легко «купить» и «продать» модули для композиции, в то время как наследование делает вас «партнером» разработчика модуля.
Нет никакой опасности при аккуратном и продуманном использовании любого метода; проблемы могут возникнуть, когда наследование используется беспорядочно, поскольку при этом может возникнуть слишком много зависимостей между компонентами программной системы. Мы оставляем подробное обсуждение относительных достоинств этих двух понятий специализированным работам по ООП. О преимуществах наследования см. книгу Мейера по конструированию объектно-ориентированного программного обеспечения (Meyer, Object-oriented Software Construction, Prentice-Hall International, 1988), особенно гл. 14 и 19. Сравните ее с точкой зрения предпочтения композиции, выраженной в статье J.P. Rosen, «What orientation should Ada objects take?» Communications of the ACM, 35(11), 1992, стр. 71—76.
Удобно разделить случаи применения наследования
Использование наследования
Удобно разделить случаи применения наследования на несколько категорий:
Подобие поведения. SST ведет себя как Airplane. Это простое применение наследования для совместного использования кода: операции, подходящие для Airplane, подходят для SST. Операции при необходимости могут быть замещены.
Полиморфная совместимость. Linked-Set (связанное множество) и Bit-Set (битовое множество) полиморфно совместимы с Set. Происходя от общего предка, множества, которые реализованы по-разному, могут быть обработаны с помощью одних и тех же операций. Кроме того, вы можете создавать разнородные структуры данных, отталкиваясь от предка, который содержит элементы всего семейства типов.
Родовая совместимость. Общие свойства наследуются несколькими классами. Эта методика применяется в больших библиотеках, таких как в языках Smalltalk или Eiffel, где общие свойства выносятся в классы-предки, иногда называемые аспект-классами (aspect classes). Например, класс Comparable (сравнимый) мог бы использоваться для объявления таких операций отношения, как «<», и любой такой класс, как Integer или Float, обладающий такими операциями, наследуется из Comparable.
Подобие реализации. Класс может быть создан путем наследования логических функций из одного класса и их реализации — из другого. Классический пример — Bounded_Stack, который (множественно) наследует функциональные возможности из Stack и их реализации из Array. В более общем смысле, класс, созданный множественным наследованием, наследовал бы функциональные возможности из нескольких аспект-классов и реализацию из одного дополнительного класса.
Эти категории не являются ни взаимоисключающими, ни исчерпывающими; они представлены как руководство к использованию этой мощной конструкции в ваших программных проектах.
Перегрузка и полиморфизм
Хотя перегрузка (overloading) — это форма полиморфизма («многофор-менности»), эти две концепции применяются в совершенно разных целях.
Перегрузка используется как удобное средство
Перегрузка используется как удобное средство для задания одного и того же имени подпрограммам, которые функционируют на различных типах, в то время как динамический полиморфизм используется для реализации операции для семейства связанных типов. Например:
C++ |
void proc put(float);
представляет перегрузку, потому что общее имя используется только для удобства, и между int и float нет никакой связи. С другой стороны:
C++ |
является одной подпрограммой, которая может быть реализована по-разному для разных типов самолетов.
Технически трудно совместить перегрузку и динамический полиморфизм и не рекомендуется использовать эти два понятия вместе. Не пытайтесь внутри порожденного класса перегружать подпрограмму, которая появляется в базовом классе:
C++ |
public:
void set_speed(float); //float, а не int
};
Правила языка C++ определяют, что эта подпрограмма и не перегружает, и не замещает подпрограмму в базовом классе; вместо этого она скрывает определение в базовом классе точно так же, как внутренняя область действия!
Язык Ada 95 допускает сосуществование перегрузки и замещения :
with Airplane_Package; use Airplane_Package;
package SST_Package is
Ada |
procedure Set_Speed(A: in out SST_Data; I: in Integer);
-- Замещает примитивную подпрограмму из Airplane_Package procedure Set_Speed(A: in out SST_Data; I: in Float);
-- Перегрузка, не подпрограмма-примитив
end SST_Package;
Поскольку нет примитивной подпрограммы Set_Speed с параметром Float для родительского типа, второе объявление — это просто самостоятельная подпрограмма, которая перегружает то же самое имя. Хотя это допустимо, этого следует избегать, потому что пользователь типа, скорее всего, запутается.
жете сказать, какая именно подпрограмма
Посмотрев только на SST_Package (и без комментариев!), вы не смо жете сказать, какая именно подпрограмма замещается, а какая перегружается:
Ada |
begin
Set_Speed(A, 500); -- Правильно, диспетчеризуется
Set_Speed(A, 500.0); -- Ошибка, не может диспетчеризоваться!
end Proc;
15.6. Методы динамического полиморфизма
Мы заключаем эту главу подведением итогов по динамическому полиморфизму в языках для объектно-ориентированного программирования.
Smalltalk. Каждый вызов подпрограммы требует динамической диспетчеризации, которая включает поиск по иерархии наследования, пока подпрограмма не будет найдена.
Eiftel. Каждый вызов подпрограммы диспетчеризуется динамически (если оптимизация не привела к статическому связыванию). В отличие от языка Smalltalk, возможные замещения известны во время компиляции, поэтому диспетчеризация имеет фиксированные издержки, вносимые таблицей переходов.
C++. Подпрограммы, которые явно объявлены виртуальными и вызываются косвенно через указатель или ссылку, диспетчеризуются динамически. Диспетчеризация во время выполнения имеет фиксированные издержки.
Ada 95. Динамическая диспетчеризация неявно используется для примитивных подпрограмм тегового типа, когда фактический параметр является CW-типом, а формальный параметр имеет конкретный тип. Затраты на диспетчеризацию во время выполнения фиксированы.
Языки отличаются деталями программирования и затратами, требующимися для динамического полиморфизма, и это влияет на стиль программирования и эффективность программ. Ясное понимание заложенных в языках принципов поможет вам сравнивать объектно-ориентированные языки и разрабатывать и создавать хорошие объектно-ориентированные программы на любом языке, который вы выберете.
Реализуйте пакеты на языке Ada
15.7. Упражнения
1. Реализуйте пакеты на языке Ada 95 и классы на языке C++ для работы с множествами.
2. Может ли абстрактный тип в языке Ada 95 или абстрактный класс в языке C++ иметь компоненты-данные? Если так, для чего они могли бы использоваться?
type Item is abstract tagged
Ada |
I: Integer;
end record;
3. Напишите программу для неоднородной очереди, основываясь на абстрактном классе.
4. Реализуйте пакеты/классы для множеств с родовым типом элемента, а не только для целочисленных элементов.
5. Подробно изучите множественное наследование в языке Eiffel и сравните его с множественным наследованием в языке C++.
6. Стандартный пример множественного наследования в языке Eiffel -список фиксированного размера, реализованный с помощью наследования, как от списка, так и от массива. Как бы вы написали такие ADT (абстрактные типы данных) на языке Ada 95, в котором нет множественного наследования?
7. Чем опасно определение защищенных (protected) данных в языке C++? Относится ли это также к пакетам-детям в языке Ada 95?
7. Изучите структуру стандартной библиотеки в языке Ada 95, в котором широко используются пакеты-дети. Сравните ее со структурой стандартных классов ввода-вывода в языке C++.
9. Изучите пакет Finalization в языке Ada 95, который может использоваться для написания конструкторов и деструкторов. Сравните его с конструкциями языка C++.
10. Какова связь между операторами присваивания и конструкторами/де структорами?
11. Дайте примеры использования CW-объектов.
5Непроцедурные
языки
программирования
Глава 16
Функциональное программирование
16.1. Почему именно функциональное программирование?
В разделе 1.8 мы упоминали, что и Черч и Тьюринг предложили модели для вычислений задолго до того, как появились первые компьютеры.
Машины Тьюринга очень похожи на
Машины Тьюринга очень похожи на современные компьютеры тем, что они основаны на обновляемой памяти, т. е. наборе ячеек памяти, содержимое которых изменяется при выполнении команд. Это также известно как архитектура фон Неймана.
Формулировка модели вычислений Черча (названная лямбда-исчислением) совершенно другая — она основана на математическом понятии функции. Эта формулировка полностью эквивалентна формулировке Тьюринга в смысле представления вычислений, которые могут быть точно описаны, но в качестве формализма, применяемого для вычислений на практике, функциональный подход всегда был менее популярен. В языке Lisp, разработанном в 1956 г., для вычислений используется функциональный подход, подобный модели лямбда-исчисления, хотя многие его особенности поощряют стиль процедурного программирования.
В 1980-е годы дальнейшие исследования в области функционального программирования привели к разработке языков на чисто теоретических основаниях, которые тем не менее могут быть эффективно реализованы. Основное различие между современными функциональными языками программирования и языком Lisp состоит в том, что в них типы и контроль соответствия типов являются базисными понятиями, поэтому значительно возросли и надежность, и эффективность программ.
Многие проблемы, с которыми мы сталкиваемся при написании надежной программы, возникают непосредственно из-за использования обновляемой памяти:
• Память может быть «затерта», потому что мы непосредственно изменяем ячейки памяти (используя индексы массива или указатели), а не просто вычисляем значения.
• Трудно создавать сложные программы из компонентов, потому что подпрограммы могут иметь побочные эффекты. Поэтому может оказаться, что даже осознать все последствия работы подпрограммы нельзя в отрыве от всей остальной программы.
Строгий контроль соответствия типов и методы инкапсуляции объектно-ориентированного программирования могут смягчить эти проблемы, но не могут устранить их полностью.
При функциональном подходе обе эти
При функциональном подходе обе эти проблемы исчезают.
Дальнейшее обсуждение будет базироваться на популярном языке Standart ML, хотя эти понятия справедливы и для других языков.
16.2. Функции
Функции определяются в языке ML заданием равенства между именем функции с формальным параметром и выражением:
fun even n = (n mod 2 = 0)
Различие состоит в том, что здесь нет никаких глобальных переменных, никакого присваивания, никаких указателей и, следовательно, никаких побочных эффектов. После того как функция была определена, она может быть применена (applied), и вычисление (evaluation) ее применения и дает результат:
even 4 = true
even 5 = false
С каждой функцией связан тип, точно так же, как в языках программирования типы связаны с переменными. Тип функции even (четное) задается следующим образом:
even: int -> bool
Это означает, что она отображает значение целочисленного типа в значение булева типа.
В языке ML выражения могут содержать условия:
fun min (x,y) = if x < у then x else у
Приведем пример вычисления при применении функции:
min (4,5) =
(if x < у then x else у) (4,5) =
if 4 < 5 then 4 else 5 =
if true then 4 else 5 =
4
Обратите внимание, что это не if-оператор, а условное выражение, аналогичное имеющемуся в языке С:
х< у?х:у
Какой тип у min? В функциональном программировании считается, что функция имеет в точности один аргумент, если же вам требуется большее число аргументов, вы должны создать кортеж (двойной, тройной и т.д.), используя функцию декартова произведения. Таким образом, (4,5) имеет тип int x int, a функция min имеет тип:
min: (int x int) -> int
Вместо кортежей вы можете определить функцию, которая будет применяться к каждому аргументу по очереди:
fun min_c x у = if x < у then x else у
Это карризованная функция (curriedfunction, от имени математика Н.В.
вом обращении создается другая функция,
Сипу). Когда эта функция применяется к последовательности аргументов, при пер вом обращении создается другая функция, которая затем применяется ко второму аргументу.
Функция min_c берет один целочисленный аргумент и создает новую функцию, также с одним аргументом:
min_c 4 = if 4 < у then 4 else у
Эта функция может затем применяться к другому одиночному аргументу:
min_c 4 5 =
(if 4 < у then 4 else у) 5 =
if 4 < 5 then 4 else 5 =
if true then 4 else 5 =
4
Карризованные функции могут использоваться в частичных вычислениях для определения новых функций:
fun min_4 = min_c 4
min_4 5 =
(if 4 < у then 4 else y) 5 =
if 4 < 5 then 4 else 5 =
if true then 4 else 5 =
4
16.3. Составные типы
Списки
Список можно создать из элементов любого предварительно определенного типа, в частности из встроенных типов, например целого или булева. Следующие списки:
[2,3,5,7,11 ] [true, false, false]
имеют типы int list и bool list, соответственно. Список можно создать также с помощью конструкторов (constructors); конструкторы списка — это [] для пустого списка, и element::list для непустого списка, создаваемого добавлением элемента (element) к существующему списку (list). Конструкторы могут использоваться при определении функций путем сопоставления с образцом:
fun member [] e = false
| member [e :: tail] e = true
j member [e1 :: tail] e = member tail e
Тип функции member (член) определяется как:
member: int list x jnt -> boolean
и это можно прочитать следующим образом:
Когда функция member применяется к списку L, а (затем) к элементу е, вычисление основывается на вариантах выбора в зависимости от аргументов: 1) если L пуст, е не является членом L; 2) если е — первый элемент L, то е является членом L; 3) в противном случае, е1, первый элемент списка L, отличен от е, и мы (рекурсивно) проверяем, является ли е членом оставшейся части списка L.
В языке ML вам не
В языке ML вам не нужно объявлять тип функции; компилятор автоматически выводит тип функции из типов аргументов и типа результата. Если компилятор не может вывести тип, вам придется задать нужное количество объявлений типа, чтобы ликвидировать неопределенность выражения. Контроль соответствия типов статический, поэтому, когда функция применяется к значению, проверка соответствия типа функции типу параметра делается во время компиляции.
Обратите внимание, что эта функция рекурсивная. Рекурсия чрезвычайно важна в функциональных языках программирования; при отсутствии «операторов» это единственный способ создания циклов при вычислении выражения.
В качестве заключительного примера покажем, как на языке ML написать алгоритм сортировки вставкой (insertion sort). Вы используете этот алгоритм для сортировки при игре в карты: по очереди берете карты из колоды и кладете их в нужное место:
fun insertion_sort [] = []
| insertion_sort head:: tail =
insert_element head insertion_sort tail
and
fun insert_element x [] = [x]
| insert_element x head:: tail =
if x < head then x::head:: tail
else head:: (insert_element x tail)
Эти функции имеют типы:
insertion_sort: int list -> int list
insert_element: int -> int list -> int list
Как только вы привыкнете к этой записи, читать такие программы будет просто:
Отсортированный пустой список является пустым. При сортировке непустого списка берется первый элемент х, сортируется оставшаяся часть списка tail, а затем х вставляется в подходящее место отсортированного списка.
Элемент х вставляется в пустой список, создавая список из одного элемента. Чтобы вставить х в непустой список, нужно сравнить х с первым элементом (head) списка: 1) если х меньше head, сделать х новым первым элементом списка; 2) в противном случае создать новый список, составленный из head, за которым следует остаток списка, в который вставлен х.