В данной статье рассматривается один из подходов к следующей ступени развития ООП (объектно-ориентированного программирования). Классический подход к ООП строится на концепции наследования, что в свою очередь накладывает серьезные ограничения по использованию и модификации уже готового кода. Создавая новые классы, не всегда получается наследоваться от уже существующих классов (проблема ромбовидного наследования) или модифицировать существующие классы от которых уже унаследовалось множество других классов (хрупкий (или чрезмерно раздутый) базовый класс). При разработке языка программирования Delight был выбран альтернативный подход для работы с классами и их композицией - КОП (компонентно-ориентированное программирование).
Сразу к делу
Начать следовало бы с основ языка, его синтаксиса и правил работы КОП. Но это довольно скучно, так что сразу перейдем к конкретному игровому примеру. Для понимания всего нижеизложенного, требуется полноценное знание ООП, так как сама композиция строится на тех же принципах что и ООП. Более детально о том, как это все-таки работает, можно прочитать в следующем после этого разделе.
Рассмотрим пример из условной игры, где некие существа могут передвигаться по карте. Напишем код поведения этих существ. Начнем с базовых классов.
class BaseBehavior
unitPos: UnitPos [shared]
fn DoTurn [virtual]
class PathBuilder
unitPos: UnitPos [shared]
fn Moving:boolean [virtual]
...
fn BuildPath(x:int, y:int) [virtual]
...
// ... and some more helper functions ...
BaseBehavior - отвечает за базовое поведение существа, сам класс не имеет логики, только необходимые декларации.
PathBuilder - класс который отвечает за поиск пути по земле (включая обход препятствий).
Модификатор [shared] означает что это поле будет общим для всех подклассов финального класса.
Дальше пропишем классы, в которых уже есть непосредственно логика поведения:
class SimpleBehavior
base: BaseBehavior [shared]
path: PathBuilder [shared]
fne DoTurn // override of BaseBehavior.DoTurn
if path.Moving = false
path.BuildRandomPath
class AgressiveBehavior
open SimpleBehavior [shared]
fne DoTurn // override of SimpleBehavior.DoTurn
d: float = path.GetDistance(player.x, player.y) // get distance from this unit to player
if d < 30
path.BuildPath(player.x, player.y) // run to player
else
nextFn // inherited call to next DoTurn
class ScaredBehavior
open SimpleBehavior [shared]
fne DoTurn // override of SimpleBehavior.DoTurn
d: float = path.GetDistance(player.x, player.y) // get distance from this unit to player
if d < 50
path.BuildPathAwayFrom(player.x, player.y) // run away from player
else
nextFn // inherited call to next DoTurn
Здесь все просто:
SimpleBehavior - существо будет перемещаться по карте по случайным координатам.
AgressiveBehavior - если игрок находится близко, то существо бежит к нему. Иначе управление передается в SimpleBehavior.
ScaredBehavior - если игрок находится недалеко, то существо отбегает от него или двигается согласно SimpleBehavior.
open - означает открытое поле класса заданного типа но без имени.
fne - перегрузка (override) виртуальной функции.
nextFn - виртуальный вызов следующей в цепочке функции.
Пока что весь код можно отобразить и с помощью обычного ООП, но для следующего поведения используем КОП:
class UncertainBehavior
open AgressiveBehavior [shared]
open ScaredBehavior [shared]
Здесь уже включается "магия" композиции. В этом простом коде, при вызове DoTurn, управление сначала передаётся в AgressiveBehavior.DoTurn. Если игрок близко, то существо побежит к нему. Если нет, то управление переходит к ScaredBehavior.DoTurn - если игрок недалеко, то существо убегает от него. Если нет, то дальше вызывается SimpleBehavior.DoTurn и существо просто бродит по карте.
На этом коде уже можно создать существ Волка (AgressiveBehavior), Зайца (ScaredBehavior) и Кошку (UncertainBehavior). Но что делать для других видов существ? Летающих или плавающих? Или комбинированных? В ООП подобная иерархия уже не сработает. Зато очень помогает композиция. Сначала создадим новые классы для поиска пути в разных средах:
class PathBuilder_air // поиск пути по воздуху
path: PathBuilder [shared]
fne BuildPath(x:int, y:int)
...
class PathBuilder_water // поиск пути в воде
path: PathBuilder [shared]
fne BuildPath(x:int, y:int)
...
А дальше просто подменим этими классами уже существующий код поведения:
class Shark
open PathBuilder_water [shared]
open AgressiveBehavior [shared]
В этом классе "Акулы", сначала создаётся класс поиска пути по воде, дальше используется код с AgressiveBehavior, только учитывая, что класс PathBuilder общий (shared), то в AgressiveBehavior (как и в SimpleBehavior) будет использоваться PathBuilder_water (так как он был объявлен ранее чем обычный PathBuilder). Соответственно вся логика AgressiveBehavior сохранилась, но поиск пути будет работать уже по воде. Таким же способом, просто перебирая классы-компоненты и используя минимум кода, можно создать существ с разным поведением в разных средах обитания:
class Fish
open PathBuilder_water [shared]
open ScaredBehavior [shared]
class Eagle
open PathBuilder_air [shared]
open UncertainBehavior [shared]
class Pigeon
open PathBuilder_air [shared]
open ScaredBehavior [shared]
class Wolf
open AgressiveBehavior [shared]
Как видим, суть компонентно-ориентированного программирования состоит в создании небольших классов-компонентов и правильной комбинации этих классов в финальном объекте-сущности.
Основы композиции в Delight
Объявление простого класа в Delight выглядит следующим образом:
class NonVirtualClass
val: OtherClass
fn SomeFn
Trace('Hello world')
Здесь val является обычным членом класса, с типом OtherClass.
Функции, как и в ООП языках могут быть виртуальными, для этого используется модификатор [virtual]
fn SomeFn [virtual]
Trace('Hello virtual world')
и перегружаться/дополняться с помощью ключевого слова fne (вместо fn)
fne SomeFn
Trace('Hello overrided world')
А вот синтаксис наследования (вернее композиции) сильно отличается от классических языков. В случае, если класс хочет перегрузить функцию своего базового класса, он должен объявить базовый класс с модификатором [shared] (общий), и использовать fne для перегрузки функции:
class BaseClass
fn SomeFn [virtual]
Trace('Hello virtual world')
class NewClass
base: BaseClass [shared]
fne SomeFn
Trace('Hello overrided world')
nextFn
Ключевое слово nextFn вызовет следующую функцию в цепочке виртуальных вызовов.
Для примера похожий (но не эквивалентный) код на С++
class BaseClass
{
public:
virtual void SomeFn()
{
Trace('Hello virtual world');
}
};
class NewClass : public virtual BaseClass
{
virtual void SomeFn() override
{
Trace('Hello overrided world');
BaseClass::SomeFn();
}
};
В классе может быть множество полей с модификатором [shared], что соответствует концепции множественного наследования. Более того, shared поля одного типа могут повторяться в любом месте иерархии класса, но при этом в финальном объекте, независимо от количества [shared] деклараций одного типа, создастся только один общий объект этого типа, а все [shared] поля соответствующего типа во всех общих классах будут содержать только ссылки на этот объект (вернее запись в vtable).
Таким образом в коде:
class Base
val: int
class ClsA
base: Base [shared]
class ClsB
base: Base [shared]
class ClsC
a: ClsA [shared]
b: ClsB [shared]
при создании класса ClsC, этот объект будет содержать три базовых объекта в единичном экземпляре (Base, ClsA, ClsB) и значение val будет всегда одинаковым (общим) для всех этих объектов. Подобный подход соответствует виртуальному наследованию классов, которое используется в С++.
Как видно из синтаксиса, у общих полей при композиции задаются также имена (в отличие от классического ООП, где при наследовании, достаточно указать тип), и последующее обращение к такому полю должно начинаться с имени поля. Однако в Delight есть синтаксический сахар в виде декларации полей через ключевое слово open (делает поле открытым). Для компилятора это ничего не меняет, но программисту не нужно будет каждый раз обращаться к такому полю по имени (все поля и функции открытого класса могут быть доступны просто через this).
class ClsA
open Base [shared]
fne Constructor
val = 10
Обход классов и функций
Поскольку такая декларация классов представляет собой зацикленный граф, то обход и построение финального класса будет сложнее чем построение дерева иерархии в ООП. По сути, при строительстве класса используются следующие правила:
Класс проходится сверху вниз, если находится новый общий (shared) класс, то происходит строительство этого класса;
Если в поле общий (shared) класс такого типа уже был построен, то используется указатель на уже построенный класс.
Таким же способом строится цепочка вызовов виртуальных функций, но с двумя важным деталями:
В цепочку вызовов сначала попадают функции из тела текущего класса, и только после них функции из общих полей класса;
Декларация функции всегда будет стоять в конце цепочки вызовов.
Для вызова следующей в цепочке виртуальной функции, используется оператор nextFn. Важно понимать, что этот оператор по сути является виртуальным вызовом (virtual call), в отличие от статического вызова перегруженной функции в классическом ООП (inherited call).
Например, такой код:
class Base
fn SomeVirtFn [virtual]
Trace('Base')
class ClsA
open Base [shared]
fne SomeVirtFn
Trace('ClsA')
class ClsB
open Base [shared]
fne SomeVirtFn
Trace('ClsB')
class ClsC
open ClsA [shared]
open ClsB [shared]
fne SomeVirtFn
Trace('ClsC')
....
fn Main
c: ClsC
c.SomeVirtFn
выдаст:
ClsC
ClsA
ClsB
Base
Такой подход позволяет перегружать функции одного класса, другим, находясь при этом на одном уровне иерархии. Благодаря этому, классы могут состоять из готовых компонентов, которые перекрывают или дополняют чужие функции, что существенно облегчает композицию кода. При композиции, основной функционал финального класса разбит на несколько базовых кирпичиков, комбинация которых и дает нужные результаты. Также Delight поддерживает статическую композицию кода, но это уже материал для другой статьи.