Pull to refresh

Comments 48

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

Не только иерархии наследования, но и используемых алгоритмов.
В статье верно подмечено про квадрат и прямоугольник. Наследование не проблема, если интерфейс(который внешний контракт взаимодействия) не нарушен или изменен. В корректном коде метод вычисления площади успешно наследуется и алгоритм полагается на метод, и для раскрашеного квадрата наследование реализации не должно привести к проблемам. Но в случае внешних алгоритмов, полагающихся на интерфейс, унаследованная реализация зачастую проникает в интерфейсную часть и протекает в алгоритм, который самостоятельно использует поле ширины. И тут уже возникают проблемы нарушения SOLID.
Использование композии по умолчанию менее рискованно, и обращаться к наследованию стоит только в случае необходимости.
Цветные прямоугольники являются подмножеством прямоугольников. Наследуем ColoredRect от Rect? Квадраты являются подмножеством прямоугольников. Наследуем Square от Rect? Что делать если нас не устраивают возникшие накладные расходы на хранение размера квадрата (две величины – длина и ширина, вместо одной)? От кого будем теперь наследовать ColoredSquare, от ColoredRect или от Square?

То что что-то является подмножеством чего-то не является достаточным основанием для использования наследования. Многие множества одновременно могут выступать подмножествами разных множеств, а реализация некоторого интерфейса на определенном подмножестве может требовать специализации алгоритма и/или способа хранения состояния.

Но хранение ширины и высоты в прямоугольнике — это особенность реализации, а не его интерфейс. В обсуждаемом примере интерфейсом является только метод вычисления площади. И он спокойно наследуется в квадрат. А чтобы накладных расходов не возникало, то Rect просто не должен содержать данных и превращается в интерфейс (по классификации Java). Теоретически, ColoredRect может содержать данные о цвете, и ColoredSquare будет унаследован и от Square, и от ColoredRect. Но если у нас появится GradientRect (который по логике тоже цветной), то опять возникнут те же проблемы. Придется опять разделять хранение данных и интерфейс получения цвета. Если есть опасность, что это понадобиться то такое разделение следует сделать уже при проектировании. В противном случае не вижу ничего страшного, чтобы не разделять.

Постойте-постойте, откуда эта мысль — обсуждать иерархию в отрыве от назначения? Не стоит ли начать с целей и задач? Может, цветность вообще должна быть в отдельном объекте, чтобы скины на лету менять.

UFO just landed and posted this here
Джентльмены, а посоветуйте хорошую книгу по OOD, если такова имеется.
Practical Object-Oriented Design, An Agile Primer Using Ruby (POODR). Понравилась эта книга, написана простым языком. Скорее для начинающих. Мне, например, трудно читать классические книги по ООП, а эта хорошо пошла. Возможно кому то не понравятся примеры на ruby.
Если вы используете подход с наследованием реализаций, то совершенно не учитываете LSP и думаете с практической точки зрения о возможности многократного использования кода, пользуясь наследованием как инструментом.

Примеры иллюстрируют желании уменьшить количества кода, а не о его многократном использовании.
(Liskov substitution principle). Каждая реализация интерфейса должна на 100% соответствовать требованиям этого интерфейса, т.е. любой алгоритм, работающий с интерфейсом, должен работать с любой реализацией.

В этой формулировке утрачена строгая определённость, что есть «требования интерфейса», и почему строгое им соответствие эквивалентно тому, что «любой алгоритм должен...» и почему просто «работать».
Самая большая проблема LSP — это его интерпритация. Вот что говорила Барбара Лисков (из книги Роберта Мартина «Принципы, паттерны...»):
Мы хотели бы иметь следующее свойство подстановки: если для
каждого объекта o 1 типа S существует объект o 2 типа T, такой, что
для любой программы P, определенной в терминах T, поведение P
не изменяется при замене o 1 на o 2, то S является подтипом T.

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

