Главная мысль урока: Clojure не прячет платформу, на которой работает. Классы и методы JVM или объекты JavaScript вызываются напрямую, одной формой с точкой. А ошибки своей программы Clojure предлагает описывать данными:
ex-infoнесёт мапу, и обработчик разбирает её так же, как любую другую.
Ирма заходит в лавку. Название лавки надо показать заглавными буквами, дату завоза посчитать от сегодняшнего дня, а если золота не хватает, вежливо отказать и сказать, сколько не хватило. Для заглавных букв, дат и исключений в clojure.core своих функций почти нет. Они и не нужны: у JVM и у JavaScript всё это есть, а Clojure умеет звать их напрямую.
Сниппеты урока работают в браузере, поэтому начнём с JavaScript:
Четыре строки, четыре формы интеропа. Каждую читают так: «у этого объекта вызови метод» или «у этого объекта возьми поле».
Точка в начале списка
Весь интероп держится на одном соглашении: если символ в голове списка начинается с точки, это обращение к хосту.
| Форма | Что делает | JavaScript | Java |
|---|---|---|---|
(.method obj args) | вызвать метод | obj.method(args) | obj.method(args) |
(.-field obj) | прочитать поле | obj.field | obj.field |
(Class/static args) | статический метод | Math.max(...) как js/Math.max | Math.abs(...) |
(Class. args) или (new Class args) | создать объект | new Date(...) как js/Date. | new StringBuilder() |
(set! (.-field obj) v) | записать поле | obj.field = v | obj.field = v |
Метод стоит первым, объект вторым. Это тот же порядок, что у любой функции Clojure: сначала что делать, потом с чем. Поэтому (.toUpperCase s) идёт в map через анонимную функцию: (map #(.toUpperCase %) names).
В ClojureScript глобальные объекты браузера живут в пространстве имён js: js/Math, js/Date, js/JSON, js/console. Объект JavaScript можно создать литералом #js, а данные Clojure перегнать туда и обратно функциями clj->js и js->clj:
Здесь видна главная разница между мирами. Объект shop изменяемый: set! поменял его на месте, и старого значения больше нет. Мапа Clojure так не умеет. Поэтому изменяемые объекты хоста держат у самой границы, а внутрь программы пускают уже неизменяемые данные после js->clj.
Цепочки: .. и doto
Когда методы вызываются друг за другом, вложенные скобки быстро растут. Два макроса выпрямляют их, как потоковые макросы выпрямляют обычные вызовы:
..передаёт результат каждого метода в следующий:" ирма ".trim().toUpperCase().substring(0, 2);dotoвызывает все методы на одном объекте и возвращает сам объект. Так настраивают изменяемые вещи: массив, построитель строк, HTTP-клиент.
Тот же код на JVM
На JVM всё то же самое, только вместо js/ пишут имена классов Java. Классы из java.lang видны сразу, остальные подключают через import, как в Java:
(import '(java.time LocalDate)
'(java.time.temporal ChronoUnit))
(assert (= "ЛАВКА ГНОМА" (.toUpperCase "лавка гнома")))
(assert (= 3 (Math/abs -3)))
(assert (= 42 (Long/parseLong "42")))
;; Сколько дней прошло с релиза 2.0 до конца выгрузки лога.
(def days (.between ChronoUnit/DAYS
(LocalDate/parse "2026-05-14")
(LocalDate/parse "2026-05-27")))
(assert (= 13 days))
(def receipt
(str (doto (StringBuilder.)
(.append "Ирма")
(.append " · ")
(.append 18))))
(assert (= "Ирма · 18" receipt))
(println (.. " ирма " trim toUpperCase (substring 0 2))) ; ИР
Код почти не отличается от браузерного. Различаются библиотеки: в Java есть java.time, java.nio.file, потоки и сокеты, в браузере есть DOM, fetch и JSON. Язык один, а платформа под ним своя.
Подсказки типов и рефлексия
Есть одна вещь, которой нет в ClojureScript. Когда компилятор JVM видит (.length s) и не знает, какого класса s, он не может вызвать метод напрямую. Тогда вызов идёт через рефлексию: метод ищется по имени при каждом вызове. Чтобы такие места найти, включают предупреждения, а чинят их подсказкой типа:
(set! *warn-on-reflection* true)
(defn name-length [^String s]
(.length s))
(assert (= 4 (name-length "Ирма")))
На JVM без ^String компилятор напечатает предупреждение вроде такого:
Reflection warning, shop.clj:4:3 - reference to field length can't be resolved.
Babashka интерпретирует код и предупреждений не печатает, поэтому листинг выше их не покажет. В настоящем проекте *warn-on-reflection* включают в каждом файле, где есть интероп в горячем цикле. В остальных местах рефлексия обычно никому не мешает.
Исключения хоста
Исключения тоже приходят от хоста. На JVM это объекты Throwable, в браузере всё, что бросили через throw, обычно js/Error. Форма try одна для обеих платформ, отличается только то, что пишут в catch:
(defn safe-div [a b]
(try
(/ a b)
(catch ArithmeticException e
(str "ошибка: " (ex-message e)))
(finally
(println "касса закрыта"))))
(assert (= "ошибка: Divide by zero" (safe-div 1 0)))
(assert (= 1/2 (safe-div 1 2)))
На JVM catch ловит по классу, и самый общий класс для обычных ошибок это Exception. В ClojureScript ловят по конструктору (js/Error) или пишут :default, чтобы поймать всё, даже брошенную строку. finally выполняется в любом случае. Заодно заметь 1/2: на JVM деление целых даёт дробь, а в браузере (/ 1 2) вернёт 0.5.
ex-info: ошибка с данными
Исключения хоста несут текст и класс, а программе обычно нужно больше: что пытались сделать, с какими значениями, почему не вышло. Разбирать это из строки сообщения ненадёжно. Clojure предлагает ex-info: исключение, у которого есть мапа.
Обработчик не парсит текст. Он берёт ex-data, деструктурирует её, как в уроке про деструктуризацию, и решает, что показать игроку. Ключ :error/type пригодится, когда видов ошибок станет много: по нему можно выбрать ответ через case или мультиметод.
На JVM catch ловит по классу, и для ex-info это clojure.lang.ExceptionInfo. Так ошибки предметной области отделяют от ошибок хоста:
(defn buy [hero price]
(when (< (:hero/gold hero) price)
(throw (ex-info "Не хватает золота" {:need price :have (:hero/gold hero)})))
(update hero :hero/gold - price))
(defn try-buy [hero price]
(try
{:ok (buy hero price)}
(catch clojure.lang.ExceptionInfo e
{:error (ex-message e) :data (ex-data e)})
(catch Exception e
{:error "сбой" :class (.getName (class e))})))
(assert (= {:error "Не хватает золота" :data {:need 30 :have 12}}
(try-buy {:hero/gold 12} 30)))
(assert (= {:ok {:hero/gold 7}} (try-buy {:hero/gold 12} 5)))
(assert (= "java.lang.NullPointerException" (:class (try-buy {} 5))))
Порядок catch важен: первым стоит частный случай, последним общий. Последняя проверка показывает, зачем нужен общий: у героя без :hero/gold сравнение (< nil 5) бросило исключение хоста, и игра не упала, а вернула мапу.
Проверь себя
Задание
Дальше
Интероп открыл дверь в платформу. Следующая её часть это конкурентность: в уроке про core.async процессы игры будут обмениваться событиями через каналы, как горутины в Go.
домашка