Раздел 28 · Clojure

Функции: fn, арность, замыкания, partial и comp

junior~15 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Главная мысль урока: функция в 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 в начале урока вернул не вектор, а ленивую последовательность, и чем это грозит. А функции высшего порядка из стандартной библиотеки соберём в одну карту в уроке про библиотеку последовательностей.

домашка

Домашка