Работа с данными
Литералы отображений, множеств и векторов вам уже знакомы. Они, можно сказать, описывают начальное состояние данных, но большая часть программ в процессе работы данные преобразует. Когда речь идёт о преобразовании неизменяемых структур данных, подразумевает создание новой версии данных без потери доступа к старой. Рассмотрим такой пример:
(def cart [{:type :apple :amount 3} {:type :orange :amount 1}])
;; => #'user/cart
В этом примере, как и в последующих, комментарий вида "
;; => …
" показывает то, что выводит REPL в ответ на введённую команду. А конкретно#'user/cart
означает квалифицированное имя определения, которое мы только что дали. Подробнее о таких именах мы поговорим позже. Сейчас вам достаточно запомнить, что в ответ на вычисление кода некоторого определения вы получаете только что определённое вами имя в качестве подтверждения.
В данном определении описан вектор, представляющий список товаров в продуктовой корзине, и этот вектор связан с именем cart
. У Clojure имеется универсальная функция "добавления элемента", работающая с разными структурами данных. В случае вектора эта функция добавляет элементы в конец этого вектора:
(conj cart {:type :banana :amount 5})
;; => [{:type :apple, :amount 3} {:type :orange, :amount 1} {:type :banana, :amount 5}]
Всё верно, связка из пяти бананов попала в конец вектора и именно этот новый вектор из трёх элементов стал результатом вычисления вызова функции conj
. Однако, если обратиться к определению cart
, то окажется, что исходная корзина не изменилась:
cart
;; => [{:type :apple :amount 3} {:type :orange :amount 1}]
Как же работать с тем же продуктовыми корзинами, если однажды их создав, менять в них ничего не получится? Можно сохранять промежуточные результаты как локальные определения. Или сразу передавать следующей функции в качестве аргумента.
Передача состояния между функциями
Предположим, что корзина покупок cart
не отвечает изменившимся требованиям и теперь нам нужно убрать одно яблоко, добавить один апельсин и положить в корзину новый продукт — бутылку молока. Это три отдельных преобразования, каждое из которых вычисляет новую версию данных:
;; оригинальное состояние корзины:
;; [{:type :apple :amount 3} {:type :orange :amount 1}]
(conj
(update-in
(update-in
cart
[0 :amount] dec)
[1 :amount] inc)
{:type :milk :amount 1})
;; => [{:type :apple, :amount 2} {:type :orange, :amount 2} {:type :milk, :amount 1}]
Здесь функция conj
вам уже знакома, она добавляет новый элемент в вектор. А функция update-in
принимает в качестве аргументов
- структуру данных, с которой она будет работать,
- "путь" до нужного элемента: вектор, содержащий индексы векторов и ключи отображений,
- функцию, которая поменяет значение по указанному пути.
В этом примере (update-in cart [0 :amount] dec)
"спускается" вглубь cart
, обращаясь сначала к первому элементу вектора — к отображению, а потом уже к значению ключа :amount
внутри этого отображения. К значению, найденному в конце пути, применяется функция dec
, которая осуществляет вычитание единицы.
Функцию
update-in
можно было применить и так:(update-in cart [0 :amount] - 1)
— здесь изменение будет осуществлять функция "-
", которая получит исходное значение в качестве первого аргумента и "1
" в качестве второго. Проще говоря, аргументы "- 1
" будут означать вычисление выражения(- x 1)
с подстановкой исходного значения вместо "x", а "dec
" это всего лишь простейший случай вычисления(dec x)
. Можно передавать и больше одного дополнительного аргумента для функциимодификатора:(update-in ... + 1 2 3)
Такой стиль программирования, когда вы данные пропускаете через конвейер, в котором в один момент времени с данными работает ровно один участок конвейера, называется "Data Flow". Этот стиль достаточно популярен среди сторонников функционального программирования, так как хорошо сочетается с неизменяемыми структурами данных. И в целом читать код, не думая о том, что любой его фрагмент может менять состояние любых других участков кода, сильно проще.
Локальные определения
Часто в процессе работы с какой-то сложной структурой данных или просто при выполнении многоступенчатого вычисления хочется временно некое промежуточное значение "назвать" и обращаться потом к значению по имени. Такие именования значений, актуальные лишь в рамках некоторого участка кода, называются локальными определениями и создаются с помощью конструкции let
:
(let [a 100
b (- a 13)
c (* (/ a 2) b)]
[a b c])
;; => [100 87 4350]
Структура конструкции let
проста: сначала идёт символ let
, следом вектор из последовательности чередующихся символов и сопоставляемых с ними значений, а затем так называемое "тело" — строчки кода, в которым будут доступны описанные опеределения.
Если вы попробуете ввести данный пример в REPL на сайте try.clojure.org построчно, то интерпретатор после ввода первой строки выведет предупреждение о том, что во введённом коде не хватает каких-то закрывающих скобок. Однако, как только вы введёте выражение до конца, REPL сможет его вычислить. А вот если вы наберёте код в текстовом редакторе и вставите в REPL, то вставится код в виде одной длинной строчки. Результат вычисления при этом не изменится. Дело в том, что Clojure не увидит разницу, если вы введёте одной строчкой
(let [a 100 b (- a 13) …]…)
— переносы строк для Clojure равнозначны пробелам и взаимозаменяемы с оными. Этим удобно пользоваться именно в REPL, чтобы не видеть лишних предупреждений и иметь возможность редактирования кода до самого момента отправки его на вычисление.
В рамках одной конструкции let
последующим определениям доступны предыдущие, но не наоборот. Так в примере выше значение определения b
будет зависеть от значения определения a
, а определение c
будет зависеть от обоих предшествущих. Это же справедливо и для обычных определений, сделанных с помощью def
/defn
.
А вот за пределами тела соответствующей конструкции let
локальные определения видны не будут вовсе. Это, в частности, позволяет временно "изменить" значение какого-то определения, существующего на момент вычисления конструкции let
, не затронув само оригинальное определение:
(def x 42)
;; => #'user/x
(let [x (+ x 1) ; это вторая версия x,
y (let [x (* x 2)] ; а это уже третья версия x
x)]
[x y])
;; => [43 86]
x
;; => 42
Threading macros
Если взять пример с модификацией корзины покупок и переписать с помощью локальных определений, то получится что-то такое:
(let [result (update-in cart [0 :amount] dec)
result (update-in result [1 :amount] inc)
result (conj result {:type :milk :amount 1})]
result)
;; => [{:type :apple, :amount 2} {:type :orange, :amount 2} {:type :milk, :amount 1}]
В целом всё похоже на обычное использование вспомогательной переменной и let
даже позволяет использовать одно и то же имя, так как каждое последующее определение "затеняет" ("shadows") предыдущее. Однако даже для такой переменной нужно придумать имя и в целом код смотрится несколько громоздко. Зато сам порядок преобразований данных читается лучше, чем в оригинальном примере, в котором читать выражение нужно было, двигаясь мысленно изнутри наружу — именно в таком порядке вычисляются вложенные выражения.
К счастью, Clojure позволяет получить хорошо читаемый "прямой порядок преобразований" и при этом избежать введения лишних "переменных":
(-> cart
(update-in [0 :amount] dec)
(update-in [1 :amount] inc)
(conj {:type :milk :amount 1}))
;; => [{:type :apple, :amount 2} {:type :orange, :amount 2} {:type :milk, :amount 1}]
Здесь ->
, это не функция, а макрос. А это означает, что код перед вычислением результата будет преобразован. Сейчас вы видите "вызовы" вида (update-in [0 :amount] dec)
, которые выглядят как некорректные. Но дело тут в том, что макрос ->
подставляет результат вычисления предыдущего шага в качестве первого аргумента в последующий вызов и затем повторяет этот процесс. Макрос ->
делает подстановки, пока не преобразует весь "список действий" в обычное вложенное выражение, где каждый последующий шаг оборачивает предудущий.
Возможно такое преобразование именно потому, что код — это данные! Отдельные "шаги" в последовательности преобразований являются списками, да и сама последовательность — тоже список. Макрос ->
лишь перетасовывает список, подставляя одни элементы внутрь других.
На результат преобразований можно даже посмотреть, не производя сами вычисления. Делается это с помощью функции macroexpand-1
. Этой функции нужно передать процитированный фрагмент кода и вы увидите то, во что этот код превратится после разворачивания первого слоя макросов. Вот что получится, если развернуть ->
в примере выше:
(macroexpand-1
'(-> cart
(update-in [0 :amount] dec)
(update-in [1 :amount] inc)
(conj {:type :milk :amount 1})))
;; => (conj (update-in (update-in cart [0 :amount] dec) [1 :amount] inc) {:type :milk, :amount 1})
Получился такой же код, какой был в примере передачи результата из функции в функцию!
Благодаря макросу ->
и ему подобным цепочки преобразований данных писать становится очень просто — после того как вы к такому подходу привыкнете. А к хорошему привыкаешь быстро!