Главная мысль урока: на Clojure программу не перезапускают после каждой правки. Она работает в одном живом процессе, а ты вычисляешь в нём форму за формой: определил функцию, проверил на данных, поправил, вычислил снова. Состояние программы при этом остаётся на месте.
Вспомни, как ты чинишь баг во фронтенде. Поправил функцию, сохранил файл, дождался пересборки, страница перезагрузилась, ты снова залогинился, снова дошёл до третьего экрана, снова открыл модалку. Каждая проверка стоит минуту, и половину этой минуты ты восстанавливаешь состояние, которое было до правки.
В Clojure этот цикл выглядит иначе. Процесс запущен один раз и живёт весь рабочий день. Редактор подключён к нему, и когда ты нажимаешь сочетание клавиш, форма под курсором уходит в процесс и вычисляется там. Мир игры с героями и квестами остаётся в памяти, меняется только функция, которую ты поправил. Попробуй прямо здесь:
Этот сниппет живёт как настоящий REPL: определения одного запуска видны следующему. Ты переопределил end-turn, а журнал с прошлыми ходами никуда не делся. За это отвечает defonce: в отличие от def, он не трогает переменную, если она уже определена. Так в REPL держат состояние, которое не хочется терять при каждой правке.
Поставить инструменты
Для настоящей работы нужны две программы. Clojure CLI запускает Clojure на JVM, babashka запускает скрипты без JVM. CLI нужна установленная Java, подойдёт любая версия с долгой поддержкой, например 21.
# macOS
brew install clojure/tools/clojure
brew install borkdude/brew/babashka
# Linux: установщики с clojure.org и babashka.org
curl -L -O https://github.com/clojure/brew-install/releases/latest/download/linux-install.sh
chmod +x linux-install.sh && sudo ./linux-install.sh
bash < <(curl -s https://raw.githubusercontent.com/babashka/babashka/master/install)
Первый запуск clj скачает сам Clojure и откроет REPL. Сразу посмотри на три вещи, которые есть только у настоящего REPL: *1 хранит последний результат, *e последнее исключение, а doc печатает справку к любой функции.
$ clj
Clojure 1.12.4
user=> (+ 18 7)
25
user=> (* *1 2)
50
user=> (/ 1 0)
Execution error (ArithmeticException) at user/eval3 (REPL:1).
Divide by zero
user=> (ex-message *e)
"Divide by zero"
user=> (doc inc)
-------------------------
clojure.core/inc
([x])
Returns a number one greater than num. Does not auto-promote
longs, will throw on overflow. See also: inc'
nil
В браузерной песочнице *1 и *e нет, а справки к clojure.core нет, чтобы не раздувать загрузку. Справка к твоим функциям работает и здесь, об этом ниже.
Проект: deps.edn
Проект на Clojure CLI описывает один файл deps.edn в корне. Это обычная мапа Clojure: где лежит код, какие нужны библиотеки и какие есть дополнительные режимы запуска.
chronicles/
├── deps.edn
├── src/
│ └── chronicles/
│ └── core.clj ; неймспейс chronicles.core
└── test/
└── chronicles/
└── core_test.clj
{:paths ["src"]
:deps {org.clojure/clojure {:mvn/version "1.12.4"}}
:aliases
{:test {:extra-paths ["test"]}
:nrepl {:extra-deps {nrepl/nrepl {:mvn/version "1.3.1"}}
:main-opts ["-m" "nrepl.cmdline"]}}}
Ключ :aliases задаёт алиасы, наборы добавок, которые включаются флагом. Дефис в имени неймспейса превращается в подчёркивание в имени файла: chronicles.core-test лежит в core_test.clj. Это требование JVM, а не прихоть языка.
clj # REPL с путями и зависимостями из deps.edn
clj -M -m chronicles.core # запустить функцию -main из chronicles.core
clj -M:nrepl # поднять nREPL-сервер, к нему подключится редактор
Редактор, подключённый к процессу
REPL в терминале годится для первого знакомства, но работают на Clojure из редактора. Редактор подключается к процессу по протоколу nREPL и умеет отправлять в него форму под курсором, весь файл или выделение.
| Редактор | Плагин | Как подключиться |
|---|---|---|
| VS Code | Calva | команда Jack-in: Calva сама запустит REPL с nREPL |
| Emacs | CIDER | cider-jack-in |
| IntelliJ IDEA | Cursive | конфигурация запуска Clojure REPL |
| Neovim | Conjure | подключается к nREPL из файла .nrepl-port |
Плагины разные, а цикл работы один:
- Пишешь функцию в файле.
- Ставишь курсор на форму и вычисляешь её. Функция определена в процессе.
- Ниже пишешь вызов на настоящих данных и вычисляешь его. Результат появляется рядом с кодом.
- Правишь функцию, вычисляешь её снова. Данные, загруженные в процесс, остались на месте.
Черновые вызовы из шага 3 принято держать прямо в файле, в блоке comment. Макрос comment игнорирует всё внутри и возвращает nil, поэтому при загрузке файла ничего не выполнится. А в редакторе ты вычисляешь формы внутри блока по одной:
(defn damage [attack armor]
(max 1 (- attack armor)))
(comment
(damage 10 3) ; вычисли курсором: 7
(damage 2 5) ; 1, броня не делает урон отрицательным
)
Такой блок называют rich comment. Он работает как записная книжка: примеры вызовов лежат рядом с кодом, и любой, кто откроет файл, может их повторить.
Справка, не выходя из процесса
Справку пишут прямо в определении функции, строкой между именем и аргументами. doc читает её из работающего процесса, поэтому справка всегда про ту версию функции, которую ты вычислил последней:
Вторая полезная функция ищет по именам. Не помнишь, как называется перевод строки в верхний регистр? Спроси процесс:
На JVM к этим двум добавляется source, которая печатает исходник функции. Вот как это выглядит в babashka:
(require '[clojure.repl :refer [doc source]])
(doc inc)
(source inc)
Babashka: Clojure без JVM
Clojure CLI стартует за секунду и дольше: JVM нужно время, чтобы проснуться. Для сервиса это неважно, для скрипта на три строки раздражает. babashka закрывает эту дыру. Это нативный бинарник, внутри которого работает тот же SCI, что на этой странице, только с хозяином JVM.
bb -e '(+ 1 2)' # вычислить выражение: 3
bb repl # REPL в терминале
bb nrepl-server 1667 # nREPL для редактора
bb stats.clj # выполнить скрипт
Скрипт на babashka это обычный файл с Clojure. Вот подсчёт событий по типу, такой скрипт ты мог бы натравить на лог игры:
(require '[clojure.string :as str])
(def lines ["login irma" "quest irma" "login tim" "quest irma" "logout irma"])
(def by-type (frequencies (map #(first (str/split % #" ")) lines)))
(println by-type)
(assert (= 2 (get by-type "login")))
В проекте babashka читает свой файл bb.edn, в нём живут задачи, что-то вроде scripts из package.json:
{:tasks
{run (shell "clj -M -m chronicles.core")
repl (shell "clj -M:nrepl")
stats (load-file "scripts/stats.clj")}}
bb run запустит игру, bb repl поднимет REPL, bb stats выполнит скрипт. Подробно про babashka, ClojureScript и тесты поговорим в уроке 28. А как то же самое выглядит в Janet, ты видел в уроке про его REPL.
Проверь себя
Задание
Дальше
В следующем уроке разберём, из чего состоят формы, которые ты отправлял в REPL: литералы, списки вызовов и несколько особых форм, на которых держится весь язык.
домашка