Поясняю, т.к. вокруг меня множество людей, которые на словах понимают LSP, а ревью их кода показывает, что на самом деле не понимают.
UFO just landed and posted this here
Квантор всеобщности в данном случае просто затем, чтобы ограничить все программы только теми, что определены в терминах Т:
… такой, что
для любой программы 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, потому что для прямоугольника соблюдается консистентность всех операций: как установление размера, так и его получение. Т.е. мы будем обращаться с ним как с прямоугольником. А сохранение, скажем, ширины при изменении высоты не является элементом контракта прямоугольника как такового — нам не стоит ожидать этого и от объекта.
Сугубо ИМХО.

Почему не является? Логично ожидать, что если я уменьшил высоту прямоугольная в два раза, то его площадь тоже уменьшиться в два раза. А вот площадь квадрата при пропорциональном уменьшении станет равна четверти начальной.


Правильное решение — превратить квадрат обратно в прямоугольник. Только это нельзя выразить средствами большинства языков.

Уменьшение площади прямоугольника в два раза при уменьшении высоты — это следствие контракта «высота не зависит от ширины». Если мы этот контракт отбрасываем — то можно без проблем наследовать квадрат от прямоугольника. Насколько это было бы удобное и правильное решение — зависит уже от контекста.

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


Кажется, первый принцип, который можно из этого примера вывести: двигаться надо от целей к реализации, а не зная смысла задачи — не стоит приступать к проектированию.

+100.
Только ожидания свойств и поведения (обычно формулируемые как контракт) определяют, что можно и как менять в свойствах и реализации.
Логично ожидать, что если я уменьшил высоту прямоугольная в два раза, то его площадь тоже уменьшиться в два раза.

Только если вы используете объект просто как запись для хранения данных и произвольным образом делаете предположения о внутреннем состоянии объекта.
В противном случае, в норме, вычисление площади — это его, объекта, задача.
Согласен, если независимость ширины от высоты не является контрактом прямоугольника — то никакого нарушения LSP здесь не будет. Автору оригинала стоило бы более четко сформулировать, какие именно объекты моделируются в его коде. Но если контракт на независимость высоты от ширины есть, то при любом из вариантов наследования(Square -> Rectange/Rectangle -> Square) LSP будет нарушаться.
UFO just landed and posted this here

С точки зрения геометрии, квадрат — частный случай прямоугольника.
Но если рассматривать более сложное поведение, например, изменение размеров, то получается, что в нашей модели квадрат не совсем прямоугольник, поскольку отличается от него особенностями поведения (не позволяет независимо менять ширину и высоту).
Придется корректировать модель в соответствии с особенностями задачи.
Можно посчитать квадрат и прямоугольник разными сущностями.
Можно сделать базовый класс без метода изменения размера, реализовав его в наследниках "обычный прямоугольник" и "квадрат".
Можно придумать что-то еще.

поскольку отличается от него особенностями поведения (не позволяет независимо менять ширину и высоту).

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

Это можно и повернуть вспять: если мы считаем квадрат частным случаем прямоугольника, при этом квадрат не позволяет изменять W и H независимо друг от друга, то и прямоугольник, как более общий случай, не может налагать таких ограничений. Иначе, если бы прямоугольник обязан был иметь независимые W и H (т.е. объект прямоугольник обязательно должен иметь возможность различных W и Н), то квадрат не мог бы быть частным случаем.
UFO just landed and posted this here
Хоть статья и понравилась. То что нужно геймдеву не увидел ни у критикуемого, ни у критикующего. Во-первых наличие методов в компоненте это какой-то отдельный пример ECS паттерна. Корректный вариант всё-таки считаю Entity — id, Component — данные(и только данные), System — алгоритм. В этом плане метод Update в компоненте это уже изначально неверный посыл (как и в куче других примеров по ECS), который перетёк в другой неверный посыл. ECS для игр в корректном варианте реализует тот самый переход AoS -> SoA, который так любят современные процы, который cache-friendly и легко масштабируется по ядрам.

