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

14.3. Наследование

В разделе 4.6 мы показали, как в языке Ada один тип может быть получен из другого так, что производный тип получает копии значений и операций, которые были определены для порождающего типа. Задав порождающий тип:

package Airplane_Package is

type Airplane_Data is

record

Ada

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;

производный тип можно объявить в другом пакете:

Ada


type New_Airplane_Data is

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

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

C++

public:

float get_mach() const {return mach;};

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

};

Производный класс SST_Data получен из существующего класса Airplane_Data. Это означает, что каждый элемент данных и каждая подпро­грамма, которые определены для базового класса (base class), доступны и в производном классе. Кроме того, каждое значение производного класса SST_Data будет иметь дополнительный компонент данных mach, и есть две новые подпрограммы, которые могут применяться к значениям производно­го типа.

Производный класс — это обычный класс в том смысле, что могут быть объявлены экземпляры и вызваны подпрограммы:

C++

SST_Data s;

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:

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++ была демонстрация того, что динамический полимор­физм может быть реализован без неограниченного поиска во время выполнения.