М. Бен-Ари - Языки программирования. Практический сравнительный анализ (2000) (1160781), страница 40
Текст из файла (страница 40)
Говорят, что язык программирования поддерживает ООП, если он включает конструкции для:
• инкапсуляции и абстракции данных,
• наследования,
• динамического полиморфизма.
Позвольте нам вернуться к обсуждению инкапсуляции и абстракции данных из предыдущей главы.
Такие модули, как пакеты в языке 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 |
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 |
А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 {