Работа с данными

Литералы отображений, множеств и векторов вам уже знакомы. Они, можно сказать, описывают начальное состояние данных, но большая часть программ в процессе работы данные преобразует. Когда речь идёт о преобразовании неизменяемых структур данных, подразумевает создание новой версии данных без потери доступа к старой. Рассмотрим такой пример:

(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})

Получился такой же код, какой был в примере передачи результата из функции в функцию!

Благодаря макросу -> и ему подобным цепочки преобразований данных писать становится очень просто — после того как вы к такому подходу привыкнете. А к хорошему привыкаешь быстро!