163
Двойная передача
В обобщенном виде задачу можно представить в виде матрицы, строки которой соответствуют типам
левого операнда, а столбцы — всевозможным типам правого операнда. В каждой ячейке матрицы
находится конкретный алгоритм для обработки сочетания типов. Чаще всего такая ситуация возникает
для гомоморфных иерархий вроде нашей, но вообще типы левого операнда не обязаны совпадать с
типами правого операнда.
Конечно, возможны силовые решения — например, запрятать в каждом экземпляре сведения о его
типе. Однако более элегантное решение (и обычно более эффективное) решение носит название
двойной передачи (double dispatch).
class Number {
protected:
//
Диспетчерские функции для оператора +
virtual Number& operator+(const Integer&) = 0;
virtual Number& operator+(const Complet&) = 0;
//
И т.д. для всех производных типов
public:
virtual Number& operator+(const Number&) = 0;
virtual Number& operator-(const Number&) = 0;
//
И т.д.
};
class Integer : public Number {
private:
int
I;
protected:
virtual Number& operator+(const Integer&);
virtual Number& operator+(const Complex&);
public
Integer(int x) : i(x) {}
virtual Number& operator+(const Number&);
//
И т.д.
};
Number& Integer::operator+(const Number& n)
{
return n + *this; // Поменять местами левый и правый операнд
}
Number& Integer::operator+(const Integer& n)
{
//
Ниже приведен псевдокод
if (i + n.i слишком велико для int) {
return
ЦелоеСПовышеннойТочностью
}
else return Integer(i + n.i);
}
С этим фрагментом связана одна нетривиальная проблема, к которой мы вернемся позже, а пока
сосредоточьте все внимание на концепции. Она похожа на стереограмму — чтобы скрытая картинка
проявилась, вам придется расслабить глаза и некоторое время рассматривать код. Когда клиент
пытается сложить два
Integer
, компилятор передает вызов
Integer::operator+()
, поскольку
operator+(Number&)
является виртуальным — компилятор правильно находит реализацию
производного класса. К моменту выполнения
Integer::operator+(Number&)
настоящий тип левого
164
операнда уже известен, однако правый операнд все еще остается загадкой. Но в этот момент наступает
второй этап двойной передачи:
return n + *this
. Левый и правый операнды меняются местами, а
компилятор приступает к поискам v-таблицы
n
. Однако на этот раз он ищет переопределение
Number::operator+(Integer&)
, так как он знает, что
*this
в действительности имеет тип
Integer
.
Это приводит к вызову
Integer::operator+(Integer&)
, поскольку типы обоих операндов известны
и можно наконец произвести вычисления. Если вы так и не поняли, что же происходит, прогуляйтесь
на свежем воздухе и попробуйте снова, пока не поймете. Возможно, вам поможет следующая
формулировка: вместо кодирования типа в целой переменной мы определили настоящий тип
Number
с
помощью v-таблицы.
Такое решение не только элегантно. Вероятно, оно еще и более эффективно, чем те, которые
приходили вам в голову. Скажем, приходилось ли вам видеть код, генерируемый компилятором для
конструкции
switch/case
? Он некрасив и вдобавок куда менее эффективен, чем последовательное
индексирование двух v-таблиц.
Несмотря на всю элегантность, двойная передача довольно дорого обходится по объему кода и
сложности:
•
Если у вас имеется m производных классов и n операторов, то каждый производный класс
должен содержать m*(n+1) виртуальных функций, да еще столько же чисто виртуальных
заглушек в классе-предке. Итого мы получаем (m+1)*m*(n+1) диспетчерских функций. Для
всех иерархий, кроме самых тривиальных, это довольно много.
•
Если оператор не является коммутируемым (то есть ему нельзя передать повторный вызов с
аргументами, переставленными в обратном порядке), это число удваивается, поскольку вам
придется реализовать отдельные функции для двух вариантов порядка аргументов. Например,
y/x
— совсем не то же, что
x/y
; вам понадобится оператор
/
и специальная функция
DivideInto
для переставленных аргументов.
•
Клиенты базового класса видят все устрашающие защищенные функции, хотя это им
совершенно не нужно.
Тем не менее, в простых ситуациях двойная передача оказывается вполне разумным решением — ведь
проблема, как ни крути, достаточно сложна. Специфика ситуации неизбежно требует множества
мелких фрагментов кода. Двойная передача всего лишь заменяет большие, уродливые, немодульные
конструкции
switch/case
более быстрой и модульной виртуальной диспетчеризацией.
Как правило, количество функций удается сократить, но при этом приходится в той или иной степени
идти на компромисс с нашим строгим правилом — никогда не спрашивать у объекта, каков его
настоящий тип. Некоторые из этих приемов рассматриваются ниже. Видимость производных классов
для клиентов
Number
тоже удается ликвидировать минимальной ценой; об этом будет рассказано в
главе 12. Как и во многих проблемах дизайна в С++, в которых задействованы матрицы операций, вам
придется на уровне здравого смысла решить, стоит ли повышать модульность за счет быстродействия
или объема кода.
Гетероморфная двойная передача
Двойная передача обычно возникает в ситуациях, когда оба аргумента происходят от общего предка,
но это не обязательно. Левый и правый операнды могут принадлежать к разным классам, не имеющим
общего предка.
Один из моих любимых примеров относится к обработке событий в графических средах. Существует
множество возможных событий: операции и мышью, события от клавиатуры, события операционной
системы и даже такая экзотика, как распознавание голоса или световое перо. С другой стороны, в
пользовательский интерфейс обычно входят разнообразные виды, панели или окна (терминология
зависит от операционной системы и используемого языка) — внешние окна с заголовками и кнопками
закрытия, поля для редактирования текста и области, в которых можно рисовать красивые картинки.
Для каждой комбинации конкретного события с конкретным типом вида может потребоваться
уникальная реализация. Возникает та же проблема, что и с иерархией чисел, хотя на этот раз события и
виды не имеют общего базового класса. Тем не менее, методика двойной передачи все равно работает.
class Event {
// Чисто виртуальный базовый класс для событий
public:
Do'stlaringiz bilan baham: |