Clojure, что это
Clojure VS ClojureScript
В этой небольшой книге вы познакомитесь с языком Clojure в лице его диалекта ClojureScript. И, в отличие от пары совершенно разных языков Java и JavaScript, эти два языка по сути являются одним и отличаются только тонкостями интеграции с конкретной платформой: программы на Clojure запускаются на Java Virtual Machine, а программы на ClojureScript, скомпилированные в JavaScript выполняются в Web-браузере (или, к примеру, на NodeJS).
Тот факт, что языки семейства Clojure очень похожи между собой, означает, что и навыки при изучении вы получаете универсальные: если вам вдруг захочется после ClojureScript попробовать пописать на Clojure, вы будете чувствовать себя как дома! И по этой же причине вы, когда увидите в тексте упоминание Clojure, можете считать, что упоминается всё семейство языков. А вот если будет упомянут именно ClojureScript, то это будет означать, что описывается какая-то особенность этой конкретной реализации.
Clojure — это Lisp
Если вы когда-либо интересовались историей компьютерных наук, вы точно встречались с упоминанием семейства языков Lisp. А если даже история вас не слишком интересует, то само название вы наверняка слышали.
Семейство это старое и влиятельное. Сейчас трудно найти языки, авторы которых не вдохновлялись лиспами в той или иной степени. Тот же JavaScript изначально выглядел как ещё одна реализация Scheme, представителя одной из основных ветвей семейства лиспоподобных языков. И пусть в итоге JavaScript оказался синтаксически похож на Java, но внутри это до сих пор Lisp в овечьей шкуре.
Clojure визуально очень похож на другие лиспы, хотя ни в какие другие семейства не входит. Clojure, как всякий уважающий себя Lisp, имеет
- функции как сущности первого класса
- упрощённый синтаксис, пригодный для программного манипулирования
- систему метапрограммирования, также известную как "макросы"
Особенности Clojure
Автор Clojure Рич Хикки, создавая этот язык, выбрал для себя главной задачей борьбу со сложностью. В его понимании наиболее сложными концепциями — сложными для восприятия человеком и удержания в голове при работе с кодом — являются изменяемое состояние и одновременное исполнение нескольких подпрограмм. Кроме того в язык были заложены богатые средства для работы с данными и инструменты для взаимодействия с платформой. Рассмотрим же все эти составляющие "образа языка".
Изменяемые данные
Чтобы программист реже сталкивался с изменяемым состоянием, в мире Clojure в качестве основного подхода для решения задач используется функциональное программирование. Этот подход характерен использованием неизменяемых структур данных и чистых функций — таких функций, которые при вычислении результата не влияют на другие части программы и сами не зависят от того, в каком состоянии сейчас находится система.
Неизменяемые структуры данных подразумевают то, что когда вы "меняете" что-то в такой структуре, вы не затрагиваете оригинал, а вместо этого получаете новую структуру, содержащую нужные изменения. Чтобы такое поведение не приводило к ненужному перерасходу ресурсов машины, используются специальные персистентные неизменяемые структуры данных, которые в новых версиях данных используют повторно те части структуры, которые не менялись. Clojure, как типичный язык для "в первую очередь функционального программирования", поддерживает такие структуры и умеет работать с ними эффективно.
Одновременное исполнение подпрограмм
Сложность разработки программ, части которых работают одновременно, часто тоже связана с изменяемыми данными. Когда изменяемые данные доступны сразу нескольким потокам (они же сопрограммы, то есть программы, работающие сообща) и в любой момент времени могут быть изменены любым из них, часто практически невозможно предсказать то, как будет себя вести система в целом. Обычно программисты применяют специальные примитивы синхронизации, таких как semaphores и locks, чтобы управлять тем, какая из сопрограмм модифицирует данные в какой момент времени — и получают специфические для такого подхода проблемы вроде блокировок (dead locks) или состояния гонки (race condition).
Clojure наступает на класс проблем, связанных с многопоточностью, с двух фронтов:
- неизменяемые структуры данных позволяют сопрограмме не бояться за сохранность данных, пока она с этими данными работает;
- высокоуровневые средства позволяют и вовсе обойтись без передачи нескольким сопрограммам данных, доступных для прямого изменения. Вместо этого вы связываете взаимодействующие сопрограммы, к примеру, каналом, который доставляет сообщения от одной стороны к другой.
Работа с данными
Лучше иметь 100 функций, работающих с одной структурой данных, чем иметь 10 функций, работающих с 10 структурами данных.
Алан Перлис так писал в своих "Эпиграммах о программировании" (1982). Эта цитата очень хорошо характеризует тот факт, что в языках семейства Lisp (название расшифровывается как "List Processor"!) принято на всё смотреть через призму списков. Список — великий уравнитель, и всё есть список, даже сам код на Lisp. Любые другие структуры данных реализуются с помощью односвязных списков, для работы с которыми лиспы обычно имеют богатейший набор инструментов.
С одной стороны наличие универсальной структуры "под капотом" у большинства других структур позволяет легко писать код, преобразующий одни данные в другие. А нужный уровень абстракции достигается предоставлением функций, при использовании которых вам не нужно знать, что внутри у данных списки списков списков. С другой стороны, как показывает многолетняя практика, некоторый набор структур общего назначения нужен настолько часто, что неплохо бы иметь эти структуры уже реализованными в языке и снабжёнными нужным количеством инструментов для работы с ними.
Получается, что полезно иметь "штук пять структур с сотней функций для каждой"! Недаром говорят:
если нужно оставить только одну структуру данных, выбирай hash map
Вот и Рич Хикки в дополнение к списку заложил в Clojure такие структуры как отображение (map), множество (set) и вектор (vector) и стандартная библиотека языка обладает множеством инструментов для работы с ними. И даже сам код на Clojure строится с использованием этих структур, а не одних лишь круглых скобок как в классических lisp.
Взаимодействие с платформой
Clojure изначально создавался для запуска на Java Virtual Machine во многом потому, что платформа эта существует давно, а значит хорошо оптимизирована и "обросла" изрядным количеством библиотек, написанных для неё на Java и других языках, компилируемых в тот же формат байткода. Возможность легко подключить и использовать в своём коде сторонние библиотеки как правило требует от языка соответствующей поддержки.
Так как JVM исполняет код, который живёт в классах и оперирует объектами этих классов, то работать с классами нужно и со стороны Clojure/JVM. Более того, иногда нужно представить код, написанный на Clojure/JVM, в виде тех самых классов так, чтобы внешний код мог их использовать. Такая интеграция с платформой, работающая в обе стороны, называется interoperability или сокращённо interop.
У Clojure/JVM имеется хороший interop с JVM, а у ClojureScript — хороший interop с Browser API и NodeJS API. Благодаря тому, что использовать interoperability достаточно просто, многие популярные "не родные" библиотеки силами сообщества уже снабжены соответствующими "обёртками" и вам погружаться в interop не нужно.
Данные == код
Данные — это код
Обычно в синтаксисе языка программирования присутствуют конструкции, отвечающие за описание значений встроенных типов. Такие конструкции называют литералами. Так числовые литералы описывают числа, а строковые — строки. Часто, если в языке есть встроенные структуры данных, в синтаксисе этого языка присутствуют и литералы для описания значений соответствующих типов. В JavaScript литералы объектов и массивов описывают объекты и массивы соответственно.
Поскольку литералы являются частью синтаксиса языка, то можно сказать, что любые описанные с помощью литералов данные — это код.
Однако, практически всегда синтаксис литералов является лишь небольшой частью синтаксиса всего языка. Вы просто не сможете описать, скажем, массив ключевых слов языка или добавить новый оператор в тело цикла: ключевые слова и управляющие инструкции не являются данными, доступными самому языку для манипулирования.
Иногда тот или иной язык предоставляет специальные средства для манипулирования собственным кодом — так называемые средства метапрограммирования. Но даже если язык и поддерживает метапрограммирование, то обычно манипулирование синтаксисом и манипулирование "обычными" данными — разные виде деятельности. И фаза манипулирования кодом, как правило, отделена от фазы его выполнения: сначала весь код приводится к окончательному виду, и только потом исполняется (возможно, предварительно компилируется).
Код — это данные
В лиспоподобных языках для записи кода используются те же литералы, которые описывают данные. В классических лиспах вообще используется преимущественно одна структура данных — список. Любая программа состоит из списков и манипулирует списками же, а значит она может манипулировать собственным кодом. Получается, что в таких языках любой код — это данные!
Такое свойство языка, заключающееся в том, что и код и данные описываются одинаковым образом, называется гомоиконичностью (homoiconicity). При этом не важно, используется ли для описания любых значений одна структура данных или несколько. Важно лишь то, что любой код можно использовать ещё и как сложносоставное значение, которое можно преобразовывать силами языка и затем исполнять.
Код на Clojure и EDN
Clojure — язык гомоиконичный. Подобно другим лиспам, язык использует списковые литералы для описания кода, но добавляет к ним ещё несколько структур, чтобы людям было проще этот код читать.
Кроме общего синтаксиса Clojure ещё существует его подмножество, называемое EDN — Extensible Data Notation. Это такой формат представления данных, который позволяет описать любые, понятные Clojure данные, но при этом не завязан именно на язык программирования. Можно сказать, что EDN относится к Clojure, как JSON относится к JavaScript — оба формата удобны для хранения и передачи данных, но могут быть и без каких-либо преобразований вставлены в код на соответствующем языке, где станут работать как литералы.
Поскольку код на Clojure практически полностью состоит из EDN, стоит начать знакомиться с синтаксисом EDN, а потом плавно перейти к специфичным именно для Clojure вещам.
Синтаксис Clojure
Read-Eval-Print Loop
Пока мы будем рассматривать EDN и основы синтаксиса Clojure, вам стоит держать под рукой Clojure REPL. "REPL" — сокращение от "Read-Eval-Print Loop". Это такая программа, которая позволяет вводить выражения на языке программирования, вычисляет их, печатает результат и ждёт следующей команды. У вас, скорее всего, уже имеется JavaScript REPL в вашем Web Browser, только называется он "Консоль разработчика" или как-то похоже.
Clojure — язык компилируемый, поэтому ему нужен компилятор, который пока в браузеры не встроили. Но на начальном этапе можно воспользоваться сайтом https://tryclojure.org — это как раз и есть "Clojure REPL в браузере", реализованный с помощью ClojureScript. Можете попробовать ввести на сайте строчку (println "Hello world!")
и нажать
Числа, строки и прочие примитивы
Числа в Clojure записываются так же, как в большинстве других языков: 42
, 3.14
— это целое число и число с плавающей точкой. Строки записываются в двойных кавычках: "Hello"
.
Для булевых значений в языке имеются отдельные литералы: true
и false
, при этом булев в Clojure самостоятельный, а не является фасадом для чисел "1" и "0", как бывает в некоторых языках.
Отдельно стоит отметить литерал, отвечающий за "отсутствие значения", пусть даже сам по себе он значением является. Это литерал nil
, по смыслу похожий на null
в JavaScript. Если вы пробовали выполнить в REPL кусочек кода, упомянутый в предыдущем разделе, то вы видели вывод nil
после приветствия: nil
является результатом вычисления выражения (println ...)
, потому что именно это значение вернула функция println
.
Ключевые слова
Ключевые слова (keywords) — это имена, начинающиеся с двоеточия, и включающие в себя буквы, цифры и некоторые другие символы, такие как знак минуса. Ключевые слова ценны тем, что всегда представляют только самих себя, ни больше, ни меньше. Ключевое слово, сконструированное с помощью литерала или другим способом, всегда будет означать то же самое, что означают другие "экземпляры" этого ключевого слова когда бы и где бы вы их не создали. Поэтому keywords часто используются для описания каких-то номинальных значений вроде :carrot
или :city
. Вот ещё несколько примеров:
:kebab-cased-name
:0xFF00FF
— нет, это не число!:42
— и это не число:/usr/local/bin/gzdoom
— а это не путь к файлу
Попробуйте ввести каждый из этих символов в REPL и опробуйте заодно несколько своих keywords.
Вы можете спросить, а зачем нужен отдельный тип для "просто имён". В других языках в роли таких "просто каких-то имён" часто используются строки. Вот только у этого решения есть недостатки. Так, две строки, созданные в разных частях программы, могут быть равны посимвольно, но чтобы удостовериться, что вы видите два экземпляра одной и той же сущности, вам придётся произвести это самое посимвольное сравнение. А ещё использование строк не только в роли текста, но и в роли "имён общего назначения" заставляет каждый раз, видя строковый литерал, думать, смотрите ли вы на текст или на другого рода значение — именно поэтому во многих руководствах по оформлению кода рекомендуется строковые константы не использовать как литералы, но сохранять предварительно в переменные (константные) и ссылаться на них по именам переменных.
Keywords, в отличие от "строк как имён", не являются текстом, а значит нет никаких проблем с двойственностью "текст VS имя". А ещё ключевые слова быстро сравниваются друг с другом: среда исполнения помнит все встреченные в коде ключевые слова и сравнивает их адреса в памяти вместо посимвольного сравнения имен.
Символы
Символы (symbols) похожи на ключевые слова, только они не предваряются двоеточием. И символы тоже что-то именуют. Но то, что в данном конкретном месте кода означает символ, всецело* зависит от контекста*: в одном месте имя может означать функцию, в другом строку, в третьем может не означать ничего, то есть будет не определено. Можете пока думать о символах, как об именах переменных и функций, потому что чаще всего символы именно в качестве таковых и используются.
Важно понять, что при вычислении выражений символ должен быть связан с чем-то, иначе вы получите ошибку "Could not resolve symbol: XYZ". Чтобы использовать символ сам по себе, нужно его процитировать (quote), то есть добавить перед именем символ '
(одинарная кавычка): так вы сообщите вычислителю, что вам важно само имя символа, а не значение, которое сейчас с именем связано. Вот несколько примеров символов, каждый из которых цитирован и может быть введён в REPL, где будет вычислен сам в себя:
'hello
'/bin/bash
Векторы и отображения
Векторы выполняют роль массивов из других языков — это упорядоченные наборы значений. К элементам векторов можно обращаться по индексу. Вот несколько примеров векторов:
[]
— пустой вектор[1 2 3]
— вектор чисел[true false 3.14 nil ["a" 7]]
— вектор с элементами разных типов
Отображения (maps) выполняют роль хранилищ значений, доступных по ключу. Подобные структуры в других языках называются словарями или хеш-таблицами, а в JavaScript это объекты. Вот несколько примеров отображений:
{}
— пустое отображение{"a" 1 "b" 2}
— отображение, в котором по ключу"a"
хранится значение1
, а по ключу"b"
значение2
{1 "a" 2 "b"}
— а здесь уже ключами выступают числа, а значениями являются строки
Заметьте, ни в векторах ни в отображениях не используются запятые для разделения элементов, а в отображениях даже ключи со значениями не связаны в пары никакими конструкциями, они просто идут друг за другом через одного. Единственный разделитель здесь, это пробел. И переносить на новую строку длинный литерал можно в любом месте, где имеется этот пробел. Да, к такому нужно привыкнуть. Но в целом такое единообразие записи литералов, как "разделённых через пробелы последовательностей чего-то либо", позволяет меньше заботиться о синтаксисе и больше думать о сути.
Выше в примерах вы видели строки и числа в роли ключей, однако гораздо чаще вы будете встречать в роли ключей ключевые слова. Ведь что есть ключ отображения, как не "имя значения"? И как значения, ключевые слова тоже используются частенько. Вот вам пример структуры, описывающей группу домашних животных:
[{:type :pet ; <- тут и ключ, и значение это keywords
:kind :dog ; (к слову, с точки с запятой начинаются комментарии)
:name "Spike"
:age 3}
{:type :pet
:kind :cat
:name "Thomas"
:age 2}]
Списки
У списков в Clojure роль особая. Как структура данных, односвязные списки подходят для не слишком широкого круга задач, поэтому именно в "данных" списки встретить можно довольно редко. А вот в "коде" списки превалируют: почти всегда, когда вы видите список, подразумевается вызов функции или другая управляющая конструкция, большая часть из которых — макросы, то есть, опять же, функции, только работающие с кодом. Вычислитель вообще всегда пытается трактовать литерал списка как вызов функции, если вы список предварительно не процитируете!
Вызов функции всегда выглядит как список, первый элемент которого вычисляется в функцию, а потом этой функции передаются остальные элементы списка в качестве аргументов. Чаще всего первым элементом будет выступать символ, который на момент вычисления связан со значением-функцией. Вот несколько примеров, опробуйте их в REPL:
(println "Hi!")
(+ 1 2)
(if (> 2 1) :greater :not-greater)
Заметьте, как выглядят сложение и сравнение чисел: оператор идёт перед операндами! Дело в том, что в лиспоподобных языках обычно нет такой вещи как "оператор", есть только функции, связанные с символами +
, >
и так далее. А поскольку имя функции всегда идёт первым в списке, то +
в выражении, вычисляющем сумму, будет идти первым!
У такого единообразия трактовки функций есть рад преимуществ. Например, вам не нужно думать о приоритетах операторов, потому что перед вызовом функции всегда вычисляются её аргументы, поэтому выражение (* (+ 5 7) 9)
вычисляется как "(5 + 7) * 9" и не нужно помнить, что в арифметике приоритет у умножения выше, чем у сложения.
Второе преимущество заключается в том, что у функций вроде +
появляется возможность принимать больше (или меньше!) аргументов, чем бывает операндов у соответствующего оператора. Можно посчитать сумму пяти чисел одним вызовом функции сложения: (+ 1 2 3 4 5)
. Или можно проверить, что все числа следуют друг за другом в порядке возрастания: (< 5 7 9)
. В частности, сравнение трёх аргументов можно часто встретить в ситуации, когда нужно проверить, входит ли число в интервал: (if (<= 0 x 10) :yes :no)
читается проще, чем (if (and (<= 0 x) (<= x 10)) ...)
, не правда ли?
Цитирование и децитирование
Цитирование уже упоминалось выше в разделе про символы как способ приостановить замену символа на связанное с ним значение. Но цитирование используется не только с символами, оно работает в любом месте кода и останавливает вычисление всего выражения, которое идёт следом за символом одиночной кавычки. Например, если вы введёте '(+ 1 2)
в REPL, вы не получите тройку, вместо этого будет напечатан список из трёх элементов, первый из которых — символ "+". Заметьте, именно символ, а не функция сложения, потому что замена имени на значение — тоже часть вычисления! Именно поэтому в цитате можно указывать символы, которые ещё не определены: '(if foo (println bar) (explode))
— вы можете ввести это выражение в REPL и не получите в ответ сообщений о том, что "foo" и "bar" не определены.
Цитирование широко используется в метапрограммировании, потому что вам постоянно приходится приостанавливать вычисление, чтобы поработать со списками как с данными, а потом уже выполнять полученный код как обычно. Выполнение кода осуществляется вызовом функции eval
, которая вычисляет то, что запретила вычислять кавычка, и (eval '(+ 1 2))
вычислится в число "3".
Попробуйте процитировать выражения разной вложенности и посмотрите, что выводится, если эти цитаты вычислить с помощью eval
. Можете, скажем, поизучать выражение '(+ (eval '(* 3 4)) 5)
— посмотрите, выполнится ли вложенный eval
до того, как вы форсируете вычисление всей внешней цитаты?
В разделе, рассказывающем про списки, было сказано, что любой список трактуется как вызов функции, если литерал списка не процитировать. Но как быть, если список нужен как структура данных, но не хочется откладывать вычисление его элементов? Как, к примеру, получить именно список из чисел 1, 2 и 3 из литерала '(1 (+ 1 1) 3)
, а не иметь (+ 1 1)
вместо двойки? Если вы встретитесь с такой задачей, воспользуйтесь функцией list
, которая создаёт список, но не вычисляет его как вызов функции, ведь она сама выступает в роли таковой: выражение (list 1 (+ 1 1) 3)
вычислится в список '(1 2 3)
— можете проверить в REPL.
Определения
Определение (definition) чего либо в Clojure — это связывание некоторого значения с именем. Именем всегда выступает символ, а вот значения могут быть самых разных типов. Самый простой вид определения — это определение константы, то есть единоразовое и постоянное связывание значения и имени. Выглядят такие определения следующим образом:
;; RGB-цвет, представленный как вектор из трёх чисел,
;; записанных в 16-ичной сисмете исчисления
(def fuchsia [0xFF 0x00 0xFF])
(def pi
"A number Pi" ; это строка документации (docstring)
3.1415926) ; а это уже значение
;; документацию по символу можно посмотреть в REPL,
;; вызвав функцию (doc SYMBOL)
(doc pi)
(doc list)
Определения функций выглядят так:
(defn foo [x y] ; параметры функции
(+ x (* y 10))) ; x + y * 10
Заметьте, что формальные параметры функции описываются как вектор. А само определние выглядит как список, а точнее как вызов какой-то функции. Если вы тоже так подумали, то интуиция вас не подвела: такие определения функций являются вызовами — вызовами макроса defn
, который преобразует примерно во что-то такое:
(def foo (fn [x y]
(+ x (* y 10))))
Как видите, это тоже определение константы, только в виде значения выступает литерал, описывающий анонимную функцию или, как её ещё называют, "лямбда-функцию" или просто "лямбду" (название восходит к Лямбда-исчислению, это такая математическая модель).
Довольно часто в функциональном программировании вам требуется функция, которая нужна ровно в одном месте, и анонимные функции как раз в таких случаях и используюся. В некоторых языках именнованные функции и анонимные представлены разными сущностями. А в Clojure и здесь всё привычно единообразно: именованные функции — это всего лишь константы с анонимными функциями в роли значений.
Строго говоря, макрос defn
не только переписывает определение, но и выполняет некоторые другие преобразования. Но пока мы их рассматривать не будем. Как только нам понядобятся специфичные возможности defn
, о них будет рассказано.
Работа с данными
Литералы отображений, множеств и векторов вам уже знакомы. Они, можно сказать, описывают начальное состояние данных, но большая часть программ в процессе работы данные преобразует. Когда речь идёт о преобразовании неизменяемых структур данных, подразумевает создание новой версии данных без потери доступа к старой. Рассмотрим такой пример:
(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})
Получился такой же код, какой был в примере передачи результата из функции в функцию!
Благодаря макросу ->
и ему подобным цепочки преобразований данных писать становится очень просто — после того как вы к такому подходу привыкнете. А к хорошему привыкаешь быстро!
Инструментарий разработчика
Shadow-ClJS
REPL в браузере хорош для первого знакомства с языком, однако с его помощью не получится заниматься непосредственно разработкой. Чтобы разрабатывать реальные проекты на ClojureScript, вам потребуются установленные на вашей ваш компьютер компилятор ClojureScript в JavaScript, инструмент для управления зависимостями (сторонними библиотеками), REPL.
Сейчас все эти задачи берёт на себя инструмент под названием Shadow-ClJS. Вы можете найти этот проект по ссылке: https://github.com/thheller/shadow-cljs Самый простой способ установки shadow-cljs, который при этом подходит для большинства пользователей — это генерация проекта с помощью следующей команды:
$ npx create-cljs-project hello
...
Эта команда создаст директорию с именем "hello" и заполнит необходимым минимумом файлов, среди которых наиболее интересен shadow-cljs.edn
— основной файл настройки того, как будет компилироваться ваш проект.
После того, как npx закончит подготавливать проект, вы сможете запустить REPL.
REPL
Компилятор ClojureScript сам по себе является программой, написанной на Clojure и запускается на Java Virtual Machine. Но код на языке JavaScript, которые появляется в результате компиляции ClojureScriptкода, тоже нужно на чём-то запускать — нужна среда исполнения JavaScript. Таковых обычно имеется две на выбор: NodeJS и Web-браузер. REPL, использующий NodeJS для выполнения JavaScript, запускается командой
$ npx shadow-cljs node-repl
Этот REPL максимально похож на тот, что вы видели на странице Try.Clojure. Вы можете вводить код и видеть результат его выполнения в вашем окне терминала.
REPL, исполняющий JS силами Webбраузера, запускается командой
$ npx shadow-cljs browser-repl
shadow-cljs - config: /home/astynax/Projects/cljs/hello/shadow-cljs.edn
shadow-cljs - server version: 2.20.17 running at http://localhost:9630
shadow-cljs - nREPL server started on port 37463
[:browser-repl] Configuring build.
[:browser-repl] Compiling ...
После того, как вы запустите эту команду, через какое-то время ваш браузер откроет страницу с текстом "Code entered in a browser-repl prompt will be evaluated here.". При этом в окне терминала вы всё так же будете видеть так называемое приглашение "cljs.user=>
" и сможете вводить код.
Если окно браузера на открывается автоматически, то вы можете вручную открыть в браузере адрес http://localhost:9630 (номер порта может отличаться, нужный вам вы найдёте среди сообщений, выводимых в терминале — ищите строчку с текстом "server … running at …").
Browser REPL является основным, когда вы разрабатываете Webприложения для браузера. При этом вам доступны многие привычные для Webразработчика функции, только видите вы их в обёртке ClojureScript. Например, если ввести в ответ на приглашение строчку (js/alert "Hello!")
, то браузер покажет модальное окно с соответствующим сообщением. А если ввести (.log js/console "ping")
, то сообщение уже будет выведено в консоль инструментария разработчика — опять же, в окне браузера.
Первый код
Сгенерированный проект содержит поддиректории src/main
и src/test
, в которых и принято размещать код и тесты к нему. Код на языке Clojure хранится в файлах, каждый из которых описывает пространство имён (в других языках эти файлы могут называется "модулями"), при этом и файлы, и пространства имён называются в соответствии с соглашением Java Naming Conventions. И как бы нам не хотелось назвать файл просто app.cljs
, следует придумать более уникальное и "говорящее" имя вида "company.project.name".
Создайте файл src/main/com/frontend/app.cljs
со следующим содержимым:
(ns com.frontend.app)
(defn init []
(js/alert "Hello World!"))
Чтобы компилятор смог найти этот файл и использовать функцию init
как точку входа в программу (вызывать эту функцию при показе Webстраницы), нужно модифицировать shadow-cljs.edn
. Нужно изменить значение ключа :builds
так, чтобы оно выглядело следующим образом:
{
...
:builds
{:frontend
{:target :browser
:modules {:main {:init-fn com.frontend.app/init}}
}}
}
Компилятор помещает полученный JavaScriptкод в файл publis/js/main.js
. А чтобы браузер мог этот файл загрузить, вам понадобится HTMLстраница public/index.html
следующего вида:
<!doctype html>
<html>
<body>
<div id="root"></div>
<script src="/js/main.js"></script>
</body>
</html>
И, наконец, нужно включить HTTPserver, встроенный в Shadow-ClJS, чтобы тот мог отдавать браузеру файлы. В shadow-cljs.end
нужно добавить следующее:
{
...
:dev-http {8080 "public"} ; <- порт и директория
:builds
{...}
}
Теперь, если вы запустите shadow-cljs в "режиме разработки" командой
$ npx shadow-cljs watch frontend
...
shadow-cljs - HTTP server available at http://localhost:8080
Теперь компилятор будет собирать новый JSфайл в ответ на каждое изменение ClojureScriptкода, после чего останется только обновить страницу в браузере! А если вы откроете в браузере адрес http://localhost:8080 то будет вызвана та самая функция init
и браузер покажет модальное окно с сообщением.
Важное свойство "режима разработки" заключается в том, что каждое изменение кода после компиляции оного (успешной, разумеется) загружается в браузер автоматически. Это обычно означает, что вам не нужно перезагружать страницу каждый раз, когда вы вносите изменения в код на ClojureScript. Более того, есть способы сохранить текущее состояние приложения, даже если логика его работы периодически меняется!
server, watch, cljs-repl
Режим "watch", он же "режим разработки" всем хорош, кроме того, что вы не имеете возможность коммуницировать с REPL, а можете только редактировать код, который затем загружается в браузер. Но иногда всё же хочется иметь возможность повзаимодействовать с приложением в интерактивном режиме.
Часто такую возможность даёт ваш редактор — если он умеет работать с Clojure и ClojureScript. Для таких редакторов Shadow ClJS предоставляет доступ к так называемому протоколу "nREPL", поэтому при старте shadow-cljs
вы видите сообщение вида "nREPL server started on port …". Если же вам хочется запускать REPL вручную из терминала, лучше сразу запускать shadow-cljs в режиме сервера командой
$ npx shadow-cljs server frontend
...
shadow-cljs - HTTP server available at http://localhost:8000
shadow-cljs - server version: 2.20.17 running at http://localhost:9630
shadow-cljs - nREPL server started on port 43035
HTTPserver будет запущен, так что открыть страницу в браузере вы сможете. Но горячая загрузка кода пока не будет работать.
Теперь, уже в другом окне терминала вам нужно запустить ещё один экземпляр Shadow в режиме watch
и этот новый экземпляр подключится к уже запущенному серверу:
$ npx shadow-cljs watch frontend
...
shadow-cljs - connected to server
shadow-cljs - watching build :frontend
В этот момент заработает горячая перезагрузка кода.
Чтобы запустить REPL, вам потребуется третье окно терминала, в котором следует вызвать команду:
$ npx shadow-cljs cljs-repl frontend
...
shadow-cljs - connected to server
cljs.user=>
Вот и REPL, который работает в связке с браузером, а значит можно с оным пообщаться:
cljs.user=> (js/alert "Ping!")
nil
Чтобы получить доступ к коду, который мы написали в модуле app.cljs
, вам нужно в REPL "переключиться" на соответствующее пространство имён — то, которое мы указали в форме (ns ...)
:
cljs.user=> (in-ns 'com.frontend.app) ;; имя процитировано!
com.frontend.app=> (init) ;; теперь функция init видна
nil
Узнайте, как в вашем любимом редакторе обстоят дела с поддержкой Clojure nREPL. Многие редакторы позволяют подключиться к nREPL server напрямую, так что отдельно запускать REPL вам не придётся. Однако знать о такой возможности полезно, поэтому она здесь и упомянута.
Взаимодействие с платформой
ClojureScript компилируется в JavaScript и выполняется в браузере. Это означает, что либо стандартная библиотека языка должна иметь все средства для взаимодействия с браузером, либо язык должен предоставлять удобные средства для взаимодействия со средой исполнения JavaScript. Язык Clojure в целом следует второму пути и ClojureScript в частности позволяет вашему коду общаться с браузером посредством уже имеющегося JavaScript API.
Теперь, когда у вас уже есть настроенный проект, можно начать применять ClJS на практике. Реализуем приложение, которое бы при загрузке страницы модифицировало содержимое оной. В index.html
уже имеется тег <div id="root">
, его содержимое мы и будем менять. В файле src/main/com/frontend/app.cljs
(в ближайшее время весь код будет относиться к этому файлу) опишите функцию следующего вида:
(defn update []
(-> (.querySelector js/document "#root")
.-innerHTML
(set! "<h1>Hello World!</h1>")))
Здесь используются сразу три вида взаимодействия с JavaScript objects:
(.querySelector js/document "#root")
— это вызов методаquerySelector
применительно к объектуdocument
со строкой"#root"
в качестве аргумента. Такой вызов в JavaScript выглядел бы как строчкаdocument.querySelector("#root")
.(.-innerHTML node)
— обращение к атрибуту объекта, эквивалентное выражениюnode.innerHTML
в JS.(set! value attribute)
— присвоение нового значения атрибуту объекта. В JS это выглядело бы какobject.attribute = value
.
В примере выше обращение к полю не обёрнуто в скобки, поскольку макрос
->
позволяет опускать оные, если на текущем шаге конвейера нужно применить функцию к одному лишь передаваемому по конвееру аргументу. С тем же успехом можно было написать(-> .. (.-innerHTML) ..)
, но макрос->
позволяет слегка сэкономить на скобках. И возможно такое упрощение потому, что обращение к атрибуту — тоже функция. Сам же макрос->
ничего не знает об объектах и атрибутах!
Теперь, когда у вас есть функция update
, её можно вызвать из "точки входа в программу" — из функции init
:
(defn init []
(update))
Попробуйте открыть страницу при запущенном dev server, вы должны увидеть вставленный в тело страницы тег h1
с текстом приветствия.
Вот ещё несколько примеров использования JS interoperability:
(defn log-some-date []
(let [date (js/Date. 1999 12 31)] ;; создание объекта
(.log js/console (.toISOString date)))) ;; вызовы методов
Здесь (js/Date. ...)
, это аналог создания нового объекта типа Date
. В JavaScript вы бы написали new Date(...)
. Заметьте: точка в конце имени типа означает, что вы вызываете конструктор данного типа, без точки вы бы получили доступ к самому классу js/Date
, который не может быть вызван как функция сам по себе.
Работа с изменяемым состоянием
Практически любое приложение меняет своё состояние в процессе работы. В JavaScript состояние хранится в глобальных переменных, в лексических замыканиях, в атрибутах объектов. ClojureScript, как hosted язык, тоже может создавать JSобъекты и изменять их содержимое в процессе работы программы. Однако, в мире JavaScript достаточно часто эти объекты, изображающие фрагменты одного большого "состояния всей системы", изменяются разными частями программы и уследить за всеми изменениями бывает достаточно сложно, если вообще возможно. Поэтому в Clojure используются неизменяемые структуры данных, доступ к которым осуществляется через изменяемые ссылки.
Атомы
В Clojure существует несколько видов изменяемых ссылок, обладающих различными свойствами, однако в ClojureScript доступен ровно один вид: атомы. В процессе работы с атомом мы получаем текущее значение по ссылке, создаём новое значение на основе старого и только после этого перенаправляем ссылку на это новое значение. При этом непосредственно изменение ссылки происходит мгновенно, насколько сильно бы мы не меняли само значение. Поэтому не возникает печально известное состояние гонки, когда часть данных уже поменялась, а часть ещё не успела измениться и какие-то другие процессы в программе могут получить доступ к неконсистентному состоянию. При использовании атомов по ссылке всегда доступно либо старое состояние, либо уже новое, никакой возможности получить частично изменённые данные нет!
Создаётся атом функцией (atom начальное-значение)
и обычно связывается с некоторым именем:
(def counter (atom 0))
Получить текущее значение, на которое ссылается атом, можно с помощью так называемого разыменования (dereferencing), указав перед именем символ @
: (println @counter)
. При разыменовывании вы получаете именно текущее значение, поэтому булево выражение (= @counter @counter)
иногда может оказаться ложным — если другая часть программы успеет изменить ссылку между двумя разыменованиями.
И, наконец, перенаправить ссылку на новое значение можно с помощью функции (reset! имя-атома новое-значение)
.
Заметьте, функция
set!
, изменяющая значение атрибутов JSобъектов, и функцияreset!
, перенаправляющая ссылки атомов, имеют восклицательные знаки в именах. Так в Clojure принято называть функции, за использованием которых нужно внимательно следить. Как правило, это функции, меняющие что-то за пределами видимости текущего участка кода.
reset!
против swap!
У функции reset!
есть один недостаток: если вы разыменовали атом, долго вычисляли новое значение, а потом перенаправили на него атом с помощью reset!
, то может так случиться, что какой-то другой процесс успеет изменить атом после вашего разыменования, но до вашего reset!
. Если такое случится, то часть информации будет потеряна. Вот так эта ситуация может выглядеть:
;; [ первый процесс ] | ;; [ второй процесс ]
;; | ;;
;; получаем текущий баланс счёта | ;;
(let [acc @bob-account] | ;;
;; что-то долго делаем | (let [acc @bob-account]
;; ... | ;; быстро снимаем 10 кредитов
;; ... | (reset! bob-account (- acc 10)))
;; списываем 100 кредитов | ;;
(reset! bob-account (- acc 100))) | ;;
Здесь первый процесс даже не узнает о том, что баланс счёта уже поменялся на -10 кредитов, и перезапишет с расходом -100 кредитов, а первые -10 потеряются!
Чтобы попадать в такие ситуации пореже, обычно стараются разыменовывать атомы максимально близко к вызову reset!
. Однако есть более безопасный способ: функция swap!
. Эта функция не принимает новое значение для атома, а вместо этого ожидает, чтобы вы предоставили ей функцию из старого значения в новое. При этом само внутреннее устройство атома гарантирует, что при вызове такой функциимодификатора между получением старого значения и переключением ссылки на новое никакой другой процесс не вклинится. То есть использование swap!
делает изменение истинно атомарным (именно поэтому атомы называются так как называются).
Чтобы списать со счёта Боба десять кредитов, нужно применить swap!
таким образом: (swap! bob-account - 10)
— значение по ссылке будет передано в функциюмодификатор первым, но вы можете указать и другие аргументы для функции. Таким образов в одной части программы можно сделать (swap! bob-account - 100)
, а в другой выполнить (swap! bob-account - 10)
, и общие траты составят -110!
Атомарность и побочные эффекты
Функция swap!
устроена так, что при перед вызовом функциимодификатора запоминает старый адресат ссылки. А после того, как модификатор вернул новое значение, то swap!
проверяет, указывает ли ссылка на запомненный ранее адрес. Если ссылка оказалась перенаправлена, пока модификатор выполнял вычисления, то swap!
запускает модификатор ещё раз — уже с новым значением (тем, на которое теперь указывает ссылка) в качестве аргумента.
Такие повторы могут происходить не единожды, если с атомом одновременно и достаточно активно работают несколько процессов в рамках одной программы. Поэтому очень важно не производить в теле функциимодификатора никакие побочные эффекты вроде изменения других атомов, отправки запросов к серверу и тому подобные действия. Иначе вы рискуете ощутить эти побочные эффекты несколько раз, даже если сделали один вызов swap!
.
Рекомендация тут может быть такая: вам нужно выносить любые затратные с точки зрения времени или сопряжённые с побочными эффектами вычисления за пределы функциимодификатора, а swap!
использовать только в самом конце. Следовать этой рекомендации обычно не слишком сложно, и отделение побочных эффектов от изменения состояния идёт на пользу читаемости кода.
Атомы и реактивность
У атомов в Clojure есть ещё одна особенность, которая очень полезна при разработке Webприложений: вы можете подписаться на изменения атома и, к примеру, своевременно обновлять пользовательский интерфейс.
Добавим в наше приложение атом, который будет хранить значение некоего счётчика:
(ns ...)
;; атомы часто располагают в начале модуля
(defonce counter (atom 0))
...
Здесь используется форма defonce
, которая похожа на обычное объявление с помощью def
. Отличается defonce
от def
тем, что при горячей перезагрузке кода (которую производит Shadow ClJS в "режиме разработки") значение такого объявления не изменяется.
Это достаточно удобно — не терять состояние приложения при перезагрузке кода. Однако нужно понимать, что иногда состояние приходится таки сбрасывать, например если поменялась сама структура того, как данные должны в этом конкретном атоме храниться. В таких случаях вы можете перезагрузить страницу целиком или же в REPL вызвать reset!
, перенаправив таким образом атом на новое, корректное значение.
Подписка на изменения
Чтобы наше приложение начало реагировать на изменения атома counter
, нужно оформить подписку. Проще всего подписываться на атомы в функции, которая выступает как точка входа. В нашем случае это init
:
(defn init []
(draw @counter) ;; первый раз draw вызывается принудительно
(add-watch counter ;; атом, на который оформляется подписка
:draw ;; "ключ", идентифицирующий подписку
(fn [_ _ _ value] ;; функция-обработчик изменений
(draw value))))
Ключ здесь нужен для того, чтобы функцияобработчик могла отличать один атом от другого, если эта функция оказывается подписана на несколько атомов.
Сейчас в роли обработчика выступает анонимная функция. И как любой обработчик, функция эта получает при вызове четыре аргумента:
- сам атом,
- упомянутый выше ключ,
- значение, на которое атом указывал ранее,
- значение, на которое атом указывает сейчас
В примере выше нам интересно только новое значение, поэтому остальные параметры функцииобработчика названы именем
_
. Это общепринятый способ называть аргументы, наличия которых требует вызывающая сторона, а самой функции эти параметры не важны.
В данный момент обработчик вызывает функцию draw
с текущим значением счётчика в качестве аргумента. И принудительный вызов (draw @counter)
в начале работы программы тоже передаёт значение счётчика в функцию draw
Нужно изменить функцию так, чтобы она имела соответствующий параметр и отображала передаваемые ей значения:
(defn draw [value]
(-> (.querySelector js/document "#root")
.-innerHTML
(set! (str "<h1>" value "</h1>"))))
Функция
str
конкатенирует все свои аргументы в одну строку, приводя к строке значения других типов, если таковые встречаются.
Если теперь обновить страницу, или вызвать (init)
в REPL, то страница должна показать начальное значение счётчика — "0". И если в REPL увеличить значение счётчика с помощью вызова (swap! counter inc)
, то текст на странице также изменится и произойдёт это автоматически — благодаря подписке.
Реакция на перезагрузку кода
Стоит добавить ещё один кусочек реактивности в приложение: научить его реагировать на горячую загрузку кода. Для этого достаточно описать функцию и добавить ей определённый флаг в качестве метаданных:
(defn ^:dev/after-load redraw []
(draw @counter))
Здесь ^:dev/after-load
означает "дописать к метаданным функции redraw значение true
по данному ключу". Дело в том, что метаданные хранятся в виде отображения. Но часто важно именно наличие ключа, а само значение не существенно. Поэтому для такого случая разрешён укороченный вариант записи. Если вдруг вам будет интересно, можете запросить в REPL метаданные для функции redraw:
com.frontend.app> (meta #'redraw)
;; => {:ns com.frontend.app,
:name redraw,
:file "com/frontend/app.cljs",
...
:dev/after-load true, ; вот и флаг!
... }
Метаданные широко используются в самых разных задачах. Метаданные объявляемых функций описывают, в каком файле какая функция была объявлена, с какой и по какую строчку это объявление в файле располагается и тому подобное. В данном же примере Shadow ClJS по наличию этого флага узнаёт, что данную функцию нужно вызывать каждый раз сразу после загрузки новой версии кода.
В Clojure метаданные можно добавлять практически к любому значению за исключением примитивных типов вроде строк или чисел. Для этого используется функция
(with-meta значение {:ключ :значение ...})
. И дажеdefn
в реальности представляет собой макрос, который связывает символ имени с анонимной функцией, попутно с помощьюwith-meta
добавляя к ссылке на функцию порцию метаданных.
Интерактивность
На внешние воздействия счётчик уже реагирует, теперь нужно добавить пару кнопок, чтобы и пользователь приложения мог менять значение счётчика. Пока что мы описываем теги в виде строковых литералов, поэтому <button>
пока будут получать обработчики нажатия в атрибуте onclick
. А это значит, что в качестве значения атрибута будет выступать код на JavaScript, пусть даже мы и хотим вызывать функции, которые напишем на Clojure. Впрочем, так мы сможем увидеть ещё один вид взаимодействия ClJS с платформой: вызов функций из мира ClojureScript в коде на JavaScript.
Так как проект сейчас имеет всего один модуль, описывающий пространство имён com.frontend.app
, то все определения, описанные в этом модуле, будут видны со стороны JavaScript как атрибуты объекта app
, являющегося атрибутом объекта frontend
, являющегося атрибутом объекта com
, который, наконец, является атрибутом глобального объекта document
. Другими словами уже имеющаяся у нас функция init
может быть вызвана в коде на JavaScript по полному имени: com.frontend.app.init()
— всё настолько просто! Разве что имена, которые полисповски названы в стиле kebab-case
, со стороны JS будут выглядеть как snake_case
.
Добавим сразу обе функции, которые будут обрабатывать нажатия кнопок, и модифицируем функцию draw
так, чтобы кнопки встраивались в вёрстку:
(defn inc-counter [] (swap! counter inc))
(defn dec-counter [] (swap! counter dec))
(defn button [text action]
(str "<button onclick=\""
(-> action
.-name
(clojure.string/replace "$" "."))
"()\">"
text
"</button>"))
(defn draw [value]
(-> (.querySelector js/document "#root")
.-innerHTML
(set! (str "<h1>"
value
(button "+" inc-counter) ; вот и кнопки
(button "-" dec-counter)
"</h1>"))))
Сами обработчики устроены просто: они всего лишь обновляют атом с помощью функций inc
и dec
(увеличение и уменьшение на единицу, соответственно).
В draw
тоже добавились только два вызова функции button
. Каждый раз в функцию передавались строка и функция, тоже с виду всё просто.
А вот функция button
интересна тем, как она работает со значениями своего параметра action
. В качестве значений этот параметр получает функции на ClojureScript, но в HTMLто нужно вставить правильные имена JavaScriptобъектов, в которые эти функции будут скомпилированы!
К счастью, каждая скомпилированная функция получает имя, составленное из полного имени пространства имён плюс имени функции, только в этом имени все точки заменяются на "$
", а все дефисы — на символ "_
". И сохраняется это имя в атрибуте .name
объекта. Нам остаётся только извлечь значение атрибута, заменить в нём $
обратно на .
, и мы получим "внешнее имя": "com$frontend$app$inc_counter
" превратится в "com.frontend.app.counter
". Такое преобразование и выполняется выше в коде функции button
.
Подведём итоги
Итак, теперь приложение стало по-настоящему интерактивным: кнопки можно нажимать, нажатия изменят состояние приложения, интерфейс прореагирует на изменения — цикл замкнулся, любые более сложные приложения реализуются похожим образом... или нет?
Вы, наверняка, подумали, что работать этими "внутренними" и "внешними" именами функций утомительно и склеивание строк не выглядит как надёжный и быстрый способ наполнить страницу. Да, можно заменить склеивание строк на создание и вставку элементов DOM. И вместе с этим перейти от назначения обработчиков событий через атрибуты тегов к прямому связыванию функций с соответствующими атрибутами всё тез же элементов DOM. Это сделает код более надёжным и быстрым, но и количество кода вырастет кратно!
Вот только и с точки зрения JS работать с DOM напрямую утомительно. Поэтому большинство разработчиков использует библиотеки, скрывающие всю низкоуровневую работу с документом. Точно так же поступают и в мире ClojureScript, зачастую используя те же библиотеки из мира JS, только в более удобной для программирования именно на ClojureScript обёртке.
Таким образом, текущая версия приложения выглядит сложнее, чем могла бы быть. Но зато и никакой "магии" сейчас в проекте нет — видно, как ClojureScript работает с платформой. И в будущем вы сможете себе представить, как та или иная библиотека работает "под капотом".
Сложное состояние
Счётчик с одним значением — на самое впечатляющее приложение. В реальности состояние приложения включат в себя много "движущихся частей" и воздействуют на это состояние разные элементы интерфейса по разному. Усложним задачу: пусть теперь приложение показывает несколько счётчиков одновременно и позволяет добавлять новые счётчики равно как и удалять существующие.
Поменяются функция draw
и содержимое атома counter
. Кажется, стоит уже и назвать их иначе:
;; бывший counter
(defonce counters (atom {0 0}))
;; бывшая draw
(rum/defc view-counters < rum/reactive []
;; состояние читается один раз в самом начале
(let [cs (rum/react counters)]
[:div
[:ul
;; в качестве "ребёнка" тега может выступать список,
;; который может быть построен динамически
(for [i (sort (keys cs))]
[:li {:key (str "counter-" i)}
(str (get cs i))
[:button {:on-click (fn [] (swap! counters update i inc))} "+"]
[:button {:on-click (fn [] (swap! counters update i dec))} "-"]
[:button {:on-click (fn [] (swap! counters dissoc i))} "X"]])]
[:button
{:on-click
(fn [] (swap! counters assoc
(inc (apply max (keys cs))) 0))}
"+"]]))
Поменялось многое. В первую очередь атом теперь ссылается на отображение идентификатора счётчика в его значение. {0 0}
означает "счётчик с идентификатором 0 имеет значение 0".
Маркированный список :ul
теперь наполняется динамически: функция for
пробегается по отсортированному списку ключей упомянутого выше отображения и создаёт по элементу списка (:li
) для каждого ключа. Обратите внимание: каждый элемент списка имеет атрибут key
с уникальным значением. С помощью значений этого атрибута React понимает то, какие элементы в списке нужно обновить, какие нужно добавить, а какие убрать из документа — это достаточно важная оптимизация и если вы её не сделаете, React сообщит в консоли браузера об этом.
Стоит рассмотреть каждый обработчик нажатия, коих теперь уже четыре:
(swap! counters update i inc)
можно трактовать как(update @counters i inc)
, то есть увеличивает (inc
) значение по ключуi
(swap! counters update i inc)
— это(update @counters i dec)
, то есть уменьшение (dec
) значения по ключуi
(swap! counters dissoc i)
— это(dissoc @counters i)
, то есть удаление из отображения значения по ключуi
(swap! counters assoc (inc (apply max (keys cs))) 0)
— это добавление значения0
по ключу(inc (apply max (keys cs)))
. Вычисляемый этим с виду сложным выражением ключ будет равен числу, следующему за максимальным значением среди ключей отображенияcs
(полученное выше значение по ссылке из атомаcounters
).
Функция
max
находит максимальное значение среди своих аргументов. Но мы хотим применить её к списку значений. Здесь нам помогаетapply
, которая и распаковывает список аргументов:(apply max '(4 2 3 5))
превращается в(max 4 2 3 5)
.
Сейчас каждая кнопка конструируется вручную и это выглядит как несколько довольно сильно похожих друг на друга кусочков кода. Но каждая кнопка, это всего лишь вектор, а значит мы можем обобщить создание кнопок. Причём мы можем сделать как отдельный компонент, так и просто формировать с помощью функции вектор. Сделаем сначала функцию:
(defn button [label function & args]
[:button
{:on-click (fn [] (apply swap! counters function args))}
label])
(rum/defc view-counters < rum/reactive []
(let [cs (rum/react counters)]
[:div
[:ul
(for [i (sort (keys cs))]
[:li {:key (str "counter-" i)}
(str (get cs i))
(button "+" update i inc)
(button "-" update i dec)
(button "X" dissoc i)])]
(button "+" assoc (inc (apply max (keys cs))) 0)]))
"& args
" в списке параметров функции button
означает, что при вызове этой функции все дополнительные аргументы после label
и function
будут доступны в теле функции в виде списка, связанного с одним параметром args
. А далее в теле функции button
всё та же функция apply
применяет swap!
к counters
, function
и любым другим дополнительным аргументам, которые были указаны при вызове button
.
Даже такая простая функция как button
в значительной степени разгружает код функции view-counters
. А возможна такая лёгкая декомпозиция потому, что мы всего лишь манипулируем знакомыми структурами данных — вот она, сила datadriven design!
Что же случится, если заменить defn
на rum/defc
в объявлении button
? Обычная функция станет компонентом! Полезно такое преобразование тем, что компонент может понять, что "перерисовывать" ничего не нужно, если с прошлого раза значения аргументов не изменились. Если компонент состоит из достаточно большого количества тегов, то выигрыш может быть существенным. Однако и просто так превращать каждую отдельно взятую функцию в компонент не стоит: компоненты "тяжелее" функций с точки зрения используемой памяти. Так что проще начинать с функций, а потом, если понадобится, превратить некоторые из них в компоненты — посмотрев с помощью профилировщика React что и когда перерисовывается и насколько затратна каждая конкретная перерисовка. Как говорится: "семь раз отмерь, один раз отрежь!".
Rum
В мире JavaScript популярным средством построения графических интерфейсов является библиотека React. И так уж вышло, что библиотека эта резонирует с идеями функционального программирования: чистые функции без побочных эффектов вроде прямого изменения структуры документа, передача текущего состояния через аргументы функций вместо использования локальных изменяемых состояний.
Да, современный React не запрещает использование компонент с собственными кусочками состояния, да и от чистых функций часто уходят в сторону классов. Но именно в мире ФП принято React использовать по старинке, в виде функций из состояния в представление.
React можно использовать в ClJS и напрямую через JavaScript interoperability, но гораздо удобнее взять готовую обёртку. Rum — одна из таких обёрток. И это довольно тонкая обёртка в сравнении с другими библиотеками и фреймворками: вы имеете полную свободу во всём, кроме "отрисовки" интерфейса — Rum берёт на себя только эту часть работы.
Установка Rum
Чтобы добавить Rum в проект, вам нужно в shadow-cljs.edn
упомянуть библиотеку вместе с версией:
…
:dependencies
[[rum "0.12.10"]]
…
Теперь останется только перезапустить сервер Shadow ClJS. Но перед новым запуском сервера стоит установить ещё и соответствующие библиотеки для JavaScript. Это делается привычно силами NPM:
$ npm install react@16 react-dom@16
Версия 16 была выбрана потому, что Rum не использует новые штуки, появившиеся в React в последнее время. Но использует кое-какие старые, поэтому чтобы не видеть лишних предупреждений в консоли браузера, стоит остановиться на "стабильной" версии
react@16
. Впрочем, вы можете попробовать установить и самую свежую в качестве эксперимента.
Теперь, когда зависимости установлены, запустите сервер Shadow ClJS.
Первое использование
Перепишем счётчик с помощью Rum. Для этого импортируем модуль rum.core
(изменения коснутся файла app.cljs
):
(ns com.frontend.app
(:require [rum.core :as rum]))
;; ^ здесь :as означает "импортировать под именем таким-то"
Теперь нужно удалить объявления функций inc-counter
и dec-counter
— сможем обойтись без них. А функцию draw
нужно изменить таким образом:
(rum/defc draw < rum/reactive []
[:h1
(str (rum/react counter))
[:button {:on-click (fn [] (swap! counter inc))} "+"]
[:button {:on-click (fn [] (swap! counter dec))} "-"]])
Здесь defc
выступает как аналог defn
, но объявляет не обычную функцию, а "компонент". Последовательность "< rum/reactive
" называется добавлением "примеси" ("mixin"). Примеси добавляют функциикомпоненту особые свойства.
Примесь rum/reactive
делает компонент реактивным — реагирующим на изменения некоторых ссылок. В данном случае интерфейс будет реагировать на изменение атома counter
, потому что в "теле" компонента этот атом использован в вызове (rum/react counter)
. Такой вызов работает как разыменовывание ссылки и одновременно с этим подсказывает компоненту, за какими ссылками следует следить. При этом вам не нужно самостоятельно оформлять никакие подписки — если, конечно, вы не наблюдаете за атомами в коде, не связанном с построением GUI.
Компонент определён, теперь его нужно "смонтировать" — привязать к уже имеющемуся узлу дереве документа. Делается это так:
(defn init []
(rum/mount (draw) (.querySelector js/document "#root")))
Теперь счётчик должен снова заработать. А ведь кода стало меньше раза в три!
Rum и HTML
Стоит отметить две вещи, которые уде видны в нашем коде:
- "вёрстка" выполнена с помощью обычных элементов, которыми мы описываем данные — векторов, отображений, ключевых слов, строк
- Обработчики событий — это обычные функции на ClojureScript и больше нет нужды помнить особенности компиляции ClJS в JS
Второй пункт скорее просто приятен. А вот первый демонстрирует одну из особенностей ClojureScript — построение доменноориентированных языков с помощью структур данных или, как ещё говорят, "datadriven design". Строить языки под задачу вообще любят пользователи большинства лиспоподобных языков, но именно в Clojure предпочитают явно отделять что-то, описанное с помощью векторов и отображений, от "обычного кода", записанного с помощью списков. Считается, что это разделение идёт на пользу читаемости.
Итак, HTML в Rum представлен как дерево из вложенных друг в друга векторов. Первый элемент вектора описывает имя тега, CSSклассы, ему назначенные и идентификатор в любых разумных сочетаниях:
:div#content
— это<div id="content">
:.counter
— это<div class="counter">
, потому что тегdiv
подразумевается:span#foo.bar.baz
— это<span id="foo" class="bar baz">
Если нужно добавить какие-то дополнительные атрибуты тегу, то вторым элементов вектора указывается отображение, где имена атрибутов кодируются ключевыми словами, а значения строками (или тем, что безопасно приводится к строке). К примеру, обработчики нажатия у тегов button
в нашем коде записаны как {:on-click (fn [] …)}
.