Раздел 28 · Clojure

Потоковые макросы: ->, ->>, some->, cond-> и as->

junior~15 мин

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

Главная мысль урока: потоковый макрос ничего не вычисляет сам. Он переставляет формы так, чтобы вложенный вызов читался сверху вниз, в порядке выполнения. -> вставляет значение первым аргументом, ->> последним, и выбор между ними диктует сигнатура функций.

Ирма выпила зелье, сделала шаг на восток и подобрала верёвку. Три изменения подряд, каждое из урока про мапы. Запишем их вложенными вызовами:

Работает, но прочитай вслух. Последнее действие, conj, стоит первым слева, а первое действие, зелье, спрятано в самой глубине. Глаз прыгает изнутри наружу и считает скобки. Теперь то же самое через ->:

Результат тот же, а код читается как рецепт: возьми Ирму, вылечи, сдвинь, положи верёвку. Это и есть потоковый макрос: он берёт значение и протаскивает его через формы сверху вниз.

Во что раскрывается ->

-> это макрос, а не функция. Он работает до вычисления: получает код и возвращает другой код. Это видно через macroexpand, он показывает, во что раскрывается форма:

Правило одно: значение вставляется в каждую следующую форму вторым элементом, то есть первым аргументом. Если форма это голый символ вроде :hp или inc, макрос сам оборачивает её в скобки. Раскрытие совпало с вложенным вызовом, который мы писали руками, поэтому никакой цены во время выполнения у -> нет. Подробнее про макросы и macroexpand поговорим в уроке про код как данные, а пока хватит того, что макрос переписывает код до его выполнения.

->> для последовательностей

Попробуем тем же -> посчитать вес тяжёлых предметов в сумке:

-> поставил сумку первым аргументом filter, а filter ждёт её последним: сначала предикат, потом коллекция. ->> вставляет значение последним аргументом, и всё встаёт на места.

Отсюда простое правило выбора, и оно держится на соглашении стандартной библиотеки:

МакросКуда вставляетС какими функциямиЧто течёт по конвейеру
->первым аргументомassoc, update, dissoc, get, conjодна сущность, мапа
->>последним аргументомmap, filter, reduce, sort-by, takeпоследовательность

Функции над одной коллекцией-сущностью принимают её первой, функции над последовательностями последней. Если в конвейере течёт мапа героя, бери ->. Если течёт список событий, бери ->>. Смешивать в одном конвейере не надо: когда функции требуют разного места, это знак, что цепочку стоит разрезать на две.

Если ты писал на Elixir или F#, ->> напомнит оператор |>. Ту же идею с точки зрения функционального программирования разбирали в уроке про композицию.

some-> останавливается на nil

У героя может быть гильдия, а может не быть. Достать тег гильдии и перевести в верхний регистр:

Попробуй в REPL (-> tim :guild :tag str/upper-case): у Тима нет гильдии, :tag от nil даст nil, а upper-case от nil упадёт. some-> проверяет результат каждого шага, и как только видит nil, возвращает nil, не вызывая остальное. Это аналог оператора ?. из TypeScript. Есть и пара some->> для последовательностей.

Раскрытие показывает, как это устроено:

Внутри let с именем вида G__123: макрос сгенерировал уникальное имя, чтобы не столкнуться с твоими переменными. Такие имена создаёт gensym, этим займёмся в уроке про гигиену макросов.

cond-> применяет шаги по условию

Сила атаки зависит от состояния героя: оружие прибавляет бонус, бафф удваивает, яд отнимает 3. Без потоковых макросов это лестница из if и let. С cond-> каждый шаг идёт парой «условие, форма»:

Шаги выполняются сверху вниз, как в ->. Ложное условие пропускает свой шаг, но не останавливает цепочку, в этом отличие от cond, который выбирает одну ветку. cond-> часто строит мапу запроса или ответа по набору флагов: (cond-> {} admin? (assoc :role :admin) beta? (assoc :features [:new-map])).

as-> даёт имя

Иногда значение должно встать не первым и не последним аргументом, а в середину. Тогда выручает as->: он даёт текущему значению имя, и ты ставишь его куда нужно.

Раскрытие показывает, что as-> это цепочка let с одним и тем же именем. Он задуман для использования внутри ->, как в первом примере, а не вместо него.

Каждый макрос закрывает своё место:

МакросКогда брать
->мапа проходит через assoc и update
->>последовательность проходит через map, filter, reduce
some->, some->>любой шаг может вернуть nil
cond->, cond->>шаги зависят от флагов
as->значению нужно место в середине аргументов

Последний совет из гайда clojure.org: не протаскивай через конвейер всё подряд. Два шага читаются и без макроса, (inc (:hp irma)) короче (-> irma :hp inc). Потоковый макрос окупается с трёх шагов.

Как заглянуть внутрь конвейера

Конвейер из шести шагов вернул не то, что ты ждал. На каком шаге всё пошло не так? Во вложенной форме пришлось бы разбирать скобки, а в конвейер можно вставить шаг, который печатает значение и пропускает его дальше без изменений:

spy принимает одно значение и возвращает его же, поэтому встаёт в любой конвейер, хоть в ->, хоть в ->>: у голого символа первый аргумент он же и последний. В -> хватит и стандартного (doto prn), doto вызывает prn на значении и возвращает само значение. Отладил, убрал строку, конвейер прежний. С вложенными вызовами так не получится: чтобы вставить печать в середину (update (update (update ...))), придётся переписать полформы.

Попробуй руками

В задаче четыре цепочки, по одной на макрос: конец хода через ->, тяжёлые предметы через ->>, безопасный тег гильдии через some-> и сила атаки через cond->.

Проверь себя

Задание

Дальше

Потоковые макросы прячут вложенность вызовов. Вложенность другого рода, рекурсию, разберём в следующем уроке: почему в Clojure нет оптимизации хвостовых вызовов и как loop с recur обходятся без неё.

домашка

Домашка