SoftCraft
разноликое программирование

Top.Mail.Ru

ООП, мультиметоды и пирамидальная эволюция

Можно скачать текст статьи в формате html (38 кб)
Примеры программ к статье (88 кб)

© 2002 г. А. И. Легалов

Расширенная версия статьи, опубликованной в журнале "Открытые системы", №3, 2002 г.
(ее электронная версия расположена по адресу: http://www.osp.ru/os/2002/03/041.htm)

Одним из основных достоинств объектно-ориентированного (ОО) подхода является поддержка им эволюционной разработки, что позволяет наращивать функциональность, не изменяя уже существующий код. Такое развитие программы особенно эффективно, когда расширение ведется за счет добавления новых производных классов, вызов виртуальных методов которых осуществляется через указатель или ссылку на базовый класс.

Вместе с тем, достаточно часто возникают ситуации, когда гомоморфные иерархии, определяемые как иерархии классов с одинаковым открытым интерфейсом, унаследованным от общего базового класса [1], взаимодействуют через функцию, виртуальную к произвольному числу полиморфных параметров. Такая функция называется мультиметодом [2], а возможность ее использования существует в языке программирования CLOS (Common Lisp Object System). Однако широко распространенные ОО языки программирования не поддерживают подобный механизм в связи с отсутствием его эффективной реализации [3].

Традиционные подходы

Демонстрируя мультиметоды, Джефф Элджер манипулировал числами [1], Скотт Мейерс сталкивал разнородные космические объекты [2], Бьерн Страуструп занимался пересечением геометрических фигур. Мне ближе числа, которые я, к тому же, использовал в подобной ситуации [4]. Поэтому, в качестве демонстрационного примера рассмотрим реализацию операции вычитания для чисел, заданных соответствующими классами. Предположим, что методы, осуществляющие вычитание заданных альтернатив отличаются друг от друга. Этот позволит не акцентировать внимание на проблемах симметрии и минимизации.

Самый простой вариант, опирающийся на ОО подход, заключается в использовании виртуального метода для выявления типа первого аргумента. Второй аргумент выявляется с помощью 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<Int*>(&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<Int*>(&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);
}
 

Тестовая функция является одинаковой для всех последующих примеров:

 
void main(void) {
  Number* rez;
  Int i1(10);       i1.StdOut();
  Int i2(3);        i2.StdOut();
  Double d1(3.14);  d1.StdOut();
  Double d2(2.7);   d2.StdOut();
  rez = i1 - i2;  rez->StdOut(); delete rez;
  rez = d1 - d2;  rez->StdOut(); delete rez;
  rez = i1 - d2;  rez->StdOut(); delete rez;
  rez = d1 - i2;  rez->StdOut(); delete rez;
}
 

К недостаткам следует отнести необходимость изменения методов вычитания во всех производных классах при добавлении новой разновидности чисел, например, комплексных. Кроме этого, с увеличением числа альтернатив, замедляется анализ вариантов. Эволюционное расширение программы усложняется при большом числе мультиметодов, реализующих подобные функции.

В аналогичной ситуации традиционная ОО парадигма предлагает двойную (и более) диспетчеризацию [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);
}
// Вычитание второго действительного операнда из первого целого
// Продолжатель диспетчеризации, начатой Subtract
Number* Double::SubtFromInt(int v) {
  return new Double(v - _value);
}
// Вычитание второго действительного операнда из первого действительного
// Продолжатель диспетчеризации, начатой Subtract
Number* Double::SubtFromDouble(double v) {
  return new Double(v - _value);
}
 

Использование двойной диспетчеризации совершенно не подходит для эволюционного программирования, так как добавление нового класса ведет к переопределению интерфейсов всей, уже существующей, гомоморфной иерархии.

Для исправления ситуации Скотт Мейерс, в правиле 31 одного из своих бестселлеров [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 обеспечивает динамическую проверку второго аргумента на принадлежность к классу целых чисел. Если принадлежность установлена, то осуществляется выполнение функции. Когда же второй аргумент не является целым числом, вызывается метод, перенаправляющий его одному из расположенных выше виртуальных обработчиков. А там, что будет


Number* Int::Subtract(Number& num2)
{
  if(Int* pInt = dynamic_cast<Int*>(&num2))
  {
    return new Int(pInt->GetValue() - _value);
  }
  else
  {
    return num2.SubtFrom(*this);
  }
} 

Так как сформированный целочисленный класс никого, кроме себя, не знает, то и его обработчик 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<Int*>(&num1))
  {
    // Первый аргумент - целое число. 
    return new Double(pInt->GetValue() - _value); 
  }
  else
  {
    return 0;
    // Возможно, в реальном приложении 
    // должно порождаться исключение.
  }
}

Думаю, что после всего сказанного не возникнет проблем в эволюционном добавлении следующего класса, например, комплексного числа.

Немного двойной диспетчеризации

