Бьерн Страуструп (947334), страница 9
Текст из файла (страница 9)
s.push ( kitty );
cat c2 = s.pop ();
// ...
}
Поскольку интерфейс стека ничего не сообщает о его представлении, от
пользователей стека полностью скрыты детали его реализации.
Можно предложить несколько различных реализаций стека. Например, стек
может быть массивом:
template < class T >
class astack : public stack < T >
{
// истинное представление объекта типа стек
// в данном случае - это массив
// ...
public:
astack ( int size );
~astack ();
void push ( T );
T pop ();
};
Можно реализовать стек как связанный список:
template < class T >
class lstack : public stack < T >
{
// ...
};
Теперь можно создавать и использовать стеки:
void g ()
{
lstack < cat > s1 ( 100 );
astack < cat > s2 ( 100 );
cat Ginger;
cat Snowball;
some_function ( s1, Ginger );
some_function ( s2, Snowball );
}
О том, как представлять стеки разных видов, должен беспокоиться только
тот, кто их создает (т.е. функция g()), а пользователь стека (т.е. автор
функции some_function()) полностью огражден от деталей их реализации.
Платой за подобную гибкость является то, что все операции над стеками
должны быть виртуальными функциями.
1.5 Поддержка объектно-ориентированного программирования
Поддержку объектно-ориентированного программирования обеспечивают
классы вместе с механизмом наследования, а также механизм вызова
функций-членов в зависимости от истинного типа объекта (дело в том, что
возможны случаи, когда этот тип неизвестен на стадии трансляции). Особенно
важную роль играет механизм вызова функций-членов. Не менее важны
средства, поддерживающие абстракцию данных (о них мы говорили ранее). Все
доводы в пользу абстракции данных и базирующихся на ней методов, которые
позволяют естественно и красиво работать с типами, действуют и для языка,
поддерживающего объектно-ориентированное программирование. Успех обоих
методов зависит от способа построения типов, от того, насколько они
просты, гибки и эффективны. Метод объектно-ориентированного
программирования позволяет определять более общие и гибкие
пользовательские типы по сравнению с теми, которые получаются, если
использовать только абстракцию данных.
1.5.1 Механизм вызова
Основное средство поддержки объектно-ориентированного программирования
- это механизм вызова функции-члена для данного объекта, когда истинный
тип его на стадии трансляции неизвестен. Пусть, например, есть указатель
p. Как происходит вызов p->rotate(45)? Поскольку С++ базируется на
статическом контроле типов, задающее вызов выражение имеет смысл только
при условии, что функция rotate() уже была описана. Далее, из обозначения
p->rotate() мы видим, что p является указателем на объект некоторого
класса, а rotate должна быть членом этого класса. Как и при всяком
статическом контроле типов проверка корректности вызова нужна для того,
чтобы убедиться (насколько это возможно на стадии трансляции), что типы в
программе используются непротиворечивым образом. Тем самым гарантируется,
что программа свободна от многих видов ошибок.
Итак, транслятору должно быть известно описание класса, аналогичное
тем, что приводились в $$1.2.5:
class shape
{
// ...
public:
// ...
virtual void rotate ( int );
// ...
};
а указатель p должен быть описан, например, так:
T * p;
где T - класс shape или производный от него класс. Тогда транслятор
видит, что класс объекта, на который настроен указатель p, действительно
имеет функцию rotate(), а функция имеет параметр типа int. Значит,
p->rotate(45) корректное выражение.
Поскольку shape::rotate() была описана как виртуальная функция, нужно
использовать механизм вызова виртуальной функции. Чтобы узнать, какую
именно из функций rotate следует вызвать, нужно до вызова получить из
объекта некоторую служебную информацию, которая была помещена туда при его
создании. Как только установлено, какую функцию надо вызвать, допустим
circle::rotate, происходит ее вызов с уже упоминавшимся контролем типа.
Обычно в качестве служебной информации используется таблица адресов
функций, а транслятор преобразует имя rotate в индекс этой таблицы. С
учетом этой таблицы объект типа shape можно представить так:
center
vtbl:
color &X::draw
&Y::rotate
...
...
Функции из таблицы виртуальных функций vtbl позволяют правильно
работать с объектом даже в тех случаях, когда в вызывающей функции
неизвестны ни таблица vtbl, ни расположение данных в части объекта,
обозначенной ... . Здесь как X и Y обозначены имена классов, в которые
входят вызываемые функции. Для объекта circle оба имени X и Y есть circle.
Вызов виртуальной функции может быть по сути столь же эффективен, как
вызов обычной функции.
1.5.2 Проверка типа
Необходимость контроля типа при обращениях к виртуальным функциям
может оказаться определенным ограничением для разработчиков библиотек.
Например, хорошо бы предоставить пользователю класс "стек чего-угодно".
Непосредственно в С++ это сделать нельзя. Однако, используя шаблоны типа и
наследование, можно приблизиться к той эффективности и простоте
проектирования и использования библиотек, которые свойственны языкам с
динамическим контролем типов. К таким языкам относится, например, язык
Smalltalk, на котором можно описать "стек чего-угодно". Рассмотрим
определение стека с помощью шаблона типа:
template < class T > class stack
{
T * p;
int sz;
public:
stack ( int );
~stack ();
void push ( T );
T & pop ();
};
Не ослабляя статического контроля типов, можно использовать такой стек
для хранения указателей на объекты типа plane (самолет):
stack < plane * > cs ( 200 );
void f ()
{
cs.push ( new Saab900 ); // Ошибка при трансляции :
// требуется plane*, а передан car*
cs.push ( new Saab37B );
// прекрасно: Saab 37B - на самом
// деле самолет, т.е. типа plane
cs.pop () -> takeoff ();
cs.pop () -> takeoff ();
}
Если статического контроля типов нет, приведенная выше ошибка
обнаружится только при выполнении программы:
// пример динамическое контроля типа
// вместо статического; это не С++
Stack s; // стек может хранить указатели на объекты
// произвольного типа
void f ()
{
s.push ( new Saab900 );
s.push ( new Saab37B );
s.pop () -> takeoff (); // прекрасно: Saab 37B - самолет
cs.pop () -> takeoff (); // динамическая ошибка:
// машина не может взлететь
}
Для способа определения, допустима ли операция над объектом, обычно
требуется больше дополнительных расходов, чем для механизма вызова
виртуальных функций в С++.
Рассчитывая на статический контроль типов и вызов виртуальных функций,
мы приходим к иному стилю программирования, чем надеясь только на
динамический контроль типов. Класс в С++ задает строго определенный
интерфейс для множества объектов этого и любого производного класса, тогда
как в Smalltalk класс задает только минимально необходимое число операций,
и пользователь вправе применять незаданные в классе операции. Иными
словами, класс в С++ содержит точное описание операций, и пользователю
гарантируется, что только эти операции транслятор сочтет допустимыми.
1.5.3 Множественное наследование
Если класс A является базовым классом для B, то B наследует атрибуты
A. т.е. B содержит A плюс еще что-то. С учетом этого становится очевидно,
что хорошо, когда класс B может наследовать из двух базовых классов A1 и
A2. Это называется множественным наследованием.
Приведем некий типичный пример множественного наследования. Пусть есть
два библиотечных класса displayed и task. Первый представляет задачи,
информация о которых может выдаваться на экран с помощью некоторого
монитора, а второй - задачи, выполняемые под управлением некоторого
диспетчера. Программист может создавать собственные классы, например,
такие:
class my_displayed_task: public displayed, public task
{
// текст пользователя
};
class my_task: public task {
// эта задача не изображается
// на экране, т.к. не содержит класс displayed
// текст пользователя
};
class my_displayed: public displayed
{
// а это не задача
// т.к. не содержит класс task
// текст пользователя
};
Если наследоваться может только один класс, то пользователю доступны
только два из трех приведенных классов. В результате либо получается
дублирование частей программы, либо теряется гибкость, а, как правило,
происходит и то, и другое. Приведенный пример проходит в С++ безо всяких
дополнительных расходов времени и памяти по сравнению с программами, в
которых наследуется не более одного класса. Статический контроль типов от
этого тоже не страдает.
Все неоднозначности выявляются на стадии трансляции:
class task
{
public:
void trace ();
// ...
};
class displayed
{
public:
void trace ();
// ...
};
class my_displayed_task:public displayed, public task
{
// в этом классе trace () не определяется
};
void g ( my_displayed_task * p )
{
p -> trace (); // ошибка: неоднозначность
}
В этом примере видны отличия С++ от объектно-ориентированных диалектов
языка Лисп, в которых есть множественное наследование. В этих диалектах
неоднозначность разрешается так: или считается существенным порядок
описания, или считаются идентичными объекты с одним и тем же именем в
разных базовых классах, или используются комбинированные способы, когда
совпадение объектов доля базовых классов сочетается с более сложным
способом для производных классов. В С++ неоднозначность, как правило,
разрешается введением еще одной функции:
class my_displayed_task:public displayed, public task
{
// ...
public:
void trace ()
{
// текст пользователя
displayed::trace (); // вызов trace () из displayed
task::trace (); // вызов trace () из task
}
// ...
};
void g ( my_displayed_task * p )
{
p -> trace (); // теперь нормально
}
1.5.4 Инкапсуляция
Пусть члену класса (неважно функции-члену или члену, представляющему
данные) требуется защита от "несанкционированного доступа". Как разумно
ограничить множество функций, которым такой член будет доступен? Очевидный
ответ для языков, поддерживающих объектно-ориентированное
программирование, таков: доступ имеют все операции, которые определены для
этого объекта, иными словами, все функции-члены. Например:
class window
{
// ...
protected:
Rectangle inside;
// ...
};
class dumb_terminal : public window
{
// ...
public:
void prompt ();
// ...
};
Здесь в базовом классе window член inside типа Rectangle описывается
как защищенный (protected), но функции-члены производных классов,
например, dumb_terminal::prompt(), могут обратиться к нему и выяснить, с
какого вида окном они работают. Для всех других функций член
window::inside недоступен.
В таком подходе сочетается высокая степень защищенности
(действительно, вряд ли вы "случайно" определите производный класс) с
гибкостью, необходимой для программ, которые создают классы и используют
их иерархию (действительно, "для себя" всегда можно в производных классах
предусмотреть доступ к защищенным членам).
Неочевидное следствие из этого: нельзя составить полный и
окончательный список всех функций, которым будет доступен защищенный член,
поскольку всегда можно добавить еще одну, определив ее как функцию-член в
новом производном классе. Для метода абстракции данных такой подход часто
бывает мало приемлемым. Если язык ориентируется на метод абстракции
данных, то очевидное для него решение - это требование указывать в
описании класса список всех функций, которым нужен доступ к члену. В С++
для этой цели используется описание частных (private) членов. Оно
использовалось и в приводившихся описаниях классов complex и shape.
Важность инкапсуляции, т.е. заключения членов в защитную оболочку,
резко возрастает с ростом размеров программы и увеличивающимся разбросом
областей приложения. В $$6.6 более подробно обсуждаются возможности языка
по инкапсуляции.
1.6 Пределы совершенства
Язык С++ проектировался как "лучший С", поддерживающий абстракцию