Простые типы 14 страница

14.1. Объектно-ориентированное программирование на языке C++
Говорят, что язык программирования поддерживает ООП, если он включает конструкции для:
• инкапсуляции и абстракции данных,
• наследования,
• динамического полиморфизма.
Такие модули, как пакеты в языке Ada, инкапсулируют вычислительные ресурсы, выставляя только спецификацию интерфейса. Абстракция данных может быть достигнута через определение представления данных в закрытой части, к которой нельзя обращаться из других единиц. Единица инкапсуляции и абстракции в языке C++ — это класс (class), который содержит объявления подпрограмм и типов данных. Из класса создаются фактические объекты, называемые экземплярам u (inslances). Пример класса в языке 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) функцией, т.е. не используется механизм вызова подпрограммы и возврата из нее. Вместо этого код тела подпрограммы вставляется непосредственно внутрь последовательности кода в точке вызова. Поскольку при подстановке функции мы расплачиваемся пространством за время, подпрограммы должны быть очень маленькими (не более двух или трех команд). Другой фактор, который следует рассмотреть перед подстановкой подпрограммы, это то, что она вводит дополнительные условия для компиляции. Если изменить подставляемую подпрограмму, все клиенты должна быть перекомпилированы.

14.2. Наследование
В языке Ada один тип может быть получен из другого так, что производный тип получает копии значений и операций, которые были определены для порождающего типа. Задав порождающий тип:
package Airplane_Package is
type Airplane_Data is
record
ID: String(1..80);
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;
производный тип можно объявить в другом пакете:

type New_Airplane_Data is
new Airplane_Package.Airplane_Data;
Можно объявлять новые подпрограммы, которые выполняют операции на производном типе, и заменять подпрограммы родительского типа новыми:

procedure Display_Airplane(Data: in New_Airplane_Data);
--Дополнительная подпрограмма
procedure Get_Airplane(Data: in New_Airplane_Data; I: out Integer);
-- Замененная подпрограмма
-- Подпрограмма New_Airplane скопирована из Airplane_Data
Производные типы образуют семейство типов, и значение любого типа из семейства может быть преобразовано в значение другого типа из этого семейства:

А1: Airplane_Data;

А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;

public:

float get_mach() const {return mach;};

void set mach(float m) {mach = m;};

};
Производный класс SST_Data получен из существующего класса Airplane_Data. Это означает, что каждый элемент данных и каждая подпрограмма, которые определены для базового класса (base class), доступны и в производном классе. Кроме того, каждое значение производного класса SST_Data будет иметь дополнительный компонент данных mach, и есть две новые подпрограммы, которые могут применяться к значениям производного типа.
Производный класс — это обычный класс в том смысле, что могут быть объявлены экземпляры и вызваны подпрограммы:
SST_Data s; C++
s.set_speed(1400); //Унаследованная подпрограмма
s.set_mach(2.4); // Новая подпрограмма
Подпрограмма, вызванная для set_mach, — это подпрограмма, которая объявлена внутри класса SST_ Data, а подпрограмма, вызванная для set_speed, -это подпрограмма, которая унаследована от базового класса. Обратите внимание, что производный класс может быть откомпилирован и скомпонован без изменения и перекомпиляции базового класса; таким образом, расширение на существующий код воздействовать не должно.

14.3. Динамический полиморфизм в языке C++

Когда один класс порожден из другого класса, вы можете замещать (override) унаследованные подпрограммы в производном классе, переопределяя их:

class SST_Data: public Airplane_Data {
public:
int get_speed() const; // Заместить

void set_speed(int): //Заместить

};