Возможно, что представленное выше решение многих не устроит по идеологическим причинам:

  • Использование RTTI – это, по сути, все-таки процедурный, а не ОО прием, хотя без него не может обойтись ни один из ОО языков.
  • Также ближе к процедурному подходу стоит и использование методов GetValue(), которые вполне можно было бы заменить непосредственным доступом к переменным класса.

Существует и более реальная причина, обуславливающая поиск других решений: последовательный анализ типов аргументов тормозит вычисления. Их скорость также сильно зависит от числа классов, подключенных к эволюционной пирамиде. При двойной диспетчеризации все протекает гораздо быстрее.

На поверхности лежит простое решение, позволяющее уменьшить расходы на анализ типа объекта примерно наполовину. Оно заключается в том, что двойную диспетчеризацию можно непосредственно применить для вызова методов, ранее располагавшихся выше главной диагонали или на ней.

Основная идея заключается в следующем. Если первый аргумент расположен в иерархии выше, то он может передать свое значение (или себя целиком) любому второму аргументу, который и произведет необходимые вычисления. Для этого второй аргумент должен переопределять метод обработки первого в соответствии со своей организацией. Однако перед использованием двойной диспетчеризации необходимо поставить фильтр, отсекающий вторые аргументы, расположенные в эволюционной иерархии раньше. Ведь они ничего не знают о "строительстве пирамиды"! Таким образом, можно сохранить эволюцию и ускорить процесс вычислений.

Вполне естественно, что каждый класс, расположенный в иерархии ниже других, должен знать методы предшественников, обеспечивающих двойную диспетчеризацию. То есть, необходимо организовать наследование пирамидальных классов. Ясно также, что при непосредственном наследовании, производные классы, наряду с необходимыми методами, будут содержать мусор, определяющий реализацию предшественников. Избежать этого можно путем выделения абстрактных классов, описывающих только интерфейсы, необходимые для двойной диспетчеризации.

Кратко рассмотрим код, реализующий этот подход. Абстрактный класс Number поддерживает только вход в иерархию классов (StdOut – не в счет), так как ему не нужно знать, какой первый аргумент переправляется второму аргументу. Об этом позаботятся производные пирамидальные классы.


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);
}

Добавление в пирамиду действительного числа, определяемого классом Double, осуществляется по аналогичной схеме. Строится интерфейс DoubleFace, расширяющий двойную диспетчеризацию, унаследованную от IntFace, и используемый в реализации самого класса.


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;
};

Реализация методов данного класса практически идентична предшественникам. Необходимо только добавить в метод Subtaract фильтр, обеспечивающий обработку вторых аргументов, расположенных в иерархии выше данного класса.


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. До тех пор, пока внешние спецификации не поставили к стенке. Интуитивно не нравились два фактора: необходимость установки специальных опций при компиляции (или параметров инструментальной среды), а также, какие-то непонятные, дополнительные расходы на реализацию. Позднее я привык к этому механизму, как привыкаешь ко всему, что упрощает разработку. Вместе с тем следует отметить, что применение 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.


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, что позволяет явно проверить допустимые комбинации и отсечь потенциальные, но не предусмотренные возможности. Вот здесь-то и могут пригодиться исключения, о которых говорилось при обсуждении первого способа.

Примерно в середине 90-х я "изобрел" и "отругал" двойную диспетчеризацию [4]. После этого, от Джефа Элджера [1] я узнал, что она существовала и раньше (у Страуструпа [3] отмечено, что впервые она была описана в 1986 г.). И хотя он говорил об ее достоинстве, Скот Мейерс [2] опередил и в критике недостатков. В данном случае проявился эффект "очевидных открытий", когда использование определенного инструмента само наталкивает на методы решения задач. Поэтому, говоря о перспективах пирамидальной эволюции, хотелось бы выразить сомнение в своей роли "первооткрывателя" описанных трюков и удивление по поводу того, что они, несмотря на очевидность, мне ранее не встречались. Возможно, потребность в их использовании такая же, как и необходимость в отлове "Неуловимого ковбоя Джо". То есть, они просто не нужны, так как не ведут к повышению эффективности по сравнению с популярными решениями. В любом случае буду благодарен за ссылки на материалы, в которых подобные приемы уже рассмотрены.

В заключении приношу свои извинения сообществу C++ программистов за отсутствие перегрузок методов и операций, слабую инкапсуляцию. Хотелось максимально упростить примеры и сделать их реализуемыми на любых ОО языках программирования.

Литература

  1. Элджер Дж. C++: библиотека программиста. Пер. с англ. - СПб.: ЗАО "Издательство Питер", 1999. - 320 с.

  2. 2. Мейерс С. Наиболее эффективное использование C++. 35 новых рекомендаций по улучшению ваших программ и проектов: Пер. с англ. - М.: ДМК Пресс, 2000. - 304 с.

  3. Страуструп Б. Дизайн и эволюция C++: Пер. с англ. - М.: ДМК Пресс, 2000. - 448 с.

  4. Легалов А.И. Разработка программ на основе объектно-реляционной методологии. - Математическое обеспечение и архитектура ЭВМ: Сб. научных работ. Вып. 2. КГТУ, Красноярск, 1997. с. 223-235.