К достоинствам объектно-ориентированного подхода относится поддержка эволюционной разработки, что позволяет наращивать функциональность, не изменяя существующий код. Однако реализация мультиметодов порождает определенные проблемы и зачастую ведет к использованию приемов из арсенала процедурного программирования. В статье рассмотрен ряд трюков, позволяющих удержаться в русле объектной ориентации.
Достаточно часто возникают ситуации, когда гомоморфные иерархии, определяемые как иерархии классов с одинаковым открытым интерфейсом, унаследованным от общего базового класса [1], взаимодействуют через функцию, виртуальную к произвольному числу полиморфных параметров. Такая функция называется мультиметодом [2], а возможность ее использования существует в языке программирования CLOS (Common Lisp Object System). Однако широко распространенные объектно-ориентированные языки программирования не поддерживают подобный механизм в связи с отсутствием его эффективной реализации [3].
Традиционные подходы
Для иллюстрации воспользуемся вычитанием чисел, задаваемых соответствующими классами. Числами манипулировал и Джефф Элджер [1], Скотт Мейерс в подобной ситуации сталкивал разнородные космические объекты [2], а Бьерн Страуструп занимался пересечением геометрических фигур [3].
Самый простой вариант, опирающийся на объектно-ориентированный подход, заключается в использовании виртуального метода для определения типа первого аргумента. Второй аргумент выявляется с помощью RTTI (runtime type identification — «идентификация типов во время выполнения») [2]. Классы, входящие в гомоморфную иерархию, реализованы следующим образом:
class Number { public: // Вычитание второго аргумента из данного virtual Number* Subtract (Number& num2) = 0; virtual void StdOut() = 0; }; class Int: public Number { public: // Вычитание второго аргумента из целочисленного Number* Subtract(Number& num2); void StdOut(); Int(int v): _value(v) { } // конструктор int GetValue() {return _value;} // получение значения private: int _value; }; class Double: public Number { public: // Вычитание второго аргумента из действительного Number* Subtract(Number& num2); void StdOut(); Double(double v): _value(v) { } // конструктор double GetValue() {return _value;} // получение значения private: double _value; };
Метод StdOut, осуществляющий вывод значения числа, используется только для тестирования и в дальнейшем не рассматривается. Производные классы реализуют вычитание из себя второго аргумента, динамически выявляя его тип:
Number* Int::Subtract(Number& num2) { // Второй аргумент - целое число if(Int* pInt = dynamic_cast (&num2)) { return new Int(_value - pInt-> GetValue()); } // Второй аргумент - действительное число else if(Double* pDouble = dynamic_cast< Double*>(&num2)) { return new Double(_value - pDouble-> GetValue()); } else { return 0; } } Number* Double::Subtract (Number& num2) { // Второй аргумент - целое число if(Int* pInt = dynamic_cast (&num2)) { return new Double(_value - pInt-> GetValue()); } // Второй аргумент - действительное число else if(Double* pDouble = dynamic_cast< Double*> (&num2)) { return new Double(_value - pDouble-> GetValue()); } else { return 0; } }
Мультиметод, выполняющий вычитание, обращается к методу класса, инициирующему диспетчеризацию:
Number* operator - (Number& n1, Number& n2) { return n1.Subtract(n2); }
К недостаткам следует отнести изменение методов во всех производных классах при добавлении новой разновидности чисел, например, комплексных. Кроме этого, с увеличением числа альтернатив, замедляется анализ вариантов.
Более быстрым является чисто объектно-ориентированное решение, на основе двойной диспетчеризации [1]. Она делит монолитную операцию на два метода, каждый из которых определяет тип первого и второго операнда, используя полиморфизм. Базовый класс задает весь необходимый интерфейс:
class Number { public: virtual Number* Subtract (Number& num2) = 0; virtual Number* SubtFromInt (int v) = 0; virtual Number* SubtFromDouble (double v) = 0; virtual void StdOut() = 0; };
Метод Subtract инициирует вычитание и определяет тип первого числа. Для получения типа второго числа необходимы функции, вычитающие значения текущих объектов из передаваемых им первых аргументов. Эти методы тоже прописываются в базовом классе; производные классы обеспечивают реализацию в соответствии с характеристиками моделируемых объектов.
class Int: public Number { public: Number* Subtract(Number& num2); Number* SubtFromInt(int v); Number* SubtFromDouble(double v); void StdOut(); Int(int v) : _value(v) { } private: int _value; }; class Double: public Number { public: Number* Subtract(Number& num2); Number* SubtFromInt(int v); Number* SubtFromDouble(double v); void StdOut(); Double(double v) : _value(v) { } private: double _value; }; // — Реализация методов — // Диспетчеризация при первом целом аргументе Number* Int::Subtract(Number& num2) { return num2.SubtFromInt(_value); } Number* Int::SubtFromInt(int v) { return new Int(v - _value); } Number* Int::SubtFromDouble(double v) { return new Double(v - _value); } // Диспетчеризация при первом действительном аргументе Number* Double::Subtract(Number& num2) { return num2.SubtFromDouble(_value); } Number* Double::SubtFromInt(int v) { return new Double(v - _value); } Number* Double::SubtFromDouble(double v) { return new Double(v - _value); }
Двойная диспетчеризация тоже не подходит для эволюционного программирования, поскольку как добавление нового класса ведет к переопределению интерфейсов всей, уже существующей, гомоморфной иерархии.
Для исправления ситуации в [2] предложена смесь из RTTI, ассоциативных массивов и процедурного подхода. Не сомневаясь в возможности использования подобного решения, я все же решил попробовать «построить эволюцию» только с применением классов. В результате возник ряд трюков, целесообразность которых и выносится на обсуждение.
Нужна ли всеобщая универсальность?
Основной причиной, заставляющей изменять разработанные классы, является стремление обеспечить их универсальность по отношению к новым понятиям. Например, при реализации функции F(A1, A2), задающей взаимодействие двух гомоморфных агентов, всегда формируется матрица отношений.
При ее объектно-ориентированной реализации проявляется один из «порочных» принципов: каждый агент должен сам уметь обрабатывать себя. Следовательно, эволюционное появление новых классов, осуществляющих взаимодействие с уже существующими, ведет к необходимости «революционной» адаптации, реализуемой через внедрение в «старые» классы дополнительных связей.
Вместе с тем, эволюционное расширение изначально предполагает естественную упорядоченность новых понятий в порядке поступления. Этим можно воспользоваться, если установить, что добавляемый класс будет нести нагрузку по взаимодействию с существующими. Старый класс только должен уметь предоставлять информацию, необходимую для новых связей. При этом можно применить обычный объектно-ориентированный полиморфизм для выбора первого аргумента мультиметода. Схематически результат подобного решения можно представить в виде треугольника, клетки которого, расположенные ниже главной диагонали ранее существовавшего квадрата, содержат по два обработчика: свой и «того парня».
Чтобы не именовать подобный прием «треугольным», мысленно добавим третий аргумент и представим образ пирамиды. Подыскать соответствующую реализацию этой концептуальной схеме в виде пирамидальных классов — дело техники.
Поголовное использование RTTI
Самый простой и эффективный способ — использование RTTI для идентификации второго аргумента. Ее осуществляет первый аргумент, выход на который осуществляется по всем канонам объектно-ориентированного программирования. Если класс второго аргумента, в эволюционной иерархии, находится выше первого, то нет проблем реализовать в нем метод обработки. А что делать, если наоборот? Тогда достаточно вызвать метод, полиморфно перенаправляющий первый аргумент второму. А тот, с помощью RTTI, разберется. Подобная схема допускает наследование только от общего абстрактного базового класса всех членов гомоморфной иерархии.
Рассмотрим реализацию вычитания. Класс Number задает интерфейсы, определяющие взаимодействие внутри эволюционной пирамиды. Метод Subtract осуществляет полиморфную идентификацию первого аргумента. Вспомогательный метод SubtFrom предназначен для выявления второго аргумента в том случае, если он располагается в иерархии ниже первого. То есть, метод используется для доступа к виртуальным функциям, обеспечивающим взаимодействие текущего класса с более поздними членами эволюционной цепочки.
class Number { public: virtual Number* Subtract(Number& num2) = 0; virtual Number* SubtFrom(Number& num1) = 0; virtual void StdOut() = 0; };
Разместим на вершине иерархии класс целых чисел (или любой другой). Он переопределяет методы, наследуемые от Number. Дополнительный метод GetValue предназначен для непосредственного доступа к значению из классов — продолжателей эволюционной цепочки.
class Int: public Number { public: Number* Subtract(Number& num2); Number* SubtFrom(Number& num1); void StdOut(); Int(int v) : _value(v) {} int GetValue() {return _value;} private: int _value; };
Метод Subtract динамически проверяет второй аргумент на принадлежность к целым числам и выполняет вычитание. Если же второй аргумент не является числом известного типа, вызывается метод, перенаправляющий его одному из виртуальных обработчиков SubtFrom.
Number* Int::Subtract (Number& num2) { if(Int* pInt = dynamic_cast< Int*>(&num2)) { return new Int(pInt->GetValue() - _value); } else {return num2.SubtFrom(*this);} }
Так как целочисленный класс никого, кроме себя, «не знает», то его собственный обработчик SubtFrom используется в качестве заглушки. Все классы, разработанные позднее, будут сами отвечать за свое взаимодействие с Int. В методе SubtFrom может находиться генератор исключений, сигнализирующий о появлении числа неизвестного типа. Такая ситуация вполне возможна и будет рассмотрена ниже.
Number* Int::SubtFrom(Number&) { return 0; // В реальном приложении возможно порождение исключения }
Следующим классом будет Double, определяющий действительное число. Как и для целых чисел, задается реализация интерфейса класса Number и вводится функция GetValue:
class Double: public Number { public: Number* Subtract(Number& num2); Number* SubtFrom(Number& num1); void StdOut(); Double(double v): _value(v) {} double GetValue() {return _value;} private: double _value; };
Реализация методов будет сложнее, так как необходимо учитывать рост пирамиды. Double::Subtract, должен обрабатывать не только действительные, но и целые числа:
Number* Double::Subtract (Number& num2) { if(Int* pInt = dynamic_cast< Int*>(&num2)) { // Второй аргумент — целое число return new Double(_value - pInt-> GetValue()); } else if(Double* pDouble = dynamic_cast< Double*> (&num2)) { // Действительное вычитается из действитеьного return new Double(_value - pDouble-> GetValue()); } else {return num2.SubtFrom(*this); } }
SubtFrom обрабатывает числа в том случае, если первым аргументом является целочисленное значение:
Number* Double::SubtFrom (Number& num1) { if(Int* pInt = dynamic_cast (&num1)) { // Первый аргумент - целое число. return new Double(pInt->GetValue() - _value); } else { return 0;} }
Думаю, что после всего сказанного эволюционное добавление комплексного числа не вызовет проблем.
Немного двойной диспетчеризации
Возможно, что представленное решение многих не устроит по идеологическим причинам.
- Использование RTTI — это, по сути, процедурный, а не объектный прием, хотя без него не может обойтись ни один из объектно-ориентированных языков.
- Близко к процедурному подходу стоит и использование методов GetValue(), которые вполне можно было бы заменить непосредственным доступом к переменным класса.
Существует и более реальная причина, обуславливающая поиск других решений: последовательный анализ типов аргументов тормозит вычисления. Их скорость также сильно зависит от числа классов, подключенных к эволюционной пирамиде. При двойной диспетчеризации все протекает гораздо быстрее. На поверхности лежит простое решение, позволяющее уменьшить расходы на анализ типа примерно наполовину. Оно заключается в том, что двойную диспетчеризацию можно применить для методов, ранее располагавшихся на главной диагонали или над ней.
Если первый аргумент расположен в иерархии выше, то он может передать свое значение любому второму аргументу, а тот произведет необходимые вычисления. Для этого второй аргумент должен переопределять метод обработки первого. Необходимо только поставить фильтр, отсекающий вторые аргументы, расположенные в эволюционной иерархии раньше. Ведь они ничего не знают о «строительстве пирамиды»! Таким образом, можно сохранить эволюцию и ускорить процесс вычислений.
Вполне естественно, что каждый класс, подключенный позже, должен знать методы предшественников, обеспечивающие двойную диспетчеризацию. То есть, необходимо организовать наследование пирамидальных классов. Понятно, что при непосредственном наследовании, производные классы, наряду с необходимыми методами, будут содержать мусор, определяющий реализацию предшественников. Избежать этого можно выделением абстрактных классов, задающих только интерфейсы, необходимые для двойной диспетчеризации.
Рассмотрим код, реализующий этот подход. Абстрактный класс Number поддерживает только вход в иерархию классов:
class Number { public: virtual Number* Subtract (Number& num2) = 0; virtual void StdOut() = 0; };
Следующий в иерархии класс целого числа разбивается на интерфейс IntFace, поддерживающий двойную диспетчеризацию и реализацию Int, наследующую этот интерфейс:
class IntFace: public Number { public: // Вычитание второго операнда из первого, целочисленного, аргумента virtual Number* SubtFromFirstInt (int v) = 0; }; class Int: public IntFace { public: // Общие виртуальные методы Number* Subtract(Number& num2); void StdOut(); // Виртуальный метод, обеспечивающий двойную диспетчеризацию Number* SubtFromFirstInt(int v); // Конструктор, обеспечивающий инициализацию числа Int(int v): _value(v){ } // Получение значения числа int GetValue() {return _value;} private: // Значение целого числа int _value; };
Его методы знают только о том, как обработать себя:
Number* Int::Subtract (Number& num2) { IntFace* i_num = static_cast< IntFace*>(&num2); return i_num->SubtFromFirstInt (_value); } // Вычитание второго операнда из первого, целочисленного Number* Int::SubtFromFirstInt(int v) { return new Int(v - _value); }
Добавление действительного числа, осуществляется по аналогичной схеме. Строится интерфейс DoubleFace, расширяющий двойную диспетчеризацию, унаследованную от IntFace, и используемый в реализации класса Double.
class DoubleFace: public IntFace { public: // Вычитание второго операнда из первого, действительного virtual Number* SubtFromFirstDouble (double v) = 0; }; class Double: public DoubleFace { public: Number* Subtract(Number& num2); void StdOut(); Number* SubtFromFirstInt(int v); Number* SubtFromFirstDouble(double v); Double(double v) : _value(v){ } double GetValue() {return _value;} private: double _value; };
Реализация методов практически идентична предшественникам — необходимо только добавить в Subtract фильтр, обеспечивающий обработку вторых аргументов, расположенных в иерархии выше данного класса.
Number* Double::Subtract (Number& num2) { // Фильтр, отсекающий верхние классы if(Int* pInt = dynamic_cast< Int*>(&num2)) { // Вычитание из первого с использованием RTTI. return new Double(_value - pInt-> GetValue()); } else { // Обработка второго аргумента DoubleFace* d_num = static_cast< DoubleFace*>(&num2); return d_num->SubtFromFirstDouble (_value); } } // Переопределение методов вышестоящих пирамидальных классов Number* Double::SubtFromFirstInt(int v) { return new Double(v - _value); } Number* Double::SubtFromFirstDouble (double v) { return new Double(v - _value); }
По аналогичной схеме возможно добавление последующих пирамидальных классов. Следует также отметить, что мультиметод и клиентская часть не изменились.
Прощай, RTTI!
Применение RTTI продолжает снижать эффективность во втором трюке. Но как от него избавиться? Вернемся к решению с использованием только двойной диспетчеризации. Мысленно разрежем квадратную матрицу по главной диагонали и развернем ее верхнюю часть против часовой стрелки вокруг оси, установленной в верхнем левом углу. Получился равнобедренный треугольник, содержащий методы, расположенные в различных клетках. Можно даже оставить дублирование методов главной диагонали, но можно разместить их только в одном из полученных прямоугольных треугольников.
Схема отражает эволюционное порождение двух разновидностей виртуальных методов, одновременно размещаемых в разных половинах общего треугольника. Достаточно только иметь интерфейсы, обеспечивающие двойную диспетчеризацию отдельно для правой и левой частей. Остается выяснить: как выбрать точку входа, обеспечивающую нужную диспетчеризацию?
Одним из наиболее простых решений является использование специальной внутренней переменной, задающей ранг класса в соответствии с его местоположением в иерархии. Чем ниже класс расположен в эволюционной иерархии, тем выше его ранг. Тогда достаточно сравнения, чтобы определить механизм диспетчеризации. Используя этот прием можно переписать класс Number. Введем методы, запускающие прямую и обратную двойную диспетчеризацию, добавим переменную, сохраняющую ранг объекта.
class Number { friend Number* operator- (Number& n1, Number& n2); public: virtual Number* SubtractDirect (Number& num2) = 0; virtual Number* SubtractReverse (Number& num2) = 0; virtual void StdOut() = 0; protected: int _rank; // ранг объекта };
В интерфейсный класс целого числа IntFace добавляются методы, обеспечивающие выполнение прямой и обратной диспетчеризации. Сам же класс Int реализует эти методы. Конструктор класса должен не только устанавливать начальное значение целого, но и задавать ранг, который является одинаковым для всех экземпляров данного класса (пусть он будет равным 0).
class IntFace: public Number { public: virtual Number* SubtFromFirstInt (int v) = 0; virtual Number* SubtInt(int v) = 0; }; class Int: public IntFace { public: Number* SubtractDirect (Number& num2); Number* SubtractReverse (Number& num1); void StdOut(); Number* SubtFromFirstInt(int v); Number* SubtInt(int v); Int(int v): _value(v) {_rank = 0;} private: // Значение целого числа int _value; };
Реализация методов, обеспечивающих прямую и обратную диспетчеризацию, осуществляется достаточно просто:
Number* Int::SubtractDirect (Number& num2) { IntFace* i_num = static_cast< IntFace*>(&num2); return i_num->SubtFromFirstInt (_value); } Number* Int::SubtractReverse (Number& num1) { IntFace* i_num = static_cast< IntFace*>(&num1); return i_num->SubtInt(_value); } Number* Int::SubtFromFirstInt(int v) { return new Int(v - _value); } Number* Int::SubtInt(int v) { return new Int(_value - v); // Метод является избыточным }
Следует отметить, что в представленном коде используется симметричная реализация. Так как самый верхний класс в эволюционной иерархии осуществляет обработку только самого себя, реверсивные методы, попадающие только на главную диагональ, избыточны. Поэтому, вместо второго метода можно поставить заглушку.
Добавление действительных чисел осуществляется по аналогичному принципу. Создается интерфейс, наследующий интерфейс целого, от него формируется реализация класса, методы которого переопределяют прямую и обратную диспетчеризацию предшественника, а также реализуют свой дополнительный интерфейс. Естественно, что ранг действительных должен быть выше ранга целых чисел (в примере он равен 1).
class DoubleFace: public IntFace { public: virtual Number* SubtFromFirstDouble (double v) = 0; virtual Number* SubtDouble (double v) = 0; }; class Double: public DoubleFace { public: Number* SubtractDirect (Number& num2); Number* SubtractReverse (Number& num1); void StdOut(); Number* SubtFromFirstInt(int v); // наследует от целого Number* SubtInt(int v); Number* SubtFromFirstDouble(double v); // собственные методы Number* SubtDouble(double v); Double(double v): _value(v) {_rank = 1;} private: double _value; }; Number* Double::SubtractDirect (Number& num2) { DoubleFace* d_num = static_cast< DoubleFace*>(&num2); return d_num->SubtFromFirstDouble (_value); } Number* Double::SubtractReverse (Number& num1) { DoubleFace* d_num = static_cast< DoubleFace*>(&num1); return d_num->SubtDouble(_value); } Number* Double::SubtFromFirstInt(int v) { return new Double(v - _value); } Number* Double::SubtInt(int v) { return new Double(_value - v); }
Мультиметод, выполняющий вычитание, объявлен другом класса Number и переписан, Он используется для сравнения рангов и выбора варианта диспетчеризации. «Дружба» позволила защитить переменную, определяющую ранг, от непосредственного доступа.
Number* operator- (Number& n1, Number& n2) { if(n1._rank <= n2._rank) return n1.SubtractDirect(n2); else return n2.SubtractReverse(n1); }
Несмотря на то что в последней версии приходится вводить дополнительную переменную, отсутствие RTTI ускоряет выполнение вычислений. Не надо последовательно проверять типы чисел, что особенно сказывается при длинной иерархической цепочке. Для выбора нужной диспетчеризации достаточно одной проверки, что обуславливается заменой отношения равенства на отношение предшествования. Однако в каждом из представленных трюков есть свои привлекательные черты, что не позволяет мне отдать чему-то личное предпочтение.
Есть ли перспективы?
Предлагаемые методы позволяют расширить возможности эволюционного объектно-ориентированного программирования при реализации мультиметодов. Базовый класс компактен и не изменяется. Производные классы могут добавляться в любой последовательности, а их интерфейсы совершенно не влияют на предысторию. При использовании только RTTI интерфейсы пирамидальных классов вообще не связаны друг с другом, так как существует зависимость лишь от базового класса. В целом подход легко позволяет строить программу из постепенно добавляемых и хорошо инкапсулированных модулей.
Внутренний протест может вызвать необходимость размещения классов в определенной последовательности. Ведь предлагаемые повсюду способы построения мультиметодов позволяют использовать произвольный порядок! А какой от этого прок? И так ли страшен порядок? Ведь от его введения практически ничего не меняется, так как процесс разработки почти всегда упорядочен и регламентирован.
Использование эволюционной иерархии позволяет вводить некоторые побочные эффекты, например, уже построенная иерархическая цепочка
X = { A0, A1, A2, A3 ... }
может служить основой для создания побочных ветвей. При этом, новые подцепочки формируются в виде ответвлений от любого звена существующей иерархии, являясь при этом совершенно независимым продолжением.
Подобный прием можно использовать, если, к примеру, захочется осуществлять операции с целыми числами и символами, целыми числами и указателями отдельно от действительных чисел. Естественно, что в этом случае возможны ситуации, когда в мультиметод могут быть переданы несовместимые аргументы, принадлежащие различным подцепочкам. Поэтому, подобный прием эффективно можно реализовать только с использованием RTTI, что позволяет явно проверить допустимые комбинации и отсечь потенциальные, но не предусмотренные возможности. Вот здесь-то и могут пригодиться исключения, о которых говорилось при обсуждении первого способа.
До недавнего времени я был уверен, что объектно-ориентированный подход не позволяет в принципе реализовать эволюционное наращивание классов, обрабатываемых мультиметодами. Поэтому, обнаружение описанных трюков оказалось достаточно неожиданным.
Литература
[1] Элджер Дж. C++: библиотека программиста. Пер. с англ. СПб.: Питер, 1999
[2] Мейерс С. Наиболее эффективное использование C++. 35 новых рекомендаций по улучшению ваших программ и проектов: Пер. с англ. М.: «ДМК Пресс», 2000
[3] Страуструп Б. Дизайн и эволюция C++: Пер. с англ. М.: «ДМК Пресс», 2000
Александр Легалов (lai@softcraft.ru) — сотрудник Красноярского государственного технического университета.