|
Двойная передача В обобщенном виде задачу можно представить в виде матрицы, строки которой соответствуют типам левого операнда, а столбцы - всевозможным типам правого операнда. В каждой ячейке матрицы находится конкретный алгоритм для обработки сочетания типов. Чаще всего такая ситуация возникает для гомоморфных иерархий вроде нашей, но вообще типы левого операнда не обязаны совпадать с типами правого операнда. Конечно, возможны силовые решения - например, запрятать в каждом экземпляре сведения о его типе. Однако более элегантное решение (и обычно более эффективное) решение носит название двойной передачи (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&) настоящий тип левого операнда уже известен, однако правый операнд все еще остается загадкой. Но в этот момент наступает второй этап двойной передачи: return n + *this. Левый и правый операнды меняются местами, а компилятор приступает к поискам v-таблицы n. Однако на этот раз он ищет переопределение Number::operator+(Integer&), так как он знает, что *this в действительности имеет тип Integer. Это приводит к вызову Integer::operator+(Integer&), поскольку типы обоих операндов известны и можно наконец произвести вычисления. Если вы так и не поняли, что же происходит, прогуляйтесь на свежем воздухе и попробуйте снова, пока не поймете. Возможно, вам поможет следующая формулировка: вместо кодирования типа в целой переменной мы определили настоящий тип Number с помощью v-таблицы. Такое решение не только элегантно. Вероятно, оно еще и более эффективно, чем те, которые приходили вам в голову. Скажем, приходилось ли вам видеть код, генерируемый компилятором для конструкции switch/case? Он некрасив и вдобавок куда менее эффективен, чем последовательное индексирование двух v-таблиц. Несмотря на всю элегантность, двойная передача довольно дорого обходится по объему кода и сложности: 1. Если у вас имеется m производных классов и n операторов, то каждый производный класс должен содержать m*(n+1) виртуальных функций, да еще столько же чисто виртуальных заглушек в классе-предке. Итого мы получаем (m+1)*m*(n+1) диспетчерских функций. Для всех иерархий, кроме самых тривиальных, это довольно много. 2. Если оператор не является коммутируемым (то есть ему нельзя передать повторный вызов с аргументами, переставленными в обратном порядке), это число удваивается, поскольку вам придется реализовать отдельные функции для двух вариантов порядка аргументов. Например, y/x - совсем не то же, что x/y; вам понадобится оператор / и специальная функция DivideInto для переставленных аргументов. 3. Клиенты базового класса видят все устрашающие защищенные функции, хотя это им совершенно не нужно. Тем не менее, в простых ситуациях двойная передача оказывается вполне разумным решением - ведь проблема, как ни крути, достаточно сложна. Специфика ситуации неизбежно требует множества мелких фрагментов кода. Двойная передача всего лишь заменяет большие, уродливые, немодульные конструкции switch/case более быстрой и модульной виртуальной диспетчеризацией. Как правило, количество функций удается сократить, но при этом приходится в той или иной степени идти на компромисс с нашим строгим правилом - никогда не спрашивать у объекта, каков его настоящий тип. Некоторые из этих приемов рассматриваются ниже. Видимость производных классов для клиентов Number тоже удается ликвидировать минимальной ценой; об этом будет рассказано в главе 12. Как и во многих проблемах дизайна в С++, в которых задействованы матрицы операций, вам придется на уровне здравого смысла решить, стоит ли повышать модульность за счет быстродействия или объема кода. |
Copyright 2005. Климов Александр. All Right Reserved.