Godot
January 5, 2023

Декларативный vs Императивный подход к написанию кода

В клубе спросили, в чём разница этих двух подходов, решил вкратце расписать в виде поста.

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

Вкратце на классическом примере

Декларативный подход - это когда код описывает "что хотим получить", а не "как это сделать".

Т.е. если говорить про условную функцию расчёта факториала, то мы говорим, что:

  • Факториал 0 - это 1
  • Факториал N (где N > 0) - это N * факториал N - 1
Да, кстати, функциональное программирование - это подвид декларативного

В императивном же мы расписываем это "по-кодерски".

Мол, создадим такую-то переменную, пройдёмся циклом от 1 до N, присвоим этой переменной произведение, затем вернём результат:

Применяем на практике

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

Императивно это будет выглядеть примерно так:

Тут я опустил некоторые детали, поскольку кода и так много, но суть, думаю, ясна.

Недостаток такого подхода налицо - слишком много служебного кода, слишком мало сути. Если при этом понадобится навесить каких-то эффектов при тычке итд, всё это ещё многократно раздуется.

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

Будет что-то типа:

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

Здесь есть смысл плясать от другого.

Какие сущности, участвующие в логике, мы можем выделить?

  • Сам активатор скилла
  • Зона тычки
  • Некий калькулятор урона
  • Таймер

Окей, объявим же их:

Под капотом эти штуки содержат свои служебные состояния, методы и события.

К примеру, Duration имеет метод StartTimer, свойство IsReady, события Started/Finished итд.

Окей, теперь нам надо их как-то связать.

Для этого я сделал логический движок, который создаёт связи между этими сущностями:

Здесь мы спрятали большую часть технических штук под капот. Например, мы больше не меняем руками is_casting и can_cast, мы не кладём руками инстансы таймеров итд. Теперь код скилла по сути выглядит как описание бизнес-требований.

Но, само это "описание" всё ещё в императивном стиле. Ведь декларативно - это когда "что", а не "как". А у нас тут "после активации включи таймер, после таймера включи другой таймер" итд.

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

Например, вот так:

Если вынесем подобные штуки в пресеты, то конечный код скилла будет выглядеть примерно так:

Итого, какие преимущества мы получили

  • Код скилла гораздо короче и консистентнее
  • Структура кода схожа с тем, как люди обычно мыслят - "ну это, короче, тычка, которая наносит урон и бьёт с таким-то интервалом"
  • Любой фрагмент логики можно вынести и переиспользовать. При этом способ вынесения и использования будет всё ещё таким же консистентным, чего не сказать об императивном подходе
  • Вся техническая внутрянка спрятана под капотом. Если потребуется что-то исправить или дополнить, код самих скиллов изменять не придётся
  • Мы также получили статичную структуру скилла, которую можем модифицировать по своему усмотрению из других мест

Из минусов:

  • GDScript - довольно бедный язык с точки зрения типизации. Даже в последней версии у нас нет возможности типизировать объекты. И если, к примеру, у пресета переименуется (или добавится новый) параметр, редактор не подсветит ошибки в местах его использования. Придётся искать их самому
  • Избыточное вынесение деталей реализации в пресеты может привести к тому, что какие-то суперкастомные скиллы будет реализовать сложно (или вовсе невозможно)
  • Перфоманс. Очевидно, прямое is_casting = true отработает гораздо быстрее, чем какая-то цепочка вызовов, которая в конечном счёте сделает то же самое. Плюс такое дерево из объектов будет кушать больше памяти. Однако, здесь стоит отметить, что эти деревья создаются один раз при инициализации скилла. А разница в скорости - число слишком малого порядка, чтобы об этом беспокоиться

Бонус: перки

Перки могут просто добавлять новые ноды в деревья логики скиллов, при этоом тоже оставаясь декларативными: