Компонент(Component)
Задача
Позволяет одной сущности охватывать несколько областей, не связывая их между собой.
Мотивация
Предположим, мы создаем платформер. Биография итальянских водопроводчиков и так всем хорошо известна, так что мы возьмем Датского пекаря Бьйорна (Bjørn). Разумеется у нас будет класс, представляющий нашего дружелюбного кондитера и он будет содержать все, что персонаж делает в игре.
Вот из-за вот таких гениальных идей я программист, а не дизайнер.
Так как Бьерном управляет игрок, это значит, что нужно считывать пользовательский ввод и переводить его в движение. И, конечно, ему нужно взаимодействовать с уровнем, т.е. происходит обработка физики и коллизий. Когда мы закончим, персонажу нужно появиться на экране, что предполагает анимацию и рендеринг. Еще потребуется проигрывание звуков.
Остановимся на минутку и подумаем. Программная архитектура 101 говорит нам, что различные области следует сохранять изолированными друг от друга. Если мы создаем текстовый процессор, то код обрабатывающий печать не должен зависеть от кода, загружающего или сохраняющего документ. В игре конечно нет такого деления, как в бизнес приложении, но общий принцип остается неизменным.
До тех пор пока это возможно, мы не хотим чтобы ИИ, физика, рендер, звук и остальные области не знали друг о друге, но пока что все это перемешано внутри одного класса. Мы знаем куда ведет эта дорога: свалка внутри исходника в 5000 строк, настолько большая, что только самый отважный ниндзя-кодер в вашей команде отважится туда отправиться.
Это отличная гарантия занятости для тех, кто решится этим заняться, и настоящий ад для всех остальных. Такой огромный размер файла свидетельствует о том, что даже сравнительно тривиальное изменение может иметь далеко идущие последствия. И совсем скоро класс начнет быстрее плодить баги, чем мы будем успевать пополнять его функциональностью.
Гордиев узел
Гораздо хуже проблемы масштабирования проблема увеличения связности. Все отдельные системы нашей игры связаны в гордиев узел кода в духе:
if (collidingWithFloor() && (getRenderState() != INVISIBLE))
{
playSound(HIT_FLOOR);
}
Несмотря на то, что подобная связность плоха в любой игре, в современных играх, использующих конкурентный режим работы она еще страшнее. Для нашего многоядерного железа жизненно важно чтобы код использовал несколько потоков одновременно. Одним из вариантов разделения выполнения игры по потокам, является разделение по областям: ИИ запускается на одном ядре, звук на другом, рендеринг на третьем и т.д.
Как только вы внедрите у себя подобную систему, отсутствие связности между областями станет для вас критичным, иначе вы рискуете получить блокировки и другие баги конкурентного выполнения. А если у вас будет класс, метод
UpdateSounds()
которого вызывается из одного потока, а методRenderGraphics()
из другого, вы очень рискуете столкнуться с подобными багами.
Каждый программист, который попытается изменить подобный код, должен хотя бы немного разбираться в физике, графике и звуке для того чтобы ничего не сломать.
Эти две проблемы дополняют друг друга: класс затрагивает так много областей, что каждый из программистов вынужден с ним работать, но настолько велик, что работать с ним просто ужасно. Если он достаточно плох, кодеры начнут добавлять всякие хаки в другие части кода, лишь бы не связываться с ужасным классом, в который превратился наш Bjorn
.
Разрубание узла
Проблему можно решить, последовав примеру Александра Великого: с помощью меча. Мы берем наш монолитный класс Bjorn
и разрезаем его на отдельные части по границам областей. Например, берем весь код обработки пользовательского ввода и переносим его в отдельный класс InputComponent
. Теперь Bjorn
будет содержать экземпляр этого компонента. Повторяем процесс для каждой области, которой касается Bjorn
.
Когда мы закончим, в самом Bjorn
практически ничего не останется. Все, что останется в его тонкой скорлупке — так это связи между компонентами. Мы решили нашу большую проблему, просто поделив ее на множество мелких классов. Но на самом деле мы добились даже большего.
Свободные концы
Наши классы компонентов теперь мало связаны (decoupled). Несмотря на то, что Bjorn
содержит PhysicsComponent
и GraphicsComponent
, эти двое друг о друге не знают. Это значит, что тот, кто работает над физикой, может изменять относящийся к ней компонент без необходимости трогать графику и наоборот.
На практике компонентам приходится как-либо друг с другом взаимодействовать. Например, компоненту ИИ может потребоваться сообщить физическому компоненту куда собирается идти Бьйорн. Однако мы можем ограничить это взаимодействие до того, что должен сообщать компонент, а не предоставлять им свободно между собой общаться.
Собираем все вместе
Еще одна возможность данного дизайна заключается в том, что компоненты теперь представляют собой пакеты, пригодные для повторного использования. До сих пор мы фокусировались на нашем пекаре, но давайте рассмотрим еще несколько объектов игрового мира. Декорации — это то, каким игрок видит мир, но с чем не взаимодействует: кусты, непролазные дебри и другие видимые детали. Реквизит похож на декорации, но мы можем его потрогать: ящики, булыжники и деревья. Зоны, в отличие от декораций невидимы, но интерактивны. Они полезны для вещей типа запуска заставок, когда Бьйорн в них входит.
Когда объектно-ориентированное программирование появилось на сцене впервые, наследование было самым любимым из всех его инструментов. Оно было объявлено ультимативным молотом повторного использования кода и кодеры постоянно им размахивали. С тех пор мы на собственных ошибках убедились в том, что этот молот может быть слишком тяжел. Наследование имеет свое применение, но для повторного использования кода оно обычно слишком громоздко.
Ему на замену в программирование пришел новый тренд: композиция взамен наследования везде, где это возможно. Вместо совместного использования кода двумя классами, которые наследуются от какого-то одного класса, мы позволяем им обеим обладать одним и тем же экземпляром этого класса.
Теперь подумаем, как мы настроили бы иерархию всех этих классов, если бы не использовали компоненты. Первая попытка может выглядеть таким образом:
У нас есть класс GameObject
, содержащий такие понятные вещи как позиция и ориентация. Zone
наследуется от него и добавляет обнаружение коллизий. Аналогично, Decoration
наследуется от GameObject
и добавляет рендеринг. Prop
наследуется от Zone
и может повторно использовать код обнаружения коллизий Collizion
оттуда. Однако Prop
не может в то же самое время наследоваться от Decoration
, чтобы использовать код рендеринга оттуда без превращения в Смертельный бриллиант.
"Смертельный бриллиант (Deadly Diamond)" случается в иерархии классов при множественном наследовании, когда у нас есть два пути, ведущие к базовому классу. Сама проблема выходит за рамки рассмотрения нашей книги, но думаю вы понимаете, что просто так вещи "смертельными" не называют.
Мы можем сделать по другому, и Prop
будет наследоваться от Decoration
, но тогда у нас получится дублирование кода определения коллизий. В любом случае, не существует простого способа для повторного использования кода определения коллизий или рендеринга без использования множественного наследования. Единственная альтернатива — поместить все снова в GameObject
, но тогда Zone
будет тратить память на данные для рендеринга, которые ей не нужны, а Decoration
будет делать тоже самое с данными для физики.
А теперь попробуем повторить тоже самое с компонентами. Наши подклассы полностью исчезли. Вместо этого у нас есть один класс GameObject
и два класса компонента: PhysicsComponent
и GraphicsComponent
. Декорация — это просто GameObject
с GraphicsComponent
, но без PhysicsComponent
. Для зоны все наоборот, а реквизит использует оба компонента. Никакого дублирования кода, никакого множественного наследования и только три класса взамен четырех.
Хорошая аналогия — это меню ресторана. Если каждый элемент меню — это монолитный класс, то можно представить себе что мы можем заказать только комбо. Для этого нам нужно иметь отдельный класс для каждой возможной комбинации. Чтобы удовлетворить запрос каждого посетителя, нам понадобятся дюжины комбинаций.
Компоненты — это блюда на выбор (à la carte): каждый посетитель может выбрать только те блюда, которые хочет, а меню — это всего лишь список блюд, из которых можно выбирать.
Компоненты — это практически механизм plug-and-play для объектов. Они позволяют нам конструировать сложные сущности с богатым поведением, просто подключая переназначенные для повторного использования объекты компоненты в сокеты сущности. Такой себе программный Вольтрон.
Шаблон
Единая сущность охватывает множество областей. Для сохранения изолированности областей, код для каждой помещается в свой собственный класс компонент. Сущность упрощается до простого контейнера компонентов.
"Компонент", как и "Объект" — это одни из слов в программировании, означающее сразу все и ничего. Потому что они использовались для описания нескольких концепций. В бизнес приложениях "Компонент" — это шаблон проектирования, описывающий слабо связанные сервисы, общающиеся по сети.
Я пытался найти для этого отдельного шаблона, применяемого в играх, отдельный термин, но "Компонент" оказался наиболее подходящим. Так как шаблоны проектирования — это документирование существующих практик, у меня нет привилегии выдумывать новые термины. Так, что вслед за
XNA
,Delta3D
и остальными, будем использовать название "Компонент".
Когда использовать
Компоненты чаще всего можно найти внутри класса-ядра, описывающего сущности в игре, но использовать их можно и в других частях игры. Этот шаблон стоит использовать, если верно что-либо из нижеперечисленного:
У вас есть класс, затрагивающий множество областей, которые вы хотите оставить несвязанными друг с другом.
Класс становится слишком массивным и сложным для работы.
Вы хотите определить множество объектов, разделяющих различные возможности, но при этом не можете использовать наследование, потому что оно не дает вам достаточно свободно подбирать части, которые вы хотите использовать.
Имейте в виду
Этот шаблон добавляет сложности в простой процесс создания класса и наполнения его кодом. Каждый концептуальный "объект" становится кластером объектов, который нужно инстанциировать, инициализировать и корректно увязать вместе. Коммуникация между разными компонентами усложняется и размещение в памяти тоже усложняется.
Для большой кодовой базы ее сложность может оправдывать снижение связности и добавление возможности повторного использования кода. Но будьте осторожны и прежде чем применить шаблон убедитесь, что не пытаетесь применить "решение" к несуществующей проблеме.
Еще компоненты могут пригодиться, когда вам часто приходится прыгать сразу через несколько уровней косвенности, чтобы сделать то, что нужно. Имея объект контейнер, вы сначала выбираете нужный компонент, а затем делаете то что вам нужно. Во внутренних циклах, где производительность критичная, такой переход по указателю может плохо отразиться на производительности.
Но есть и обратная сторона. Шаблон Компонент зачастую улучшает производительность и связность кеша. Компоненты упрощают использование шаблона Локализация данных (Data Locality), помогающего размещать данные так, как удобно процессору.
Пример кода
Одним из самых больших вызовов при написании этой книги для меня стала задача изоляции каждого шаблона. Многие шаблоны проектирования обычно содержат в себе код, который не является частью шаблона. Чтобы очистить шаблон до самой его сути я попытался убрать как можно больше всего лишнего, но на каком-то этапе это будет напоминать попытку объяснить работу двигателя внутреннего сгорания не упоминая топлива или масла.
Шаблон Компонент пожалуй в этом смысле самый сложный из всех. Вы не можете оценить его по настоящему, если не будете видеть код каждой из областей, связность между которыми он снижает. Поэтому мне придется привести немного больше кода Бьйорна, чем хотелось бы. Сам шаблон представляет из себя всего лишь классы компонентов, но их код поможет понять, для чего эти классы предназначены. Это не настоящий код и обращается он к другим классам, которые здесь не представлены. Но таким образом вы хотя бы получите представление о том что происходит.
Монолитный класс
Чтобы увидеть общую картинку применения этого шаблона, начнем с рассмотрения монолитного класса Bjorn
, который делает все что нам нужно, но не использует наш шаблон:
Хочу отметить, что использование имени персонажа в коде обычно плохая идея. У маркетологов есть плохая привычка менять названия незадолго до выпуска продукта. "Фокус группы показали что от 11 до 15 процентов тестеров негативно оценивают имя Бьйорн. Поэтому будем использовать имя Стив".
Вот поэтому многие проекты используют внутри только кодовые имена. Это между прочим еще и веселее. Приятнее ведь говорить людям, что вы работаете над "Большим электрокотом", а не просто над "очередной версией Photoshop".
class Bjorn
{
public:
Bjorn()
: velocity_(0), x_(0), y_(0)
{}
void update(World& world, Graphics& graphics);
private:
static const int WALK_ACCELERATION = 1;
int velocity_;
int x_, y_;
Volume volume_;
Sprite spriteStand_;
Sprite spriteWalkLeft_;
Sprite spriteWalkRight_;
};
У Bjorn
есть метод update()
, который вызывается на каждом кадре игре:
void update(World& world, Graphics& graphics)
{
// Применяем пользовательский ввод к скорости героя.
switch (Controller::getJoystickDirection())
{
case DIR_LEFT:
velocity_ -= WALK_ACCELERATION;
break;
case DIR_RIGHT:
velocity_ += WALK_ACCELERATION;
break;
}
// Изменение позиции на скорость.
x_ += velocity_;
world.resolveCollision(volume_, x_, y_, velocity_);
// Отрисовка соответствующего спрайта.
Sprite* sprite = &spriteStand_;
if (velocity_ < 0) {
sprite = &spriteWalkLeft_;
} else if (velocity_ > 0) {
sprite = &spriteWalkRight_;
}
graphics.draw(*sprite, x_, y_);
}
Он считывает данные от джойстика и применяет ускорение или торможение. Далее определяется новая позиция с помощью физического движка. И наконец Bjorn
, отрисовывается на экране.
Реализация в примере предельно проста. У нас нет гравитации, анимации и дюжин всяких других деталей, делающих игру персонажем приятной. И даже сейчас мы уже видим, что в одной единственной функции используется работа нескольких программистов из нашей команды и выглядит это уже довольно запутано. Представьте, что вместо этого у нас есть тысяча строк кода и поймете насколько это усложняет работу.
Разделение на области
Начнем с одной из областей. Возьмем часть Bjorn
и отправим в отдельный компонент. Начнем с первой области, с которой мы работаем: с пользовательского ввода. Первое, что делает Bjorn
— это считывает пользовательский ввод и изменяет соответствующим образом скорость. Давайте поместим эту логику в отдельный класс:
class InputComponent
{
public:
void update(Bjorn& bjorn)
{
switch (Controller::getJoystickDirection())
{
case DIR_LEFT:
bjorn.velocity -= WALK_ACCELERATION;
break;
case DIR_RIGHT:
bjorn.velocity += WALK_ACCELERATION;
break;
}
}
private:
static const int WALK_ACCELERATION = 1;
};
Довольно просто. Мы взяли первую часть метода update()
из Bjorn
и поместили в новый класс. Изменения в Bjorn
тоже будут вполне очевидными:
class Bjorn
{
public:
int velocity;
int x, y;
void update(World& world, Graphics& graphics)
{
input_.update(*this);
// Изменение позиции в зависимости от скорости.
x += velocity;
world.resolveCollision(volume_, x, y, velocity);
// Draw the appropriate sprite.
Sprite* sprite = &spriteStand_;
if (velocity < 0) {
sprite = &spriteWalkLeft_;
} else if (velocity > 0) {
sprite = &spriteWalkRight_;
}
graphics.draw(*sprite, x, y);
}
private:
InputComponent input_;
Volume volume_;
Sprite spriteStand_;
Sprite spriteWalkLeft_;
Sprite spriteWalkRight_;
};
Теперь у Bjorn
есть объект InputComponent
. Также, как раньше мы обрабатывали пользовательский ввод напрямую из метода update()
, теперь мы делегируем к компоненту:
input_.update(*this);
Мы только начали, но уже избавились от части связности: главный класс Bjorn
уже не содержит ссылки на Controller
. К этому мы еще вернемся.
Отделение остального
Продолжим и проделаем ту же самую работу по копированию и вставке для физического и графического кода. Вот наш PhysicsComponent
:
class PhysicsComponent
{
public:
void update(Bjorn& bjorn, World& world)
{
bjorn.x += bjorn.velocity;
world.resolveCollision(volume_,
bjorn.x, bjorn.y, bjorn.velocity);
}
private:
Volume volume_;
};
Помимо того, что мы вынесли из главного класса Bjorn
физическое поведение, вы можете видеть, что мы перенесли и данные: объект Volume
теперь принадлежит компоненту.
И теперь последнее, но все равно важное изменение. Теперь наш код рендеринга будет жить здесь:
class GraphicsComponent
{
public:
void update(Bjorn& bjorn, Graphics& graphics)
{
Sprite* sprite = &spriteStand_;
if (bjorn.velocity < 0) {
sprite = &spriteWalkLeft_;
} else if (bjorn.velocity > 0) {
sprite = &spriteWalkRight_;
}
graphics.draw(*sprite, bjorn.x, bjorn.y);
}
private:
Sprite spriteStand_;
Sprite spriteWalkLeft_;
Sprite spriteWalkRight_;
};
Мы убрали практически все. Так что же у нас осталось от нашего скромного кондитера? Не так уж много:
class Bjorn
{
public:
int velocity;
int x, y;
void update(World& world, Graphics& graphics)
{
input_.update(*this);
physics_.update(*this, world);
graphics_.update(*this, graphics);
}
private:
InputComponent input_;
PhysicsComponent physics_;
GraphicsComponent graphics_;
};
Класс Bjorn
теперь делает всего две вещи: хранит набор компонентов, которые его собственно и определяют и хранит состояние, разделенное между несколькими областями. Позицию и скорость мы оставили в ядре Bjorn
по двум причинам. Во-первых, это общее для всех областей (“pan-domain”) состояние: практически каждый компонент будет его использовать, так что совсем не очевидно, в каком компоненте ему нужно находиться, если мы захотим перенести его туда.
Во-вторых, и что более важно, это упрощает коммуникацию компонентов без установления между ними связи.
Робо-Бьйорн
До сих пор мы выносили поведение в отдельные классы компонентов, но не делали поведение абстрактным. Bjorn
все равно знал о вполне конкретных классах, в которых определялось его поведение. Давайте изменим это.
Мы возьмем наш компонент для обработки пользовательского ввода и спрячем его за интерфейсом. Превратим InputComponent в абстрактный базовый класс:
class InputComponent
{
public:
virtual ~InputComponent() {}
virtual void update(Bjorn& bjorn) = 0;
};
Дальше, мы возьмем наш существующий код обработки пользовательского ввода и поместим его в класс, реализующий этот интерфейс:
class PlayerInputComponent : public InputComponent
{
public:
virtual void update(Bjorn& bjorn)
{
switch (Controller::getJoystickDirection())
{
case DIR_LEFT:
bjorn.velocity -= WALK_ACCELERATION;
break;
case DIR_RIGHT:
bjorn.velocity += WALK_ACCELERATION;
break;
}
}
private:
static const int WALK_ACCELERATION = 1;
};
Мы изменим Bjorn
таким образом, чтобы он содержал указатель на компонент ввода, вместо собственного экземпляра:
class Bjorn
{
public:
int velocity;
int x, y;
Bjorn(InputComponent* input)
: input_(input)
{}
void update(World& world, Graphics& graphics)
{
input_->update(*this);
physics_.update(*this, world);
graphics_.update(*this, graphics);
}
private:
InputComponent* input_;
PhysicsComponent physics_;
GraphicsComponent graphics_;
};
Теперь, когда мы инстанцируем Bjorn
, мы можем передать ему компонент ввода, которым он сможет пользоваться:
Bjorn* bjorn = new Bjorn(new PlayerInputComponent());
Этот экземпляр может быть конкретным типом, который реализует наш абстрактный интерфейс InputComponent
. Мы платим за это тем, что теперь update()
у нас представляет собой вызов виртуального метода, что довольно медленно. Что же мы получаем в замен?
Большая часть консолей требует того, чтобы игра поддерживала "демо режим". Если игрок находится в главном меню и ничего не делает, игра начинает играть автоматически и вместо игрока играет компьютер. Таким образом игра не выжжет на вашем телевизоре главное меню и будет лучше смотреться если запустить ее на стенде в магазине.
В этом нам помогает то, что мы спрятали класс компонента ввода за интерфейсом. У нас уже есть конкретный PlayerInputComponent
, который обычно используется когда играет игрок. Сделаем еще один:
class DemoInputComponent : public InputComponent
{
public:
virtual void update(Bjorn& bjorn)
{
// ИИ для автоматического управления Бьйорном...
}
};
Когда игра переходит в демо режим, вместо того, чтобы конструировать Бьорна такжe, как и раньше, мы связываем его с новым компонентом:
Bjorn* bjorn = new Bjorn(new DemoInputComponent());
И теперь, просто подменив компонент у нас получился полноценный управляемый компьютером игрок для демо режима. Мы можем использовать повторно и другой код Бьйорна — физика и графика даже не заметит разницы. Может вы и будете считать меня несколько странным, но такие вещи помогают мне просыпаться по утрам в хорошем настроении.
Ну и кофе конечно. Ароматный, дымящийся, горячий кофе.
Никакого Бьйорна?
Если вы посмотрите на класс Bjorn
теперь, вы увидите, что в общем никакого "Бьйорна" там уже нет — это просто мешок с компонентами. На самом деле, это хороший кандидат на роль базового класса "игрового объекта", который можно использовать для любого объекта в игре. Все, что нам нужно сделать — это передать в него все компоненты и мы сможем получить любой нужный нам объект, подбирая части как доктор Франкенштейн.
Давайте возьмем наши оставшиеся компоненты — физический и рендеринга — и спрячем их за интерфейсом как мы уже поступили с вводом.
class PhysicsComponent
{
public:
virtual ~PhysicsComponent() {}
virtual void update(GameObject& obj, World& world) = 0;
};
class GraphicsComponent
{
public:
virtual ~GraphicsComponent() {}
virtual void update(GameObject& obj, Graphics& graphics) = 0;
};
Теперь наш Bjorn
перерождается в обобщенный класс GameObject
и будет использовать эти интерфейсы:
class GameObject
{
public:
int velocity;
int x, y;
GameObject(InputComponent* input,
PhysicsComponent* physics,
GraphicsComponent* graphics)
: input_(input)
, physics_(physics)
, graphics_(graphics)
{}
void update(World& world, Graphics& graphics)
{
input_->update(*this);
physics_->update(*this, world);
graphics_->update(*this, graphics);
}
private:
InputComponent* input_;
PhysicsComponent* physics_;
GraphicsComponent* graphics_;
};
Некоторые системы компонентов идут даже дальше. В них вместо
GameObject
содержащего компоненты используется простоID
— номер. И отдельно хранится колекция компонентов, каждый из которых знаетID
сущности, к которой прикреплен.Такая система компонентных сущностей (entity component systems) снижает связность компонентов до самого предела и позволяет добавлять компоненты в сущность таким образом, что сущность даже не будет об этом знать. Более подробно это описано в главе Локализация данных (Data Locality).
Наши существующие конкретные классы будут переименованы и теперь будут поддерживать эти интерфейсы:
class BjornPhysicsComponent : public PhysicsComponent
{
public:
virtual void update(GameObject& obj, World& world)
{
// Физический код...
}
};
class BjornGraphicsComponent : public GraphicsComponent
{
public:
virtual void update(GameObject& obj, Graphics& graphics)
{
// Графический код...
}
};
И теперь мы можем создать объект с полностью идентичным оригинальному Бьйорну поведению, не создавая для этого специальный класс следующим образом:
GameObject* createBjorn()
{
return new GameObject(
new PlayerInputComponent(),
new BjornPhysicsComponent(),
new BjornGraphicsComponent()
);
}
Эта функция
createBjorn()
конечно представляет собой пример из шаблона Фабричный метод (Factory Method) GoF от банды четырех.
Определяя другие функции, инстанцирующие GameObjects
с другими компонентами, мы можем создавать самые разные типы объектов, которые нам нужны в нашей игре.
Архитектурные решения
Самый главный вопрос, на который вам придется ответить, когда вы будете применять этот шаблон — это "Какой набор компонентов мне нужен?" Ответ будет зависеть от ваших нужд и от жанра вашей игры. Чем больше и сложнее ваш движок, тем более мелко вам захочется нарезать его на компоненты.
Кроме это существует еще несколько специфических возможностей, над которыми следует подумать:
Как объект будет получать свои компоненты?
Как только мы разрежем наш монолитный объект на несколько отдельных компонентов, нам нужно решить как мы будем снова собирать их вместе.
Если объект создает собственные компоненты:
Мы можем быть уверены что объект всегда будет иметь нужные ему компоненты. Вам никогда не придется волноваться о том, что кто-то забыл подвязать к объекту нужный компонент и уронил игру. Объект контейнер позаботится об этом самостоятельно.
Объект сложнее реконфигурировать. Одна из основных функций этого шаблона заключается в том, что он позволяет вам создавать новые типы объектов просто рекомбинируя компоненты. Если ваш объект всегда связывает себя с ними самостоятельно, мы не можем использовать такую гибкость.
Если компоненты предоставляет внешний код:
Объект становится более гибким. Мы можем полностью изменить поведение объекта, передав ему другие компоненты для работы. В своем максимальном виде, наш объект становится обобщенным контейнером компонентов, который мы можем повторно использовать раз за разом для самых разных целей.
Объект может быть отвязан от конкретных типов компонентов. Если мы позволим внешнему коду передавать внутрь компоненты, вполне вероятно, что мы позволим передавать и производные типы компонентов. На этом этапе, объект знает только об интерфейсах компонентов, а не самих конкретных типах. В результате получается архитектура с очень хорошей инкапсуляцией.
Как компоненты будут общаться друг с другом?
Минимально связанные компоненты с изолированной функциональностью — это скорее идеал, недостижимый на практике. Тот факт, что компоненты уже являются частью некоего объекта, т.е. единого целого, означает что им нужна координация. А это означает коммуникацию.
Так каким же образом наши компоненты будут общаться? У нас есть несколько вариантов, но в отличие от других "альтернатив" в этой книге, они не эксклюзивны: вы скорее всего будете использовать в своей архитектуре сразу несколько из них.
С помощью изменения состояния объекта контейнера:
Компоненты остаются несвязанными. Когда наш
InputComponent
устанавливает скорость Бьйорна и после этого его используетPhysicsComponent
, эти два компонента даже не знают о существовании друг друга. Все что они знают — это то, что скорость Бьйорна изменяется с помощью какой-то неизвестной черной магии.Это требует того, чтобы любая информация, которой хочется поделиться компоненту была перенесена в объект контейнер. Зачастую — это состояние, нужное только части компонентов. Например, компоненты анимации и рендеринга могут разделять графически специфичную информацию. Помещение этой информации в объект контейнер, где к ней может получить доступ любой компонент только загрязняет класс объекта.
Гораздо хуже то, что если мы используем объект контейнер с несколькими конфигурациями компонентов, может получиться так, что мы будем тратить память на состояние, которое не нужно ни одному из компонентов объекта. Если мы помещаем специфичные для рендеринга данные в объект контейнер, любой невидимый объект будет бесцельно тратить на них ценную память.
Общение становится неявным и зависящим от порядка обработки компонентов. В нашем примере кода, наш оригинальный монолитный метод
`update()
содержал очень тщательно организованную очередность операций: пользовательский ввод изменял скорость, которая затем использовалась физическим кодом для определения позиции, которая в свою очередь использовалась кодом рендера для отрисовки Бьйорна в правильной точке. После того, как мы разделили код на компоненты, мы должны быть осторожны, чтобы не нарушить очередность операций.Если мы с этим не справимся, у нас появятся неочевидные и трудноотлавливаемые баги. Например, если мы сначала обновили графический компонент, мы ошибочно отрендерим Бьйорна на позиции из прошлого кадра, а не текущего. Если вы представите себе, что у вас есть еще несколько компонентов и гораздо больше кода, вы сможете себе представить как сложно будет отлавливать такие баги.
Общие изменяемые состояния такого типа, когда одни и те же данные считывает и изменяет большое количество кода крайне сложно правильно организовать. Вот почему многие академики тратят столько времени на чисто функциональные языки типа Haskel, где вообще нет изменяемых состояний.
Обращаясь друг к другу напрямую:
Идея тут заключается в том, что компоненты, которым нужно пообщаться, буду иметь прямые ссылки друг на друга и в результате им вообще не придется общаться через контейнер.
Представим себе, что мы хотим научить Бьйорна прыгать. Графическому коду необходимо знать — нужно его рисовать с помощью спрайта прыжка или нет. Для этого он может спросить у физического движка, находится ли он сейчас на земле. Проще всего это сделать, если позволить графическому компоненту обратиться к физическому компоненту напрямую:
class BjornGraphicsComponent { public: BjornGraphicsComponent(BjornPhysicsComponent* physics) : physics_(physics) {} void Update(GameObject& obj, Graphics& graphics) { Sprite* sprite; if (!physics_->isOnGround()) { sprite = &spriteJump_; } else { // Существующий графический код... } graphics.draw(*sprite, obj.x, obj.y); } private: BjornPhysicsComponent* physics_; Sprite spriteStand_; Sprite spriteWalkLeft_; Sprite spriteWalkRight_; Sprite spriteJump_; };
Когда мы конструируем
GraphicsComponent
Бьйорна, мы передаем ему ссылку на егоPhysicsComponent
.Это просто и быстро. Общение здесь — это прямой вызов метода одним объектом из другого. Компонент может вызывать любой метод из тех, что поддерживаются компонентом, на который у него есть ссылка. Полная свобода для всех.
Два компонента крепко связаны. Негативная сторона такой свободы. Мы практически делаем шаг назад в сторону нашего монолитного класса. Впрочем, это не настолько плохо, как оригинальный единый класс, так как мы по крайне мере ограничили связность до связности двух компонентов, которым нужно взаимодействовать.
С помощью пересылки сообщений:
Это самая сложная альтернатива. Мы можем построить внутри нашего объекта контейнера небольшую систему сообщений и позволить компонентам передавать через нее друг другу информацию.
Вот пример реализации. Мы начнем с определения базового интерфейса
Component
, который будут реализовывать все компоненты.class Component { public: virtual ~Component() {} virtual void receive(int message) = 0; };
У него есть метод
receive()
, который реализуется в каждом классе компоненте для прослушивания входящих сообщений. В данном случае мы используем для идентификации сообщений простоеint
, но в полной реализации мы можем прикреплять к сообщению и другие данные.Далее мы добавим объекту контейнера метод для отсылки сообщений:
class ContainerObject { public: void send(int message) { for (int i = 0; i < MAX_COMPONENTS; i++) { if (components_[i] != NULL) { components_[i]->receive(message); } } } private: static const int MAX_COMPONENTS = 10; Component* components_[MAX_COMPONENTS]; };
А теперь, если у нашего компонента есть доступ к своему контейнеру, он может отсылать ему сообщения, которые в свою очередь в широковещательном режиме передаются всем его родственным компонентам. (B том числе и ему самому. Так что будьте осторожны и не попадитесь в ловушку бесконечного цикла сообщений). У такого решения есть несколько последствий:
Если вы действительно хотите быть модным, вы можете даже заставить эту систему сообщений организовывать сообщения в очередь для последующей обработки. Подробнее это описано в Очереди событий (Event Queue).
Родственные компоненты становятся несвязанными. Используя родительский объект контейнер, как и в случае с разделяемым состоянием, мы можем быть уверены, что наши компоненты не связаны друг с другом.
Банда четырех называет такой шаблон Посредником (Mediator) GoF: два или более объектов общаются друг с другом не напрямую, а передавая сообщения через промежуточный объект. В данном случае в роли посредника выступает сам объект контейнер.
Объект контейнер остается простым. В отличие от варианта с разделяемым состоянием, когда сам объект контейнер обладает данными и знает какие компоненты их используют, здесь он просто вслепую передает сообщения. Таким образомб можно организовать очень специфичную передачу информации между двумя компонентами, не впутывая сюда объект контейнер.
Думаю я вас не удивлю, если скажу что единого правильного ответа здесь нет. В конце концовб вы можете обнаружить, что используете их все сразу. Разделяемые состояния полезны для самых базовых вещей, таких как позиция или размер, которые могут быть практически у любого объекта.
Некоторые области разделены, но все равно достаточно родственные. Например, анимация и рендеринг, пользовательский ввод и ИИ, физика и коллизии. Если у вас есть отдельный компонент для каждой половинки этих пар, вы можете прийти к решению что вам будет проще позволить половинкам знать друг о друге.
Сообщения полезны для "менее важного" общения. Их принцип выстрелил и забыл хорош для вещей типа просьбы к звуковому компоненту проиграть звук, когда физический компонент посылает сообщение о том, что объект с чем-либо пересекся.
И как всегда, я рекомендую вам начать с простого, а затем добавлять дополнительные пути коммуникации по мере необходимости.
Смотрите также
Класс GameObject из фреймворка Unity целиком построен вокруг компонентов.
Движок с открытым кодом Delta3D содержит базовый класс
GameActor
, реализующий этот шаблон через базовый класс с соответствующим именемActorComponent
.Фреймворк XNA от
Microsoft
содержит основной классGame
. Он содержит коллекцию объектовGameComponent
. И пусть наш пример использует компоненты на уровне отдельных игровых сущностей, аXNA
реализует шаблон на уровне самого главного игрового объекта, зато принцип один и тот же.Этот шаблон очень похож на шаблон Стратегия (Strategy) GoF от банды четырех. Оба шаблона посвящены тому, чтобы забрать часть поведения объекта и делегировать его к отдельному подчиненному объекту. Разница заключается в том, что в случае с шаблоном стратегия, отдельный объект стратегия обычно не имеет состояния — он инкапсулирует алгоритм, а не данные. Он определяет как объект себя ведет, а не что он из себя представляет.
Компоненты несколько в большей степени самодостаточны. Они зачастую хранят состояния, описывающие объект и помогающие определить его истинную сущность. Однако это не обязательно. У вас вполне могут быть компоненты, которым не нужны никакие локальные состояния. В этом случае, вы легко можете использовать один и тот же экземпляр компонента для нескольких объектов контейнеров. В таком случае он будет себя вести практически как стратегия.