- PVSM.RU - https://www.pvsm.ru -

Этот пост возник благодаря недавней публикации Араса Пранцкевичуса [1] о докладе, предназначенном для программистов-джуниоров. В нём рассказывается о том, как адаптироваться к новым ECS-архитектурам. Арас следует привычной схеме (объяснения ниже): показывает примеры ужасного ООП-кода, а затем демонстрирует, что отличным альтернативным решением является реляционная модель (но называет её «ECS», а не реляционной). Я ни в коем случае не критикую Араса — я большой фанат его работ и хвалю его за отличную презентацию! Я выбрал именно его презентацию вместо сотен других постов про ECS из Интернета потому, что он приложил дополнительные усилия и опубликовал git-репозиторий для изучения параллельно с презентацией. В нём содержится небольшая простая «игра», используемая в качестве примера выбора разных архитектурных решений. Этот небольшой проект позволил мне на конкретном материале продемонстрировать свои замечания, так что спасибо, Арас!
Слайды Араса выложены здесь: http://aras-p.info/texts/files/2018Academy — ECS-DoD.pdf [2], а код находится на github: https://github.com/aras-p/dod-playground [3].
Я не буду (пока?) анализировать получившуюся ECS-архитектуру из этого доклада, но сосредоточусь на коде «плохого ООП» (похожего на уловку «чучело») из его начала. Я покажу, как бы он выглядел на самом деле, если бы правильно исправили все нарушения принципов OOD (object-oriented design, объектно-ориентированного проектирования).
Спойлер: устранение всех нарушений OOD приводит к улучшениям производительности, аналогичным преобразованиям Араса в ECS, к тому же использует меньше ОЗУ и требует меньше строк кода, чем ECS-версия!
TL;DR: Прежде чем прийти к выводу, что ООП отстой, а ECS рулит, сделайте паузу и изучите OOD (чтобы знать, как правильно использовать ООП), а также разберитесь в реляционной модели (чтобы знать, как правильно применять ECS).
Я уже долгое время принимаю участие во множестве дискуссий про ECS на форуме, частично потому, что не думаю, что эта модель заслуживает существовать в качестве отдельного термина (спойлер: это просто ad-hoc-версия реляционной модели [4]), но ещё и потому, что почти каждый пост, презентация или статья, рекламирующие паттерн ECS, повторяют следующую структуру:
Такая структура бесит меня, потому что: (A) это уловка «чучело»… сравнивается мягкое с тёплым (плохой код и хороший код)… и это нечестно, даже если сделано ненамеренно и не требуется для демонстрации того, что новая архитектура хороша; и, что более важно: (B) это имеет побочный эффект — такой подход подавляет знания и непреднамеренно демотивирует читателей от знакомства с исследованиями, проводившимися в течение полувека. О реляционной модели впервые начали писать в 1960-х. На протяжении 70-х и 80-х эта модель значительно улучшалась. У новичков часто возникают вопросы типа "в какой класс нужно поместить эти данные?", и в ответ им часто говорят нечто расплывчатое, наподобие "вам просто нужно набраться опыта и тогда вы просто научитесь понимать нутром"… но в 70-х этот вопрос активно изучался и на него в общем случае был выведен формальный ответ; это называется нормализацией баз данных [5]. Отбрасывая уже имеющиеся исследования и называя ECS совершенно новым и современным решением, вы скрываете это знание от новичков.
Основы объектно-ориентированного программирования были заложены столь же давно, если не раньше (этот стиль начал исследоваться в работе 1950-х годов)! Однако именно в 1990-х годах объектно-ориентированность стала модной, виральной и очень быстро превратилась в доминирующую парадигму программирования. Произошёл взрыв популярности многих новых ОО-языков, в том числе Java и (стандартизированной версии) C++. Однако так как это было связано с ажиотажем, то всем нужно было знать это громкое понятие, чтобы записать в своё резюме, но лишь немногие по-настоящему в него углублялись. Эти новые языки создали из многих особенностей ОО ключевые слова — class, virtual, extends, implements — и я считаю, что именно поэтому в тот момент ОО разделилась на две отдельные сущности, живущие собственными жизнями.
Я буду называть применение этих вдохновлённых ОО языковых особенностей "ООП [6]", а применение вдохновлённых ОО техник создания дизайна/архитектур "OOD [7]". Все очень быстро подхватили ООП. В учебных заведениях есть курсы ОО, выпекающие новых ООП-программистов… однако знание OOD плетётся позади.
Я считаю, что код, использующий языковые особенности ООП, но не следующий принципам проектирования OOD, не является ОО-кодом. В большинстве критических отзывов, направленных против ООП, используется для примера выпотрошенный код, на самом деле не являющийся ОО-кодом.
ООП-код имеет очень плохую репутацию, и в частности потому, что бОльшая часть ООП-кода не следует принципам OOD, а потому не является «истинным» ОО-кодом.
Как сказано выше, 1990-е стали пиком «моды на ОО», и именно в то время «плохой ООП», вероятно, был хуже всего. Если вы изучали ООП в то время, то, скорее всего, узнали о «четырёх столпах ООП»:
Я предпочитаю называть их не четырьмя столпами, а «четырьмя инструментами ООП». Это инструменты, которые можно использовать для решения задач. Однако недостаточно просто узнать, как работает инструмент, необходимо знать, когда нужно его использовать… Со стороны преподавателей безответственно обучать людей новому инструменту, не говоря им, когда каждый из них стоит применять. В начале 2000-х оказывалось сопротивление активному неверному использованию этих инструментов, своего рода «вторая волна» OOD-мышления. Результатом этого стало появление мнемоники SOLID [8], предоставлявшей быстрый способ оценки сильных сторон архитектуры. Надо заметить, что эта мудрость на самом деле была широко распространена в 90-х, но не получила ещё крутого акронима, позволившего закрепить их в качестве пяти базовых принципов…
Так мы получаем SOLID-C(++) ![]()
Ниже я буду ссылаться на эти принципы, называя их по акронимам — SRP, OCP, LSP, ISP, DIP, CRP…
Ещё несколько замечаний:
И, наконец, мне стоит показать несколько примеров ужасного обучения ООП и того, как оно приводит к плохому коду в реальной жизни (и плохой репутации OOP).
Нет-нет-нет. Здесь я вас остановлю. Негласный подтекст принципа LSP гласит, что иерархии классов и алгоритмы, которые их обрабатывают, являются симбиотическими. Это две половины целой программы. ООП — это расширение процедурного программирования, и оно по-прежнему в основном связано с этими процедурами. Если мы не знаем, какие типы алгоритмов будут работать с Students и Staff (и какие алгоритмы будут упрощены благодаря полиморфизму), то будет полностью безответственно приступать к созданию структуры иерархий классов. Сначала вам нужно узнать алгоритмы и данные.
На самом деле, это хороший пример для демонстрациии разницы между наследованием реализаций и наследованием интерфейсов.
С этой точки зрения совершенно логично следующее:
struct Square { int width; }; struct Rectangle : Square { int height; };
У квадрата есть только ширина, а у прямоугольника есть ширина + высота, то есть расширив квадрат компонентом высоты, мы получим прямоугольник!
Квадрат всегда имеет одинаковые высоту и ширину, поэтому из интерфейса квадрата совершенно верно предположить, что площадь равна «ширина * ширина».
Наследуясь от квадрата, класс прямоугольников (в соответствии с LSP) должен подчинятся правилам интерфейса квадрата. Любой алгоритм, правильно работающий для квадрата, должен также правильно работать и для прямоугольника.
std::vector<Square*> shapes; int area = 0; for(auto s : shapes) area += s->width * s->width;
Он корректно будет работать для квадратов (вычисляя сумму их площадей), но не сработает для прямоугольников.
Следовательно, прямоугольник нарушает принцип LSP.
struct Shape { virtual int area() const = 0; };
struct Square : public virtual Shape { virtual int area() const { return width * width; }; int width; };
struct Rectangle : private Square, public virtual Shape { virtual int area() const { return width * height; }; int height; };
TL;DR — ваш ООП-класс говорил вам, каким было наследование. Ваш отсутствующий OOD-класс должен был сказать вам не использовать его 99% времени!
Разобравшись с предпосылками, давайте перейдём к тому, с чего начинал Арас — к так называемой начальной точке «типичного ООП».
Но для начала ещё одно дополнение — Арас называет этот код «традиционным ООП», и на это я хочу возразить. Этот код может быть типичным для ООП в реальном мире, но, как и приведённых выше примерах, он нарушает всевозможные базовые принципы ОО, поэтому его вообще не стоит рассматривать, как традиционный.
Я начну с первого коммита, прежде чем он начал переделывать структуру в сторону ECS: «Make it work on Windows again» 3529f232510c95f53112bbfff87df6bbc6aa1fae [23]
// -------------------------------------------------------------------------------------------------
// super simple "component system"
class GameObject;
class Component;
typedef std::vector<Component*> ComponentVector;
typedef std::vector<GameObject*> GameObjectVector;
// Component base class. Knows about the parent game object, and has some virtual methods.
class Component
{
public:
Component() : m_GameObject(nullptr) {}
virtual ~Component() {}
virtual void Start() {}
virtual void Update(double time, float deltaTime) {}
const GameObject& GetGameObject() const { return *m_GameObject; }
GameObject& GetGameObject() { return *m_GameObject; }
void SetGameObject(GameObject& go) { m_GameObject = &go; }
bool HasGameObject() const { return m_GameObject != nullptr; }
private:
GameObject* m_GameObject;
};
// Game object class. Has an array of components.
class GameObject
{
public:
GameObject(const std::string&& name) : m_Name(name) { }
~GameObject()
{
// game object owns the components; destroy them when deleting the game object
for (auto c : m_Components) delete c;
}
// get a component of type T, or null if it does not exist on this game object
template<typename T>
T* GetComponent()
{
for (auto i : m_Components)
{
T* c = dynamic_cast<T*>(i);
if (c != nullptr)
return c;
}
return nullptr;
}
// add a new component to this game object
void AddComponent(Component* c)
{
assert(!c->HasGameObject());
c->SetGameObject(*this);
m_Components.emplace_back(c);
}
void Start() { for (auto c : m_Components) c->Start(); }
void Update(double time, float deltaTime) { for (auto c : m_Components) c->Update(time, deltaTime); }
private:
std::string m_Name;
ComponentVector m_Components;
};
// The "scene": array of game objects.
static GameObjectVector s_Objects;
// Finds all components of given type in the whole scene
template<typename T>
static ComponentVector FindAllComponentsOfType()
{
ComponentVector res;
for (auto go : s_Objects)
{
T* c = go->GetComponent<T>();
if (c != nullptr)
res.emplace_back(c);
}
return res;
}
// Find one component of given type in the scene (returns first found one)
template<typename T>
static T* FindOfType()
{
for (auto go : s_Objects)
{
T* c = go->GetComponent<T>();
if (c != nullptr)
return c;
}
return nullptr;
}
Да, в ста строках кода сложно разобраться сразу, поэтому давайте начнём постепенно… Нам нужен ещё один аспект предпосылок — в играх 90-х популярно было использовать наследование для решения всех проблем многократного использования кода. У вас была Entity, расширяемая Character, расширяемая Player и Monster, и так далее… Это наследование реализаций, как мы описывали его ранее («код с душком»), и кажется, что правильно начинать с него, но в результате это приводит к очень негибкой кодовой базе. Потому что в OOD есть описанный выше принцип «composition over inheritance». Итак, в 2000-х стал популярным принцип «composition over inheritance», и разработчики игр начали писать подобный код.
Что делает этот код? Ну, ничего хорошего
Если говорить вкратце, то этот код заново реализует уже существующую особенность языка — композицию как библиотеку времени выполнения, а не как особенность языка. Можно представить это так, как будто код на самом деле создаёт новый метаязык поверх C++ и виртуальную машину (VM) для выполнения этого метаязыка. В демо-игре Араса этот код не требуется (скоро мы его полностью удалим!) и служит только для того, чтобы примерно в 10 раз снизить производительность игры.
Однако что же он на самом деле выполняет? Это концепция "Entity/Component" («сущность/компонент») (иногда по непонятной причине называемая "Entity/Component system" («система сущность/компонент»)), но она полностью отличается от концепции "Entity Component System" («сущность-компонент-система») (который по очевидным причинам никогда не называется "Entity Component System systems). Он формализует несколько принципов «EC»:
Подобная концепция была очень популярна в 2000-х годах, и несмотря на свою ограничительность, оказалась достаточно гибкой для создания бесчисленного количества игр и тогда, и сегодня.
Однако это не требуется. В вашем языке программирования уже есть поддержка композиции как особенность языка — для доступа к ней нет необходимости в раздутой концепции… Зачем же тогда существуют эти концепции? Ну, если быть честным, то они позволяют выполнять динамическую композицию во время выполнения. Вместо жёсткого задания типов GameObject в коде их можно загружать из файлов данных. И это очень удобно, потому что позволяет дизайнерам игр/уровней создавать свои типы объектов… Однако в большинстве игровых проектов бывает очень мало дизайнеров и в буквальном смысле целая армия программистов, поэтому я бы поспорил, что это важная возможность. Хуже того — это ведь не единственный способ, которым можно реализовать композицию во время выполнения! Например, Unity использует в качестве «языка скриптов» C#, и во многих других играх используются его альтернативы, например Lua — удобный для дизайнеров инструмент может генерировать код C#/Lua для задания новых игровых объектов без необходимости использования подобного раздутой концепции! Мы заново добавить эту «функцию» в следующем посте, и сделаем это так, чтобы он не стоил нам десятикратного снижения производительности…
Давайте оценим этот код в соответствии с OOD:
Однако он не так хорош в соблюдении DIP — многие компоненты имеют непосредственное знание друг о друге.
Итак, весь показанный выше код на самом деле можно удалить. Всю эту структуру. Удалить GameObject (в других фреймворках называемые также Entity), удалить Component, удалить FindOfType. Это часть бесполезной VM, нарушающая принципы OOD и ужасно замедляющая нашу игру.
Если мы удалим фреймворк композиции, и у нас не будет базового класса Component, то как нашим GameObjects удастся использовать композицию и состоять из компонентов? Как сказано в заголовке, вместо написания этой раздутой VM и создания поверх неё GameObjects на странном метаязыке, давайте просто напишем их на C++, потому что мы программисты игр и это в буквальном смысле наша работа.
Вот коммит, в котором удалён фреймворк Entity/Component: https://github.com/hodgman/dod-playground/commit/f42290d0217d700dea2ed002f2f3b1dc45e8c27c [28]
Вот первоначальная версия исходного кода: https://github.com/hodgman/dod-playground/blob/3529f232510c95f53112bbfff87df6bbc6aa1fae/source/game.cpp [29]
Вот изменённая версия исходного кода: https://github.com/hodgman/dod-playground/blob/f42290d0217d700dea2ed002f2f3b1dc45e8c27c/source/game.cpp [30]
Вкратце об изменениях:
Поэтому вместо этого кода «виртуальной машины»:
// create regular objects that move
for (auto i = 0; i < kObjectCount; ++i)
{
GameObject* go = new GameObject("object");
// position it within world bounds
PositionComponent* pos = new PositionComponent();
pos->x = RandomFloat(bounds->xMin, bounds->xMax);
pos->y = RandomFloat(bounds->yMin, bounds->yMax);
go->AddComponent(pos);
// setup a sprite for it (random sprite index from first 5), and initial white color
SpriteComponent* sprite = new SpriteComponent();
sprite->colorR = 1.0f;
sprite->colorG = 1.0f;
sprite->colorB = 1.0f;
sprite->spriteIndex = rand() % 5;
sprite->scale = 1.0f;
go->AddComponent(sprite);
// make it move
MoveComponent* move = new MoveComponent(0.5f, 0.7f);
go->AddComponent(move);
// make it avoid the bubble things
AvoidComponent* avoid = new AvoidComponent();
go->AddComponent(avoid);
s_Objects.emplace_back(go);
}
У нас теперь есть обычный код C++:
struct RegularObject
{
PositionComponent pos;
SpriteComponent sprite;
MoveComponent move;
AvoidComponent avoid;
RegularObject(const WorldBoundsComponent& bounds)
: move(0.5f, 0.7f)
// position it within world bounds
, pos(RandomFloat(bounds.xMin, bounds.xMax),
RandomFloat(bounds.yMin, bounds.yMax))
// setup a sprite for it (random sprite index from first 5), and initial white color
, sprite(1.0f,
1.0f,
1.0f,
rand() % 5,
1.0f)
{
}
};
...
// create regular objects that move
regularObject.reserve(kObjectCount);
for (auto i = 0; i < kObjectCount; ++i)
regularObject.emplace_back(bounds);
Ещё одно серьёзное изменение внесено в алгоритмы. Помните, в начале я сказал, что интерфейсы и алгоритмы работают в симбиозе, и должны влиять на структуру друг друга? Так вот, антипаттерн "virtual void Update" стал врагом и здесь. Первоначальный код содержит алгоритм основного цикла, состоящий всего лишь из этого:
// go through all objects
for (auto go : s_Objects)
{
// Update all their components
go->Update(time, deltaTime);
Вы можете возразить, что это красиво и просто, но ИМХО это очень, очень плохо. Это полностью обфусцирует и поток управления, и поток данных внутри игры. Если мы хотим иметь возможность понимать своё ПО, если мы хотим поддерживать его, если мы хотим добавлять в него новые вещи, оптимизировать его, выполнять его эффективно на нескольких процессорных ядрах, то нам нужно понимать и поток управления, и поток данных. Поэтому «virtual void Update» нужно предать огню.
Вместо него мы создали более явный основной цикл, который сильно упрощает понимание потока управления (поток данных в нём по-прежнему обфусцирован, но мы исправим это в следующих коммитах).
// Update all positions
for (auto& go : s_game->regularObject)
{
UpdatePosition(deltaTime, go, s_game->bounds.wb);
}
for (auto& go : s_game->avoidThis)
{
UpdatePosition(deltaTime, go, s_game->bounds.wb);
}
// Resolve all collisions
for (auto& go : s_game->regularObject)
{
ResolveCollisions(deltaTime, go, s_game->avoidThis);
}
Недостаток такого стиля в том, что для каждого нового типа объекта, добавляемого в игру, нам придётся добавлять в основной цикл несколько строк. Я вернусь к этому в последующем посте из этой серии.
Здесь множество огромных нарушений OOD, сделано несколько плохих решений при выборе структуры и остаётся много возможностей для оптимизации, но я доберусь до них в следующем посте серии. Однако на уже на этом этапе понятно, что версия с «исправленным OOD» почти полностью соответствует или побеждает финальный «ECS»-код из конца презентации… И всё, что мы сделали — просто взяли плохой код псевдо-ООП, и заставили его соблюдать принципы ООП (а также удалил сто строк кода)!

Здесь я хочу рассмотреть гораздо больший спектр вопросов, в том числе решение оставшихся проблем OOD, неизменяемые объекты (программирование в функциональном стиле [32]) и преимущества, которые они могут привнести в рассуждениях о потоках данных, передачу сообщений, применение логики DOD к нашему OOD-коду, применение относящейся к делу мудрости в OOD-коде, удаление этих классов «сущностей», которые в результате у нас получились, и использование только чистых компонентов, использование разных стилей соединения компонентов (сравнение указателей и обработчиков), контейнеры компонентов из реального мира, доработку ECS-версии для улучшения оптимизации, а также дальнейшую оптимизацию, не упомянутую в докладе Араса (например многопоточность/SIMD). Порядок не обязательно будет таким, и, возможно, я рассмотрю не всё перечисленное…
Ссылки на статью распространились за пределы кругов разработчиков игр, поэтому добавлю: "ECS [33]" (эта статья Википедии плоха, кстати, она объединяет концепции EC и ECS, а это не одно и то же...) — это фальшивый шаблон, циркулирующий внутри сообществ разработчиков игр. По сути, он является версией реляционной модели, в которой «сущности» — это просто ID, обозначающие бесформенный объект, «компоненты» — это строки в конкретных таблицах, ссылающиеся на ID, а «системы» — это процедурный код, который может модифицировать компоненты. Этот «шаблон» всегда позиционировался как решение проблемы избыточного применения наследования, но при этом не упоминается, что избыточное применение наследования на самом деле нарушает рекомендации ООП. Отсюда моё возмущение. Это не «единственно верный способ» написания ПО. Пост предназначен для того, чтобы люди на самом деле изучали существующие принципы проектирования.
Автор: PatientZero
Источник [34]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/programmirovanie/309528
Ссылки в тексте:
[1] Араса Пранцкевичуса: https://twitter.com/aras_p
[2] http://aras-p.info/texts/files/2018Academy — ECS-DoD.pdf: http://aras-p.info/texts/files/2018Academy%20-%20ECS-DoD.pdf
[3] https://github.com/aras-p/dod-playground: https://github.com/aras-p/dod-playground
[4] реляционной модели: https://en.wikipedia.org/wiki/Relational_model
[5] нормализацией баз данных: https://en.wikipedia.org/wiki/Database_normalization#Normal_forms
[6] ООП: https://en.wikipedia.org/wiki/Object-oriented_programming
[7] OOD: https://en.wikipedia.org/wiki/Object-oriented_design
[8] SOLID: https://en.wikipedia.org/wiki/SOLID
[9] Принцип единственной ответственности: https://en.wikipedia.org/wiki/Single_responsibility_principle
[10] Принцип открытости/закрытости: https://en.wikipedia.org/wiki/Open/closed_principle
[11] Принцип подстановки Барбары Лисков: https://en.wikipedia.org/wiki/Liskov_substitution_principle
[12] Принцип разделения интерфейса: https://en.wikipedia.org/wiki/Interface_segregation_principle
[13] Принцип инверсии зависимостей: https://en.wikipedia.org/wiki/Dependency_inversion_principle
[14] ПСД (POD): https://en.wikipedia.org/wiki/Plain_old_data
[15] «Предпочитать композицию наследованию»: https://en.wikipedia.org/wiki/Composition_over_inheritance
[16] PIMPL: https://en.cppreference.com/w/cpp/language/pimpl
[17] непрозрачные указатели: https://en.wikipedia.org/wiki/Opaque_pointer
[18] утиную типизацию: https://en.wikipedia.org/wiki/Duck_typing
[19] сокрытия реализации: https://en.wikipedia.org/wiki/Information_hiding
[20] полиморфными: https://en.wikipedia.org/wiki/Polymorphism_(computer_science)
[21] описание данных: https://en.wikipedia.org/wiki/Data_definition_language
[22] «код с душком»: https://en.wikipedia.org/wiki/Code_smell
[23] «Make it work on Windows again» 3529f232510c95f53112bbfff87df6bbc6aa1fae: https://github.com/aras-p/dod-playground/blob/3529f232510c95f53112bbfff87df6bbc6aa1fae/source/game.cpp
[24] шаблон «локатор служб»: https://en.wikipedia.org/wiki/Service_locator_pattern
[25] антипаттерн: https://en.wikipedia.org/wiki/Anti-pattern
[26] побочные эффекты: https://en.wikipedia.org/wiki/Side_effect_(computer_science)
[27] дальнодействием: https://en.wikipedia.org/wiki/Action_at_a_distance_(computer_programming)
[28] https://github.com/hodgman/dod-playground/commit/f42290d0217d700dea2ed002f2f3b1dc45e8c27c: https://github.com/hodgman/dod-playground/commit/f42290d0217d700dea2ed002f2f3b1dc45e8c27c
[29] https://github.com/hodgman/dod-playground/blob/3529f232510c95f53112bbfff87df6bbc6aa1fae/source/game.cpp: https://github.com/hodgman/dod-playground/blob/3529f232510c95f53112bbfff87df6bbc6aa1fae/source/game.cpp
[30] https://github.com/hodgman/dod-playground/blob/f42290d0217d700dea2ed002f2f3b1dc45e8c27c/source/game.cpp: https://github.com/hodgman/dod-playground/blob/f42290d0217d700dea2ed002f2f3b1dc45e8c27c/source/game.cpp
[31] инвариантов класса: https://en.wikipedia.org/wiki/Class_invariant
[32] программирование в функциональном стиле: https://en.wikipedia.org/wiki/Functional_programming
[33] ECS: https://en.wikipedia.org/wiki/Entity%E2%80%93component%E2%80%93system
[34] Источник: https://habr.com/ru/post/441174/?utm_source=habrahabr&utm_medium=rss&utm_campaign=441174
Нажмите здесь для печати.