Декларативный vs Императивный подход к написанию кода
В клубе спросили, в чём разница этих двух подходов, решил вкратце расписать в виде поста.
Вообще эти понятия используются слегка размыто, поскольку трактовать "декларативность" можно по-разному в зависимости от того, что мы считаем деталями реализации. Плюс ко всему, чисто декларативные языки зачастую не Тьюринг-полные, так что приходится идти на компромиссы
Вкратце на классическом примере
Декларативный подход - это когда код описывает "что хотим получить", а не "как это сделать".
Т.е. если говорить про условную функцию расчёта факториала, то мы говорим, что:
Да, кстати, функциональное программирование - это подвид декларативного
В императивном же мы расписываем это "по-кодерски".
Мол, создадим такую-то переменную, пройдёмся циклом от 1 до N, присвоим этой переменной произведение, затем вернём результат:
Применяем на практике
Предположим, что у нас есть действие - атака. При её активации нам нужно отрисовать зону поражения, продамажить попавших в неё юнитов и через какое-то время эту зону спрятать. И добавить ещё задержку между атаками, чтобы её нельзя было спамить.
Императивно это будет выглядеть примерно так:
Тут я опустил некоторые детали, поскольку кода и так много, но суть, думаю, ясна.
Недостаток такого подхода налицо - слишком много служебного кода, слишком мало сути. Если при этом понадобится навесить каких-то эффектов при тычке итд, всё это ещё многократно раздуется.
Мы можем, конечно, вынести всякие штуки вроде создания таймера в сторонние функции и сократить таким образом код, можем попрятать какие-то детали реализации, но из-за императивного стиля полной изоляции добиться всё равно не выйдет.
Общую для всех скиллов логику как бы спрятали внутрь родительского класса, но всё ещё дёргаем её руками. А специфичная для скилла логика всё ещё кишит служебной информацией.
Здесь есть смысл плясать от другого.
Какие сущности, участвующие в логике, мы можем выделить?
Под капотом эти штуки содержат свои служебные состояния, методы и события.
К примеру, Duration
имеет метод StartTimer
, свойство IsReady
, события Started
/Finished
итд.
Окей, теперь нам надо их как-то связать.
Для этого я сделал логический движок, который создаёт связи между этими сущностями:
Здесь мы спрятали большую часть технических штук под капот. Например, мы больше не меняем руками is_casting
и can_cast
, мы не кладём руками инстансы таймеров итд. Теперь код скилла по сути выглядит как описание бизнес-требований.
Но, само это "описание" всё ещё в императивном стиле. Ведь декларативно - это когда "что", а не "как". А у нас тут "после активации включи таймер, после таймера включи другой таймер" итд.
Но засчёт того, что код теперь состоит из вложенных друг в друга объектов, мы можем выносить повторяющиеся комбинации в эдакие пресеты. И засчёт этого достичь той самой декларативности.
Если вынесем подобные штуки в пресеты, то конечный код скилла будет выглядеть примерно так:
Итого, какие преимущества мы получили
- Код скилла гораздо короче и консистентнее
- Структура кода схожа с тем, как люди обычно мыслят - "ну это, короче, тычка, которая наносит урон и бьёт с таким-то интервалом"
- Любой фрагмент логики можно вынести и переиспользовать. При этом способ вынесения и использования будет всё ещё таким же консистентным, чего не сказать об императивном подходе
- Вся техническая внутрянка спрятана под капотом. Если потребуется что-то исправить или дополнить, код самих скиллов изменять не придётся
- Мы также получили статичную структуру скилла, которую можем модифицировать по своему усмотрению из других мест
- GDScript - довольно бедный язык с точки зрения типизации. Даже в последней версии у нас нет возможности типизировать объекты. И если, к примеру, у пресета переименуется (или добавится новый) параметр, редактор не подсветит ошибки в местах его использования. Придётся искать их самому
- Избыточное вынесение деталей реализации в пресеты может привести к тому, что какие-то суперкастомные скиллы будет реализовать сложно (или вовсе невозможно)
- Перфоманс. Очевидно, прямое
is_casting = true
отработает гораздо быстрее, чем какая-то цепочка вызовов, которая в конечном счёте сделает то же самое. Плюс такое дерево из объектов будет кушать больше памяти. Однако, здесь стоит отметить, что эти деревья создаются один раз при инициализации скилла. А разница в скорости - число слишком малого порядка, чтобы об этом беспокоиться
Бонус: перки
Перки могут просто добавлять новые ноды в деревья логики скиллов, при этоом тоже оставаясь декларативными: