Главная мысль урока: функция в Clojure это такое же значение, как число или мапа. Её кладут в вектор, передают в другую функцию и возвращают из функции. Поэтому сложное поведение собирают из маленьких функций, а не пишут одним большим
if.
Ирма бьёт гоблина мечом. Базовый урон меча 10. Ирма под зельем силы, урон удваивается. На гоблине кожаная куртка, она гасит 3 единицы. Сколько получит гоблин?
На TypeScript такой расчёт обычно превращается в метод calculateDamage с пятью if: есть ли бафф, какая броня, был ли крит. Каждое новое правило игры дописывает в метод ещё одну ветку. В Clojure каждое правило становится отдельной функцией от урона к урону, а расчёт собирается из них:
Последняя строка главная. Правила лежат в векторе, как любые данные, и reduce прогоняет урон через каждое по очереди. Добавить правило значит добавить элемент в вектор. Весь урок про то, как такие функции писать, настраивать и склеивать.
Три способа написать функцию
Ты уже видел defn. Это сокращение: def плюс fn. Сама функция создаётся формой fn, а defn даёт ей имя в неймспейсе. Есть и третья запись, #(), для коротких функций прямо на месте вызова:
Строка после имени в defn это докстринг, его покажет (doc halve) в REPL. В #() аргумент зовётся %, если их несколько, то %1, %2 и дальше. Такая запись хороша на одну строку внутри map или filter. Если функция не помещается в строку или ей нужно имя аргумента, бери fn: #() нельзя вложить в другой #(), а % через три строки читать тяжело.
Арность: одно имя, несколько тел
Арность это число аргументов. В Clojure одна функция может иметь несколько тел, по одному на арность. Так делают значения по умолчанию: короткая арность вызывает длинную и подставляет недостающее.
Во второй функции & собирает все аргументы после первого в последовательность more. Такая функция называется вариадической: +, str, max и merge из стандартной библиотеки устроены так же.
Обрати внимание на apply. Он вызывает функцию, раскрывая последнюю коллекцию в аргументы: (apply + 5 [7 2]) это то же, что (+ 5 7 2). Без apply пришлось бы передать в + вектор, а + складывает числа, не векторы. apply нужен каждый раз, когда аргументы уже лежат в коллекции: урон всех ударов за ход, список имён для str.
Замыкание: функция, которая помнит
В игре десяток видов брони: куртка гасит 3, кольчуга 6, латы 10. Писать десять функций leather, chainmail, plate скучно, они отличаются одним числом. Лучше одна функция, которая принимает это число и возвращает правило:
armor вернула новую функцию, и эта функция помнит n, хотя вызов armor давно закончился. Это замыкание. Если ты проходил урок про замыкания в JavaScript, механика та же. Разница в том, что в Clojure замкнутое значение неизменяемо: leather всегда гасит 3, никто не поменяет её n снаружи.
Функция, которая принимает или возвращает функции, называется функцией высшего порядка. armor возвращает функцию, map и reduce принимают. В Clojure таких функций большинство, и стандартная библиотека даёт готовые для типичных случаев.
Собрать функцию из функций
Пять комбинаторов встречаются в коде на Clojure постоянно. Все они берут функции и возвращают новую функцию:
| Комбинатор | Что делает | Пример |
|---|---|---|
partial | фиксирует первые аргументы | (partial * 2) удваивает |
comp | склеивает функции, справа налево | (comp inc halve) сначала делит, потом прибавляет |
juxt | вызывает несколько функций на одном аргументе | (juxt :hp :mana) даёт [hp mana] |
complement | переворачивает ответ предиката | (complement alive?) |
constantly | игнорирует аргументы, отдаёт одно значение | (constantly 0) для неуязвимого |
Главная ловушка тут comp. Он применяет функции справа налево, как в математике: (comp f g) это f(g(x)). Поэтому (comp (partial + 1) double-dmg) сначала удваивает, а потом прибавляет единицу, и получается 21. Поменяй местами, будет 22. Если читать «слева направо» привычнее, через три урока тебя ждут потоковые макросы, они пишут конвейер в порядке выполнения.
partial похож на каррирование из Haskell, но работает проще: Clojure не каррирует функции сам, partial только запоминает первые аргументы и добавляет к ним остальные при вызове. Больше про композицию как идею в уроке про композицию и итерацию без циклов.
Правила как данные
Вернёмся к бою. Теперь правила можно описывать не кодом, а данными: снаряжение героя хранит, какие правила к нему применять, а расчёт урона их собирает.
(mapv buffs buff-ids) работает, потому что мапа это функция от ключа, про это был урок о мапах как сущностях. Получился вектор функций, conj добавил броню в конец, reduce прогнал урон. Захочет геймдизайнер новое проклятие, это одна строка в мапе buffs, функция damage не меняется.
Последний вызов отдал 0: проклятие уменьшило урон до 10, а латы погасили всё. В браузере это 0, на JVM было бы 0.0, потому что 0.5 превращает целое в дробное. Здесь ClojureScript и JVM расходятся, в JS все числа одного типа.
Попробуй руками
В задаче ты напишешь фабрики правил multiplier и armor, а затем два способа их собрать: pipeline применяет правила по очереди слева направо, best-of выбирает лучший результат. Пригодятся замыкания, comp, juxt и apply.
Проверь себя
Задание
Дальше
В следующем уроке посмотрим, почему map в начале урока вернул не вектор, а ленивую последовательность, и чем это грозит. А функции высшего порядка из стандартной библиотеки соберём в одну карту в уроке про библиотеку последовательностей.
домашка