Раздел 28 · Clojure

Зачем Clojure

junior~15 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Главная мысль урока: Clojure это лисп, который живёт на чужом рантайме и делает ставку на неизменяемые данные. Сущности программы описываются мапами и векторами, функции работают с любыми из них, а меняется только то, что ты явно назвал изменяемым.

Представь, что ты чинишь баг в «Хрониках Пограничья», той игре, лог событий которой разбирали в курсе аналитика. Игрок жалуется: герой выпил зелье во время боя, и здоровье стало отрицательным. Ты открываешь код на TypeScript и находишь объект hero, у которого поле hp меняют три места: система боя, система зелий и таймер яда. Каждое делает hero.hp -= ... или hero.hp += ..., и порядок, в котором они сработали, зависит от того, кто первым получил событие. Чтобы понять, как здоровье ушло в минус, придётся восстановить всю историю вызовов.

Теперь тот же герой на Clojure:

Баг никуда не делся: здоровье всё так же ушло в минус. Но посмотри, что изменилось. Герой это мапа, и её нельзя поменять. Каждое действие дало новую версию, а все старые остались на месте. Где всё сломалось, видно по цепочке значений, без отладчика и логов. Вот ради этой разницы и стоит учить Clojure.

Лисп на чужом рантайме

Clojure придумал Рич Хики и выпустил в 2007 году. Он годами писал конкурентные системы на C++ и Java и устал от одной и той же беды: общие объекты, которые меняют из разных потоков. Его ответ звучал так: пусть значения будут неизменяемыми по умолчанию, а изменяемое состояние живёт в нескольких местах с понятными правилами.

Второе решение Хики: не писать свою виртуальную машину. Clojure это хостовый язык: он компилируется в байткод JVM и получает даром всё, что есть у Java, от сборщика мусора до тысяч библиотек. Позже появились другие хозяева:

ВариантХозяинГде встречается
ClojureJVMсерверы, обработка данных, долгоживущие сервисы
ClojureScriptJavaScriptбраузер и 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 на фоне языков, которые ты знаешь:

ClojureJanetHaskellTypeScript
Данные по умолчаниюнеизменяемыеизменяемые и неизменяемые параминеизменяемыеизменяемые
Типыдинамическиединамическиестатические, выводстатические поверх 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: внутри живого процесса. А синтаксис целиком разберём в уроке про формы и литералы.

домашка

Домашка