Издательский дом ООО "Гейм Лэнд"ЖУРНАЛ ХАКЕР 138, ИЮНЬ 2010 г.

Кодерские типсы и триксы. Правила кодинга на C++ для настоящих спецов

В этот раз мы отступим от привычного формата «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++ вглубь и вширь. До встречи!

Содержание
загрузка...
Журнал Хакер #151Журнал Хакер #150Журнал Хакер #149Журнал Хакер #148Журнал Хакер #147Журнал Хакер #146Журнал Хакер #145Журнал Хакер #144Журнал Хакер #143Журнал Хакер #142Журнал Хакер #141Журнал Хакер #140Журнал Хакер #139Журнал Хакер #138Журнал Хакер #137Журнал Хакер #136Журнал Хакер #135Журнал Хакер #134Журнал Хакер #133Журнал Хакер #132Журнал Хакер #131Журнал Хакер #130Журнал Хакер #129Журнал Хакер #128Журнал Хакер #127Журнал Хакер #126Журнал Хакер #125Журнал Хакер #124Журнал Хакер #123Журнал Хакер #122Журнал Хакер #121Журнал Хакер #120Журнал Хакер #119Журнал Хакер #118Журнал Хакер #117Журнал Хакер #116Журнал Хакер #115Журнал Хакер #114Журнал Хакер #113Журнал Хакер #112Журнал Хакер #111Журнал Хакер #110Журнал Хакер #109Журнал Хакер #108Журнал Хакер #107Журнал Хакер #106Журнал Хакер #105Журнал Хакер #104Журнал Хакер #103Журнал Хакер #102Журнал Хакер #101Журнал Хакер #100Журнал Хакер #099Журнал Хакер #098Журнал Хакер #097Журнал Хакер #096Журнал Хакер #095Журнал Хакер #094Журнал Хакер #093Журнал Хакер #092Журнал Хакер #091Журнал Хакер #090Журнал Хакер #089Журнал Хакер #088Журнал Хакер #087Журнал Хакер #086Журнал Хакер #085Журнал Хакер #084Журнал Хакер #083Журнал Хакер #082Журнал Хакер #081Журнал Хакер #080Журнал Хакер #079Журнал Хакер #078Журнал Хакер #077Журнал Хакер #076Журнал Хакер #075Журнал Хакер #074Журнал Хакер #073Журнал Хакер #072Журнал Хакер #071Журнал Хакер #070Журнал Хакер #069Журнал Хакер #068Журнал Хакер #067Журнал Хакер #066Журнал Хакер #065Журнал Хакер #064Журнал Хакер #063Журнал Хакер #062Журнал Хакер #061Журнал Хакер #060Журнал Хакер #059Журнал Хакер #058Журнал Хакер #057Журнал Хакер #056Журнал Хакер #055Журнал Хакер #054Журнал Хакер #053Журнал Хакер #052Журнал Хакер #051Журнал Хакер #050Журнал Хакер #049Журнал Хакер #048Журнал Хакер #047Журнал Хакер #046Журнал Хакер #045Журнал Хакер #044Журнал Хакер #043Журнал Хакер #042Журнал Хакер #041Журнал Хакер #040Журнал Хакер #039Журнал Хакер #038Журнал Хакер #037Журнал Хакер #036Журнал Хакер #035Журнал Хакер #034Журнал Хакер #033Журнал Хакер #032Журнал Хакер #031Журнал Хакер #030Журнал Хакер #029Журнал Хакер #028Журнал Хакер #027Журнал Хакер #026Журнал Хакер #025Журнал Хакер #024Журнал Хакер #023Журнал Хакер #022Журнал Хакер #021Журнал Хакер #020Журнал Хакер #019Журнал Хакер #018Журнал Хакер #017Журнал Хакер #016Журнал Хакер #015Журнал Хакер #014Журнал Хакер #013Журнал Хакер #012Журнал Хакер #011Журнал Хакер #010Журнал Хакер #009Журнал Хакер #008Журнал Хакер #007Журнал Хакер #006Журнал Хакер #005Журнал Хакер #004Журнал Хакер #003Журнал Хакер #002Журнал Хакер #001