Изменяемость в Clojure
Содержание
- Общие проблемы изменяемых данных
- Атомы
- Volatile
- Переходные коллекции
- Подмена переменных. Alter-var-root
- Немного о set!
- Изменения в контексте. Binding
- Локальные переменные в контексте
- Глобальные изменения в контексте
- Все вместе
В этой главе мы поговорим про изменяемость данных. Тем, кто программирует на классических языках, покажется странным, что теме уделено так много внимания. Причина кроется в дизайне языка. Неизменяемые коллекции – одна из центральных идей Clojure.
Внимание! Вы читаете черновик к книге “Clojure на производстве”. Для книги я переписывал его много раз, но в блоге осталась старая версия. Здесь она для истории, а вам я рекомендую купить книжку.
В классических языках данные по умолчанию изменяются, а стандартная библиотека предлагает ограничения: локи, атомарные изменения, постоянные коллекции. Clojure устроена наоборот: по умолчанию данные не изменяются, а стандартная библиотека включает несколько техник, чтобы изменять данные. Этим техникам и посвящена глава.
Руководства по Clojure учат работать с неизменяемыми данными. Это приводит к тому, что начинающие программисты на Clojure испытывают трудности, когда возникает потребность менять данные. На время этой главы мы займем противоположную позицию: рассмотрим, какими способом хранить и управлять состоянием в программах.
Общие проблемы изменяемых данных
Clojure устроена так, что на ней трудно писать императивный код. В императивном подходе делают акцент на изменение данных. Например, чтобы получить список удвоенных чисел, проделывают шаги:
- создать новый пустой список;
- пройти по всем элементам исходного списка;
- на каждом шаге вычислить новый элемент;
- добавить его к новому списку.
Базовые типы Clojure неизменяемы, поэтому к ним нельзя применить алгоритм выше. Программистам на императивных языках кажется невозможным писать код без изменения коллекций. Привычка менять данные настолько укрепилась в них, что иммутабельность кажется почти физическим ограничением.
Это сделано нарочно. Создатель языка полагает, что изменяемость данных — наиболее острая проблема в разработке ПО. Когда мы пишем код, то видим его первичное состояние. В этом состоянии он будет только первый такт машинного времени. Затем императивный код инициирует классы, изменит их внутренние поля. Каждый объект читает и изменяет другие объекты и так далее.
Код на экране это снимок системы в момент времени. Мы видим ее двумерный срез. Правильно рассматривать систему как изменение состояния во времени.
Именно поэтому так трудно расследовать инциденты. Мы не знаем, в каком состоянии была система на момент сбоя. Чтобы исправить ошибку, ее сперва повторяют в локальном окружении. Но вопрос как прийти в конкретное состояние становится проблемой. Не всегда система спроектирована так, что ее можно запустить с произвольным состоянием.
Неизменяемые данные отсекают целый пласт ошибок, которым подвержены императивные языки. Рассмотрим примеры на Python.
Пусть на уровне модуля объявлен словарь. Это стандартные параметры запроса к стороннему API. Функция принимает дополнительные параметры, объединяет со стандартными и передает в HTTP-клиент:
DEFAULT_PARAMS = {
"allow_redirects": True,
"timeout": 5,
"headers": {"Content-Type": "application/json"},
"auth": ("username", "password"),
}
def api_call(**params):
params.update(DEFAULT_PARAMS) # !
resp = requests.post("https://api.host.com", **params)
return resp.json()
Первая строка в функции критична, хотя на первый взгляд это незаметно. Метод
update
словаря дополняет его другим словарем. В данном случае изменяют словарь
params
. Он живет внутри функции и скрыт от внешнего мира.
Вариант ниже несет грубую ошибку. Переменная api_params
получает не копию
глобальных параметров, а только ссылку. Изменяя api_params
, мы на самом деле
меняем DEFAULT_PARAMS
. От вызова к вызову словарь будет меняться, что ведет к
странному поведению программы.
def api_call(**params):
api_params = DEFAULT_PARAMS
api_params.update(params)
resp = requests.post("https://api.host.com", **api_params)
return resp.json()
На собеседованиях часто задают такой вопрос. Есть сигнатура функции:
def foo(bar=[]):
Объясните, чем плоха эта функция и приведите пример ошибочного поведения?
Ответ в том, что параметры функции по умолчанию создаются однажды. В данном
случае bar
равен пустому списку. В Python список изменяется. Если в bar
не
был передан другой параметр, мы получим ссылку на исходный список. Достаточно
изменить его методом append
, и тогда каждый вызов foo
будет иметь дело с
разным значением bar
:
def foo(bar=[]):
bar.append(1)
return bar
Последовательный вызов foo
вернет списки [1]
, [1, 1]
, [1, 1, 1]
и так
далее. Это очень неприятная ошибка. Ее легко исправить, но трудно отловить.
Современные IDE анализируют код и показывают потенциально опасные места. Пример выше обнаружить довольно легко, и с ним справляются все анализаторы и линтеры. Но мы не можем полностью положиться на утилиты. Когда данные в коде меняются постоянно, невозможно отследить где ошибка, а где умышленное действие.
Clojure делает упор на обработке коллекций. Из стандартной библиотеки доступны несколько сотен функций. Это сортировка, фильтрация, добавление элементов, взаимная конвертация и многое другое. Функции не требуют промежуточный список или словарь для накопления результата.
Начинающих кложуристов выдает следующий код:
(let [result (atom [])
data [1 2 3 4 5]]
(doseq [item data]
(let [new-item (* 2 item)]
(swap! result conj new-item)))
@result)
Проблема в том, что программист следует привычке из императивного прошлого. В
примере выше не нужен атом-аккумулятор, достаточно map
или for
:
(map (partial * 2) [1 2 3 4 5])
(for [n [1 2 3 4 5]]
(* n 2))
Оба примера короче и понятней. Нам не нужно создавать новую коллекцию и присоединять элементы вручную. Это делают встроенные функции.
Мы поговорим о коллекциях в отдельной главе, а пока что запомните правило. Вам не нужно состояние для обработки коллекций. Если код опирается на атом или Java-коллекцию, скорее всего это слабое решение.
Авторы Clojure сделали все, чтобы выделить состояние на фоне общего кода. К состоянию прибегают только в крайних случаях. Если вы написали код на атомах без уважительной причины, в лучшем случае вам сделают замечание. В худшем — не примут код в проект.
В оставшейся части главы мы поиграем в адвоката дьявола — изучим особые возможности Clojure. Язык предлагает несколько техник, чтобы изменять данные и даже писать в императивном стиле. Мы рассмотрим, когда эти возможности действительно нужны и как ими пользоваться.
В защиту состояния
Выше мы говорили, что состояние несет потенциальные ошибки. Это слишком линейное заявление. Без состояния работают только небольшие, чаще учебные программы. Например, скрипты, которые запускают раз в неделю. Но писать промышленный код без состояния невозможно.
Неизменяемые данные избавляют нас от нужды переписывать поля классов и словарей. Это значимый выигрыш, но кроме внутренних данных приложение завязано на сторонних ресурсах, например, базе данных и файлах. В их отношении действует правило: гораздо дешевле работать с открытым ресурсом, чем каждый раз открывать и закрывать его. В общих словах, состояние повышает скорость.
Много лет назад веб-серверы работали по протоколу CGI, Common Gateway Interface. Получив запрос, сервер запускал в отдельном процессе скрипт или бинарный файл. Входные параметры передавались через переменные среды. Программа считывала их, выполняла логику и писала ответ в стандартный поток. Сервер перехватывал поток и выводил пользователю.
Схема была простой и удобной. Приложение могло быть скриптом на Perl, shell-сценарием или бинарным файлом. У сервера не было состояния. Разработчик в любой момент менял файл на новый, и изменения вступали в силу немедленно.
За преимущества платили скоростью. Каждый запрос к серверу порождал новый процесс. Даже если программа написана на C, запуск нового процесса это дорогая операция. Через протокол FastCGI и аналоги индустрия пришла к тому, что приложение должно жить в памяти постоянно, а не по запросу.
Типичное FastCGI-приложение работало как самостоятельный сервер. Его производительность была на два порядка выше, чем у CGI. Но теперь появилось состояние — открытый порт и цикл ввода-вывода. Этот цикл читал запрос, делегировал его отдельному треду и передавал ответ пользователю. Это усложнило разработку, потребовало новых фреймворков и подходов.
По той же модели работают соединения с базами данных. Представим, что на каждый запрос мы открываем соединение с базой, работаем с ней и закрываем. В рамках машинного времени открыть новое соединение это долгая операция. Так появились пулы соединений.
Пул это особый объект, который держит несколько открытых соединений. Пул знает, какое из них в данный момент занято или свободно. Чтобы работать с базой, мы занимаем одно из свободных соединений, передаем по нему данные, а затем возвращаем обратно. С точки зрения наблюдателя пул — это примитивный объект, который выдает и забирает соединения.
Внутренняя логика пула может быть очень сложной. Если соединений не хватает, он увеличивает свою емкость, а при избытке сокращает. Для каждого соединения пул считает время работы и сколько раз им пользовались. Он же решает, когда следует закрыть соединение и заменить его новым. Логика пула работает в отдельном треде, чтобы не блокировать основную программу.
Столь сложное устройство пула оправдано скоростью доступа. Теперь каждый запрос к базе протекает по заранее открытому соединению, что намного быстрее, чем открывать его каждый раз.
Наконец, сама архитектура современных систем поощряет изменять данные. В начальной школе нам объясняют устройство памяти как массив ячеек. Запись нового значения в ячейку по адресу считается дешевой операцией. И в C++, и в Python одинаково легко обновить элемент массива:
items[4] = 5;
Неизменяемые структуры ложатся на современную модель памяти хуже. Этим объясняется их сложность. Типичный неизменяемый список это дерево узлов с внутренним указателем. Его устройство гораздо сложнее линейного массива ячеек. Неизменяемые коллекции достаточно умны, чтобы при изменении копировать данные не полностью, а лишь частично. Но при больших объемах данных выгоднее работать с изменяемыми структурами.
Сказанное выше не отменяет достоинства постоянных коллекций. Автор не призывает пользоваться состоянием повсеместно. Это обратная сторона медали, цена, которую мы платим за удобство. Разработчик должен знать, на что идет, когда добавляет в проект состояние или избавляется от него.
Атомы
Clojure предлагает несколько способов изменять данные. Самый простой из них — атом. Это объект, который прячет внутри себя другой объект. Чтобы создать атом, достаточно вызвать одноименную функцию с начальным значением:
(def store (atom 42))
Если напечатать атом, увидим примерно следующее:
#<Atom@10ed2e87: 42>
Чтобы извлечь значение 42
, применяют оператор @
. Запись @store
это
укороченный вариант (deref store)
. Функция deref
принимает атом и возвращает
его внутренний объект. Семантически это то же самое, что получить значение по
указателю. В русской литературе эту операцию называют “разыменование”. В
разговорном языке оператор @
называют “дереф”, “дерефнуть”.
@store ;; 42
В отличии от коллекций, атом меняет содержимое. При этом он остается тем самым атомом. Это важное отличие. Если добавить к словарю ключ, получим новый словарь, при этом старый не изменится. Если изменить содержимое атома, это будет все тот же атом с номером 10ed2e87.
Наивный способ изменить атом заключается в функции reset!
. Она принимает атом
и другое значение. Оно может быть любого типа, в том числе nil, коллекцией или
Java-объектом:
(reset! store nil)
(reset! store {:items [1 2 3]})
(reset! store (ex-info "error" {:id 42}))
Если выполнить @store
после каждого выражения, получим то же значение, что
передали в reset!
. Это nil
, словарь, исключение.
Приращение атома
Мы назвали reset!
наивным, потому что функция не учитывает текущее значение
атома. На практике атом изменяют, отталкиваясь от его содержимого. Например,
если это счетчик, то нам неважно, какое значение в нем сейчас. Мы посылаем атому
команду “прибавь к содержимому единицу”. Если это вектор, то сообщение выглядит
как “добавь к содержимому новый элемент”.
Значение атома уходит на второй план. Нас интересует действие, функция. Чтобы обновить атом с учетом текущего состояния, мы посылаем атому функцию. Она принимает текущее значение атома и возвращает новое. Это новое значение заменит содержимое атома.
Функция swap!
принимает атом и другую функцию, которая рассчитывает новое
значение:
(def counter (atom 0))
(swap! counter inc) ;; 1
Если повторять вызов swap!
, значение counter
каждый раз увеличится на
единицу.
Swap!
принимает дополнительные параметры для расчета. Например, мы хотим
увеличить счетчик сразу на три позиции или отмотать его назад. Очевидно, функция
inc
здесь не поможет.
Возникает вопрос о приращении и как его передать. Воспользуемся сложением и
вычитанием. Первым аргументом станет текущее значение атома, а второй аргумент
мы передаем в swap!
:
(swap! counter + 3) ;; increase by 3
(swap! counter - 2) ;; decrease by 2
Новое содержимое атома рассчитано по принципу:
(+ <current> 3)
(- <current> 2)
, где <current>
это текущее значение атома.
Пример выше это частный случай swap!
с одним аргументом. В общем случае
функция принимает их неограниченное количество:
(swap! storage func arg2 arg3 arg4 ...)
Тогда новое значение вычисляется формой:
(func <current> arg2 arg3 arg4 ...)
До сих пор мы хранили в атомах счетчики, то есть обычные числа. Но на практике редко считают только одну сущность. Гораздо чаще встречаются счетчики в разрезе чего-то. Например, просмотры страниц по адресам, количество сообщений у пользователя и так далее.
Чтобы не создавать по атому на каждую сущность, используют один со словарем внутри. Рассмотрим подсчет системных ресурсов. Это атом, внутри которого словарь. Ключи означают тип ресурса, значения — степень потребления в байтах или процентах.
(def usage
(atom {:cpu 35
:store 63466734
:memory 10442856}))
Отдельная функция вычисляет потребление диска. Чтобы записать новое значение в
атом по ключу :store
, вызовем swap!
со следующими параметрами:
(defn get-used-store
[]
(rand-int 99999999))
(let [store (get-used-store)]
(swap! usage assoc :store store))
Эта форма перепишет значение в :store
на новое. Возможен и другой подход,
когда мы не вычисляем все занятое место на диске, а фиксируем разницу на каждое
изменение файловой системы. Например, если пользователь создал или удалил файл,
мы перехватываем это событие и обновляем :store
с приращением.
Пусть функция get-file-event
каким-то образом получает последнее событие,
связанное с файловой системой. Это словарь с ключами :action
и :size
. В
зависимости от :action
мы наращиваем или уменьшаем потребление диска.
Наша версия get-file-event
будет заглушкой, которая случайно возвращает одно
из двух событий:
(defn get-file-event
[]
(rand-nth
[{:action :delete
:path "/path/to/deleted/file.txt"
:size 563467}
{:action :create
:path "/path/to/new/photo.jpg"
:size 7345626}]))
Тогда логика пересчета выглядит так:
(let [{:keys [action size]} (get-file-event)]
(cond
(= action :delete)
(swap! usage update :store - size)
(= action :create)
(swap! usage update :store + size)))
По такому принципу считают ресурсы в облачных платформах. Обращение к диску это дорогая операция. Мы не можем в любой момент пробежаться по дереву папок и посчитать суммарное потребление. Иногда файлы одного пользователя хранятся на разных дисках и серверах. Поэтому ресурсы считают итеративно, мелкими изменениями и раз в определенный интервал сверяют данные.
Усложним пример с ресурсами. Теперь мы считаем их в разрезе пользователей. Ключи
верхнего уровня означают номер пользователя, а значения — словари ресурсов. Для
каждого пользователя фиксируем список запущенных процессов. Это множество их
идентификаторов, PID
.
(def usage-all
(atom
{1005 {:cpu 35
:store 63466734
:memory 10442856
:pids #{6266, 5426, 6542}}}))
Чтобы добавить новый процесс пользователю 1005, выполним следующий swap!
:
(swap! usage-all update-in [1005 :pids] conj 9999)
Это двойное комбо: мы передали в swap!
функцию, которая принимает функцию. В
данном случае это update-in
и conj
. Выразим эту емкую запись по шагам:
-
получим множество процессов
<pids>
:(get-in <current> [1005 :pids])
; -
добавим к нему новый процесс:
(conj <pids> 9999)
. Обозначим новое множество через<pids*>
; -
обновим
<current>
новым множеством:(assoc-in <current> [1005 :pids] <pids*>)
.
Чтобы удалить процесс, достаточно заменить conj
на disj
. Это противоположная
функция: она удаляет элемент из множества.
(swap! usage-all update-in [1005 :pids] conj 9999)
Очевидно, что swap!
мощнее reset!
. Последний используют редко, в основном
чтобы сбросить атом в исходное значение. В остальных случаях важно знать текущее
значение атома. Относительно него мы рассчитываем новое значение.
Совместный доступ
Функция, которую передают в swap!
, должна быть без сторонних эффектов. В
терминах функционального программирования ее бы назвали чистой функцией. Она не
должна обращаться к базе данных, файлам, потоку вывода на экран. Технически это
возможно, но тогда вы столкнетесь со странным поведением программы. Иногда
функция, переданная в swap!
, выполяется несколько раз. Причина кроется в
способе, которым атом обновляет содержимое.
Предположим, сразу несколько потоков работают с атомом. Пусть это будет распределенный подсчет ресурсов. Один поток слушает события файловой системы и обновляет потребление диска. Другой мониторит запущенные процессы, подсчитывает свободную память и так далее.
Возникает проблема совместного доступа к ресурсу. Возможна ситуация, когда оба потока начали обновлять одни и те же данные. Первый поток справился быстрее и записал в атом свою версию данных. Но второй поток рассчитывает собственную версию относительно исходных данных. Когда второй поток изменит атом, действие первого аннулируется.
Это классическая задача про терминал и семейную пару. Муж и жена одновременно вносят наличные на общий счет. На начальном этапе счет пуст. Жена вносит 100 рублей, терминал прибавляет эту сумму к нулю и записывает в базу. Муж вносит 50 рублей, терминал делает то же самое. Итого на счете 50 рублей, а 100 пропали.
Атом достаточно умен, чтобы не допустить такого поведения. Он запоминает свое внутреннее значение на момент вычисления нового. Назовем это значение начальным. Перед тем как обновить содержимое, атом проверяет, что текущее значение совпадает с начальным. Если они не равны, это значит, что атом обновили из другого потока.
В таком случае атом повторяет цикл. Текущее становится начальным, от него рассчитывается новое значение. Атом снова сравнивает текущее и начальное значения. Цикл повторяется до тех пор, пока они не равны. Это значит, что за время вычислений атом никто не обновил. Атом меняет текущее значение на новое, цикл закончен.
Покажем сказанное на примере. Имеем атом со словарем:
(def sample (atom {:number 0}))
Объявим функцию медленного сложения. Она принимает число, приращение и время простоя. Чтобы было ясно, в какой момент она работает, добавим вывод в консоль.
(defn +slow
[num delta timeout]
(println (format "Current: %s, timeout: %s" num timeout))
(Thread/sleep timeout)
(+ num delta))
Обновим атом одновременно из двух тредов. В первом треде функция спит две секунды, во втором пять:
(do
(future (swap! sample update :number +slow 1 2000))
(future (swap! sample update :number +slow 2 5000)))
Спустя некоторое время проверим атом:
@sample ;; {:number 3}
Это правильное значение. Вывод консоли:
Current: 0, timeout: 2000
Current: 0, timeout: 5000
Current: 1, timeout: 5000
Видим, что вторая функция выполнена два раза. Это в точности следует алгоритму
выше. Второй swap!
начал расчеты с начальным значением {:number 0}
, а к их
завершению значение атома стало {:number 1}
. Такое значение уже записал первый
swap!
. Чтобы избежать ошибки, атом запустил второй swap!
повторно
относительно {:number 1}
.
Когда атом меняют из нескольких потоков, перезапуск функции случается больше двух раз. Это недопустимое поведение для базы данных или сетевых сервисов. Вот почему функция не должна обращаться к сторонним ресурсам.
Валидаторы и вотчеры
Поведение атомов расширяют валидаторы и вотчеры (анг. watcher,
наблюдатель). Валидаторы это функции-предикаты. Они принимают новое значение
атома до того, как оно записано в текущее. Если валидатор вернул ложь, вызов
swap!
завершается ошибкой.
Чтобы добавить валидатор к атому, используют функцию set-validator!
. В случае
с счетчиком предположим, что он не может быть отрицательным. Попытка понизить
его в нулевом состоянии вызовет исключение:
(def counter (atom 2))
(set-validator! counter (complement neg?))
(swap! counter dec) ;; repeat 3 times...
;; Execution error (IllegalStateException) at ...
;; Invalid reference state
Вотчеры это побочные эффекты атома. Они срабатывают после того, как атом перешел в новое состояние. Вотчер связан с уникальным ключом и функцией. Эта функция принимает четыре аргумента: ключ, атом, старое и новое значения. Одному атому можно назначить несколько вотчеров.
Разберемся, когда полезны вотчеры. Вспомним пример с подсчетом ресурсов. Система получает события извне и обновляет атом. В отличии от валидации счетчика, мы не можем бросить исключение, если потребление памяти или диска превысило лимит. В этом нет смысла, потому что события поступают из внешней системы. Выброс исключения на нашей стороне не остановит новые события.
Логично вынести проверку потребления в вотчер. Если один из ресурсов превысил порог, вотчер совершит нужные действия. Например, уведомит пользователя письмом, что ресурс исчерпан. Или направит запрос в другую подсистему, чтобы приостановить загрузку файлов для этого пользователя.
В упрощенном примере мы пишем ошибку в лог, если потребление превысло лимит. Объявим функцию вотчера:
(require '[clojure.tools.logging :as log])
(def STORE_LIMIT (* 1024 1024 1024 25)) ;; 25 Gb
(defn store-watcher
[_key _atom _old value]
(let [{:keys [store]} value]
(when (> store STORE_LIMIT)
(log/errorf "Disk usage %s has reached the limit %s"
store STORE_LIMIT))))
Обратите внимание, что из четырех параметров мы используем только последний, новое значение атома. Было бы правильно назначить первым трем символы подчеркивания. Это предотвращает связывание лишних переменных и потому работает быстрее. Но мы оставили имена для семантики.
Назначим вотчер атому с ключом :store
:
(def usage
(atom {:cpu 35
:store 63466734
:memory 10442856}))
(add-watch usage :store store-watcher)
Если потребление диска превысит лимит, увидим запись в лог:
(swap! usage update :store + STORE_LIMIT)
;; Disk usage 26907012334 has reached the limit 26843545600
Валидацию и вотчеры рассматривают как пре- и пост-эффекты. Разница в том, что первые могут прервать дальнейшее исполнение, а вторые нет. У них разная семантика. Предварительные эффекты проверяют то, что может случиться, а постэффекты — то, что уже случилось. Поэтому на них реагируют по-разному.
Другие примеры
Мы уже уточнили, что атомы не нужны для обработки коллекций. Clojure предлагает десятки функций для обхода и преобразования одних коллекций в другие. Атом рядом с коллекцией говорит о слабом решении.
Читатель возразит, что выше мы использовали атом, чтобы обновить словарь
ресурсов. Это не совсем верно. Если бы требовалось получить новую коллекцию, мы
бы использовали update
или assoc
. Настоящая цель в том, чтобы обеспечить
доступ к одной и той же коллекции из разных частей кода. В этом и есть
предназначение атома: контроллировать доступ к неизменяемым объектам.
На атомы опираются некоторые из стандартных функций Clojure, например,
memoize
. Это декоратор, который возвращает улучшенную версию функции. Такая
функция запоминает свой результат относительно аргументов и записывает во
внутреннюю таблицу. Если вызвать функцию с теми же аргументами, функция вернет
результат из таблицы без вычислений.
Роль таблицы играет атом. Функция-результат memoize
замкнута относительно
атома, который виден только ей. Вот как выглядит декоратор:
(defn memoize
[f]
(let [mem (atom {})]
(fn [& args]
(if-let [e (find @mem args)]
(val e)
(let [ret (apply f args)]
(swap! mem assoc args ret)
ret)))))
Может показаться странным, что для поиска в словаре авторы пользуются find
вместо get
. Разница в том, как функции трактуют пустое значение. Если по ключу
записан nil
, то get тоже вернет nil
, и форма if-let
выполнит вторую
ветку. Но find
вернет особую сущность MapEntry
, значение из которой
извлекают функцией val
.
Убедимся, что декоратор работает на функции +slow
, которую мы объявили
выше. Объявим аналог этой функции с кешем и замерим вызовы:
(def +mem (memoize +slow))
(time (+mem 1 2 2000))
"Elapsed time: 2004.699832 msecs"
(time (+mem 1 2 2000))
"Elapsed time: 0.078052 msecs"
Первый вызов долгий, а второй и последующие с теми же аргументами — быстрые.
Атомы используют в веб-разработке. Это дешевый способ хранить состояние между запросами. На атомах легко сделать счетчики просмотров, сессии, кеши. Вот так, например, выглядит счетчик просмотренных страниц. Это комбинация из атома и middleware:
(def page-counter
(atom {"/" 0}))
(defn wrap-page-counter
[handler]
(fn [request]
(let [{:keys [request-method uri]} request]
(when (= request-method :get)
(swap! page-counter update uri (fnil inc 0)))
(handler request))))
На каждый GET-запрос мы увеличиваем счетчик для текущего адреса. Обратите
внимание на форму fnil
, которую мы передаем в update. Она возвращает особую
версию inc
, которая не вызовет исключение, если первый аргумент был
nil
. Такое возможно, когда в словаре еще нет нужного ключа. Тогда вместо nil
будет передан ноль.
Функция page-seen
возвращает число просмотров по адресу страницы. Как ей
пользоваться зависит от способа, которым сервер генерирует HTML-разметку. Если
это hiccup-подобная структура, то компонент подвала выглядит так:
(defn page-seen
[uri]
(get @page-counter uri 0))
(defn footer
[uri]
[:div {:class "footer"}
(let [seen (page-seen uri)]
[:p "This page has been seen " seen " times."])])
Решения на атомах подвержены следующим недостаткам. Они локальны, то есть не связаны с другими экземплярами программы. Если веб-приложение состоит из нескольких нод, то каждая хранит собственный счетчик. Поэтому на каждый запрос пользователь видит разные данные. Чтобы избежать странного поведения, данные хранят в сетевых централизованных сервисах, например, Redis.
Атомы не постоянны. Если завершить программу, они теряют состояние. Допустим вариант, когда атом читает начальные данные из ресурса и время от времени сохраняет состояние в ресурс.
Volatile
На первый взгляд атом работает просто. С точки зрения разработчика это несколько
удобных функций. Но внутри атом довольно сложен. Он регулирует параллельный
доступ к значению, вызывает валидацию и отслеживает изменения. Иногда эти
возможности излишни. Когда требуется только менять значение, пользуются
упрощенной версией атома — volatile
.
Объект volatile
тоже хранит и изменяет внутреннее значение. Одноименная
функция создает объект с начальным состоянием. Функции vreset!
и vswap!
аналогичным тем, что мы рассматривали для атома. Префикс v
означает, что они
работают для volatile
.
Пример с ресурсами. Вместо атома используем другой тип хранилища:
(def vusage
(volatile! nil))
(vreset! vusage
{:cpu 35
:store 63466734
:memory 10442856})
(vswap! vusage update :store + (* 1024 1024 5))
(println "Disk usage is" (get @vusage :store))
;; Disk usage is 68709614
Volatile
отличается от атома тем, что не контролирует запись из нескольких
тредов. Перепишем пример с двумя потоками:
(def vsample (volatile! {:number 0}))
(do
(future (vswap! vsample update :number +slow 1 2000))
(future (vswap! vsample update :number +slow 2 5000)))
;; Current: 0, timeout: 2000
;; Current: 0, timeout: 5000
@vsample ;; {:number 2}
Вывод консоли говорит, что, во-первых, вторая операция сработала один
раз. Во-вторых, результат первого треда утерян. Если для атома итоговое число
было 3, то в случае с volatile
это 2. Операцию +1
мы просто потеряли. Из
этого следует, что volatile
не подходит для многопоточного кода.
Валидаторы и вотчеры не работают для volatile. Это освобождает его от слежки за содержим. Запись без проверок и пост-эффектов работает быстрее.
Применение
У volatile
две области применения — трансдьюсеры и императивный
код. Трансдьюсеры это особый способ обработки коллекций. Они оборачивают
стандартные функции map
, reduce
и другие таким образом, что их комбинация не
порождает промежуточных коллекций. Технически это это возможно за счет
внутреннего состояния.
Трансдьюсер на раскрывает состояние внешнему миру. Он заинтересован не в
валидации и побочных эффектах, а в скорости записи. Поэтому volatile
подходит
на роль состояния лучше, чем атом. В этой главе мы не будем касаться
трансдьюсеров. Это отдельная тема, которую мы рассмотрим в главе про коллекции.
Volatile
полезен, когда мы пишем императивный код. Это случается, и к этому
следует относиться спокойно. Иногда бизнес-требования слишком сложны и
противоречивы. Накладывать их на функциональный стиль становится слишком дорого.
Например, дана задача получить плоскую структуру из дерева. Оно устроено по
сложным правилам: если в первой ветке одно значение, то рассматривать вторую, а
иначе третью. И дальше: если для первой и третьей веток справедливо условие X
,
положить в список произведение их значений.
Эти требования насквозь императивны, и нам выгодно описать их таким же образом. Так мы сделаем код ближе к бизнес-логике и облегчим поддержку.
Пример с малым подмножеством такого дерева:
(def data
{:items [{:result {:value 74}}
{:result {:value 74}}]
:records [{:usage 99 :date "2018-09-09"}
{:usage 52 :date "2018-11-05"}]})
Код обработки похож на набор блоков, где каждый блок это каскад when-let. На нижнем уровне каскада мы изменяем коллекцию. Это императивный стиль, но в данном случае он удобен. Если одно из правил вдруг потеряет актуальность, достаточно удалить блок. Удобно, когда над блоком пишут комментарий с ссылкой на документацию:
(let [result (volatile! [])]
;; see section 5.4 from the doc: http://...
(when-let [a (some-> data :items first :result :value)]
(when-let [b (some-> data :records last :usage)]
(when (> a b)
(vswap! result conj (* a b)))))
;; more and more expressions
@result)
Переходные коллекции
С помощью атома мы создали подобие изменяемых коллекций. Получилась устойчивая
модель: постоянная коллекция и особый объект, чтобы менять содержимое. С новой
техникой данные можно менять и без атомов. Clojure предлагает настоящие
изменяемые коллекции. По-другому они называются transient
(анг. временный,
переходный).
Изменяемые коллекции получают из их постоянных аналогов. Такая коллекция
поддерживает очень малый набор функций, буквально добавить и удалить
элемент. Стадартные map
, filter
и сотня других функций не работают с
transient-коллекциями. Происходит своего рода размен. Мы теряем мощь стандартной
библиотеки, но обретаем скорость и императивный подход.
Транзиентные коллекции в несколько раз быстрее постоянных. Исторически компьютеры устроены так, что изменить ячейку памяти проще, чем выделить новую. Неизменяемые структуры похожи на дерево коммитов с изменениями. Вставка в дерево работает за несколько шагов. Их число либо постоянно, либо зависит от размера дерева. В целом это медленней, чем переписать i-тый элемент массива.
Транзиентная коллекция это изменяемый слепок ее постоянной копии. В этом режиме коллекцию меняют императивно. Когда алгоритм закончил работу, ее замораживают. В результае получают неизменяемую коллекцию.
Функция transient
порождает переходную коллекцию из исходной. Для работы с ней
используют особые версии conj!
, assoc!
, dissoc!
и другие. Функции изменяют
содержимое коллекции, а не возвращают их новую копию, как это делают обычные
conj
и assoc
.
Технические детали
Функция persistent!
завершает жизненный цикл переходной коллекции. Она
возвращает ее постоянную копию. Одновременно функция запечатывает изменяемую
коллекцию, и ее становится невозможно обновить.
Рассмотрим пример с переходным вектором. На него действуют всего две функции:
conj!
и pop!
, добавить и убрать элемент из хвоста:
(let [items* (transient [1 2 3])]
(conj! items* :a)
(conj! items* :b)
(pop! items*)
(persistent! items*))
;; [1 2 3 :a]
Вариант со словарем и assoc!
и dissoc!
:
(let [params* (transient {:a 1})]
(assoc! params* :b 2)
(assoc! params* :c 3)
(dissoc! params* :b)
(persistent! params*))
;; {:a 1, :c 3}
Читатель заметит, что имя изменяемой переменной заканчивается звездочкой. Это не нарушает синтаксис Clojure. В отличии от других языков, в имени переменной могут быть дефис, апостроф и другие символы. Считается хорошим тоном выделять особые переменные штрихом или звездочкой. Поскольку к изменяемым переменным прибегают редко, их считают особыми.
После того как коллекция заморожена, изменить ее невозможно. Следующий пример бросает исключение:
(let [params* (transient {:a 1})]
(assoc! params* :b 2)
(let [result (persistent! params*)]
(assoc! params* :c 3)
result))
;; IllegalAccessError: Transient used after persistent! call
Поэтому форма (persistent! <mutable>)
, как правило, замыкает блок с изменяемой
переменной.
Переходные коллекции помогают там, где нужен императивный подход. Выше мы
рассматривали случай с обходом сложного дерева. Тогда мы использовали volatile
в качестве аккумулятора данных. То же самое с транзиентным вектором:
(let [result* (transient [])
push! (fn [item]
(conj! result* item))]
;; see section 5.4 from the doc: http://...
(when-let [a (some-> data :items first :result :value)]
(when-let [b (some-> data :records last :usage)]
(when (> a b)
(push! (* a b)))))
;; more and more expressions
(persistent! result*))
Пример демонстрирует еще одну технику. Чтобы не писать каждый раз (conj!
result* item)
, мы вводим локальную функцию push!
. Она замкнута на результате
и принимает только значение. Чтобы добавить элемент, достаточно вызвать (push!
x)
. Это экономит код и скрывает детали реализации.
Итерация с изменением
Мы упоминали, что переходные коллекции быстрее постоянных. Это заметно на долгих
итерациях через loop/recur
. Как правило, одна из переменных это
коллекция-результат. В каждом recur
мы передаем ее копию, дополненную с
помощью conj
или assoc
.
Когда число итераций велико, прибегают к уловке. Вместо постоянной коллекции передают ее транзиентный вариант. Тем самым итерацию ускоряют в 2-4 раза. С точки зрения кода изменения минимальны. Достаточно учесть следующие пункты:
- изменить тип результата на
(transient <coll>)
; - вместо
conj
илиassoc
вызывать их аналоги:conj!
,assoc!
; - вернуть персистентную коллекцию с помощью
persistent!
.
Для дальнейших экспериментов объявим переменную nums, последовательность из миллиона чисел:
(def nums (range 999999))
Пример наивной итерации с помощью loop
. Результат — такой же вектор чисел:
(loop [result []
nums nums]
(if-let [n (first nums)]
(recur (conj result n) (rest nums))
result))
Эти же вычисления с изменяемым вектором:
(loop [result* (transient [])
nums nums]
(if-let [n (first nums)]
(recur (conj! result* n) (rest nums))
(persistent! result*)))
Число строк осталось прежним, изменилось имя переменной и некоторые
функции. Важно, что правки в loop
не распространяются выше по коду. Говорят,
что рефакторинг изолирован внутри loop
.
Это дает свободу действий. На ранних стадиях мы пишем код без изменяемых коллекций. На стадии улучшения итерацию исправляют так, что внутренние данные меняются. Если рефакторинг не затрагивают участки выше, оставляют ускоренный вариант.
Макрос time
выполняет тело и печатает затраченное время. Если обернуть в
time
оба примера, получим следующие результаты:
;; 166.688721 msecs
;; 69.415038 msecs
Конкретные цифры зависят от оборудования, операционной системы и версии языка, но разница в несколько раз сохраняется. Транзиентные коллекции действительно быстрее постоянных аналогов.
Мутабельные коллекции работают для reduce
. В других языках эта функция
называется fold
или свертка. Центральной точкой в reduce
становится
коллекция-аккумулятор. Строго говоря, аккумулятором может быть любой тип,
например, число для сложения. Но чаще всего это списки и словари.
Различают два способа начать свертку. Первый — когда роль аккумулятора играет
первый элемент коллекции. Для списка 1, 2, 3
и функции сложения им станет
единица. Второй — когда аккумулятор задают отдельным параметром. Например,
пустой вектор для списка удвоенных чисел.
Идея в том, чтобы передать в качестве аккумулятора изменяемую коллекцию. На
каждом шаге reduce изменять ее функциями conj!
и аналогами. Сравним обычный
reduce
с постоянной коллекцией:
(reduce
(fn [result n]
(conj result n))
[]
nums)
И его мутабельную версию:
(persistent!
(reduce
(fn [result* n]
(conj! result* n))
(transient [])
nums))
Обратите внимание, форму reduce пришлось обернуть в persistent!
. В случае с
loop
мы смогли втянуть persistent!
внутрь и тем самым изолировать
изменения. Но reduce не такой гибкий в этом плане. Мы не можем определить внутри
анонимной функции, достигли ли мы конца итерации или нет. Без вынужденного
persistent!
код уровнем выше получит транзиентную коллекцию, что недоспустимо.
Семантика и ограничения
Изменяемые данные относятся к продвинутым техникам. Они предъявляют новые требования к программисту, причем не только на уровне кода. Как мы выяснили, в техническом плане достаточно нескольких функций, чтобы код стал императивным. Транзиентные коллекции меняют дизайн, архитектуру приложения.
Выигрыш в скорости еще не значит, что транзиентные коллекции применяют на каждом шагу. Худшее, что может сделать Clojure-разработчик — написать код, где функции обмениваются такими коллекциями. На этом месте стоит задуматься, почему авторы языка уделили так много времени неизменяемости. Будет грубейшей ошибкой игнорировать их идеи.
Феномен, когда на этапе разработки пытаются выжать максимум скорости, называется преждевременной оптимизацией. Ее вред либо известен программисту, либо еще предстоит познать. Занимаясь оптимизацией, задавайте вопросы. Действительно ли важно ускорить этот цикл? Поможет ли это продукту? Или вы действуете из любопытства?
Транзиентные коллекции должны быть изолированы в небольших функциях. При таком подходе переход от постоянных типов к изменяемым не влияет на ее вызов и результат. Рефакторинг должен затрагивать только внутренности функции, но не сторонних потребителей.
Изменяемая коллекция не имеет право быть глобальной переменной. Воздержитесь от
определений вроде (def users* (transient []))
на уровне модуля. Вы придете к
тому, что users*
станет буфером обмена между функциями. Вызывая функцию, вы не
знаете наверняка ее результат, потому что он зависит от глобальной
переменной. Так писали тридцать лет назад на Си и Паскале за неимением других
средств.
В отличии от атома, транзиентные типы не регулируют обращение к ним из разных потоков. Придерживайтесь правила, что только один тред изменяет коллекцию. Не передавайте их в футуры.
Подмена переменных. Alter-var-root
И атомы, и транзиентные коллекции обладают особенностью. Они изменяют содержимое объекта, а не сам объект. Это не всегда то поведение, которые мы ожидаем.
Например, переменная size это атом:
(def size (atom 0))
Чтобы изменить его, вызовем reset!
или swap!
как в примерах выше. Но
переменная size
всегда будет атомом. Каждый раз, чтобы прочитать ее значение,
понадобится оператор @
. Это не всегда удобно.
Аналогично ведут себя транзиентные коллекции. К ним легко добавить и удалить
элемент, но это будет та же самая коллекция. Невозможно присвоить ей nil
.
В некоторых случаях нам хочется менять глобальную переменную. Например, чтобы
сперва она была nil
, затем словарем, затем снова nil
. Авторы Clojure
намеренно усложнили этот сценарий. Менять переменные технически возможно, но
нежелательно с точки зрения дизайна языка.
Нежелательно писать на Clojure так, как вы привыкли в Python или Java. Когда программист изменяет глобальную переменную, он должен понимать, зачем она понадобилась и возможно ли от нее избавиться. Глобальные переменные без уважительной причины — признак плохого кода.
Встречаются два сценария, когда глобальные переменные оправданы. Это состояние системы и monkey-patch.
Условный проект на Clojure состоит из отдельных компонентов или доменов. Это
веб-сервер, база данных, очередь сообщений. Каждый домен помещают в свой модуль:
http.clj
, db.clj
и так далее.
На практике редко бывает так, что приложение работает с двумя базами или
очередями. Поэтому в модуле объявляют глобальную переменную, которая хранит
состояние этого компонента. Например, project.http/server
или
project.db/conn
.
Возникает проблема, как объявить такую переменную. Начинающие допускают ошибку, когда инициируют состояние на уровне модуля:
(def server (jetty/run-jetty app {:port 8080}))
Функция run-jetty
запустит сервер при загрузке модуля. Это плохая практика,
потому что загрузка модуля не должна нести побочных эффектов. Возможно, что на
локальной машине уже запущен сервер с таким портом. С таким кодом невозможно
работать в сеансе REPL. Он делает то, о чем его не просили.
Сервер, базы данных, очереди и другие компоненты должны включаться только по
требованию. Поэтому переменная server
вначале равна nil
. Затем функция
start!
запускает сервер и записывает его объект в server
. Функция stop!
останавливает его и меняет значение на nil
.
Понятие переменной
Чтобы переписать переменную, прибегают к alter-var-root
. Это функция, которая
изменяет объекты, объявленные через def
и defn
. Ее вызов похож на swap!
для атома. Функция принимает объект Var
и другую функцию, которая вычисляет
новое значение на базе текущего.
Рассмотрим, что такое Var. Это экземпляр класса clojure.lang.Var
из библиотеки
Clojure. Var
описывает переменную в пространстве имен. Чтобы получить объект
переменной, ее символ передают в макрос var
, например (var server)
. Эта
запись аналогична #'server
, что немного короче.
Символ переменной и ее объект это разные сущности. Сам по себе символ ничего не значит. Он равен только самому себе. Можно представить символ как слово языка. Слово это определенная комбинация букв, и в языке не может быть двух одинаковых слов. Но у слова может быть несколько значений в зависимости от контекста. Объект, на который ссылается слово в данном контексте, и есть переменная.
Символ это посредник между пространством имен и переменными. Когда мы пишем
(def num 42)
, это не значит, что переменная num
равна числу 42. На самом
деле мы создали объект Var
со значением 42. Затем поместили этот объект в
текущее пространство под символом num
.
Технически пространство имен устроено как словарь. Его ключи это символы, а
значения переменные. Формы def
и defn
наполняют этот словарь. Можно сказать,
что def
задает слову смысл, и в момент компиляции Clojure понимает это слово.
Над символом работает операция вычисления. Если в сеансе REPL ввести num
,
интерпретатор выполнит поиск с таким ключом в пространстве имен. Если ключ
найден, REPL вернет значение переменной, в нашем случае 42.
Clojure намеренно скрывает от нас стадию переменной, и это правильно. Если бы
выражение num
вернуло переменную, в этом не было бы никакого смысла. Объект
Var
это не цифра, а сложный объект. Значение 42 это лишь одно из множества его
полей.
Мы подробно поговорим о пространствах и переменных в другой главе. Пока что
отметим, что переменные, как правило, скрыты от пользователя. Обычно разработчик
видит либо их символ (num
), либо значение (42
). Функция alter-var-root
—
тот случай, когда переменные заявляют о себе.
Запуск по требованию
Вернемся к примеру с сервером. Объявим переменную, которая в будущем станет
объектом jetty.server.Server
. По умолчанию это nil
:
(def server nil)
Функция start!
заменяет server
результатом анонимной функции. Эта функция
принимает текущее значение. Чтобы не запускать сервер дважды, мы проверяем, был
ли он уже запущен. Если нет, создаем новый сервер и возвращаем его объект. Если
да, просто вернем текущий.
(defn start!
[]
(alter-var-root
(var server)
(fn [server]
(if-not server
(run-jetty app {:port 8080 :join? false})
server))))
Аналогично работает stop!
: выключаем сервер если он уже был
запущен. Устанавливаем переменную в nil.
(defn stop!
[]
(alter-var-root
(var server)
(fn [server]
(when server
(.stop server))
nil)))
Вызов (start!)
в REPL запустит сервер в фоновом режиме. Браузер начнет
отвечать на запросы по адресу http://127.0.0.1:8080/ (путь зависит от маршрутов
приложения). Выражение server
в REPL напечатает Java-объект сервера.
По такому же принципу устроена работа с базой. Чтобы не создавать подключение на
каждый запрос, применяют пулы соединений. В модуле для работы с БД объявляют
переменную conn
со значением nil
. Функция start!
создает новый пул и
обновляет переменную.
После старта к базе посылают запросы. Это функции query
, update!
, insert!
и другие из пакета clojure.java.jdbc
. Первым параметром они принимают
подключение к базе или пул:
(jdbc/query conn "select 1 as result")
(defn get-user-by-id
[user-id]
(jdbc/get-by-id conn :users user-id))
Функция stop!
выключает пул, закрывает соединения с базой и восстанавливает
conn
в nil. Мы опустим код этих функций, потому что они отличаются только
именем глобальной переменной (conn
вместо server
) и строкой, где создается
сервер или пул.
Коротко о системах
Техника alter-var-root
вводит модуль в одно из двух состояний: включен и
выключен. Это особенно удобно для разработки, когда нас интересует конкретная
подсистема. Например, для отладки базы не требуется включать веб-сервер, а
подсистема кеширования не зависит от рассылки писем.
В широком смысле описанное выше называют системой. Это набор компонентов, из
которых состоит приложение. С помощью alter-var-root
строят системы для
несложных проектов. Как правило, это веб-приложения с сервером и базой
данных. По-другому их называют системами для бедных.
Преимущество таких систем в том, что они не зависят от сторонних
библиотек. Каждый модуль выставляет универсальные “ручки управления”: функции
start!
и stop!
, которые обращаются к alter-var-root
. Это простая и
понятная схема.
С другой стороны, “бедные” системы не знают, как один компонент зависит от
другого. Ручные зависимости становятся проблемой с ростом проекта. Поэтому
большие системы строят с помощью специальных библиотек. Некоторые из них тоже
опираются на alter-var-root
. Мы подробно рассмотрим системы в другой главе.
Патчинг
Выше мы изменяли переменные в текущем пространстве. Фактически это улучшенный
вариант с атомом. Разница в том, что переменная не требует оператора @
, когда
ссылаются на ее значение. Это делает код чище и короче.
Истинная мощь alter-var-root
в том, что функция работает с переменными из
любого пространства. Под любым мы понимаем:
- текущий модуль;
- соседние модули проекта;
- сторонние библиотеки;
- стандартные модули Сlojure, например,
clojure.core
.
С помощью alter-var-root
можно повлиять на любую точку всего проекта. В том
числе вторгнуться в чужие модули и даже те, что идут в поставке с Clojure!
Это мощная техника, но ей пользуются редко. Менять код в момент исполнения считается сомнительной практикой. По-другому это называется monkey patch. Термин означает изменение классов и функций не на уровне кода, а когда программа уже запущена.
Если программист злоупотребляет патчингом, программа ведет себя неочевидным образом. Коллегам будет трудно понять, почему в коде написано одно, а выполняется другое. Это затрудняет поддержку проекта, привносит раздражение в повседневную работу.
К патчингу прибегают, если одновременно сошлись несколько условий:
- без этого изменения мы не сможем двигаться дальше;
- проблема не в нашем коде, а в сторонней библиотеке или самой платформе;
- чтобы устранить проблему в коде, понадобиться существенное время.
Типичный случай, когда патчинг оправдан — ошибка в чужой библиотеке. Наверняка многие сталкивались с тем, что автор не принимает изменения, которые вы прислали. Причины могут быть разными. Автор считает, что проблема не в коде, а неверных данных. Автор забросил проект или опасается, что часть прав будет принадлежать вам, — словом, исправить код не удается.
Технически не сложно клонировать репозиторий и внести правки. По-другому это называют форкнуть, сделать форк (анг. fork — вилка, ответвление). Но встают организационные проблемы. Где хранить новый репозиторий? Как сделать так, чтобы изменения в оригинальной библиотеке отражались на ее клоне? Как настроить окружение, чтобы система сборки качала нашу версию, а не оригинал? Разрешает ли автор использовать измененную версию его кода?
Эти проблемы сложны тем, что вовлекают других людей: админов, юристов, руководство фирмы. Вряд ли мы найдем время, чтобы на середине проекта все отложить и заняться ими. В данном случае monkey patch будет разумным решением.
Патчинг полезен во время разработки. Мы часто печатаем данные на экран, чтобы
исследовать их. Недостаток функции println
в том, что ее вывод не
структурирован. Отдельные части коллекций “слипаются”, и их трудно прочитать.
Пакет clojure.pprint
(сокращение от pretty printing) решает эту
проблему. Функция pprint
из этого модуля выводит данные с отступами и
переносами строк. Это особенно удобно для вложенных словарей.
Другое достоинство pprint
— ограничение на глубину и длину коллекций. На
больших данных печать в лоб чревата задержкой. Функция pprint
опирается на
глобальные настройки Clojure. Они определяют, сколько брать элементов из
коллекции и на какую глубину погружаться.
Не всегда удобно писать (clojure.pprint/pprint data)
вместо (println
data)
. Как и любой модуль, clojure.pprint
сперва импортируют. Чтобы не
отвлекаться на импорт, сделаем так, что на время разработки вызов println
аналогичен pprint
. Пропатчим функцию следующим образом:
(require 'clojure.pprint)
(alter-var-root
(var println)
(fn [_] clojure.pprint/pprint))
Достаточно выполнить этот код один раз в любом месте проекта. Теперь вызов
(println data)
напечатает данные так, как это делает pprint
:
(println <vector-of-dicts-of-vectors...>)
;; [{:foo 42, :bar [1 2 3 4 5 {:foo 42, :bar [1 2 {#, #}]}]}
;; {:foo 42, :bar [1 2 {:foo 42, :bar [1 2 {#, #}]}]}]
Функция заменяет вложенные участки на символ #, чтобы не обрушить на вас лавину данных. На глубину и длину печати влияют особые глобальные переменные. Позже мы рассмотрим, как задать им другие значения.
Подмена println
на pprint
полезна только во время разработки. Чтобы не
повлиять на боевой режим, код с alter-var-root
выносят в отдельный
модуль. Такие модули называют модулями среды, потому что они загружаются не
всегда, а по условию. Код с заменой печати логично поместить в файле
env/dev/print.clj
. Проект настраивают так, что в режиме разработки он
просматривает дополнительные пути для загрузки кода.
Например, если это lein-проект, то в файле project.clj
пишут следующее:
{:profiles
:dev {:source-paths ["env/dev"]}
:test {:source-paths ["env/test"]}}
Запись означает, что в режиме разработки мы получим доступ ко всем модулям из
папки env/dev
, а во время прогона тестов – из env/test
. Мы рассмотрим как
настроить проект в отдельной главе.
По аналогии с переходными коллекциями, патчинг должен быть изолирован от остального кода. Его эффект замыкают в отдельной функции или даже модуле. Рядом должен быть комментарий с описанием того, что произойдет при вызове и какую мы преследуем цель.
В боевом режиме
Рассмотрим случай, когда alter-var-root
полезен в промышленном запуске. В
предыдущей главе об исключениях мы отметили
проблему. Макросы log/info
, log/error
и другие принимают первым аргументом
исключение. При записи в файл мы не знаем, как система логирование запишет его в
текст. Длина трейса и цепочка исключений выглядят по-разному в зависимости от
бекенда (log4j, Logback и другие).
Мы написали функцию ex-print. Она принимает исключение и печатает его именно так, как нужно нам. виде. Функция не вываливает стек-трейс на весь экран, а обходит цепочку исключений и для каждого звена печатает класс, сообщение и контекст:
(defn ex-print
[^Throwable e]
(let [indent " "]
(doseq [e (ex-chain e)]
(println (-> e class .getCanonicalName))
(print indent)
(println (ex-message e))
(when-let [data (ex-data e)]
(print indent)
(clojure.pprint/pprint data)))))
Недостаток решения в том, что вместо (log/error e)
приходится писать:
(log/error (with-out-str (ex-print e)))
Это значительно длинее и вынуждает импортировать ex-print
в каждый модуль, где
логируют исключения. Было бы удобней, если б мы по-прежнему писали (log/error
e)
, а ex-print
срабатывал самостоятельно за кадром. Это возможно с помощью
alter-var-root
.
Отметим, что log/error
, log/info
и аналоги это не функции, а макросы. Макрос
это эфемерная сущность, на которую нельзя сослаться через var. Макрос живет
только во время компиляции программы. После компиляции на его месте остается
список функций и базовых форм. В этом и состоит трюк. Нельзя изменить макрос, но
можно подменить функции, на которые он опирается.
Модуль clojure.tools.logging
он устроен так, что макрос log/error
и другие
сводится к вызову функции log/log*
. Это бутылочное горлышко, через которое
проходят все логи. Вот как выглядит ее сигнатура:
(defn log*
[logger level throwable message])
Параметр throwable
это объект исключения или nil
. Подменим log*
на
анонимную функцию со следующей логикой:
- если
throwable
не nil, перевести исключение в текст; - присоединить его к исходному сообщению через перенос строки;
- передать параметры в оригинальный
log*
с новым сообщением. При этомthrowable
будет nil. В нем отпала потребность, поскольку мы сделали его частью сообщения; - если
throwable
был nil, вызватьlog*
не меняя входных параметров.
(defn install-better-logging
[]
(alter-var-root
(var clojure.tools.logging/log*)
(fn [log*] ;; 5
(fn [logger level throwable message]
(if throwable
(log* logger level nil
(str message \newline
(with-out-str
(ex-print throwable))))
(log* logger level throwable message))))))
Хитрость кроется в строке 5. Анонимная функция, переданная в alter-var-root
,
принимает прежнее значение переменной. Это оригинальная функция
clojure.tools.logging/log*
, и параметр log*
ссылается на нее. Новая функция
замкнута относительно переменной log* и может ее вызывать.
Получился своего рода декоратор. Новая версия log*
только меняет входные
параметры и вызывает старую функцию. После вызова (install-better-logging)
логирование исключений изменится. Теперь достаточно написать (log/error e)
,
чтобы ошибка была записана в файл именно так, как нужно нам.
Преимущество этого подхода в том, что поведение задано на уровне
Clojure-кода. Если потребуется улучшить логи, мы доработаем функцию ex-print
в
любой момент. Это удобнее, чем наследовать Java-класс от условного
com.logging.ThrowableRenderer
и переопределять его методы.
Мощь alter-var-root
компенсируется вредом ее необдуманного применения. Функция
нужна, чтобы точечно менять переменные в особых случаях. Прибегайте к
alter-var-root
только если альтернативный путь существенно увеличивает код и
временные затраты.
Немного о set!
Мы уже упоминали об особенности pprint
для красивой печати. Функция
замечательна тем, что проверяет вложенность и длину коллекций. Эти ограничения
помогут избежать ситуации, когда данные заливают несколько экранов, а редактор
тормозит на подсветке синтаксиса. Проверка на длину особенно важна, потому что
некоторые коллекции не просто велики, а бесконечны.
Длину и вложенность вывода определяют глобальные переменные *print-length*
и
*print-level*
. По умолчанию *print-length*
равен 100. Это довольно много,
особенно если учесть, что элементами коллекции могут быть другие
коллекции. Например, результат запроса к БД это список словарей. Печать ста
словарей это дорогая операция, поэтому логично уменьшить *print-length*
на
старте приложения. Для этого используют форму set!
:
(set! *print-length* 8)
Теперь печать бесконечной коллекции отобразит только первые 8 элементов:
(println (repeat 1))
;; (1 1 1 1 1 1 1 1 ...)
Многоточие означает, что в коллекции есть и другие элементы, но их отбросили.
Вложенность или уровень коллекции это воображаемый индекс. Когда одна коллекция становится элементом другой, ее индекс увеличивается на единицу.
Объявим вложенную структуру:
(def data {:foo {:bar {:baz [42]}}})
Так выглядит вывод при разных значениях *print-level*
:
(set! *print-level* 4)
(println data)
;; {:foo {:bar #}}
(set! *print-level* 2)
(println data)
;; {:foo #}
При нулевом уровне увидим только символ #
.
Всего в Clojure около десяти переменных с “ушками”. Две из них мы уже
рассмотрели, это *print-length*
и *print-level*
для настроек
печати. Перечислим несколько других:
-
*warn-on-reflection*
: если истина, компилятор покажет предупреждение в тех местах, где не удалось вывести Java-тип. Это продвинутая техника, и мы рассмотрим ее в другой главе. -
*assert*
: если ложь, отключаетassert
-макрос. Это особая форма, которая проверет выражение на истинность. Если выражение ложно, форма выбрасывает исключение. По умолчанию assert-ы включены, ими пользуются на этапах разработки и тестирования. В боевом режиме их выключают, чтобы повысить произвоительность. -
*in*
,*out*
: каналы стандартного ввода и вывода. Это объектыReader
иWriter
, откуда платформа читает и пишет данные.
Эти и другие переменные изменяют формой set!
. Значение зависит от семантики
переменной. Рассмотрим несколько примеров.
Показывать предупреждения, если компилятор не смог вывести тип:
(set! *warn-on-reflection* true)
Отключить assert
-формы:
(set! *assert* false)
(assert (get {:foo 42} :bar))
;; won't throw an exception
Направить вывод в файл: все, что напечатано функциями семейства print
,
появится не в консоли, а в файле. Полезно, когда объем данных слишком велик. При
выводе в файл редактор не тратит ресурсы на подсветку синтаксиса.
Ниже мы создаем объект Writer
с файлом out.log
в качестве точки сбора
данных. Затем назначаем переменной *out*
этот объект.
(require '[clojure.java.io :as io])
(def f (io/writer "out.log"))
(set! *out* f)
Теперь print
молча возвращает nil. Результат печати вы найдете в файле
out.log
в той директории, откуда запустили REPL.
Выражения (set! *<something>* <value>)
размещают в коде по такому же принципу,
что и alter-var-root
. Если значение требуется только для определенного режима,
форму set!
записывают в модуле среды, например env/dev/settings.clj
.
Утилиты проектов принимают словарь глобальных переменных. При старте они
автоматически назначают переменным значения. Например, конфигурация lein
учитывает ключ :global-vars
. Еще большей гибкости можно добиться, если
указывать переменные в профилях. Ниже мы задаем разные значения глобальным
переменным в зависимости от режима разработки (dev) или сборки (uberjar).
{:profiles
:dev {:global-vars {*warn-on-reflection* true
*assert* true}}
:uberjar {:global-vars {*warn-on-reflection* false
*assert* false}}}
С помощью set!
нельзя изменить пользовательские переменные, даже если они с
“ушками” и помечены как динамические. Пример ниже бросит исключение:
(def ^:dynaimc *data* nil)
(set! *data* {:user 1})
;; Unhandled java.lang.IllegalStateException
;; Can't change/establish root binding of: *data* with set
Воспользуйтесь функцией alter-var-root
, которую мы рассмотрели выше.
Изменения в контексте. Binding
Техники, которые мы рассмотрели к этому моменту, обладают одинаковым свойством. Их эффект длится до конца работы программы. Изменения в атомах, транзиентных коллекциях и глобальных переменных называют персистентными (анг. persistent – постоянный).
Может показаться странным, но это не всегда то поведение, на которое мы рассчитываем. Иногда требуется изменить данные временно. Например, глобальная переменная равна X, но этот участок кода ожидает Y.
Технически возможно построить временные изменения на базе постоянных. Например,
вызывать alter-var-root
с новым и старым значениями на границах блока кода. Но
такой подход влечет две проблемы: изоляцию изменений и их откат.
Проблема изоляции состоит в том, что, как правило, временные изменения происходят в рамках одного потока. Это значит, что если мы временно изменили глобальную переменную с X на Y, то в другом треде она по-прежнему будет X. Это важно, поскольку мы физически не можем знать, какой участок кода сейчас выполняет другой тред. Значит, мы не можем позволить себе менять глобальные переменные для всех тредов в произвольный момент.
Проблема отката означает, что изменения в данных необходимо отменить. После
выполнения блока кода данные должны быть в точности такими же, как и до
него. Практика, когда код оборачивают в alter-var-root
или set!
, плоха тем,
что одна из форм, чаще заключительная, может потеряться во время
рефакторинга. Это чревато странным поведением программы и трудной отладкой.
С этого момента и до конца главы мы будем рассматривать временные изменения
данных. Clojure предлагает несколько форм, чтобы выполнить произвольный код в
контексте переменных с другими значениями. Первая из них это binding
(анг. “связывание”).
Синтаксис binding
аналогичен форме let
: это форма связывания и произвольный
код. Форма связывания это вектор, где перечисляют символы переменных и их
значения. Символы должны ссылаться на уже объявленные переменные. Произвольный
код, или тело макроса, будет исполнен в рамках этих переменных с новыми
значениями. Результат binding
это последнее выражение его тела. Изменения,
которые оказывает binding
, протекают в рамках текущего треда и не влияют на
соседние.
Динамические переменные
Binding
работает только с динамическими переменными. Компилятор считает
переменную динамической, если ей назначен тег ^:dynamic
. Это сокращенная форма
^{:dynamic true}
:
(def ^:dynamic *server* nil)
;; or
(def ^{:dynamic true} *server* nil)
Словарь с крышкой в def
-определении называют метаданными. Это дополнительные
параметры будущей переменной. В данном случае мы сообщаем компилятору, что
переменная динамическая, то есть будет изменена в будущем.
Глобальные переменные принято выделять “ушками”, то есть звездочками по краям. В английском языке такую запись называют earmuffs syntax. Правило было принято еще в старых Lisp-системах, и Clojure следует традиции.
Ушки и динамичность связаны между собой. Если переменная с ушками, но не динамическая, компилятор предупредит о возможной ошибке:
(def *server* nil)
;; Warning: *server* not declared dynamic and thus [...]
;; Please either indicate ^:dynamic *server* or change the name.
Сами по себе ушки не делают переменную динамической, это просто соглашение. Если
переменная не динамическая, binding
бросит исключение:
(binding [*server* {:port 8080}]
(println *server*))
;; Execution error (IllegalStateException)
;; Can't dynamically bind non-dynamic var: *server*
Отказ от set!
Вспомним пример с ограничениями на длину и глубину печати. Чтобы ограничить вывод, мы писали что-то вроде:
(set! *print-level* 4)
(println data)
На самом деле это плохой пример. Он нарушает принципы изоляции и отката, которые
мы только что рассмотрели. Изменение переменной *print-level*
не изолировано и
повлияет на всю систему глобально. Если в этот момент другой тред что-то
напечатает, мы увидим результат с максимальной вложенностью 4, что отличается от
величины, которую задали на старте приложения. Сразу после оператора (println
data)
следует восстановить прежнее значение *print-level*
. Однако, об этом
легко забыть.
Вот как выглядит правильный вариант для печати с особыми настройками:
(binding [*print-level* 8
*print-length* 4]
(println {:foo {:bar {:baz (repeat 1)}}}))
;; {:foo {:bar {:baz (1 1 1 1 ...)}}}
Этот код избавлен от упомянутых недостатков. Вне формы binding
переменные
останутся с прежними значениями, а соседние треды не заметят изменений.
Вспомним, как мы перенаправили печать данных в файл. Мы назначили переменной out специальный объект FileWriter:
(set! *out* (io/writer "data.txt"))
Этот метод не лишен недостатков. Во-первых, не ясно, кто и в какой момент закроет файл. Программа может завершиться аварийно, и мы потеряем часть данных. Должен быть какой-то фоновый обработчик, который закроет файл даже в случае сбоя.
Во-вторых, не всегда данные пишут в один и тот же файл. Возможно, мы ожидаем
увидеть часть данных на эеране, а другую часть в файле. Как мы выяснили,
переключать *out*
глобально это опасная практика: повышается риск
непредсказуемого вывода.
Правильный подход в том, чтобы открыть файл и временно связать с ним переменную вывода:
(with-open [out (io/writer "dump.edn")]
(binding [*out* out]
(clojure.pprint/pprint {:test 42})))
Объединим оба примера в функцию для сброса данных в файл. Такая функция полезна для отладки больших данных. Она принимает путь к файлу и произвольное значение. Внутри функция связывает стандартный вывод с временно открытым файлом и печатает данные. Чтобы сделать вывод более детальным, назначаем переменным печати большие значения:
(defn dump-data
[path data]
(with-open [out (io/writer path)]
(binding [*out* out
*print-level* 32
*print-length* 256]
(clojure.pprint/pprint data))))
Вот как работает сброс данных в произвольный файл:
(dump-data "sample.edn" {:foo [1 2 3 {:foo [1 2 3]}]})
и их восстановление:
(-> "sample.edn" slurp read-string)
;; {:foo [1 2 3 {:foo [1 2 3]}]}
Доработайте функцию dump-data
так, чтобы можно было передать значения
*print-level*
и *print-length*
. В идеале это третий необязательный параметр
opt, словарь, в котором функция ищет дополнительные настройки.
Пример с переводом строк
Приведем пример из реального проекта, когда binding
чрезвычайно полезен. Речь
пойдет об интернационализации веб-приложения. Под термином понимают вывод текста
на разных языках в зависимости от настроек пользователя.
В приложениях с переводами текст хранят в виде огромных словарей. Они состоят из
двух уровней: локали и тегов. Локаль это международный код языка, например ru,
en. Локаль может состоять из доменов, разделенных подчеркиванием или дефисом,
например en_US
или en_GB
. В данном случае US и GB означают американский и
британский диалекты английского. В восточных языках встречается даже тройная
вложенность доменов, чтобы обозначить локальный диалект в провинции.
Под тегом понимают короткую машинную строку. Она описывает семантику фразы,
которая позже заменит ее в момент перевода. Например, по тегу ui/add-to-cart
становится ясно, что это надпись на кнопке “добавить в корзину”.
В зависимости от фреймворка или библиотеки словари находятся в коде, в файлах или базе данных. Но принцип интернационализации остается прежним: по локали и тегу библиотека совершает т.н. lookup, то есть поиск перевода в дереве. Изобразим наивную реализацию такого подхода на Clojure:
(def tr-map
{:en {:ui/add-to-cart "Add to Cart"}
:ru {:ui/add-to-cart "Добавить в корзину"}})
(defn tr
[locale tag]
(get-in tr-map [locale tag]
(format "<%s%s>" locale tag)))
Функция tr возвращает перевод то локали и тегу. Если перевод не найден,
результатом будет машинное выражение, например <:en:ui/sign-in>
.
Недостаток функции в том, что каждый раз ей нужно передавать локаль. Это утомительно, особенно с учетом того, что локаль вычисляется один раз на старте запроса и не меняется в процессе. Иногда на один запрос требуется перевести полсотни фраз. Это 50 вызовов tr, и каждый дополнительный параметр зашумляет код.
Еще один недостаток локали в том, что место ее определения слишком далеко от
мест перевода. Между этими участками кода лежит дистанция как в физическом, так
и ментальном плане. Под физическим мы понимаем стек вызовов. Обычно мы
определяем локаль в особом middleware. Это может быть параметр адресной строки
(/?lang=ru) или поддомен (en.wikipedia.org). Но middleware считаются логикой
высокого уровня, а переводы расположены где-то внизу внутри обработчика. Даже
если сообщить запросу поле :locale
, будет физически трудно спустить его до
уровня перевода и передать (:locale request)
в каждый вызов tr
.
Под ментальной дистанцией имеют в виду следующее. На уровне переводов нам не хочется знать, откуда приходит локаль. Наоборот, эти сведения избыточны, потому что они завязывают нас на конкретную реализацию. Это особенно очевидно, когда мы работаем с шаблонной системой, устроенной по принципу Django.
Аналог такой системы в Clojure называется Selmer. Шаблонизатор Django подразумевает, что у вас обычные HTML-файлы со вставками в фигурных скобках. Выражения в скобках вычисляются в конечные значения. В шаблонной системе выделяют фильтры. Это символьное обозначение функции, которую нужно применить к переменной. Например, запись:
<p>{{ user.name|lower }}</p>
означает, что между тегами параграфа следует разместить поле :name
словаря
user
. При этом привести имя к нижнему регистру. В Clojure эта запись выглядела
бы так:
(str/lower-case (:name user))
Фильтром может быть любая функция, в том числе наша tr
. Достаточно внести ее в
регистр фильтров. Нам бы хотелось, чтобы код шаблона выглядел так:
<div class="widget">
<a href="/login">{{ "ui/log-in"|tr }}</a>
<a href="/register">{{ "ui/register"|tr }}</a>
<a href="/help">{{ "ui/help"|tr }}</a>
</div>
Тогда фильтр “tr” должен быть функцией одной переменной. Она принимает машинную строку и возвращает ее перевод.
Очевидно, локаль должна быть как-то предопределена. Мы должны сделать так, чтобы в рамках конкретного запроса фильтр опирался на рассчитанную в middleware локаль. При этом ни в коем случае не влиять на перевод в параллельных запросах.
Поможет локальное связывание через binding
. Определим глобальную переменную
*locale*
. В терминах Clojure такая переменная называется несвязанной, потому
что ей не сообщили значение. Можно рассматривать ее как ячейку, в которой еще
нет данных.
Изменим функцию tr
: теперь она принимает только тег, а в качестве локали
ссылается на глобальную *locale*
:
(def ^:dynamic *locale*)
(defn tr
[tag]
(get-in tr-map [*locale* tag]))
Чтобы изолировать *locale*
от внешних потребителей, предоставим специальный
макрос with-locale
. Он выполняет блок кода в момент, когда переменная временно
связана с переданной локалью. Теперь любой перевод, вызванный внутри макроса,
сработает для этой локали:
(defmacro with-locale
[locale & body]
`(binding [*locale* ~locale]
~@body))
(with-locale :en
(tr :ui/add-to-cart))
;; "Add to Cart"
(with-locale :ru
(tr :ui/add-to-cart))
;; "Добавить в корзину"
Напишем middleware, который определяет локаль по запросу. Для простоты решим,
что это параметр lang из адресной строки. Если не удалось найти параметр, берем
локаль по умолчанию. Весь нижележащий middleware-стек будет выполнен под
макросом with-locale
:
(defn wrap-locale
[handler]
(fn [request]
(let [locale (get-in request [:params "lang"] :en)]
(with-locale locale
(handler request)))))
Наконец, напишем фильтр tr для шаблонной системы. Это обертка над одноименной
функцией. Внутри шаблона мы не можем указывать ключевые слова, только
строки. Это значит, вместо {{ :ui/sign-in }}
пишут {{ "ui/sign-in"
}}
. Фильтр tr
переводит это строку в ключ, а затем ищет по нему
перевод. Функция add-filter!
заносит функцию в регистр фильтров под именем
“tr”.
(require '[selmer.filters :refer [add-filter!]])
(add-filter! :tr
(fn [line]
(-> line keyword tr)))
Теперь мы не заботимся об источнике локали уровне перевода. С нашей точки зрения
ее предоставил кто-то другой, а кто и как именно в данном случае не важно. В
любой момент мы изменим код with-locale
и wrap-locale
, но это не отразится
на шаблонах. Запись {{ "ui/log-in"|tr }}
останется прежней, даже если механизм
переводов изменится.
Локальные переменные в контексте
Форма binding
связывает переменные с новыми значениями один раз. В блоке кода
невозможно задать одной из переменных новое значение. Это возможно только в
рамках вложенного binding
, что не всегда удобно, особенно когда мы пишем
императивный код.
Макрос with-local-vars
объявляет набор локальных переменных. Их особенность в
том, что внутри макроса им можно назначать произвольные значения. Каждая
переменная это маленький объект, для которого работают операции get
и set
,
то есть получить и установить значение.
Локальные переменные полезны, когда описывают запутанную бизнес-логику. Макрос
with-local-vars
не сдвигает код вправо, как это делают let
или
binding
. Блок с локальными переменными выглядит линейно, его проще читать.
Форма with-local-vars
похожа на let
: это вектор связывания и произвольный
блок кода. Разница в том, что внутри макроса работают функции var-get
и
var-set
. С их помощью из переменных читают и записывают значения. Например,
если макрос задал переменную a
, то форма (var-set a 9)
установит ее
содержимое в 9.
Важно, что символ переменной вернет ее объект, а не значение. Убедимся в этом на примере ниже:
(with-local-vars [a 0]
a)
;; #<Var: --unnamed-->
Выражение вернуло не ноль, а объект типа Var. Поэтому запись (+ a 1)
приведет
к ошибке приведения типов.
Чтобы извлечь значение из переменной, ее следует разыменовать или
“дерефнуть”. Для этого служит функция var-get
; для краткой записи прибегают к
оператору @
: (+ @a 1)
.
Императивный подход
Выше мы приводили пример с обработкой дерева. Из массивной структуры данных нужно извлечь несколько величин и вернуть их композицию: сумму, произведение или другое выражение. В прошлый раз мы использовали атом. Теперь решим задачу на локальных переменных.
Функция calc-billing
рассчитывает сумму к оплате для клиента. Параметр data
это сводный отчет с метриками потребленных ресурсов. На уровне Clojure это
комбинация списков и словарей. Согласно бизнес-правилам, итоговую сумму находят
из трех составляющих. Каждую составляющую рассчитывают из данных рангом ниже и
так далее. Поскольку логика включает много условий, удобно выразить ее на
изменяемых переменных.
(defn calc-billing [data]
(with-local-vars
[a 0 b 0 c 0]
;; find a
(when-let [usage (->some data :usage last)]
(when-let [days (->some data :days first)]
(var-set a (* usage days))))
;; find b
(when-let [limits ...]
(when-let [vms ...]
(var-set b (* limits vms))))
;; find c
;; ...
;; result
(+ (* @a @b) @c)))
Локальные переменные не настолько продвинуты как атомы. Для переменных нет
аналога swap!
, когда значение меняют функцией. Поэтому with-local-vars
не
подходит для наращивания коллекций. Если user это локальный словарь, добавить к
нему новое поле будет затруднительно. Функция var-set
может задать только
новый словарь, а комбинация var-set
и var-get
выглядит неуклюже:
(with-local-vars [user {:name "Ivan"}]
;; (var-set user assoc :age 33) ;; won't work
(var-set user (assoc (var-get user) :age 33))
@user)
Макросом with-local-vars
пользуются, когда сложная логика завязана на простых
типах (числах, строках). На локальных переменных удобно писать конечные автоматы
и алгоритмы с состоянием. Эта техника редко встречается в проектах на Clojure,
но в нужный момент сэкономит время и код.
Глобальные изменения в контексте
Преимущество binding
в том, что изменения происходят только в текущем
треде. Вспомним пример с переменной *out*
. Если беспорядочно менять ее в
процессе работы, получим непредсказуемый вывод. Говорят, что эффект binding
изолоированный или потокобезопасный, что расценивается как благо. И все же
бывают ситуации, когда изменения должны охватить систему глобально. Для этого
служит форма with-redefs
.
Ее синтаксис похож binding
: вектор связывания и произвольный блок кода. В
отличии от binding
, эффект with-redefs
распространяется на всю среду
исполнения. Это значит, изменения вступят в силу для каждого потока. Например,
веб-сервер обрабатывает сотни запросов в секунду в нескольких потоках. Если одна
из страниц выполняет часть логики в with-redefs
, это повлияет на параллельные
запросы. Аналогично binding
и let
, изменения откатываются в момент выхода из
макроса.
Наивный пример ниже объясняет принципы with-redefs
. Мы подменяем функцию
println
на суррогат, который печатает фиксированный текст.
В теле макроса мы запускаем футуру с телом (println 42)
. Футура (анг. future,
будущее) или фьючер это особый объект из области многопоточности. Футура
принимает блок кода и исполняет его в пуле тредов. В таком пуле каждый его тред
никогда не завершается, а только помечается как занятый или свободный. Если тред
свободен, он принимает задачу от футуры, исполняет ее и возвращает
результат. Футура это посредник между клиентом и внутренним механизмом
многопоточности.
Если коротко, тело (println 42)
будет выполнено в другом потоке. Оператор @
перед футурой предписывает ждать до тех пор, пока не будет получен результат из
пула. Код ниже напечатает “fake print”:
(with-redefs
[println (fn [_] (print "fake print"))]
@(future (println 42)))
;; fake print
Заметим, что если убрать оператор @
, футура напечатает 42. Причина в том, что
на запуск футуры, передачу задания в пул, исполнение и остальные шаги требуется
время. С машинной точки зрения это сложный цикл, каждый этап которого занимает
такты проццессора. Без оператора @
мы только запускаем футуру и сразу выходим
из with-redefs
. Пул тредов доберется до задания (println 42)
уже в тот
момент, когда эффект макроса закончился.
Изменения в нескольких потоках это особая веха в разработке ПО. На эту тему
пишут книги, этому учатся годами. Мы коснемся многопоточности в будущих главах
книги, а пока что рассмотрим пример с with-redefs
из реального проекта.
Документация with-redefs
подчеркивает, что макрос особенно полезен для
тестирования. Это связано с тем, что иногда приложение опирается на сторонние
сервисы. Например, географичесий поиск или граф связей социальных
сетей. Некоторые сервисы отвечают долго, поэтому к ним обращаются в фоне.
При тестировании возникает проблема доступа к этим сервисам. Нельзя допустить, чтобы на каждый прогон тестов приложение обращалось к настоящему ресурсу. Это усложняет экосистему, влечет утечку ключей, исчерпывает квоту на доступ к сервису.
Идея в том, чтобы с помощью with-redefs
заменить ключевые функции, которые
обращаются в сеть. Тогда кроме нормального поведения мы сможем имитировать
ошибки. Это возможно, если в качестве замены передать функцию, которая бросает
нужное исключение.
Приложение с координатами
Предположим, мобильное приложение отправляет на сервер текущее положение пользователя. Это пара чисел: долгота и широта. Позже пользователь просматривает историю путешествий. Очевидно, в списке локаций он ожидает не машинные цифры, а названия мест и стран с фотографией. Поэтому для каждой пары координат мы должны найти данные об этом месте.
Технически это устроено следующим образом. Страница POST /location
принимает
коорданаты в JSON-теле запроса. Чтобы узнать данные о месте, мы посылаем запрос
в гео-сервис Гугла. Извлекаем из ответа основные поля и записываем их в базу
вместе с координатами. Затем возвращаем ответ 200 OK. Для мобильного приложения
это знак, что новая локация записана в базу.
Начальная версия кода. В данном случае функция geo/place-info
обращается к
серверам Гугла. Она возвращает словарь с ключами :title
, :country
,
:image_url
и другими. Мы объединяем эти данные с координатами и записываем в
базу.
(defn location-handler
[request]
(let [{:keys [params]} request
point (select-keys params [:lat :lon])
place (geo/place-info point)]
(db/create-location (merge {} place point))
{:status 200 :body "OK"}))
В код закрался неочевидный недостаток. Пока мы извлекаем данные из Гугла, мобильное приложение ждет ответа. С машинной точки зрения это долго, ведь сеть не гарантирует мгновенную доставку данных. Чем больше мобильных клиентов отправляют координаты на сервер, тем больше сетевых запросов мы посылаем в Гугл. На сервере все больше висящих сетевых соединений, число запросов в секунду снижается. Это дегенеративное поведение системы.
Заметим, что geo/place-info
не гарантирует, что все данные получится извлечь
за один запрос. API Гугла со временем меняются. Например, поля с фотографиями
вполне могут переехать из условного geosearch в photosearch, что порождает
второй запрос.
Быстрое решение проблемы в том, чтобы записать координаты в базу и сразу же ответить мобильному клиенту. А сбор данных о месте вынести в футуру. Тем самым мы сократим время ожидания клиента. Теперь мобильное приложение ждет только запись в базу и запуск футуры, что намного меньше, чем несколько запросов в сеть.
В новой версии функция db/create-point
записывает коорданаты и возвращает id
новой записи. Этот id нужен, чтобы позже обновить локацию данными о месте. Поиск
данных и запись в базу протекают в футуре.
(defn location-handler
[request]
(let [{:keys [params]} request
point (select-keys params [:lat :lon])
row-id (db/create-point point)]
(future
(let [place (geo/place-info point)]
(db/update-place row-id place)))
{:status 200 :body "OK"}))
Заметим, что быстрое решение не значит лучшее. В нашем случае возможны несколько вариантов, например, с фоновым обработчиком или очередью задач. Но они дольше в реализации, а вариант с футурой затрагивает лишь три строки. Это дешевое временное решение, которое даст время на поиск оптимального.
Тесты
Напишем тест для этого обработчика. Чтобы не обращаться к реальным серверам
Гугла, временно заменим функцию geo/place-info
. Для полноты картины проверим,
как поведет себя обработчик, если geo/place-info
бросит сетевое исключение. В
таких случаях целевую функцию заменяют на анонимную, которая кидает нужное
исключение.
Каждый тест будет начинаться с выражения with-redefs
для замены
geo/place-info
. Чтобы уменьшить код, напишем макрос with-place-info
. Он
принимает будущий результат функции и тело теста:
(defmacro with-place-info
[result & body]
`(with-redefs [geo/place-info
(fn [~'point] ~result)]
~@body))
Вот как выглядит тест для положительного сценария. Внутри макроса
with-place-info
вызов geo/place-info
вернет переданный словарь. Мы вызываем
обработчик с координатами и проверяем, что получили код 200. Затем мы должны
убедиться, что футура извлекла данные и записала их базу. Добавляем небольшую
задержку и читаем из базы последнюю локацию. В ее полях должны быть данные из
тестового словаря.
(deftest test-place-ok
(with-place-info
{:title "test_title"
:country "test_country"}
(let [request {:params {:lat 11.111 :lon 22.222}}
{:keys [status body]} (location-handler request)]
(is (= 200 status))
(is (= "OK" body))
(Thread/sleep 100)
(let [location (db/get-last-location)
{:keys [title country]} location]
(is (= title "test_title"))
(is (= country "test_country"))))))
Чтобы проверить, как поведет себя приложение во время ошибки, смоделируем
негативный сценарий. Пусть при обращении к geo/place-info
будет выброшено
исключение с кодом 429. Такое возможно на практике, когда превышен лимит на
число запросов к службам Гугла. Объявим такое исключение:
(def ex-quota
(ex-info "429 Quota reached"
{:status 429
:headers {}
:body {:error_code :QUOTA_REACHED
:error_message "..."}}))
и напишем тест:
(deftest test-place-quota-reached
(with-place-info
(throw ex-quota)
(let [request {:params {:lat 11.111 :lon 22.222}}
{:keys [status body]} (location-handler request)]
;; ...
)))
Текущая версия location-handler
не перехватывает потенциальные ошибки, поэтому
тест выше пройдет неудачно. Подумайте, как улучшить обработчик страницы и тест к
нему для случая с негативным HTTP-ответом.
Возможен сценарий, когда мы получили от сервиса даже негативный ответ. Например,
из-за сбоя в сети на нашей стороне. Это значит, что вызов geo/place-info
бросает особое сетевое исключение. Напишем отдельный тест для недоступной сети:
(deftest test-place-conn-err
(with-place-info
(throw (new java.net.ConnectException "test_timeout"))
;; ...
))
Прием с подменой функций и классов называется мок или мокинг (анг. mock – поделка). Мы подробно рассмотрим тесты в следующих главах и познакомимся с другими техниками. Пока что заметим, что макрос with-redefs это простой способ что-то “замокать”, то есть подменить окружение на время тестов.
Макрос with-redefs
это улучшенный вариант формы with-redefs-fn
. Их отличия в
синтаксисе. Макрос with-redefs
напоминает привычные let
и binding
. Это
вектор связывания и блок кода. Макрос with-redefs-fn
принимает словарь и
функцию без аргументов. Ключи словаря это переменные, то есть объекты
Var
. Функция будет вызвана в момент, когда каждое значение словаря заменит
парную ему переменную.
Пример с заменой geo/place-info
другим способом выглядит так. Напомним, что
синтаксис #'<something>
аналогичен (var <something>)
:
(with-redefs-fn
{#'geo/place-info (fn [point] {:title "test"})}
(fn []
(geo/place-info {:lat 1 :lon 2})))
Если тело макроса это одна большая форма, например, let
, то не обязательно
оборачивать его в (fn [])
. Достаточно подставить спереди знак #
, чтобы
превратить форму в анонимную функцию.
(with-redefs-fn
{#'geo/place-info (fn [point] {:title "test"})}
#(let [point {:lat 1 :lon 2}
place (geo/place-info point)]
;; ... do something else
))
Недостаток формы with-redefs-fn
в том, что ее синтаксис более шумный. Легко
забыть, что тело должно быть не произвольным блоком кода, а функцией. Это делает
форму непохожей на другие макросы Clojure. With-redefs
скрывает эти отличия, и
по возможности следует пользоваться им.
Все вместе
Clojure предлагает разнообразные приемы для изменения данных. Но в отличии от императивных языков с их знаком равенства, в Clojure пользуются специальными формами. Язык спроектирован так, что к изменениям прибегают выборочно. Это считается продвинутым уровнем, к которому переходят после азов неизменяемости.
К этому моменту читатель может запутаться, в чем разница между отдельными техниками и когда ими пользоваться. Перечислим объекты и функции, рассмотренные выше и типовые сценарии, когда они полезны.
Атом это объект-обертка вокруг целевого значения. Чтобы получить его
содержимое, применяют оператор @
или форму (deref <atom>)
. Можно сообщить
атому новое значение функцией reset!
. Но чаще атом изменяют итеративно с
помощью функции, которая рассчитывает новое значение на базе старого.
В атомах хранят состояние отдельных частей проекта. Это счетчики, сессии,
локальный кеш для ускорения расчетов. Атомы участвуют в императивном коде как
изменяемые переменные. Иногда в атомах хранят состояние модуля, например,
текущее подключение к БД. Но в этом случае перед ним приходится ставить оператор
@
, что не всегда удобно.
Особый объект volatile это облегченный вариант атома. В отличии от него, volatile не поддерживает валидаторы и вотчеры.
Переходные или транзиентные коллекции это особая форма их постоянных
аналогов. Когда коллекция транзиентна, меняются ее внутренные элементы. На таких
коллекциях работают функции conj!
, assoc!
и другие с восклицательным знаком
на конце. Особая функция persistent! запечатывает изменяемую коллекцию и
возвращает ее постоянную версию.
Транзиентные коллекции полезны на больших объемах данных, поскольку быстрее неизменяемых. Изменять коллекцию можно только из одного потока. Изменения должны быть изолированы строго внутри функции. Другие части кода не должны знать о том, что внутри коллекция изменяется. Обмен переходными коллекциями между функциями считается грубой ошибкой.
Функция alter-var-root заменяет def
-определение на произвольное значение. С
помощью функции можно вторгнутся в чужое пространство имен и что-то исправить
уже после его загрузки. Как правило, к alter-var-root
прибегают, чтобы
скорректировать код, которым мы не распоряжаемся. Например, улучшить
логирование, улучшить поведение чужой функции.
Форма set! назначает новое значение глобальной переменной Clojure. Обычно это служебные переменные с “ушками”. Форма редко встречается в коде, чаще всего значения этих переменных задают в настройках проекта.
Форма binding выполняет произвольный код в рамках временных
изменений. Макрос связывает динамические переменные с новыми значениями на
период его работы. Чтобы переменная была динамической, в момент определения ей
сообщают флаг ^:dynamic
. Синтаксически такие переменные выделяют “ушками”.
К binding прибегают, чтобы временно назначить глобальным переменным Clojure
другие значения. Например, направить стандартный вывод в файл для конктретного
участка кода. Эффект binding действует только в текущем потоке. В других случах
макрос сокращает число аргументов для функции. Например, любой вызов tr внутри
формы (with-locale :en)
вернет перевод для английского языка.
Макрос with-local-vars выполняет тело в контексте произвольных переменных. Эти переменные похожи на объекты с двумя действиями: прочитать и записать значения. Форма полезна, когда имеем дело со сложной императивной логикой. При выходе из макроса переменные становятся недоступны.
Конструкция with-redefs временно изменяет def
-определения. В отличии от
binding
, она действует глобально. Все фоновые процессы (треды, футуры,
агенты), если они работают в момент действия макроса, подхватят
изменения. Технически with-redefs
это обертка над более низкоуровневой формой
with-redefs-fn
. В основном with-redefs
используют чтобы подготовить систему
к прогону тестов (мокинг).
Нашли ошибку? Выделите мышкой и нажмите Ctrl/⌘+Enter