Главным достижением ООП является наследование. Оно же является и его проклятием. 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 по вкусу. Например:
или
Получается, нам больше не нужны классы Apple и Player. Делая композицию класса GameObject из нужных свойств, мы получаем объект, который выглядит и ведёт себя как яблоко, или как игрок.
Также ничто не мешает нам получить композицию, состоящую из логики яблока и внешнего вида игрока, или наоборот. Можно делать любых мутантов.
Конструкторы
В настоящее время трендово считать композицию полной и лучшей заменой наследованию. То есть наследование, якобы, настолько плохо, что его нужно запретить вообще.
Не вдаваясь в подробности религиозных войн, можно отметить, что композиция, при всех плюсах, несёт и минусы. Это, в частности, избыточный код, необходимый для создания объекта. Так, в случае с наследованием мы могли бы написать (на условном языке)
gmo = new GameObject();
И получить объект с уже готовыми свойствами и методами.
В случае с Rust мы, во-первых, обязаны проинициализовать свойства структуры:
let gmo = GameObject { x:0, y:0, и т.д. }
Во-вторых, если используется композиция, мы должны добавить в структуру свойства для нужных "классов-сотрудников" и проинициализировать их тоже:
Писать такие блоки кода каждый раз, когда надо создать новый объект, весьма утомительно. Поэтому делается метод-конструктор, который возвращает готовый объект.
Выше я написал, что классы Apple и Player не нужны, т.к. композиция GameObject может дать нам объект с любыми требуемыми свойствами.
Но если мы хотим сделать объект со свойствами яблока, и при этом не хотим каждый раз инициализировать его свойства, то класс Apple можно сделать для удобства:
struct Apple {};
Этот класс, то есть структура, на данный момент не содержит никаких свойств, просто они не нужны. Что нам нужно – это метод new(), который порождал бы новые объекты:
С помощью impl мы добавляем в структуру Apple метод new(). Запись
-> GameObject
задаёт тип возвращаемого значения: GameObject.
Внутри функции мы как обычно создаём объект, инициализируя все нужные свойства, и возвращаем его. Обратите внимание, что поля logic и display заполняются объектами с классами LogicApple и DisplayApple, то есть на выходе получится объект со свойствами именно яблока.
Теперь, чтобы создать яблоко, достаточно написать:
let apple = Apple::new();
Это вызовет в структуре Apple метод new() и мы получим сконструированный объект.
Если вы внимательны, то отметите, что Apple::new() это статический метод.
Каждый раз, когда я собираюсь приступить уже к написанию игры, мне приходится расписывать сопутствующие темы. Вот и сейчас.
В следующем выпуске (надеюсь) напишу уже конкретный код.
Читайте дальше: Дженерики