Если задан вызов:
obj.set_speed(100);
то решение, какую именно из подпрограмм вызвать — подпрограмму, унаследованную из Airplane_Data, или новую в SST_ Data, — принимается во время компиляции на основе класса объекта obj.Это называется статическим связыванием (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); // Вызвать с Airplane_Data
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.
Преимущества динамического полиморфизма: можно писать большие блоки программы полностью в общем виде, используя вызовы виртуальных подпрограмм. Специализация обработки конкретного класса в семействе производных классов делается только во время выполнения за счет диспетчеризации виртуальных подпрограмм. Кроме того, если надо добавить производные классы в семейство, не нужно будет изменять или перекомпилировать ни один из существующих кодов, потому что любое изменение в существующей программе ограничено исключительно новыми реализациями виртуальных подпрограмм. Например, если мы порождаем еще один класс:
class Space_Plane_Data: public SST_Data {

virtual void set_speed(int); // Заместить виртуальную подпрограмму

private:

int reentry_speed;

};

Space_Plane_Data sp;

update(sp,2000,30000);
файл, содержащий определение для update, не нужно перекомпилировать, даже если а) новая подпрограмма заместила set_speed и б) значение формального параметра d в update содержит дополнительное поле reentry_speed.

14.4. Когда используется динамический полиморфизм?
Объявим базовый класс с виртуальной подпрограммой и обычной не виртуальной подпрограммой и породим класс, который добавляет дополнительное поле и дает новые объявления для обеих подпрограмм:
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 ordinary_proc();
};

Затем объявим экземпляры классов в качестве переменных. Присваивание значения производного класса переменной из базового класса разрешено:

Рис. 3. Прямое присваивание производного объекта.
Base_Class Base_Object;
Derived_Class Derived_0bject;

if (...) Base_Object = Derived_Object;
потому что производный объект является базовым объектом (плюс дополнительная информация), и при присваивании дополнительная информация может игнорироваться (см. рис. 3).
Более того, вызов подпрограммы (виртуальной или не виртуальной) однозначный, и компилятор может использовать статическое связывание:

Base_0bject.virtual_proc();

Base_0bject.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;

В этом случае семантика другая, так как базовый указатель ссылается на пол-ный производный объект без каких-либо усечений (см. рис.4). При реализации не возникает никаких проблем, т.к. все указатели представляются одинаково независимо от указуемого типа.

Рис.4. Косвенное присваивание производного объекта.
Важно обратить внимание на то, что после присваивания указателя компилятор больше не имеет никакой информации относительно типа указуемого объекта. Таким образом, у него нет возможности привязать вызов к правильной подпрограмме,
Base_Ptr->virtual_proc();
и следует выполнить динамическую диспетчеризацию. Аналогичная ситуация возникает, когда используется ссылочный параметр, как было показано выше.
Эта ситуация может внести путаницу, так как программисты обычно не делают различия между переменной и указуемым объектом. После следующих операторов:

int i1 =1;

int i2 = 2;

int*p1=&i1; // р1 ссылается на i1
int *р2 = &i2; // р2 ссылается на i2
р1 = р2; // р1 также ссылается на i2
i1 = i2; // И имеет то же самое значение, что и i2
ожидается, что i1 == i2 и *р1 ==*р2; это, конечно, правильно, пока типы в точности совпадают, но это неверно для присваивания производного класса базовому классу из-за усечения. При использовании наследования вы должны помнить, что указуемый объект может иметь тип, отличный от типа указуемого объекта в объявлении указателя.
Есть одна западня в семантике динамического полиморфизма языка C++: если вы посмотрите внимательно, то заметите, что обсуждение касалось диспетчеризации, относящейся к замещенной виртуальной подпрограмме. Но в классе могут также быть и обычные подпрограммы, которые замещаются:

Base_Ptr = Derived_Ptr;

Base_Ptr->virtual_proc(); // Диспетчеризуется по указанному типу

