Раздел 28 · Clojure

Интероп с хостом: JVM и JavaScript

middle~20 мин

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

Главная мысль урока: Clojure не прячет платформу, на которой работает. Классы и методы JVM или объекты JavaScript вызываются напрямую, одной формой с точкой. А ошибки своей программы Clojure предлагает описывать данными: ex-info несёт мапу, и обработчик разбирает её так же, как любую другую.

Ирма заходит в лавку. Название лавки надо показать заглавными буквами, дату завоза посчитать от сегодняшнего дня, а если золота не хватает, вежливо отказать и сказать, сколько не хватило. Для заглавных букв, дат и исключений в clojure.core своих функций почти нет. Они и не нужны: у JVM и у JavaScript всё это есть, а Clojure умеет звать их напрямую.

Сниппеты урока работают в браузере, поэтому начнём с JavaScript:

Четыре строки, четыре формы интеропа. Каждую читают так: «у этого объекта вызови метод» или «у этого объекта возьми поле».

Точка в начале списка

Весь интероп держится на одном соглашении: если символ в голове списка начинается с точки, это обращение к хосту.

ФормаЧто делаетJavaScriptJava
(.method obj args)вызвать методobj.method(args)obj.method(args)
(.-field obj)прочитать полеobj.fieldobj.field
(Class/static args)статический методMath.max(...) как js/Math.maxMath.abs(...)
(Class. args) или (new Class args)создать объектnew Date(...) как js/Date.new StringBuilder()
(set! (.-field obj) v)записать полеobj.field = vobj.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.

домашка

Домашка