лекции (2007) (1160825), страница 12
Текст из файла (страница 12)
...
virtual void f () {...}
...
}
class Y: public X
{
...
void f () {...}
...
}
...
X a; Y b;
X* px;
px = &a; px -> f(); //будет вызвана f() из класса X
px = &b; px -> f(); //будет вызвана f() из класса Y
Вообще говоря, у виртуальных функций нет принципиальных отличий от обычных функций; отличается лишь механизм их вызова. Для виртуальных функций вводится такое понятие, как "замещение" - это функция, которая вызывается вместо исходной виртуальной функции при её вызове. Кроме того, виртуальность не является каким-то временным свойством - если функция виртуальна, она не может перестать быть виртуальной; и наоборот, если функция не является виртуальной, она не сможет ей стать.
Механизм виртуальных функций часто бывает очень удобен. Простой пример - написание графического редактора, который позволит пользователям рисовать разные фигуры: окружность, линию, точку. У каждого из таких объектов есть функция отрисовки, но у каждого она своя, отличная от других. Вместе с тем легко может возникнуть потребность в отрисовке набора разных фигур. Как это можно реализовать традиционными средствами: заводится структура данных "фигура", способная хранить необходимую информацию о всех типах фигур (в терминах языка C это, скорее всего, будет union), и одно из её полей отводится идентификатор того, какая эта фигура (например, в роли идентификатора может выступать целое число: если оно равно 1, то это линия, если 2 - окружность и т.д.). Затем для каждого вида фигур определяются свои функции отрисовки (DrawPoint, DrawLine, DrawCircle, ...). Тогда при отрисовке необходимо проверить, какое значение имеет идентификатор данной фигуры, и вызвать соответствующую функцию отрисовки (если фигура является линией, то необходимо вызвать DrawLine и т.д.). Конечно, это неудобный способ; скажем, при добавлении в программу нового вида фигур придётся перерабатывать весь код, связанный с отрисовкой.
В языке Oberon можно поступить несколько проще:
PROCEDURE ProcMethod (VAR F : Figure);
Figure = RECORD
X, Y : INTEGER;
DP : ProcMethod;
END;
Line = RECORD (Figure)
X1, Y1 : INTEGER;
END;
...
L : Line;
L.DP := DrawLine;
Тогда при отрисовке можно будет просто вызывать L.DP. Но этот метод тоже не очень удобен, поскольку всякий раз, когда будет создаваться новая фигура, её нужно будет проинициализировать, ассоциировав с ней соответствующую функцию отрисовки.
Самый простой способ - использовать механизм виртуальных функций. Достаточно сделать класс "Figure" и определить там функцию отрисовки "Draw" как виртуальную, а затем унаследовать от класса "Figure" классы "Circle", "Line", "Point" и другие необходимые; при этом в каждом из них необходимо будет написать функцию-замещение для виртуальной функции "Draw". Тогда отрисовка произвольной фигуры сведётся, опять же, просто к вызову "Draw" для этой фигуры, но при этом не нужно будет инициализировать функцию отрисовки для каждой фигуры.
Однако программисту следует быть осторожным, так как при использовании виртуальных функций могут возникать ситуации, в которых легко сделать ошибку и тяжело её потом найти. Например, рассмотрим такой случай:
class X
{
...
virtual void f() {...}
...
}
class Y : public X
{
...
virtual void f() {...}
...
void g()
{
...
f();
...
}
}
class Z : public Y
{
...
void f() {...}
...
}
Проблема - в функции g(): выражение "f()" на самом деле подразумевает "this->f()", поэтому в объектах типа Z будет вызываться не Y::f(), как наверняка планировалось программистом, а Z::f(). Поэтому иногда бывает полезно снимать виртуальность; в данном примере следовало бы переписать функцию g() в классе Y, явно указав там Y::f().
Естественно, в языках C#, Java и Delphi тоже есть механизмы виртуальных функций, причём в Java и C# все функции считаются виртуальными (то есть связываются динамически). Это несколько увеличивает накладные расходы, но придаёт дополнительную гибкость этим языкам. Синтаксис использования виртуальных функций в них практически не отличается от синтаксиса языка C++, за исключением того, что в Java и C# введены новые ключевые слова для управления виртуальностью. В языке Java это ключевое слово "final"; оно может иметь разную семантику в зависимости от применения:
1) "final int a = 10" означает, что значение a больше нельзя будет изменять (аналог "const" в других языках)
2) "final void f() {...}" снимает виртуальность с метода f() (то есть метод нельзя будет замещать)
3) "final class Y {...}" означает, что класс Y нельзя будет наследовать
Аналогом "final" в языке C# является ключевое слово "sealed" (оно может быть применено к методу или классу). В языке C++ класс можно "закрыть" private-наследованием.
Ещё одним тонким момент - программист должен чётко различать механизмы статического перекрытия ("overloading") и динамического замещения ("overriding"). В языках C# и Delphi для этого используются ключевые слова "virtual" и "override":
class X
{
...
public virtual void f() {...}
...
}
class Y : public X
{
...
virtual void f() {...}
void f() {...}
override void f() {...}
...
}
Естественно, это условная запись; в реальных ситуациях необходимо выбирать только один подходящий вариант. Первая и вторая записи ("virtual void f()" и "void f()") означают, что Функция f() класса Y перекроет функцию f() класса X; при этом в первом случае новая функция f() будет ещё и виртуальной. А вот третья запись ("override void f()") означает подтверждение виртуальности, и функция f() класса Y будет являться замещением функции f() класса X.
В языке Delphi - всё то же самое, только синтаксис отличается: "procedure P; virtual;" и "procedure P; override;" соответственно.
Как же реализуется динамическое связывание в языках программирования? Наиболее распространённым подходом является введение так называемой таблицы виртуальных методов (ТВМ) для каждого типа, в котором есть виртуальный метод, и добавление ссылки на эту ТВМ во все объекты этого класса. В частности, в языке C++ каждая такая ТВМ состоит из указателей на реализации соответствующих виртуальных функций. Например, пусть имеется класс X, в котором определены две виртуальные функции, f() и g():
class X
{
...
virtual void f() {...}
virtual void g() {...}
...
}
Тогда ТВМ для класса X будет содержать следующие указатели:
f - &X::f
g - &X::g
Если же от класса X унаследовать класс Y, в котором переопределить функцию f() и ввести новую виртуальную функцию h(), а затем унаследовать от класса Y класс Z, в котором переопределить функции g() и h(), то ТВМ для классов Y и Z примут следующий вид:
ТВМ для класса Y:
f - &Y::f
g - &X::g
h - &Y::h
ТВМ для класса Z:
f - &Y::f
g - &Z::g
h - &Z::h
Соответственно, при вызове функции f(), g() или h() для объекта класса X, Y или Z компилятор обратится к требуемой ТВМ и вызовет нужную реализацию вызываемой функции. При этом накладные расходы, связанные с вызовом ТВМ, не зависят от структуры и иерархии типов, они постоянны. Немного по-другому реализованы ТВМ в языке Smalltalk. В этом языке память распределяется не линейным образом, а цепным. Поэтому для рассматриваемого примера:
- в ТВМ для класса Z будут находиться указатели "&Z::g" и "&Z::h", а также ссылка на ТВМ для класса Y;
- в ТВМ для класса Y будут находиться указатели "&Y::f" и "&Y::h", плюс ссылка на ТВМ для класса X;
- в ТВМ для класса X будут находиться указатели "&X::f" и "&X::g".
Получается, что при вызове виртуальной функции производится проход по иерархии снизу вверх: если указатель на реализацию нужной функции есть в ТВМ конкретного класса, то происходит вызов этой реализации; если нет, придётся подниматься на уровень выше, в ТВМ суперкласса. И так может продолжаться до тех пор, пока компилятор не дойдёт до ТВМ для типа Object, который является вершиной иерархии; если и в этой ТВМ нет реализации необходимого метода, то будет выдана ошибка. Таким образом, основным недостатком этой реализации является её неэффективность: если иерархия классов большая, то поиск необходимой реализации виртуального метода может занять достаточно большое время. В частности, такой подход неприменим в индустриальном программировании, где как раз достаточно часто встречаются большие иерархии.
У больших систем есть ещё одна неприятная особенность - иногда создавать ТВМ становится слишком накладно, даже если учесть, что для каждого типа данных нужна всего одна ТВМ. Один из самых распространённых примеров - разработка графического интерфейса. В нём вполне может быть под десяток различных типов окон, которые должны реагировать на большое число (порядка сотен) различных событий. В таких системах дополнительно вводятся специальные механизмы: например, если обработчика для события не в соответствующей ТВМ, то оно игнорируется.
Наконец, отдельно стоит отметить язык Oberon-2, который представляет собой объектно-ориентированное расширение Oberon. Динамическое связывание в нём реализовано несколько нестандартно, с помощью так называемых "процедур, динамически привязанных к типу".
Введём класс X и унаследуем от него класс Y:
TYPE X = RECORD
...
END;
TYPE Y = RECORD(X)
...
END;
Обычные процедуры на языке Oberon-2 выглядят так: "PROCEDURE Prc (VAR P : X)"; любое перекрытие и замещение имён для таких процедур запрещено. Новой по сравнению с Oberon-1 является следующая запись:
PROCEDURE (VAR P : X) Prc ();
Она означает, что процедура Prc может быть переопределена, если её параметр будет иметь тип, являющийся производным от X. Таким образом, можно переопределить процедуру Prc для типа Y и работать с ней, как с виртуальной:
PROCEDURE (VAR P : Y) Prc ();
...
VAR A : X; B : Y;
A.Prc(); (* будет вызвана "PROCEDURE (VAR P : X) Prc ()" *)
B.Prc(); (* будет вызвана "PROCEDURE (VAR P : Y) Prc ()" *)
В языке Ada (начиная со стандарта Ada-95) тоже присутствует динамическое связывание, и оно тоже реализовано реализовано необычно. Надо отметить, что при его добавлении в язык ставилась задача обратной совместимости с языком Ada-83, поэтому революционных изменений в новом стандарте не произошло. Для тэгированных типов было введено понятие типов класса ("class-wide types", или cw-типов) и соответствующее ключевое слово "class" (однако классы в языке не появилось). Переменные cw-типов имеют смысл только как формальные параметры процедур и обозначают, что на их место могут быть подставлены фактические параметры не только конкретного типа, но и любого производного от него типа. Например:
type T is tagged private;
type T1 is new T with private;
...
procedure P (X : T) is ... end P;
procedure P (X : T1) is ... end P;
procedure F (X : T) is
...
P(X);
...
end F;
procedure G (X : T' class) is
...
P(X);
...
end F;
...
A : T; B : T1;
F(A); -- будет вызвана P для T
F(B); -- будет вызвана P для T
G(A); -- будет вызвана P для T
G(B); -- будет вызвана P для T1 (!)
С помощью такого механизма можно сделать всё, что позволяет механизм виртуальных функций. Но он также даёт практически уникальную возможность динамической привязки по нескольким параметрам, создания так называемых мультиметодов (в других языках аналогов не реализовано). Иногда это может быть очень полезно. Например, может возникнуть необходимость вычислять пересечение двух фигур (фигуры могут иметь различную природу). Во всех остальных ЯП это будет достаточно неудобно реализовывать, в Ada всё будет очень просто: создаем тэгированный тип "Figure" и наследуем от него типы, соответствующие разным фигурам. Затем описываем процедуру пересечения как "procedure Intersect(X : Figure' class, Y : Figure' class);", внутри которой будет вызываться какая-то процедура IS(X, Y). Остаётся только реализовать все необходимые варианты IS для разных типов фигур, и динамически, в зависимости от типов обеих пересекаемых фигур, внутри процедуры Intersect будет вызываться нужная процедура IS.