Comments 48
Отличная статья. Не соглашусь только с противопоставлением наследования и композиции.
Это все-таки разные вещи предназначенные для реализации разных деталей модели предметной области.
Наследование реализует классификацию сущностей одного порядка. Т.е. это про отношения "является подмножеством".
Композиция реализует внутреннее утройство сущностей. Т.е про отношения "состоит из"
И в наследовании реализации нет ничего особо страшного. Ведь если оно приводит к проблемам, это всего лишь означает ошибки в выстраивании иерархии наследования.
В статье верно подмечено про квадрат и прямоугольник. Наследование не проблема, если интерфейс(который внешний контракт взаимодействия) не нарушен или изменен. В корректном коде метод вычисления площади успешно наследуется и алгоритм полагается на метод, и для раскрашеного квадрата наследование реализации не должно привести к проблемам. Но в случае внешних алгоритмов, полагающихся на интерфейс, унаследованная реализация зачастую проникает в интерфейсную часть и протекает в алгоритм, который самостоятельно использует поле ширины. И тут уже возникают проблемы нарушения SOLID.
Использование композии по умолчанию менее рискованно, и обращаться к наследованию стоит только в случае необходимости.
То что что-то является подмножеством чего-то не является достаточным основанием для использования наследования. Многие множества одновременно могут выступать подмножествами разных множеств, а реализация некоторого интерфейса на определенном подмножестве может требовать специализации алгоритма и/или способа хранения состояния.
Но хранение ширины и высоты в прямоугольнике — это особенность реализации, а не его интерфейс. В обсуждаемом примере интерфейсом является только метод вычисления площади. И он спокойно наследуется в квадрат. А чтобы накладных расходов не возникало, то Rect
просто не должен содержать данных и превращается в интерфейс (по классификации Java). Теоретически, ColoredRect
может содержать данные о цвете, и ColoredSquare
будет унаследован и от Square
, и от ColoredRect
. Но если у нас появится GradientRect
(который по логике тоже цветной), то опять возникнут те же проблемы. Придется опять разделять хранение данных и интерфейс получения цвета. Если есть опасность, что это понадобиться то такое разделение следует сделать уже при проектировании. В противном случае не вижу ничего страшного, чтобы не разделять.
Если вы используете подход с наследованием реализаций, то совершенно не учитываете LSP и думаете с практической точки зрения о возможности многократного использования кода, пользуясь наследованием как инструментом.
Примеры иллюстрируют желании уменьшить количества кода, а не о его многократном использовании.
(Liskov substitution principle). Каждая реализация интерфейса должна на 100% соответствовать требованиям этого интерфейса, т.е. любой алгоритм, работающий с интерфейсом, должен работать с любой реализацией.
В этой формулировке утрачена строгая определённость, что есть «требования интерфейса», и почему строгое им соответствие эквивалентно тому, что «любой алгоритм должен...» и почему просто «работать».
Самая большая проблема LSP — это его интерпритация. Вот что говорила Барбара Лисков (из книги Роберта Мартина «Принципы, паттерны...»):
Мы хотели бы иметь следующее свойство подстановки: если для
каждого объекта o 1 типа S существует объект o 2 типа T, такой, что
для любой программы P, определенной в терминах T, поведение P
не изменяется при замене o 1 на o 2, то S является подтипом T.
Очень важно понимать, что соответствие некой иерархии классов этому принципу совершенно невозможно оценить рассматривая эти классы в отрыве от программы, в которой они используются. Т.е. можно унаследовать квадрат от прямоугольника, или наоборот, и написать код, который использует эту иерархию таким образом, что LSP не будет нарушен.
Поясняю, т.к. вокруг меня множество людей, которые на словах понимают LSP, а ревью их кода показывает, что на самом деле не понимают.
… такой, что
для любой программы P, определенной в терминах T, ...
А затем говорится вот что:
… поведение P
не изменяется при замене o 1 на o 2, ...
Т.е. рассматривается поведение программы, которая использует рассматриваемые объекты о1 и о2.
Таким образом, именно поведение конкретной программы может говорить о том, нарушен принцип LSP или нет, а рассматривать классы отдельно от неё не приводит к верным выводам.
Также у нас есть в качестве подклассов квадраты и прямоугольники. Квадрат должен быть прямоугольником, или прямоугольник квадратом?..
С этой точки зрения совершенно логично следующее:
struct Square { int width; }; struct Rectangle: Square { int height; };
У квадрата есть только ширина, а у прямоугольника есть ширина + высота, то есть расширив квадрат компонентом высоты, мы получим прямоугольник!
Мне кажется, что это как раз пример «чучела» с вашей стороны.
Здесь есть четкая однонаправленная импликация: любой квадрат — это прямоугольник, но не любой прямоугольник — квадрат.
Следовательно, логично, наоборот, сделать базовый класс более общим (прямоугольник), а при специализации установить связь между шириной и высотой. И это будет верное наследование.
Таким образом, обращаясь к квадрату, как к прямоугольнику, мы будем получать тот же, корректный, результат.
void complexFunction(Rectangle &r) {
// ...
r.setHeight(r.getHeight() / 2 );
// ...
}
Очевидно, что любой прямоугольник останется валидным прямоугольником после такой операции. А вот квадрат уже не останется квадратом (нужно пропорционально изменить ширину) — нарушение LSP. И я не знаю, как эту ситуацию решить — далеко не все операции, которые оставят прямоугольник прямоугольником, оставят квадрат квадратом.
Я написал: «при специализации установить связь между шириной и высотой.»
Переопределенный сеттер для размеров оставит квадрат квадратом и при этом он по-прежнему будет прямоугольником.
Я не уверен, что здесь есть нарушение LSP, потому что для прямоугольника соблюдается консистентность всех операций: как установление размера, так и его получение. Т.е. мы будем обращаться с ним как с прямоугольником. А сохранение, скажем, ширины при изменении высоты не является элементом контракта прямоугольника как такового — нам не стоит ожидать этого и от объекта.
Сугубо ИМХО.
Почему не является? Логично ожидать, что если я уменьшил высоту прямоугольная в два раза, то его площадь тоже уменьшиться в два раза. А вот площадь квадрата при пропорциональном уменьшении станет равна четверти начальной.
Правильное решение — превратить квадрат обратно в прямоугольник. Только это нельзя выразить средствами большинства языков.
Да, главная проблема данного классического примера — что в нём рассматриваются, простите за мой французский, сферический прямоугольник и сферический квадрат, бесцельно болтающиеся в вакууме. То есть нам сначала предлагают построить иерархию классов, а затем начинается рассуждение о том, можем ли мы использовать полученный класс неизвестным заранее образом. И похоже, что ищется философский камень универсальное решение на все случаи жизни.
Кажется, первый принцип, который можно из этого примера вывести: двигаться надо от целей к реализации, а не зная смысла задачи — не стоит приступать к проектированию.
Логично ожидать, что если я уменьшил высоту прямоугольная в два раза, то его площадь тоже уменьшиться в два раза.
Только если вы используете объект просто как запись для хранения данных и произвольным образом делаете предположения о внутреннем состоянии объекта.
В противном случае, в норме, вычисление площади — это его, объекта, задача.
С точки зрения геометрии, квадрат — частный случай прямоугольника.
Но если рассматривать более сложное поведение, например, изменение размеров, то получается, что в нашей модели квадрат не совсем прямоугольник, поскольку отличается от него особенностями поведения (не позволяет независимо менять ширину и высоту).
Придется корректировать модель в соответствии с особенностями задачи.
Можно посчитать квадрат и прямоугольник разными сущностями.
Можно сделать базовый класс без метода изменения размера, реализовав его в наследниках "обычный прямоугольник" и "квадрат".
Можно придумать что-то еще.
поскольку отличается от него особенностями поведения (не позволяет независимо менять ширину и высоту).
Выше уже отмечено несколько раз, что это зависит от контракта. Вы считаете возможность менять ширину и высоту любого прямоугольника независимо друг от друга его, прямоугольника, неотъемлемым свойством — пожалуйста; с моей точки зрения это не так.
Это можно и повернуть вспять: если мы считаем квадрат частным случаем прямоугольника, при этом квадрат не позволяет изменять W и H независимо друг от друга, то и прямоугольник, как более общий случай, не может налагать таких ограничений. Иначе, если бы прямоугольник обязан был иметь независимые W и H (т.е. объект прямоугольник обязательно должен иметь возможность различных W и Н), то квадрат не мог бы быть частным случаем.
Какие страсти бушуют на фронте геймдева.
https://youtu.be/mNmG6dmToEc
Смотреть с 4:45
Автор так ругает неверное использование принципов ООП (согласен, ещё отмечу: канонично ООП пиарили в книгах в виде 3 основных принципов: инкапсуляция, наследование, полиморфизм). При этом в качестве базы для разбора, считаю, выбран пример с неверным в корне использованием паттерна ECS, который красивый, но ужасный с точки зрения геймдева. Например: методы в компонентах (ещё и виртуальные!), dynamic_cast где он не нужен и т.д.
То что геймдеву реализация не даёт и близко. Равно и новый вариант, который переделал кривой и неработающий SoA подход на красивый, но также не дающий требуемого, AoS формат. А в правильном ECS это одна из ключевых фишек. Которая обычно не работает без ручного аллокатора.
P.S. Мне так и не ясно, почему был выбран такой подход, ведь в репе Араса куда более корректный пример ECS, чем в том, который модифицирует автор статьи.
Один из вариантов — обычный подход Unity3D(в посте очень похожий подход, создаётся впечатление, что именно оттуда и содран) с контейнерами в компонентах. Некоторые данные, которые требуют большого количества операций в кадр в компонентах заносились в списки и обрабатывались отдельно для каждого списка(вот это похоже на ECS). И по мне так это много лучше, чем переделывать весь проект под DO, т.к. у ECS тоже есть проблемы, в т.ч. с производительностью.
Одна из проблем ECS — много независимого различного кода, выполняемого за цикл небольшое количество раз. Да, делать перемещение и отрисовку с 10к объектами и парой методов ECS даёт огромный прирост производительности, а вот с 10к методами работать это будет как тот же Update. Другая проблема — писать код для разовых действий. Интерфейсы на ECS? Конечно можно, но зачем? Получается множество контейнеров данных(объектов, компонентов), где рабочий набор всегда один. А это ни чуть не лучше ситуации с Update по производительности, а по сложности написания кода много сложнее.
ИМХО, ECS позволяет куда более масштабируемый, простой и удобный подход к архитектуре, нежели ОО. В условиях вечно меняющихся идей у геймдизов и начальства, ECS — подходит куда лучше. Но понять ECS после ОО действительно крайне трудно.
Я вот слабо представляю, как это должно работать…
Например у меня есть компонент Renderable (от которой наследуются Mesh, Material, Texture и т.д.). У меня есть компонент Transform.
Есть RenderSystem, который вызывеат Draw всех спрайтов сцены.
Если у сущности нету компонента Transform, то Renderable рисуется так как есть, в нулевой матрице трансформации. Но если у сущности есть компонент Transform, то с матрицей трансформации Renderable должна складываться матрица трансформации Transform.
В итоге, во круг Transform у нас завязаны все другие компоненты. BoundBox, CollisionBox, Renderable и т.д. Можно всем этим компоентам задать свои параметры позиционирования, сделать методы и удалить компонент Transform. Но не много ли кода получается, ради трансформации одной сущности?
Еще пример. Вот допустим, у меня есть сущность.
Мне нужно в эту сущность, в рантайме добавлять новые компоненты, в зависимости от того, что с сущнностью происходит во время процесса игры.
Например это сущность Mechanism.
Я добавляю в него компонент Wheel, MechBody, Engine — в итоге я имею автомобиль.
Но я добавляю во время игры в него компонент Cannon и Trailer — и машина становиться боевой фурой. Я удаляю Wheel и добавляю компонент MechLegs — и фура с пушкой превращается в мехо-дредноут.
Если делать как говорит автор статьи, то мне придется наплодить 100500 разных сущностей, разных типов в коде.
Ну вот зачем мне отдельный класс Ork, Demon, Human, Barrel и Dragon?
Так я добавлю пару компонентов и при взаимодействиях игрока с одной из этой сущностью, могу сделать проверку на наличие того или иного компонента. А так мне придется писать 100500 перегруженных функций на каждый тип Enemy, с которым игрок будет взаимодействовать. Да и в итоге без проверки на тип — не обойтись.
Вот допустим игрок кидает фаербол по области. Я получаю список всех HitBox компонентов, которые попали в зону действия фаербола. Что делать дальше? Если классический подход, я могу напрямую получить parent компонента и отправить туда ивент или напрямую вызвать метод нужного мне компонента, если он есть.
В случае если у нас архитектура как у автора статьи — то что тогда делать? Это может быть как и Barrel, который вообще статичен и просто коллизия в мире так и Demon, который должен лечиться от огня.
Нарушая принципы OOD, и теряя пару процентов производительности, я оставляю понятный код и легко смогу крутить компоентами как вздумается, прикрутить к ним любую систему и т.д.
>> Однако в большинстве игровых проектов бывает очень мало дизайнеров и в буквальном смысле целая армия программистов
Я не gamedev и никогда не был, но даже видя файловую структуру современных игр, предполагаю, что это очень рисковое утверждение, а в большинстве случаев ситуация обратная — особенно когда мы учитываем тысячи моддеров, которые не правят движок, но переделывают всё остальное до полной неузнаваемости.
Ну и как программист. Все таки писать тот подход, что предлагает автор, начнет рушиться, как только нужно будет создавать логику. На каждую новую сущность — писать логику взаимодействия между всеми остальными сущностями. Писать логику между компонентами не выйдет, т.к. компоненты не знаю об существовании сущности, т.к. у них нету общего базового класса. В итоге количество кода начнет расти в геометрической погрешности, с ростом разнообразных сущностей.
github.com/SH42913/pacmanecs
Самый главный плюс ECS — его легкая расширяемость, взаимозаменяемость и слабосвязность систем, в некоторых случаях(LeoECS самый лучший пример такого случая) ECS дает еще и приличный буст к производительности, ибо обработка выходит гораздо легче. Единственная трудность ECS — его освоение на первых парах, очень сложно перестроить восприятие и построение архитектуры с ОО на ECS, у меня на это ушло пол года и весь этот опыт я постарался вложить в примере PacMan.
Быть может когда-нибудь я напишу статью о том как перестроить видение архитектуры с ОО на ECS.
А что не так с UI?
Для UI лучше пойдет граф, что бы каждый элемент UI рендерился в родительских координатах. Опять же, UI — это сущность. В котором есть компонент rect, event, transform и 9TextureRect. А там уже зависит от системы, как логика реализована
В проекте, которым я сейчас занимаюсь на работе(тут свой закрытый ECS-фреймворк), например каждый UI элемент — сущность. То есть кнопка — сущность с компонентами ButtonComponent и MyMarkComponent, при нажатии она генерирует ивент по самой себе и его можно ловить в любой из систем.
Но я больше склоняюсь к варианту, который предлагает Leopotam, где UI элементы просто-напросто генерируют ивенты внутрь ECS.
github.com/Leopotam/ecs-ui
Ну и ещё OpenWorld с ECS тоже интересная тема.
Я знаю что такое ecs и давно им пользуюсь.
Ecs, ns(оно же Scene Graph) и их комбинации.
То что предлагает автор статьи — безумно не оптимально.
Можно с помощью шаблонов провести хитрую агрегацию компонентов в сущность, которую я делал для ECS в чужом движке. Но при росте проекта, мы заплатим временем компиляции.
LeoECS я изучал. Но все таки это с# а не плюсы. А у плюсов есть нюансы.
Точно так же не завидую я тому, кто с помощью ECS попытается реализовать коллекционную карточную игру по типу Magic The Gathering.
Не вижу проблем.
Сущность остается базовой.
Просто собираем сущности из компонентов.
У тебя наплодятся компоненты и системы.
При том, можно пойти по пути. Когда наличие компонента какого то типа — и есть индетификатор какой то логики.
А так, я не так давно д20 днд5 делал.
Сделал один компонент атрибутов.
Систему, которая обсчитывала атрибуты.
Ивенты, и систему ивентов, которая напрямую взаимодействовала с системой атрибутов.
Состояния — это были отдельные компоненты, с общей системой состояний. Я тут не гемороял, просто в каждый компонент эффектов передавал функцию-обработчик, которая определяла поведение эффектов.
Ну а чтобы не дублировать код, в ECS тоже возможна абстракция, только выстраивается она совсем иначе, нежели в ОО.
Отличная статья! Спасибо и респект автору.
BTW по-моему, в предложении "Наследование стоит оставить для случаев, когда она абсолютно необходима" будет правильнее использовать местоимение "оно"
Большое спасибо за статью. Есть над чем подумать и еще раз посмотреть на свой собственный код. :-)
Жду следующей части.
ООП мертво, да здравствует ООП