Главная мысль урока: потоковый макрос ничего не вычисляет сам. Он переставляет формы так, чтобы вложенный вызов читался сверху вниз, в порядке выполнения.
->вставляет значение первым аргументом,->>последним, и выбор между ними диктует сигнатура функций.
Ирма выпила зелье, сделала шаг на восток и подобрала верёвку. Три изменения подряд, каждое из урока про мапы. Запишем их вложенными вызовами:
Работает, но прочитай вслух. Последнее действие, 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 обходятся без неё.
домашка