Найти тему
ZDG

Игра Apple на Rust: Композиция игрового объекта

Оглавление

Главным достижением ООП является наследование. Оно же является и его проклятием. Rust избегает ООП именно в плане наследования, заменяя его композицией.

Предыдущие части: Модули, Начальное проектирование, Итоги про память, Что там с памятью, Колхозим интерфейсы, Где у него классы, Поддержка SDL2, Полируем ржавчину

Мне нужно сделать общий класс GameObject. Он будет описывать любой игровой объект. Например, если в игре есть объект-яблоко и объект-игрок, то оба они могут быть заданы с помощью GameObject.

Такой универсальный объект должен включать следующий функционал:

  • координаты и размеры
  • метод update() для изменения состояния
  • метод draw() для отрисовки на экране

Но есть нюанс. Для разных объектов могут требоваться разные реализации методов.

Например, метод update() для яблока отличается от метода update() для игрока: яблоко падает вниз, а игрок движется вправо-влево.

Тогда пришлось бы в класс GameObject добавить отдельные методы update_apple() и update_player(), а в методе update() вызывать их по условию:

Что здесь не так? Любой новый функционал нужно вписывать непосредственно в класс GameObject, плодя условия и раздувая код, без возможности изолировать его для каждого специфического типа.

В то же время допустимо, что у разных классов может быть один метод update(). Так что желательно избежать копирования одного и того же кода в разных классах.

Если бы у меня было наследование, я мог бы отнаследовать класс Apple от класса GameObject. Тогда Apple пользовался бы методом update() из GameObject, а если понадобится собственный метод, то я его просто перепишу в классе Apple, и он перекроет собой родительский метод.

Обратите внимание, что переписывание метода update() перенеслось бы в отдельный класс Apple, то есть стало бы более точечным и не затрагивающим основной код.

Композиция

Композиция это "сборка из частей" или "наследование, вид сбоку".

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

Сравним примеры. Сначала наследование:

Класс GameObject содержит метод update(). Класс Apple наследуется от GameObject. Мы можем вызывать метод update() так:

apple.update()

Теперь композиция. У класса Apple делаем свойство game_object и присваиваем ему объект класса GameObject. Теперь класс Apple тоже содержит метод update(), потому что он содержит класс с этим методом. Мы можем вызывать метод update() так:

apple.game_object.update()

Обратите внимание, что при наследовании Apple напрямую пользуется своим наследством, а при композиции он обращается к объекту, хранимому в свойстве game_object, и уже у этого объекта вызывает update().

По сути большой разницы нет, кроме того, что нужный метод вызывается через посредника. И самих посредников нужно "носить с собой".

Можно представить, что наследование это семья с родоначальником во главе, чьим наследством пользуются все потомки.

А композиция это рабочая команда. Класс набирает себе "сотрудников", и когда ему требуется вызвать какой-то метод, он обращается к сотруднику, который имеет данный метод. О наследстве речь уже не идёт.

Композиция не имеет иерархии и поэтому более гибкая. Что приводит нас к интересному решению:

Класс GameObject тоже можно построить с помощью композиции. Методы draw() и update() можно заменить специализированными классами. Например, мы можем написать классы:

  • Display с методом draw()
  • Logic с методом update()

Тогда вместо методов draw() и update() класс GameObject будет содержать свойства display и logic, у которых мы будем вызывать соответствующие методы:

game_object.display.draw();
game_object.logic.update();

Теперь мы можем для яблока написать классы DisplayApple и LogicApple, а для игрока – DisplayPlayer и LogicPlayer.

Из этих классов можно скомпоновать GameObject по вкусу. Например:

-2

или

-3

Получается, нам больше не нужны классы Apple и Player. Делая композицию класса GameObject из нужных свойств, мы получаем объект, который выглядит и ведёт себя как яблоко, или как игрок.

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

Конструкторы

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

Не вдаваясь в подробности религиозных войн, можно отметить, что композиция, при всех плюсах, несёт и минусы. Это, в частности, избыточный код, необходимый для создания объекта. Так, в случае с наследованием мы могли бы написать (на условном языке)

gmo = new GameObject();

И получить объект с уже готовыми свойствами и методами.

В случае с Rust мы, во-первых, обязаны проинициализовать свойства структуры:

let gmo = GameObject { x:0, y:0, и т.д. }

Во-вторых, если используется композиция, мы должны добавить в структуру свойства для нужных "классов-сотрудников" и проинициализировать их тоже:

-4

Писать такие блоки кода каждый раз, когда надо создать новый объект, весьма утомительно. Поэтому делается метод-конструктор, который возвращает готовый объект.

Выше я написал, что классы Apple и Player не нужны, т.к. композиция GameObject может дать нам объект с любыми требуемыми свойствами.

Но если мы хотим сделать объект со свойствами яблока, и при этом не хотим каждый раз инициализировать его свойства, то класс Apple можно сделать для удобства:

struct Apple {};

Этот класс, то есть структура, на данный момент не содержит никаких свойств, просто они не нужны. Что нам нужно – это метод new(), который порождал бы новые объекты:

-5

С помощью impl мы добавляем в структуру Apple метод new(). Запись

-> GameObject

задаёт тип возвращаемого значения: GameObject.

Внутри функции мы как обычно создаём объект, инициализируя все нужные свойства, и возвращаем его. Обратите внимание, что поля logic и display заполняются объектами с классами LogicApple и DisplayApple, то есть на выходе получится объект со свойствами именно яблока.

Теперь, чтобы создать яблоко, достаточно написать:

let apple = Apple::new();

Это вызовет в структуре Apple метод new() и мы получим сконструированный объект.

Если вы внимательны, то отметите, что Apple::new() это статический метод.

Каждый раз, когда я собираюсь приступить уже к написанию игры, мне приходится расписывать сопутствующие темы. Вот и сейчас.

В следующем выпуске (надеюсь) напишу уже конкретный код.

Читайте дальше: Дженерики