Base_Ptr->ordinary_proc(); // Статическое связывание с базовым типом!!
Существует различие в семантике между двумя вызовами: вызов виртуальной подпрограммы диспетчеризуется во время выполнения в соответствии с типом указуемого объекта, в данном случае Derived_Class; вызов обычной подпрограммы связывается статически во время компиляции в соответствии с типом указателя, в данном случае Base_Class. Это различие весьма существенно, потому что изменение, которое состоит в замене невиртуальной подпрограммы на виртуальную подпрограмму или обратно, может вызвать ошибки во всем семействе классов, полученных из базового.
Динамическая диспетчеризация в языке C++ рассчитана на вызовы виртуальных подпрограмм, осуществляемые через указатель или ссылку.

14.6. Реализация
Если подпрограмма не найдена в производном классе, то поиск делается в предшествующих классах, пока не будет найдено определение подпрограммы. В случае статического связывания поиск можно делать во время компиляции: компилятор просматривает базовый класс производного класса, затем его базовый класс, и так далее, пока не будет найдено соответствующее связывание подпрограммы. Затем для этой подпрограммы может компилироваться обычный вызов процедуры.
Если используются виртуальные подпрограммы, ситуация усложняется, потому что фактическая подпрограмма, которая должна быть вызвана, не известна до времени выполнения. Если виртуальная подпрограмма вызывается с объектом конкретного типа, в противоположность ссылке или указателю, то все еще может использоваться статическое связывание. С другой стороны, решение, какую именно подпрограмму следует вызвать, основано на 1) имени подпрограммы и 2) классе объекта. Но первое известно во время компиляции, поэтому нам остается только смоделировать case-оператор по классам.
Обычно реализация выглядит немного иначе; для каждого класса с виртуальными подпрограммами поддерживается таблица диспетчеризации (см. рис.5). Каждое значение класса должно «иметь при себе» свой индекс для входа в таблицу диспетчеризации для порождающего семейства, в котором оно определено. Элементы таблицы диспетчеризации являются указателями на таблицы переходов; в каждой таблице переходов содержатся адреса входов в виртуальные подпрограммы. Два элемента таблицы переходов могут указывать на одну и ту же процедуру; это произойдет, когда класс не замещает виртуальную подпрограмму. На рисунке 5. cls3 произведен из
Таблица диспетчеризации
Таблицы переходов

Рис.5. Реализация динамического полирфизма
cls2, который в свою очередь произведен из базового класса cls1. Здесь cls2 заместил р2, но не р1, в то время как cls3 заместил обе подпрограммы.
Когда встречается вызов диспетчеризуемой подпрограммы ptr->p1(), выполняется код наподобие приведенного ниже, где неявный индекс — это первое поле указуемого объекта:
load R0.ptr Получить адрес объекта
load R1,(R0) Получить индекс указуемого объекта
load R2,&dispatch Получить адрес таблицы отправлений
add R2,R1 Вычислить адрес таблицы переходов
load R3,(R2) Получить адрес таблицы переходов
load R4,p1(R3) Получить адрес процедуры
call (R4) Вызвать процедуру, адрес которой находится в R4
Даже без последующей оптимизации затраты на время выполнения относительно малы, и, что более важно, фиксированы, поэтому в большинстве приложений нет необходимости воздерживаться от использования динамического полиморфизма. Но все же издержки существуют и применять динамический полиморфизм следует только после тщательного анализа. Лучше избегать обеих крайностей: и чрезмерного использования динамического полиморфизма только потому, что это «хорошая идея», и отказа от него, потому что это «неэффективно».
Фиксированные затраты получаются благодаря тому, что динамический полиморфизм ограничен фиксированным набором классов, порожденных из базового класса (поэтому может использоваться таблица диспетчеризации фиксированного размера), и фиксированным набором виртуальных функций, которые могут быть переопределены (поэтому размер каждой таблицы переходов также фиксирован). Значительным достижением языка C++ была демонстрация того, что динамический полиморфизм может быть реализован без неограниченного поиска во время выполнения.




Понравилась статья? Добавь ее в закладку (CTRL+D) и не забудь поделиться с друзьями:  



double arrow
Сейчас читают про: