В этот раз мы отступим от привычного формата «3-х правил». У нас будет всего одна, но очень интересная тема.
Речь пойдет об альтернативах виртуальным функциям. Альтернативы эти будут реализовываться с помощью паттернов проектирования.

Предположим, что мы работаем над какой-нибудь видеоигрой и проектируем иерархию игровых персонажей. Практически во всех играх надо с кем-то сражаться и кого-то убивать, наша игрушка тоже не исключение. Все персонажи могут подвергаться ранения м или как-то иначе терять жизненные силы.

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

 

Функция healthValue

class GameCharacter {
public:
// возвращает жизненную силу
персонажа
// в производных классах
можно переопределить
virtual int healthValue()
const;

};

Тот факт, что мы не объявили функцию healthValue как чисто виртуальную, означае т, что предполагается некоторая ее реализация по умолчанию.

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

 

Паттерн «Шаблонный метод» и идиома невиртуального интерфейса

Начнем с интересной концепции, согласно которой виртуальные функции почти всегда должны быть закрытыми. Сторонники этой концепции предлагают оставить функцию healthValue открытой, но сделать ее не виртуальной и заставить закрытую виртуальную функцию, например doHealthValue, которая и выполнит реальную работу.

 

Идиома не виртуального интерфейса

class GameCharacter
{
public:
int healthValue() const
{
// выполнить предварительные действия

int retVal = doHealthValue();
// выполнить завершающие действия
...
}
private:
// алгоритм по умолчанию
// производные классы могут переопределить
virtual int doHealthValue() const
{

}
};

Основная идея этого подхода — дать клиентам возможность вызывать закрытые виртуальные функции опосредованно, через открытые не виртуальные функции-члены. Данный подход известен под названием «идиома не виртуального интерфейса» или non-virtual interface idiom (NVI). Он представляет собой частный случай более широкого паттерна проектирования — «Шаблонный метод». Также не виртуальную функцию healthValue можно называть оберткой виртуальной функции.

Преимущество идиомы NVI заключается в коде, скрытом за комментариями «выполнить предварительные действия» и «выполнить завершающие действия». Подразумевается, что перед и после выполнения виртуальной функции, обязательно будет выполнен некоторый код. Таким образом, обертка настроит контекст перед вызовом виртуальной функции, а после — очистит его. Например, предварительные действия могут заключаться в захвате мьютекса, записей некоторой информации в лог и т.д. По завершению будет выполнено освобождение мьютекса, проверка инвариантов класса и все остальное. Если позволить клиентам напрямую вызвать виртуальную функцию, то будет очень затруднительно провести такую предварительную подготовку.

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

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

 

Паттерн «Стратегия» и указатели на функции

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

 

Пример паттерна «Стратегия»

// опережающее описание
class GameCharacter;
// функция по умолчанию для вычисления жизненной силы
int defaultHealthCalc(const GameCharacter&);
class GameCharacter {
public:
typedef int (*HealthCalcFunc) (const
GameCharacter&);
explicit GameCharacter(HealthCalcFunc
hcf = defaultHealthCalc)
: healthFunc(hcf)
{}
int healthValue() const
{return healthFunc(*this);}

private:
HealthCalcFunc healthFunc;
};

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

 

Одно из преимуществ паттерна «Стратегия»

class EvilBadGay: public GameCharacter {
public:
explicit EvilBadGay(HealthCalcFunc
hcf = defaultHealthCalc)
: GameCharacter(hcf)
{…}

};
// функции вычисления жизни с разным поведением
int loseHealthQuickly(const GameCharacter&);
int loseHealthSlowly(const GameCharacter&);
// однотипные персонажи с разным поведением
// относительно здоровья
EvilBadGay ebg1(loseHealthQuickly);
EvilBadGay ebg2(loseHealthSlowly);

Другой плюс данного паттерна заключается в том, что функция вычисления жизненной силы для одного и того же экземпляра класса может изменяться с течением времени. Например, класс GameCharacter может иметь функцию-член setHealthCalculator, которая позволяет заменить текущую функцию расчета жизни.

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

 

Паттерн «Стратегия» и класс tr1::function

Класс tr1::function дарит нам еще большую гибкость по сравнению с предыдущей реализацией паттерна «Стратегия» с помощью указателей на функции. Объект типа tr::function может содержать любую вызываемую сущность (указатель на функцию, функциональный объект либо указатель на функцию-член), чья сигнатура совместима с ожидаемой. Вот пример использования tr1::function:

 

Пример использования tr1::function

class GameCharacter;
int defaultHealthCalc(const GameCharacter&);
class GameCharacter {
public:
// HealthCalcFunc — любая вызываемая
// сущность, которой можно в качестве
// параметра передать нечто, совместимое
// с GameCharacter, и которая возвращает
// нечто совместимое с int
typedef std::tr1function<int (const
GameCharacter&)> HealthCalcFunc;
explicit GameCharacter(HealthCalcFunc
hcf = defaultHealthCalc)
: healthFunc(hcf)
{}
int healthValue() const
{
return healthFunc(*this);
}

private:
HealthCalcFunc healthFunc;
};

Как видишь, HealthCalcFunc — это typedef, описывающий конкретизацию шаблона tr::function. А значит, он работает как обобщенный указатель на функцию. Объект типа HelthCalcFunc может содержать любую вызываемую сущность, чья сигнатура совместима с заданной.

Быть совместимой в данном случае означает, что параметр можно неявно преобразовать в const GameCharacter&, а тип возвращаемого значения неявно конвертируется в int. Если сравнить с предыдущим вариантом, где GameCharacter включал в себя указатель на функцию, то мы не увидим почти никаких отличий. Несмотря на то, что разница не особенно очевидна, на деле мы получаем большую степень гибкости в спецификации функций, вычисляющих жизненную силу:

 

Вся мощь tr1::function

// функция вычисления жизненной силы
short calcHealth(const gameCharacter&)
// класс функциональных объектов,
// вычисляющих жизненную силу
stuct HealthCalculator {
int operator() (const GameCharacter&) const
{…}
};
class GameLevel {
public:
// функция-член для вычисления жизни
float health(const GameCharacter&) const;

};
class EvilBadGay: public GameCharacter {

};
class EyeCandyCharacter: public GameCharacter {

};
EvilBadGay ebg1(calcHealth);
EyeCandyCharacter ecc1(HealthCalculator());
GameLevel currentLevel;

EvilBadGay ebg2(
std::tr1::bind(&GameLevel::health,
currentLevel,
_1)
};

Для вычисления жизненной силы персонажа ebg2 следует использовать функцию-член класса GameLevel. Но из объявления GameLavel::health следует, что она должна принимать один параметр (ссылку на GameCharaster), а на самом деле принимает два, потому что имеется еще неявный параметр типа GameLevel — тот, на который внутри нее указывает this. Все функции вычисления жизненной силы принимают лишь один параметр. Если мы используем функцию GameLevel::health, то должны каким-то образом адаптировать ее, чтобы вместо двух параметров она принимала только один. В этом примере мы хотим для вычисления здоровья ebg2 в качестве параметра типа GameLevel всегда использовать объект currentLevel, поэтому привязываем его как первый параметр при вызове GameLevel::health. Именно в этом и заключается смысл вызова tr1::bind — указать, что функция вычисления жизни ebg2 должна в качестве объекта типа GameLevel использовать currentLevel.

 

«Классический» паттерн «Стратегия»

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

 

Классическая реализация паттерна «Стратегия»

// опережающее описание
class GameCharacter;
class HealthCalcFunc {
public:

virtual int calc(const GameCharacter& gc)const
{…}

};
HealthCalcFunc defaultHealthCalc;
class GameCharacter {
public:
explicit GameCharacter(HealthCalcFunc
*phcf = &defaultHealthCalc)
: pHealthCalc(phcf)
{}
int healthValue() const
{return pHealthFunc->calc(*this);}

private:
HealthCalcFunc *pHealthFunc;
};

Здесь GameCharacter — корень иерархии, в которой EvilBadGay и EyeCandyCharacter являются производными классами.

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

 

Заключение

Из моей сегодняшней статьи можно извлечь одну практическую рекомендацию: размышляя над тем, как решить стоящую перед тобой задачу, имеет смысл рассматривать не только виртуальные функции. В следующий раз мы продолжим ковырять C++ вглубь и вширь. До встречи!

  • Подпишись на наc в Telegram!

    Только важные новости и лучшие статьи

    Подписаться

  • Подписаться
    Уведомить о
    0 комментариев
    Межтекстовые Отзывы
    Посмотреть все комментарии