Лекция 8: |
оглавление |
Вернемся к примеру: для изображения и удаления окружности недостаточно родительских методов Point.Show() и Point.Hide(). Поэтому потребовалось написать новые, перекрывающие их методы Circle.Show() и Circle.Hide(). Теперь сравните метод Circle.MoveTo() и Point.MoveTo().
Кто видит разницу?
Разницы, по крайней мере, внешней, нет. А что произойдет, если мы не будем перегружать этот метод?
Да, скорее всего, по экрану будет двигаться не окружность, а точка. Дело в том, что для всех методов было применено так называемое раннее связывание. Раннее или позднее связывание – это термины, относящиеся к этапу, на котором обращение к процедуре связывается с ее адресом. В случае раннего связывания адреса всех функций и процедур известны в тот момент, когда происходит компоновка программы.
В большинстве традиционных языков программирования, включая и С, используется только раннее связывание. В противоположность этому, в случае позднего связывания адрес процедуры не связывается с обращением к ней до того момента, пока обращение не произойдет фактически, т.е. во время выполнения программы. Возвращаясь к нашему примеру. Если мы вызовем Point.MoveTo(), то будет вызываться Point.Show() и Point.Hide(). Только из-за этого мы вынуждены заменять Point.MoveTo() на внешне почти полностью аналогичную функцию Circle.MoveTo().
Логика компилятора очень проста, сначала он ищет метод с таким именем, определенным внутри данного класса. Если метод с таким именем не определен внутри класса, то компилятор обращается к базовому классу и ищет его там. Если найдет, то подставит в точки вызова адрес метода из родительского класса. Если не найдет, то поднимается все выше.
Методы, которые мы обсудили, называются статическими. Они являются статическими в том смысле, что компилятор размещает их и разрешает ссылки на них во время компиляции. Это достаточно мощные средства для организации сложных программ. Однако они являются далеко не лучшим методом для обработки методов.
Рассмотренные выше проблемы обусловлены разрешением ссылок на методы во время компиляции. Способ решения этой проблемы – разрешить такие ссылки во время выполнения, динамически.
Для того чтобы это стало возможным нужен специальный механизм – в языке С++ это виртуальные методы.
Виртуальные методы реализуют чрезвычайно мощное средство для обобщения, называемое полиморфизмом, о котором мы уже говорили. В нашем случае оно означает следующее: способ задания одноименного действия, которое распределяется вверх и вниз по иерархии объектов, с выполнением этого действия способом, соответствующим каждому объекту в иерархии.
Описанная нами простая иерархия графических фигур представляет собой хороший пример полиморфизма в действии. Каждый тип объекта (класса) в нашей иерархии представляет различный тип фигуры на экране – точку или окружность. Возможно, позднее вы захотите определить классы для других фигур, таких как линия, квадрат или дуги.
Мы можем написать метод для каждой фигуры, который изобразит эту фигуру на экране. В терминах ОО мышления можно сказать, что наши классы графических фигур имеют способность отображать себя на экране. То, каким образом объект класса должен изображать себя на экране, является различным для каждого класса. Точка рисуется с помощью программы изображения точки, которой не требуется ничего, кроме координат позиции и, возможно, цвета. Для изображения окружности требуется совершенно отдельная графическая программа, принимающая в расчет не только X и Y, но также и радиус. А для дуги нужны еще начальный и конечный угол и более сложная процедура прорисовки. Можно изобразить любую графическую фигуру, но механизм, посредством которого изображается каждая фигура, должен быть предопределен. При этом одно слово Show будет использоваться для изображения всех форм.
Различие между вызовом статического метода и вызовом виртуального метода – это различие между решением сейчас и отложенным, задержанным решением. Когда мы кодируем вызов статического метода, то, по–существу, говорим компилятору: "Ты знаешь, чего я хочу. Делай вызов". Вызов виртуального метода, с другой стороны, подобен тому, что мы говорим компилятору: "Ты не знаешь, чего я хочу – пока. Когда придет время запроси образец".
Давайте подумаем над этой метафорой в терминах нашей проблемы с MoveTo(). Вызов Circle.MoveTo() может идти только в одно место: ближайшую реализацию MoveTo() вверх по иерархии объектов. В нашем случае он вызовет Point.MoveTo(), поскольку это ближайшая реализация. Это решение будет осуществлено во время компиляции. Но когда MoveTo() вызовет Show(), то это другая история. Каждый класс фигур имеет свою собственную реализацию метода Show(), поэтому то, какая реализация Show() вызывается в MoveTo(), должно полностью зависеть от того, какой объект вызвал MoveTo().
Это и является причиной того, почему вызов Show() внутри MoveTo() должен быть задержанным решением: при компиляции кода для MoveTo() мы не можем решить, вызов какого Show() сделать. Информация недоступна во время компиляции, поэтому решение следует отложить до времени выполнения, когда можно запросить тип объекта вызывающего MoveTo().
Процесс, посредством которого вызовы статического метода разрешаются однозначно во время компиляции, называется ранним связыванием.
Во время раннего связывания вызывающий метод и вызываемый метод связываются при первом удобном случае, то есть во время компиляции.
При позднем связывании вызываемого метода и вызывающего метода они не могут быть связаны во время компиляции, поэтому специальный механизм на это место для их дальнейшего связывание, потом, когда вызов будет сделан фактически.
Что же делать, если мы хотим, чтобы "наследник" вел себя отлично от "предка", сохраняя при этом свойство совместимости с ним? На этот случай существуют виртуальные методы.
Виртуальный метод – это метод, который, будучи описан в потомках, замещает собой соответствующий метод везде, даже в методах, описанных для предка, если он вызывается для потомка.
Адрес виртуального метода известен только в момент выполнения программы. Когда происходит вызов виртуального метода, его адрес берется из таблицы виртуальных методов своего класса. Таким образом, вызывается то, что нужно.
В чем же преимущество виртуальных методов? Самое главное в том, что они допускают обработку объектов, тип которых неизвестен во время компиляции.
Это новый способ мышления для С.
Предположим, вы написали пакет графических средств, который поддерживает различные типы фигур: точки, окружности, квадраты и т.п. может возникнуть необходимость написать в качестве части этого пакета подпрограмму, которая будет изображать графическую фигуру с помощью "мыши".
Старый способ заключается в написании отдельных процедур для изображения графических фигур всех типов. Написать единую процедуру для рисования всех этих фигур будет затруднительно: окружность не имеет сторон, квадрат имеет только одну длину стороны и др.
Наиболее искусные программисты будут передавать признак фигуры, а затем, например, с помощью оператора switch выбирать нужную подпрограмму.
А что делать, если вы вдруг решите включить новую фигуру, например, восьмиугольник (для рисования дорожных знаков).
В вашем switch 'е нет такого case, и даже если вы напишите необходимую для рисования функцию, вы не сможете ее вызвать (разве что добавите еще один case). Явный недостаток вашего пакета – без дополнительных затрат на модифицирование уже отлаженных подпрограмм он может работать только с типами данных, которые он знает, то есть которые были определены разработчиками.
А вот полиморфные методы, определенные только сейчас, могут быть объединены совместно с программой, которая была откомпилирована год назад. Виртуальные методы – ключ к решению этой проблемы.
О классе, содержащем виртуальный метод, говорят как о полиморфном классе. В чем же преимущество полиморфных классов?
Самое главное в том, что полиморфные классы допускают обработку объектов, тип которых неизвестен во время компиляции. Функции, описанные в базовом классе как виртуальные, могут быть модифицированы в производных классах, причем связывание произойдет не на этапе компиляции (то, что называется ранним связыванием), а в момент обращения к данному методу (позднее связывание).
Виртуальные методы описываются с помощью ключевого слова virtual в базовом классе. Это означает, что в производном классе этот метод может быть замещен методом, более подходящим для этого производного класса. Объявленный виртуальным в базовом классе, метод останется виртуальным для всех производных классов. Если в производном классе виртуальный метод не будет переопределен, то при вызове будет найден метод с таким именем вверх по иерархии классов (т.е. в базовом классе).
Перепишем нашу иерархию классов.
class Point
{
...
public:
...
virtual void Show ();
virtual void Hide ();
void MoveTo (int newX, int newY);
};
...
реализация методов класса Point
...
class Circle: public Point
{
...
public:
... // без метода MoveTo()
virtual void Show ();
virtual void Hide ();
};
...
реализация методов класса Circle
...
Прежде всего, заметим, что в классе Circle нет метода MoveTo(), он наследуется из класса Point.
При этом все вызовы отложенных в MoveTo() методов будут методами класса Circle, так как они являются виртуальными методами.
Область применения виртуальных функций не ограничивается случаями, аналогичными представленному выше. Другая сфера приложения виртуальных функций связана с использованием указателей на объекты. Особенность указателей в С++ состоит в том, что указатель, объявленный в качестве указателя на базовый класс, также может использоваться, как указатель на любой класс, производный от этого базового. Так что, в это смысле, возможно
Point pointObj (100,20); // объект базового класса
Circle circleObj (20,30,10); // объект производного класса
Point *pointPtr; // указатель базового класса
pointPtr = & pointObj; // указывает на объект базового класса
pointPtr = & circleObj; // указывает на объект производного класса
Поскольку через указатель на объект можно вызывать метод этого объекта, то следующая запись вполне законна.
pointPtr = & pointObj;
pointPtr->MoveTo(10,10);
Таким же образом можно обратиться и к методу объекта производного класса.
pointPtr = & circleObj;
pointPtr -> Expand(12);
Еще больший интерес представляет вызов виртуальных методов, используя этот указатель.
pointPtr = & pointObj;
pointPtr->Show(10,10); // вызов Show() объекта pointObj класса Point
pointPtr = & circleObj;
pointPtr->Show(10,10); // вызов Show() объекта circleObj класса Circle
Посмотрим, как это свойство виртуальных методов и указателей на объекты базового класса может быть полезно в наше примере с точками и окружностями.
Рассмотрим некоторую процедуру JumpFigure(), имитирующую "скачек" некоторого объекта на экране на заданную высоту h.
void JumpFigure (Point* AnyFigure, int h)
{
int oldX = AnyFigure->GetX();
int oldY = AnyFigure->GetY();
delay(100); // временная задержка на 0.1 сек
AnyFigure->MoveTo (oldX, oldY-h); // "прыжок"
delay(100); // временная задержка на 0.1 сек
AnyFigure->MoveTo (oldX, oldY); // на исходную позицию
}
Функции JumpFigure() можно передавать указатель на любой объект типа Point, либо на объект класса, порожденного из класса Point.
Как код функции JumpFigure() узнает, объект какого класса фактически передан ей? Да никак. JumpFigure() и не знает класс этого объекта. JumpFigure() только делает ссылку на идентификаторы, определенные в классе Point. Благодаря наследованию эти идентификаторы определены так же в любом классе, порожденном классом Point.
Методы GetX(), GetY(), Show() и MoveTo() фактически присутствуют в классе Circle, так же как и в Point, и будут присутствовать в любом будущем классе, определенном в качестве потомка как Point, так и Circle.
Методы GetX(), GetY() и MoveTo() статические методы. Это означает, что JumpFigure() знает адрес каждой процедуры еще во время компиляции.
С другой стороны, Show() – это виртуальный метод. Есть различные реализации Show() для Point и для Circle, и JumpFigure() не знает во время компиляции, какая реализация будет вызываться.
Короче говоря, когда JumpFigure() вызывается, она ищет адрес правильной реализации Show() в таблице виртуальных методов класса, переданного в AnyFigure. Если экземпляр имеет тип Point, то вызовется Point.Show(), если это объект класса Circle, то Circle.Show(). Решение будет принято в момент вызова.
Итак, мы достигли значительного результата. Теперь JumpFigure() может изобразить объект любого класса, порожденного классом Point, независимо от того, существовал ли данный класс при компиляции функции JumpFigure() или нет.
Иногда, когда виртуальная функция объявляется в базовом классе, она не выполняет никаких значимых действий. Это вполне обычная ситуация, поскольку часто базовый класс не определяет законченный тип, или, если рассматривать наш пример, объект базового типа не представляет особой ценности. Речь здесь идет о том, что хотя базовый класс (Point) и содержит базовый набор методов и состояний, который производный класс дополняет всем недостающим, полезность точки, как графического объекта, не велика и, программируя, например, некоторый графический интерфейс, без точки, как объекта, можно вполне обойтись. Однако мы выяснили, что именно Point, как нельзя лучше подходит для роли базового класса, поскольку Point содержит в себе главные атрибуты любых графических объектов. Т.е. мы пришли к пониманию того, что класс Point ценен не своими объектами, а своей способностью выступать в качестве базового класса.
Возвращаясь к виртуальным методам, заметим, что теперь, когда отпала необходимость создавать объекты класса Point, методы виртуальные Show() и Hide() стали нужны только для того, чтобы их обязательно переопределили в производных классах. Для реализации этого положения С++ поддерживает чисто виртуальные методы.
Чисто виртуальные методы не определяются в базовом классе. У них нет тела, а есть только декларации об их существовании.
Для чисто виртуальной функции используется общая форма
virtual тип имя_функции (список параметров) = 0;
Ключевой частью этого объявления является приравнивание функции к нулю.
Класс, содержащий хотя бы один виртуальный метод, называется абстрактным классом. Абстрактные классы не бывают изолированными, т.е. всегда абстрактный класс должен быть наследуемым. Поскольку у чисто виртуального метода нет тела, то создать объект абстрактного класса невозможно.
Абстрактным классом можно назвать класс, специально определенный для обеспечения наследования характеристик порожденными классами.
Вот как теперь станет выглядеть наш новый Point.
class Point
{
protected:
int X;
int Y;
Boolean Visible;
public:
int GetX(void) { return X; }
int GetY(void) { return Y; }
Boolean isVisible (){ return Visible;}
Point (int newX =0, int new Y =0);
virtual void Show() = 0; // чисто виртуальная функция
virtual void Hide() = 0; // чисто виртуальная функция
void MoveTo (int newX, int newY)
{
Hide();
X = newX; Y = newY;
Show();
}
};
Класс Point теперь действует как заготовка, из которого все порожденные классы могут взять элементы, общие для всех типов в иерархии, но теперь, при попытке создать объект типа Point, компилятор выдаст сообщение об ошибке.
Заметьте, что необходимость конструктора при этом не отпала, поскольку по-прежнему нужно будет инициализировать X, Y и Visible для производных классов.
Приведенное выше логическое обоснование или разумное объяснение демонстрирует важный принцип ООП: определяя иерархию классов, соберите все общие атрибуты в один абстрактный класс, и определите иерархию классов так, чтобы все общие элементы использовались из этого класса.
КОНЕЦ ВОСЬМОЙ СЕРИИ |