Главная мысль урока: Clojure это лисп, который живёт на чужом рантайме и делает ставку на неизменяемые данные. Сущности программы описываются мапами и векторами, функции работают с любыми из них, а меняется только то, что ты явно назвал изменяемым.
Представь, что ты чинишь баг в «Хрониках Пограничья», той игре, лог событий которой разбирали в курсе аналитика. Игрок жалуется: герой выпил зелье во время боя, и здоровье стало отрицательным. Ты открываешь код на TypeScript и находишь объект hero, у которого поле hp меняют три места: система боя, система зелий и таймер яда. Каждое делает hero.hp -= ... или hero.hp += ..., и порядок, в котором они сработали, зависит от того, кто первым получил событие. Чтобы понять, как здоровье ушло в минус, придётся восстановить всю историю вызовов.
Теперь тот же герой на Clojure:
Баг никуда не делся: здоровье всё так же ушло в минус. Но посмотри, что изменилось. Герой это мапа, и её нельзя поменять. Каждое действие дало новую версию, а все старые остались на месте. Где всё сломалось, видно по цепочке значений, без отладчика и логов. Вот ради этой разницы и стоит учить Clojure.
Лисп на чужом рантайме
Clojure придумал Рич Хики и выпустил в 2007 году. Он годами писал конкурентные системы на C++ и Java и устал от одной и той же беды: общие объекты, которые меняют из разных потоков. Его ответ звучал так: пусть значения будут неизменяемыми по умолчанию, а изменяемое состояние живёт в нескольких местах с понятными правилами.
Второе решение Хики: не писать свою виртуальную машину. Clojure это хостовый язык: он компилируется в байткод JVM и получает даром всё, что есть у Java, от сборщика мусора до тысяч библиотек. Позже появились другие хозяева:
| Вариант | Хозяин | Где встречается |
|---|---|---|
| Clojure | JVM | серверы, обработка данных, долгоживущие сервисы |
| ClojureScript | JavaScript | браузер и Node, фронтенд на React через reagent и re-frame |
| babashka | нативный бинарник на GraalVM | скрипты и утилиты, старт за десятки миллисекунд |
| SCI | интерпретатор на JavaScript или JVM | встраивание Clojure в чужие программы, в том числе эта страница |
Код, который ты запускаешь в этом разделе, выполняет SCI прямо в браузере, без сервера. Язык тот же, но хозяин здесь JavaScript, и в паре мест это заметно. Там, где это важно, я покажу, как тот же код ведёт себя на JVM, через листинг, который проверен настоящим Clojure в babashka.
Один код, разные хозяева
Строка в Clojure это строка хозяина. В браузере это строка JavaScript, на JVM это java.lang.String. Методы хозяина вызываются через точку:
Последняя строка обращается к объекту Math из JavaScript, такого на JVM нет. Там похожая функция живёт в классе java.lang.Math:
(println (.toUpperCase "ирма")) ; ИРМА
(println (class "ирма")) ; java.lang.String
(println (Math/max 3 7)) ; 7
Такой вызов называется интероп, ему посвящён урок про интероп с JVM и JS. Пока запомни главное: ядро языка (коллекции, функции, макросы) одинаково везде, а на краях программы ты разговариваешь с хозяином на его языке. Поэтому одна и та же функция над мапой героя работает и на сервере, и в браузере.
Четыре идеи раздела
Весь раздел держится на четырёх мыслях. Каждая вырастет в несколько уроков, здесь только набросок.
Данные важнее объектов. Герой, предмет и квест это мапы с ключевыми словами. Вектор мап это таблица. Функции из clojure.core не знают, с чем работают, поэтому одни и те же map, filter и reduce обрабатывают и отряд героев, и лог событий:
Первый вызов находит раненых: отфильтруй героев со здоровьем меньше десяти и возьми их имена. Запись #(< (:hero/hp %) 10) это короткая анонимная функция, % её аргумент. Никаких классов Party и методов getWounded().
Неизменяемость дешёвая. Новая версия мапы не копирует старую целиком. Обе версии делят общие внутренние части, копируется только путь до изменённого места. Как это устроено, разберём в уроке про персистентность.
Состояние отдельно от значений. Значения не меняются никогда, а то, что должно меняться (мир игры, счётчик ходов), живёт в ссылке с явными правилами изменения. Самая частая ссылка это атом:
Всё изменяемое в программе собрано в world, и меняется оно только через swap!. Если здоровье героя ушло в минус, ты знаешь, где искать. Модель целиком разберём в уроке про значения и ссылки.
REPL как способ работы. Программа на Clojure растёт внутри запущенного процесса: ты вычисляешь форму, смотришь на данные, правишь и вычисляешь снова, без перезапуска. Об этом весь следующий урок.
Рядом с соседями
Если ты уже прошёл раздел про Janet, скобки тебя не испугают. Но у двух лиспов разные ставки, и полезно видеть Clojure на фоне языков, которые ты знаешь:
| Clojure | Janet | Haskell | TypeScript | |
|---|---|---|---|---|
| Данные по умолчанию | неизменяемые | изменяемые и неизменяемые парами | неизменяемые | изменяемые |
| Типы | динамические | динамические | статические, вывод | статические поверх JS |
| Рантайм | JVM, JS | свой, около мегабайта | свой, нативный код | JavaScript |
| Состояние | атомы, ref, агенты | обычные переменные и таблицы | монады, IORef, STM | что угодно где угодно |
| Сильная сторона | данные и конкурентность | встраивание и скрипты | гарантии компилятора | экосистема и браузер |
Janet учит, как лисп устроен изнутри. Haskell выносит неизменяемость и эффекты в систему типов, это ты видел в разделе про Haskell. Clojure берёт неизменяемость из Haskell, но оставляет динамическую типизацию: проверка данных переезжает из компилятора в тесты, REPL и схемы на границе системы.
Где Clojure не лучший выбор
Честно о слабых местах, чтобы ты знал, на что идёшь:
- Старт JVM. Программа на Clojure запускается за секунду и дольше. Для сервиса, который живёт неделями, это неважно, для утилиты командной строки неприятно. Эту дыру закрывает babashka.
- Нет статических типов. Опечатка в ключе
:hero/hppвернётnil, и ошибка всплывёт дальше по коду. Спасают тесты, REPL и схемы, но компилятор тебя не предупредит. - Сообщения об ошибках. Исключение на JVM приходит со стеком Java, в котором легко потеряться. Со временем учишься читать первые строки и не пугаться остальных.
- Счётная математика. Плотные циклы над массивами чисел на Clojure писать можно, но для этого придётся отказаться от его коллекций и подсказать компилятору типы. Для такого кода чаще берут Rust, Go или C.
- Рынок. Вакансий на Clojure меньше, чем на TypeScript или Go. Зато идеи раздела переносятся: неизменяемые данные и одна точка изменения состояния работают и в React, и в Effect, и в Go.
Проверь себя
Задание
Дальше
В следующем уроке поставим Clojure CLI и babashka и научимся работать так, как работают на Clojure: внутри живого процесса. А синтаксис целиком разберём в уроке про формы и литералы.
домашка