Автор так ругает неверное использование принципов ООП (согласен, ещё отмечу: канонично ООП пиарили в книгах в виде 3 основных принципов: инкапсуляция, наследование, полиморфизм). При этом в качестве базы для разбора, считаю, выбран пример с неверным в корне использованием паттерна ECS, который красивый, но ужасный с точки зрения геймдева. Например: методы в компонентах (ещё и виртуальные!), dynamic_cast где он не нужен и т.д.

То что геймдеву реализация не даёт и близко. Равно и новый вариант, который переделал кривой и неработающий SoA подход на красивый, но также не дающий требуемого, AoS формат. А в правильном ECS это одна из ключевых фишек. Которая обычно не работает без ручного аллокатора.

P.S. Мне так и не ясно, почему был выбран такой подход, ведь в репе Араса куда более корректный пример ECS, чем в том, который модифицирует автор статьи.
Наличие методов в компонентах — всё тот же ООП, но не ECS. Последний тут(в примере верной реализации автора) вообще никаким боком.
Один из вариантов — обычный подход Unity3D(в посте очень похожий подход, создаётся впечатление, что именно оттуда и содран) с контейнерами в компонентах. Некоторые данные, которые требуют большого количества операций в кадр в компонентах заносились в списки и обрабатывались отдельно для каждого списка(вот это похоже на ECS). И по мне так это много лучше, чем переделывать весь проект под DO, т.к. у ECS тоже есть проблемы, в т.ч. с производительностью.
Одна из проблем ECS — много независимого различного кода, выполняемого за цикл небольшое количество раз. Да, делать перемещение и отрисовку с 10к объектами и парой методов ECS даёт огромный прирост производительности, а вот с 10к методами работать это будет как тот же Update. Другая проблема — писать код для разовых действий. Интерфейсы на ECS? Конечно можно, но зачем? Получается множество контейнеров данных(объектов, компонентов), где рабочий набор всегда один. А это ни чуть не лучше ситуации с Update по производительности, а по сложности написания кода много сложнее.
Складывается ощущение, что автор так и не понял ECS, хоть и видит разницу между EC и ECS.
ИМХО, ECS позволяет куда более масштабируемый, простой и удобный подход к архитектуре, нежели ОО. В условиях вечно меняющихся идей у геймдизов и начальства, 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 и никогда не был, но даже видя файловую структуру современных игр, предполагаю, что это очень рисковое утверждение, а в большинстве случаев ситуация обратная — особенно когда мы учитываем тысячи моддеров, которые не правят движок, но переделывают всё остальное до полной неузнаваемости.
я хз, откуда взялась армия программистов… Но с какими студиями я не говорил, почти везде на одного программиста, 3-5 дизайнеров/художников приходится и зачастую все упирается как раз таки в художников.

Ну и как программист. Все таки писать тот подход, что предлагает автор, начнет рушиться, как только нужно будет создавать логику. На каждую новую сущность — писать логику взаимодействия между всеми остальными сущностями. Писать логику между компонентами не выйдет, т.к. компоненты не знаю об существовании сущности, т.к. у них нету общего базового класса. В итоге количество кода начнет расти в геометрической погрешности, с ростом разнообразных сущностей.
Можешь посмотреть как работает и выглядит ECS на примере простенького PacMan с использованием LeopotamECS и Unity3D
github.com/SH42913/pacmanecs

Самый главный плюс ECS — его легкая расширяемость, взаимозаменяемость и слабосвязность систем, в некоторых случаях(LeoECS самый лучший пример такого случая) ECS дает еще и приличный буст к производительности, ибо обработка выходит гораздо легче. Единственная трудность ECS — его освоение на первых парах, очень сложно перестроить восприятие и построение архитектуры с ОО на ECS, у меня на это ушло пол года и весь этот опыт я постарался вложить в примере PacMan.
Быть может когда-нибудь я напишу статью о том как перестроить видение архитектуры с ОО на ECS.
А ECS + UI есть опыт или какие-то мысли? Да, нет, вообще нет? Если да, то может есть пример. Возможно ECS + Events для UI?

А что не так с UI?
Для UI лучше пойдет граф, что бы каждый элемент UI рендерился в родительских координатах. Опять же, UI — это сущность. В котором есть компонент rect, event, transform и 9TextureRect. А там уже зависит от системы, как логика реализована

Есть несколько вариантов как использовать ECS в UI.
В проекте, которым я сейчас занимаюсь на работе(тут свой закрытый ECS-фреймворк), например каждый UI элемент — сущность. То есть кнопка — сущность с компонентами ButtonComponent и MyMarkComponent, при нажатии она генерирует ивент по самой себе и его можно ловить в любой из систем.

Но я больше склоняюсь к варианту, который предлагает Leopotam, где UI элементы просто-напросто генерируют ивенты внутрь ECS.
github.com/Leopotam/ecs-ui
Мне всё-таки не очень нравится решение с добавлением компоненты для reload и прочего. Почему бы это не делать через события? Просто это провоцирует более активную работу с динамическими компонентами, что имхо медленнее. Может есть опыт и так, и так? Чтобы прокомментировать в сравнении.

Ну и ещё OpenWorld с ECS тоже интересная тема.
а чем не устраивает реактивный подход? В юнити подобное предлагает Entitas.
Так как часто под реактивным подходом имеют ввиду что-то своё, хотелось бы уточнить что именно вы имеете ввиду, и какое решение предлагаете? Я так понимаю просто событие слать вместо добавления компоненты. Если да, то в общем то оно и устраивает.

Да, по реактивщиной все понимают разное. Для меня по большей части это отслеживание изменений.
Entitas позволяет реагировать на определённые сущности, либо при изменение компонентов.

Я знаю что такое ecs и давно им пользуюсь.
Ecs, ns(оно же Scene Graph) и их комбинации.
То что предлагает автор статьи — безумно не оптимально.


Можно с помощью шаблонов провести хитрую агрегацию компонентов в сущность, которую я делал для ECS в чужом движке. Но при росте проекта, мы заплатим временем компиляции.


LeoECS я изучал. Но все таки это с# а не плюсы. А у плюсов есть нюансы.

Автор статьи все усложняет и, не до конца понимая ECS, немного передергивает.
Да, у плюсов слишком много нюансов :(
Проблема ECS в том, что оно подходит только для достаточно простых систем. Как только вы попытаетесь реализовать мало-мальски сложную систему наподобие d20, у вас просто глаза на лоб полезут, потому что сущности начнут плодиться как тараканы, код дико дублироваться, и с этим мало что можно поделать.
Точно так же не завидую я тому, кто с помощью ECS попытается реализовать коллекционную карточную игру по типу Magic The Gathering.

Не вижу проблем.
Сущность остается базовой.
Просто собираем сущности из компонентов.
У тебя наплодятся компоненты и системы.
При том, можно пойти по пути. Когда наличие компонента какого то типа — и есть индетификатор какой то логики.


А так, я не так давно д20 днд5 делал.
Сделал один компонент атрибутов.
Систему, которая обсчитывала атрибуты.
Ивенты, и систему ивентов, которая напрямую взаимодействовала с системой атрибутов.
Состояния — это были отдельные компоненты, с общей системой состояний. Я тут не гемороял, просто в каждый компонент эффектов передавал функцию-обработчик, которая определяла поведение эффектов.

ECS никак не ограничивает разработчика. Можно сделать все, что угодно. Единственное, что ECS потребует немного больше времени на построение хорошей архитектуры проекта.
Ну а чтобы не дублировать код, в ECS тоже возможна абстракция, только выстраивается она совсем иначе, нежели в ОО.

Отличная статья! Спасибо и респект автору.

BTW по-моему, в предложении "Наследование стоит оставить для случаев, когда она абсолютно необходима" будет правильнее использовать местоимение "оно"

PatientZero
Большое спасибо за статью. Есть над чем подумать и еще раз посмотреть на свой собственный код. :-)
Жду следующей части.
Sign up to leave a comment.

Articles