- Глава 1. Экосистема, Сборка и REPL
- Глава 2. Скалярные типы данных и Литералы
- Глава 3. Связывание имен и Области видимости
- Глава 4. Деструктуризация (Destructuring)
- Глава 5. Поток управления и Макросы thread
- Глава 6. Циклы, Итерации и Рекурсия
- Глава 7. Структуры данных (Persistent Collections)
- Глава 8. Ленивые последовательности (Lazy Sequences)
- Глава 9. Трансдьюсеры (Transducers)
- Глава 10. Пространства имен и Организация кода
- Глава 11. STM, Atoms и Управление состоянием
- Глава 12. Асинхронность и core.async
- Глава 13. Мультиметоды и Протоколы
- Глава 14. Теория категорий (Практический подход)
- Глава 15. Взаимодействие с Java
- Глава 16. Производительность и Transient (Временные) коллекции
- Глава 17. Валидация данных и Спецификации (
clojure.spec) - Глава 18. Работа с внешним миром
- Глава 19. Веб-разработка: Ring, Routing и Server
- Глава 20. Тестирование (
clojure.test) - Глава 21. Frontend: ClojureScript и Reagent
- Глава 22. Написание Макросов
- Глава 23. Архитектура: Integrant и DI
- Глава 24. Datomic и Datalog
- Глава 25. Продвинутый Frontend: Shadow-CLJS и NPM
- Глава 26. Скриптинг и Native Image (GraalVM)
- Итоговое напутствие для Senior Developer
В мире Java ты привык к Maven или Gradle. В Clojure есть два основных игрока: Leiningen (аналог Maven, "все включено") и Clojure CLI / deps.edn (более современный, модульный подход, близкий к философии Unix).
Это стандарт де-факто для крупных проектов последние 10 лет. Он декларативен и управляет всем жизненным циклом (компиляция, тесты, упаковка в JAR, деплой).
Структура project.clj (с подробными комментариями):
(defproject my-app "0.1.0-SNAPSHOT"
:description "Пример энтерпрайз конфигурации Leiningen"
:url "http://example.com/FIXME"
;; Указание лицензии важно для публикации в репозитории (Clojars/Maven Central)
:license {:name "EPL-2.0 OR GPL-2.0-or-later-with-Classpath-exception-2.0"
:url "https://www.eclipse.org/legal/epl-2.0/"}
;; Зависимости. Формат: [group-id/artifact-id "version"]
;; Clojure сама по себе является просто библиотекой (jar) для JVM.
:dependencies [[org.clojure/clojure "1.11.1"]
[ring/ring-core "1.9.6"]]
;; Основной namespace, где лежит функция -main (аналог psvm в Java)
:main ^:skip-aot my-app.core
;; Настройки генерации uberjar (fat jar со всеми зависимостями)
:target-path "target/%s"
;; Профили позволяют менять конфигурацию для разных окружений (dev, test, prod).
;; Это аналог Maven Profiles, но гибче (merging maps).
:profiles {:uberjar {:aot :all ;; Ahead-of-Time компиляция (в .class файлы)
:jvm-opts ["-Dclojure.compiler.direct-linking=true"]} ;; Оптимизация вызовов
:dev {:dependencies [[clj-kondo "2023.01.20"]] ;; Линтер только для дева
:plugins [[lein-kibit "0.1.8"]]}}) ;; Плагин стат. анализаОфициальный инструмент от создателей языка. Он фокусируется только на формировании Classpath. Сборка, тестирование и запуск делегируются внешним инструментам или алиасам. Ключевая киллер-фича: возможность подтягивать зависимости напрямую из Git (SHA коммита), не публикуя их в Maven Central.
Структура deps.edn:
{:paths ["src" "resources"] ;; Директории, попадающие в classpath
;; Основные зависимости
:deps {org.clojure/clojure {:mvn/version "1.11.1"}
;; Пример git-зависимости (библиотека прямо с github)
com.github.seancorfield/next.jdbc
{:git/tag "v1.3.847" :git/sha "4b08270"}}
;; Алиасы — это именованные модификаторы classpath или аргументов запуска.
;; Запускаются через: clj -M:test или clj -X:bench
:aliases
{:test {:extra-paths ["test"] ;; Добавить папку test в classpath
:extra-deps {lambdaisland/kaocha {:mvn/version "1.80.1274"}} ;; Фреймворк тестирования
:main-opts ["-m" "kaocha.runner"]} ;; Точка входа для этого алиаса
;; Алиас для запуска сервера (пример)
:run {:main-opts ["-m" "my-app.core"]}}}Для Java-разработчика REPL часто выглядит как JShell или консоль Python: место, где можно проверить 1 + 1.
В Clojure REPL — это сердце разработки.
- Подключение: Ты не пишешь код в REPL. Ты пишешь код в файле (IDE) и отправляешь формы в REPL, который подключен к работающему процессу.
- Stateful: Это не перезагрузка приложения. Это хирургическое изменение состояния работающей программы. Ты можешь переопределить функцию, пока приложение обрабатывает HTTP-запросы, и следующий запрос пойдет через новый код.
- Rich Comment Blocks: Код тестов и примеров живет прямо в файле с исходным кодом, но внутри специальных блоков, которые игнорируются компилятором, но исполняются разработчиком вручную.
В Clojure комментарии — это инструмент управления кодом, а не просто пояснительный текст.
(ns my-app.basics)
;; -----------------------------------------------------------
;; 1. Строчный комментарий (Standard line comment)
;; Используется ; для коротких, ;; для заголовков секций
;; -----------------------------------------------------------
(defn calculate-tax
"Это DOCSTRING (строка документации).
Она пишется сразу после имени функции/макроса, до вектора аргументов.
Доступна в REPL через (doc calculate-tax) или в IDE по Ctrl+Q.
Поддерживает Markdown в современных тулах."
[amount rate]
(* amount rate))
;; -----------------------------------------------------------
;; 2. Комментарий формы (Reader Macro #_)
;; Самый мощный инструмент. Он заставляет парсер прочитать следующую
;; *синтаксическую форму* (S-expression) и выкинуть её.
;; Это не просто закомментировать строку, это выключение ветки AST.
;; -----------------------------------------------------------
(defn debug-example []
(let [x 10
y 20]
;; Следующая строка игнорируется целиком, несмотря на то, что занимает 2 строки
#_(println "Debug info:"
(+ x y))
;; Часто используется для временного отключения аргументов в map или vector:
{:user "Ivan"
:active true
#_#_:admin true ;; Двойной #_#_ используется для вложенных форм или стекинга
:role :manager}))
;; -----------------------------------------------------------
;; 3. Rich Comment Blocks (idiom)
;; Макрос (comment ...) возвращает nil и тело внутри не выполняется при загрузке файла.
;; НО! В IDE (IntelliJ + Cursive, Emacs + CIDER, VSCode + Calva) ты можешь
;; поставить курсор внутрь и отправить форму на исполнение (Eval).
;; Это заменяет "Main" методы для ручного тестирования.
;; -----------------------------------------------------------
(comment
;; Этот код не скомпилируется в итоговый JAR, но я могу запустить его здесь.
(calculate-tax 100 0.2) ;; -> Выделил, нажал Exec -> получил 20.0
;; Проверка гипотез прямо рядом с кодом
(require '[clojure.reflect :as r])
(r/reflect java.lang.String)
) ;; Конец RCF (Rich Comment Form)Итог по главе:
Мы настроили среду (deps.edn или lein), поняли, что код мы пишем для отправки в REPL, и научились правильно документировать и "прятать" код через #_ и (comment ...).
В этой главе мы разберем атомарные типы данных, из которых строятся сложные структуры.
Clojure имеет унифицированную числовую систему. Тебе не нужно думать, влезет ли число в int или long, язык автоматически расширяет тип (auto-promotion) при переполнении, превращая его в BigInt.
- Long: Целые числа по умолчанию (Java
java.lang.Long). - Double: Дробные числа по умолчанию (Java
java.lang.Double). - BigInt: Для очень больших целых чисел (аналог
java.math.BigInteger, но со своим враппером). СуффиксN. - BigDecimal: Для точных финансовых вычислений. Суффикс
M. - Ratio (Дроби): Уникальная фича. Clojure хранит результат деления целых чисел как дробь, если результат не целый, чтобы не терять точность.
;; --- Числа ---
;; Обычный Long (64 bit)
123
;; Hex (16-ричное)
0xff
;; Ratio (Рациональное число)
;; В Java 1 / 3 дало бы 0 (int) или 0.333... (double).
;; В Clojure это объект, хранящий числитель и знаменатель.
1/3
;; Доказательство точности:
(= (* 1/3 3) 1) ;; -> true (абсолютно точно)
(= (* 0.3333 3) 1) ;; -> false (потеря точности плавающей точки)
;; Автоматическое расширение (Auto-promotion)
;; При переполнении Long, Clojure молча перейдет на BigInt.
;; Исключение ArithmeticException (overflow) не бросается для +, -, *, inc, dec.
;; (Для контроля переполнения есть функции unchecked-add и т.д.)
(+ Long/MAX_VALUE 10) ;; -> Вернет BigInt
;; Литералы высокой точности
2M ;; BigDecimal (как new BigDecimal("2"))
2N ;; BigIntЗдесь всё просто: это нативные Java-типы. Никаких оберток, никакого оверхеда.
- String:
java.lang.String. Неизменяемые (как и в Java). - Character:
java.lang.Character.
;; --- Строки ---
"Hello, World"
;; Многострочные строки (работают из коробки)
"Первая строка
Вторая строка"
;; Интерполяции строк в ядре нет (как f-strings или s"${}").
;; Используем str или format (аналог String.format)
(def user "Alex")
(str "Hello, " user) ;; -> "Hello, Alex" (аналог StringBuilder)
(format "User: %s, ID: %d" user 101)
;; --- Символы (Char) ---
;; Литерал начинается с обратного слеша
\a ;; char 'a'
\u0042 ;; unicode
\newline ;; спецсимволы
\space
\tabKeywords — это как очень быстрые строки, которые используются как метки или идентификаторы (аналог Enum, но открытый).
- Синтаксис: начинаются с двоеточия
:name. - Свойства:
- Указывают сами на себя (Self-evaluating).
- Интернируются (существует только один экземпляр
:fooв памяти). Сравнение происходит по ссылке (быстродействие==), а не посимвольно. - Являются функциями: Ключевое слово можно "вызвать", передав ему Map. Оно вернет значение по этому ключу.
;; --- Keywords ---
:status ;; Простой кейворд
:user-id
:my.app/id ;; Кейворд с пространством имен (namespace qualified)
;; 1. Кейворд как функция (Идиоматичный способ доставать значения из Map)
(def person {:name "Ivan" :age 30})
(:name person) ;; -> "Ivan" (эквивалент (get person :name))
(:city person "Unknown") ;; -> "Unknown" (поддержка значения по умолчанию)
;; Внимание: Null-safe. Если person будет nil, ошибки (NPE) не будет.
(:name nil) ;; -> nil
;; 2. Namespaced Keywords (::)
;; Используются для избежания коллизий имен в больших проектах или БД (Datomic).
;; Если мы находимся в namespace 'my-app.core':
::error ;; развернется компилятором в :my-app.core/error
;; Алиасы из require
;; (require '[my-lib.utils :as u])
;; ::u/status -> развернется в :my-lib.utils/statusСимволы — это идентификаторы. Они используются для ссылки на переменные, функции, классы.
- Если символ вычислить (eval), он попытается найти значение, на которое ссылается (значение Var).
- Если символ заквотить (quote) через
', он останется просто объектом данных (именем).
;; --- Symbols ---
;; Это вычисление символа map (функция из clojure.core)
map ;; -> #object[clojure.core$map ...]
;; Это сам символ (как данные)
'map ;; -> map
;; Используются в макросах для генерации кода.Здесь Clojure отличается от Java, JS и Python. Это нужно запомнить раз и навсегда.
Золотое правило Clojure: Только
falseиnilявляются логической ложью. Всё остальное (0, пустая строка "", пустой список [], пустой map {}) — это logical true.
;; --- Boolean & Nil ---
true
false
nil ;; Аналог Java null, но безопаснее (nil-punning)
;; Предикаты проверки (заканчиваются на ?)
(nil? nil) ;; -> true
(zero? 0) ;; -> true
(string? "s") ;; -> true
;; Логические операторы
(and true false) ;; -> false
(or nil 5) ;; -> 5 (возвращает первое "истинное" значение, а не true)
;; Пример "странной" для Java-дева логики:
(if 0 "Zero is true!" "Zero is false")
;; -> "Zero is true!" (потому что 0 != nil и 0 != false)
(if [] "Empty list is true!" "False")
;; -> "Empty list is true!"Литерал для создания java.util.regex.Pattern.
;; --- Regex ---
;; Создается через решетку и кавычки #"..."
(def email-regex #"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,6}$")
(type email-regex) ;; -> java.util.regex.Pattern
;; Основная функция для работы - re-find, re-matches, re-seq
(re-find #"\d+" "abc123def") ;; -> "123"(ns my-app.types-demo)
(defn types-showcase []
(println "--- Numbers ---")
(let [ratio (/ 4 3) ;; 4/3
precise (* ratio 3) ;; 4 (вернулись к точному целому)
imprecise (* (double ratio) 3)] ;; 4.0 (или 3.99999...)
(println "Ratio:" ratio "Type:" (type ratio))
(println "Precise calc:" precise)
(println "Double calc:" imprecise))
(println "\n--- Keywords vs Strings ---")
;; Сравнение ссылок (быстро) vs Сравнение контента (медленно)
(let [k1 :status
k2 :status
s1 (String. "status")
s2 (String. "status")]
(println "Keyword eq (identity):" (identical? k1 k2)) ;; true (один объект в памяти)
(println "String eq (identity):" (identical? s1 s2)) ;; false (разные объекты)
(println "String equals:" (= s1 s2))) ;; true (но это полный проход по массиву char)
(println "\n--- Boolean Logic Pitfalls ---")
(if (seq [])
(println "Non-empty seq is true")
(println "Empty seq converted to nil is false")) ;; Это сработает, т.к. (seq []) вернет nil
(when []
(println "Warning: Empty vector [] is logically TRUE!")))Итог по главе:
Мы разобрались с "атомами" языка. Ты увидел, что Clojure берет JVM типы (String, Long) для перфоманса, но добавляет свои (Ratio, Keyword, BigInt) для семантики и корректности. Также мы усвоили правило истинности: false и nil против всего мира.
def создает глобальную переменную (Var) внутри текущего пространства имен (Namespace).
Аналог в Java: public static Object name = value;.
- Var (Переменная): Это контейнер, который хранит ссылку на значение.
- Root Binding: Начальное значение, которое видят все потоки.
- Динамичность: Vars можно переопределять на лету (полезно для разработки), но в продакшн-коде они должны быть стабильными.
Антипаттерн: Никогда не используйте
defвнутри функций для создания локальных переменных. Это создаст глобальную переменную, что приведет к проблемам с потокобезопасностью и грязному коду.
;; Создаем Var с именем 'server-port' и значением 8080
(def server-port 8080)
;; def возвращает сам объект Var (не значение!)
;; -> #'my-app.core/server-portЕсли вы знаете, что значение примитивное и никогда не изменится, можно использовать метаданные ^:const.
Аналог в Java: public static final int MAX_RETRIES = 5;.
Это подсказка компилятору заинлайнить значение. Если вы измените константу, нужно перекомпилировать весь код, который её использует.
(def ^:const max-retries 5)let — это фундамент Clojure. 90% переменных создаются здесь.
Он создает лексическую область видимости. Имена существуют только внутри скобок let.
Особенности:
- Вектор привязки: Пары
[имя значение]. - Последовательность: Правые части вычисляются по порядку. Можно использовать предыдущие переменные в последующих.
- Затенение (Shadowing): Можно переопределять имена из внешней области видимости.
- Неизменяемость: Внутри
letвы не можете сделатьx = x + 1. Вы можете только создать новыйxво вложенномletили через рекурсию.
(defn calculate-price [base-price tax-rate]
;; Начало области видимости let
(let [discount 10
;; Используем base-price (аргумент) и discount (предыдущая привязка)
price-after-discount (- base-price discount)
;; Вычисляем налог
tax (* price-after-discount tax-rate)
;; Финальная цена
final-price (+ price-after-discount tax)]
;; Возвращаем результат (последнее выражение)
final-price)
;; Конец области видимости. discount, tax и т.д. здесь больше не существуют.
)Функции в Clojure — это первоклассные объекты (First-class citizens). defn — это просто макрос, который делает (def name (fn ...)).
defn: Публичная функция (видна из других namespace). Аналогpublic.defn-: Приватная функция (видна только в текущем ns). Аналогprivate.
Мульти-арность (Multi-arity): В Java есть перегрузка методов по типам аргументов. В Clojure типов в сигнатуре нет, но есть перегрузка по количеству аргументов (арности).
Вариативные функции (Variadic):
Символ & собирает все оставшиеся аргументы в список (sequence).
;; Простая приватная функция
(defn- validate-user [user]
(boolean (:id user)))
;; Функция с несколькими арностями
(defn greet
;; Арность 1: принимает только имя
([name]
(str "Hello, " name))
;; Арность 2: принимает имя и язык
([name lang]
(case lang
:ru (str "Привет, " name)
:en (str "Hello, " name)))
;; Арность 0: вызывает другую арность (значения по умолчанию)
([]
(greet "Guest")))
;; Вариативная функция
;; & args превращает хвост аргументов в последовательность
(defn log-data [level & args]
(println "Level:" level "Data:" args))
;; (log-data :info "User" "Login" 123)
;; args будет равен ("User" "Login" 123)Используются для передачи в функции высшего порядка (map, filter).
- Полная форма
(fn [args] ...): Позволяет использовать деструктуризацию, иметь несколько арностей и имя (для рекурсии). - Литерал
#(...): Синтаксический сахар для коротких функций.%или%1— первый аргумент.%2— второй аргумент.%&— остальные аргументы.- Ограничение: Нельзя вкладывать
#()в#().
;; Полная форма
(filter (fn [x] (> x 10)) [5 10 15])
;; Короткая форма
(filter #(> % 10) [5 10 15])
;; Функция сложения 2х чисел
#(+ %1 %2)Этот код демонстрирует затенение имен и взаимодействие областей видимости.
(ns my-app.scope-demo)
;; 1. Глобальный Var
(def x 100)
(defn scope-test
"Демонстрация затенения переменных (Shadowing)"
[x] ;; x здесь - это аргумент функции (локальный), он затеняет глобальный x
(println "1. Argument x:" x) ;; Выведет аргумент
(let [x (+ x 1) ;; Создаем НОВЫЙ локальный x, равный (аргумент + 1)
y 50]
(println "2. Let x:" x) ;; Выведет (аргумент + 1)
(println "3. Global x:" my-app.scope-demo/x) ;; Доступ к глобальному по полному имени
(let [x 1000] ;; Еще один вложенный let, затеняющий предыдущий x
(println "4. Inner Let x:" x)) ;; 1000
(println "5. Back to outer Let x:" x)) ;; Снова (аргумент + 1), т.к. мы вышли из внутреннего let
(println "6. Back to argument x:" x)) ;; Снова аргумент
(comment
(scope-test 10)
;; Output:
;; 1. Argument x: 10
;; 2. Let x: 11
;; 3. Global x: 100
;; 4. Inner Let x: 1000
;; 5. Back to outer Let x: 11
;; 6. Back to argument x: 10
)Итог по главе:
Вы научились создавать глобальные имена (def) и управлять локальным контекстом (let). Вы поняли, что переменные не меняются — мы просто создаем новые области видимости с новыми значениями. Также разобрали мощь функций: от перегрузки по количеству аргументов до коротких лямбд.
Суть проста: вы создаете "шаблон" (pattern), который повторяет структуру ваших данных, и Clojure заполняет переменные в этом шаблоне значениями из данных.
Работает для Векторов, Списков, Sequence и даже Java Arrays / Iterables. Любая вещь, которую можно обойти по порядку.
Синтаксис: [pattern] data
- Позиционная привязка: Переменные связываются по индексу.
- Пропуск значений (
_): Идиома для игнорирования ненужных элементов (чтобы линтер не ругался на неиспользуемые переменные). - Сбор хвоста (
&): Аналог VarArgs в Java. Собирает все оставшиеся элементы в последовательность (sequence). - Сохранение оригинала (
:as): Позволяет дать имя всей коллекции целиком.
(let [my-vector [1 2 3 4 5]]
;; 1. Простая позиционная
(let [[a b c] my-vector]
;; a=1, b=2, c=3. Остальные (4, 5) игнорируются.
(+ a b c))
;; 2. Сбор хвоста и пропуск
(let [[head _ third & tail] my-vector]
;; head -> 1
;; _ -> 2 (пропущено, переменная _ часто переиспользуется)
;; third -> 3
;; tail -> (4 5) - это Sequence
)
;; 3. Вложенность и :as
;; Допустим, у нас вектор векторов: [[1 2] [3 4]]
(let [[[x1 y1] [x2 y2] :as all-points] [[1 2] [3 4]]]
;; x1=1, y1=2, x2=3, y2=4
;; all-points=[[1 2] [3 4]]
))Работает для HashMaps, Records и любых объектов, поддерживающих интерфейс clojure.lang.ILookup.
Синтаксис: {pattern} data
Это самая мощная часть. В Java вы часто создаете DTO. В Clojure вы используете Map Destructuring.
- Явное сопоставление:
{local-name :key-in-map}. - Синтаксический сахар (
:keys): Если вы хотите назвать переменную так же, как ключ.{name :name}превращается в{:keys [name]}. - Значения по умолчанию (
:or): Спасает отNullPointerException. Если ключа нет, берется дефолт. - Типы ключей:
:keys(для keywords),:strs(для строк "key"),:syms(для символов 'key).
(def user-data {:id 101 :name "Alice" :age 30 :settings {:theme "dark"}})
(let [;; 1. Самый частый паттерн (:keys)
;; Создает переменные name и age из ключей :name и :age
{:keys [name age role]
:or {role :guest} ;; Если :role нет в мапе, role будет :guest
:as full-user} ;; Ссылка на исходный map
user-data]
(println "User:" name "Role:" role)
(println "Full map:" full-user))
;; 2. Явное переименование (реже используется)
;; Хотим переменную user-id из ключа :id
(let [{user-id :id} user-data]
(println user-id)) ;; 101
;; 3. Строковые ключи (часто при парсинге JSON)
(let [json-data {"first_name" "Bob" "age" 25}
{:strs [first_name age]} json-data] ;; Ищет ключи "first_name" и "age"
(println first_name))Вы можете "разбирать" аргументы прямо в объявлении функции. Это делает сигнатуру функции самодокументируемой. Вместо того чтобы принимать абстрактный config, вы сразу показываете, что ожидаете :port и :host.
;; ПЛОХО: непонятно, что лежит в config
(defn connect-bad [config]
(let [host (:host config)
port (:port config)]
(str host ":" port)))
;; ХОРОШО: сигнатура говорит сама за себя
(defn connect-good [{:keys [host port] :or {port 80}}]
(str host ":" port))
;; Вызов одинаковый:
(connect-good {:host "localhost" :port 5432})Давай представим реальную задачу. Нам пришел JSON-ответ от API магазина со сложной структурой. Нам нужно достать имя первого товара, цену (с учетом скидки по умолчанию) и категорию.
(ns my-app.destructuring-demo)
;; Сложная структура данных (имитация JSON ответа)
(def api-response
{:status 200
:meta {:timestamp 167888888}
:data {:products [{:id 1 :title "Laptop" :price {:amount 1000 :currency "USD"}}
{:id 2 :title "Mouse" :price {:amount 50}}] ;; тут нет currency
:category "Electronics"}})
(defn process-response
"Демонстрация глубокой деструктуризации.
Мы разбираем вложенные мапы и вектора за один проход."
[response]
(let [;; Начинаем разбор
{;; 1. Достаем статус
http-status :status
;; 2. Лезем вглубь :data
{:keys [category products]} :data} response
;; 3. Теперь работаем с вектором products, который мы достали выше.
;; Берем первый элемент.
[first-product] products
;; 4. Разбираем первый продукт
{:keys [title price]} first-product
;; 5. Разбираем цену (вложенность в мапе)
;; Используем :or для дефолтного значения currency
{:keys [amount currency] :or {currency "EUR"}} price]
(println "--- Report ---")
(println "HTTP Status:" http-status)
(println "Category:" category)
(println "First Item:" title)
(println "Cost:" amount currency)))
;; Еще более мощный вариант: Деструктуризация прямо в векторе let (Nested Map Destructuring)
;; Это может выглядеть страшно, но это очень точно описывает форму данных.
(defn fast-extract [response]
(let [{:keys [status]
{:keys [category products]} :data} response
;; Извлекаем title и amount ПРЯМО из вектора продуктов внутри мапы
[{first-title :title
{amt :amount cur :currency :or {cur "RUB"}} :price}] products]
(println "Fast extract:" first-title amt cur)))
(comment
(process-response api-response)
;; Output:
;; --- Report ---
;; HTTP Status: 200
;; Category: Electronics
;; First Item: Laptop
;; Cost: 1000 USD
(fast-extract api-response)
;; Output:
;; Fast extract: Laptop 1000 USD
)Ключевые моменты для Senior Java Developer:
- Fail-safe: Деструктуризация в Clojure очень либеральна. Если вы попытаетесь деструктурировать
nil, Clojure просто присвоит переменнымnil, а не бросит NPE (если не используете примитивные тайп-хинты).(:keys [a] nil)->aбудетnil.
- Порядок: Деструктуризация происходит до выполнения кода тела
letили функции. - Производительность: Это компилируется в эффективный байт-код (серия вызовов
rt.get,nthи т.д.). Оверхед минимален.
Итог по главе: Деструктуризация позволяет писать код, ориентированный на форму данных. Вместо императивного "достань это, проверь на null, потом достань то", вы декларативно описываете "я ожидаю данные вот такой формы, дай мне эти кусочки".
Помните главное отличие от Java: В Clojure всё является выражением (expression).
Здесь нет "statements" (инструкций). if возвращает значение. let возвращает значение. Даже try/catch возвращает значение. Аналога void методов (которые ничего не возвращают) идеологически нет (они возвращают nil).
if: Базовая форма. Принимает ровно 3 аргумента: тест, ветка-then, ветка-else.- Нюанс: Если вам нужно выполнить несколько действий в ветке, их нужно обернуть в
do.
- Нюанс: Если вам нужно выполнить несколько действий в ветке, их нужно обернуть в
when: Сахар для(if test (do ...)). У него нет веткиelse(возвращаетnil, если ложь). Используется для side-effects (логирование, запись в БД).cond: Замена вложенныхif ... else if ... else.case: Аналогswitch.- Важно для Senior:
caseделает constant-time dispatch (по хеш-коду или переходом). Он быстрееcond, но ключи должны быть константами времени компиляции (числа, строки, кейворды), и проверка идет только на строгое равенство.
- Важно для Senior:
(defn check-status [code]
;; 1. cond - линейный перебор условий
(cond
(= code 200) :ok
(= code 404) :not-found
(> code 500) :server-error
:else :unknown)) ;; :else - это просто keyword, который всегда true
(defn fast-check [code]
;; 2. case - константное время (Jump Table)
(case code
200 :ok
404 :not-found
500 :error
:unknown-value)) ;; Дефолтное значение (без флага :else)Используется, когда первый аргумент функции является "субъектом" действия. Чаще всего это структуры данных (Maps, Records), строки или Java-объекты.
Макрос берет значение и подставляет его первым аргументом в следующую функцию.
Аналогия с Java: person.setAge(10).setName("Bob").save()
;; Без макроса (Inside-out hell)
(save-user (assoc (update person :age inc) :name "Bob"))
;; С макросом (Linear flow)
(-> person ;; Берем person
(update :age inc) ;; Становится (update person :age inc) -> возвращает new-person
(assoc :name "Bob") ;; Становится (assoc new-person :name "Bob")
save-user) ;; Становится (save-user resulting-person)Используется для последовательностей (Sequences).
Большинство функций для работы с коллекциями (map, filter, reduce) принимают коллекцию последним аргументом.
Макрос берет значение и подставляет его последним аргументом.
Аналогия с Java Streams: list.stream().filter(...).map(...).collect(...)
(->> (range 10) ;; (0 1 ... 9)
(filter even?) ;; (filter even? (range 10)) -> (0 2 4 6 8)
(map #(* % %)) ;; (map sq (0 2 4 6 8)) -> (0 4 16 36 64)
(reduce +)) ;; (reduce + (0 4 ...)) -> 120Это инструменты для реального продакшена, где мир не идеален.
as->: Когда в цепочке функций аргумент прыгает (то он первый, то последний). Позволяет дать имя промежуточному результату.some->(Null-safe pipe): Аналог?.в Kotlin/Groovy. Если на каком-то шаге вернулсяnil, цепочка прерывается и возвращаетсяnil. Спасает от NPE.cond->(Conditional builder): Применяет шаг, только если условие истинно. Идеально для построения запросов или конфигураций.
Представим задачу:
- Берем заказ.
- Если есть скидка — применяем.
- Если заказана доставка — добавляем стоимость.
- Вычисляем итоговую сумму.
- Форматируем чек.
(ns my-app.flow-demo
(:require [clojure.string :as str]))
(def order-data
{:id 101
:items [{:price 100} {:price 50}]
:discount-code "SALE10" ;; Может быть nil
:shipping? true})
(defn apply-discount [order]
(if (= (:discount-code order) "SALE10")
(update order :total #(* % 0.9)) ;; -10%
order))
(defn add-shipping [order cost]
(update order :total + cost))
(defn calculate-initial-total [order]
(let [sum (reduce + (map :price (:items order)))]
(assoc order :total sum)))
;; --- Основная логика ---
(defn process-order [order]
(-> order
;; 1. Сначала считаем сумму (обычный thread-first)
calculate-initial-total
;; 2. cond-> : Условное применение функций
;; "Если есть поле :discount-code, выполни apply-discount"
(cond->
(:discount-code order) apply-discount
;; "Если :shipping? true, выполни (add-shipping 20)"
(:shipping? order) (add-shipping 20))
;; 3. as-> : Смешанная позиция аргумента
;; Нам нужно вывести лог, но println возвращает nil, а нам нужен order дальше.
;; doto - идеален для side-effects, но для примера покажем as->
(as-> processed-order ;; имя переменной
(doto processed-order (println "Debug Total:" (:total processed-order))))
;; 4. Финальное извлечение
:total))
;; --- Пример Null-Safety (some->) ---
(defn get-street-name-safe [user]
;; В Java: user != null ? user.getAddress() != null ? user.getAddress().getStreet() : null : null
(some-> user
:address ;; (:address user) -> если nil, стоп.
:street ;; (:street address) -> если nil, стоп.
str/upper-case)) ;; (str/upper-case street)
(comment
(process-order order-data)
;; Output:
;; Debug Total: 155.0
;; -> 155.0 ((100+50)*0.9 + 20)
(get-street-name-safe {:address {:street "main"}}) ;; -> "MAIN"
(get-street-name-safe {}) ;; -> nil (без исключения)
(get-street-name-safe nil) ;; -> nil
)Ключевые выводы для Senior Dev:
- Читаемость: Threading macros превращают вложенный код в "конвейер" (pipeline).
cond->— это убийца паттерна "Builder" и мутабельного накопления состояния (if (x) res.add(y)). Вы просто протаскиваете структуру через серию условных трансформаций.- Иммутабельность: На каждом шаге (
update,assoc) создается новая версия структуры, которая передается дальше. Исходныйorderне меняется.
Итог по главе:
Вы получили инструменты для управления логикой программы. Вы знаете, как ветвить код (if, cond, case) и как выстраивать красивые цепочки обработки данных (->, ->>, cond->), избегая "скобочного ада".
Это переломный момент для Java-разработчика. В Java циклы — это изменение мутабельной переменной-счетчика (i++). В Clojure переменные неизменяемы, поэтому классических циклов for/while в понимании Java здесь нет.
Вместо этого мы используем:
- Функции высшего порядка (
map,reduce) — для обработки данных (рассмотрим в следующей части). - Рекурсию — для алгоритмов.
- Специальные макросы — для генерации списков или сайд-эффектов.
Если вам нужно просто "пробежаться" по коллекции и что-то сделать (напечатать в лог, записать в БД, отправить сообщение), вы используете эти макросы.
Они всегда возвращают nil.
dotimes: Аналогfor (int i=0; i < n; i++).doseq: Аналогfor (Object obj : collection). Но мощнее: поддерживает деструктуризацию и вложенные циклы.
;; Простой счетчик
(dotimes [i 3]
(println "Iteration:" i))
;; 0, 1, 2
;; Итерация по коллекции (аналог Java foreach)
(let [users [{:name "Alice" :admin true}
{:name "Bob" :admin false}]]
;; Деструктуризация работает прямо в векторе привязки doseq!
(doseq [{:keys [name admin]} users]
(when admin
(println "Admin found:" name))))
;; Вложенные циклы (Декартово произведение)
;; В Java это было бы два вложенных for { for { ... } }
(doseq [x [:a :b]
y [1 2]]
(println x y))
;; :a 1, :a 2, :b 1, :b 2Внимание: Название for обманчиво! Это не цикл. Это генератор ленивой последовательности (как List Comprehension в Python или Stream.map в Java).
Он возвращает новые данные. Если вы не используете результат for, он (из-за ленивости) может вообще не выполниться.
Синтаксис: (for [bindings & modifiers] expr)
Модификаторы: :let, :when (фильтр), :while (выход).
;; Генерация шахматной доски
(def board
(for [file "ABCDEFGH" ;; Внешний "цикл"
rank (range 1 9) ;; Внутренний "цикл"
:let [square (str file rank)] ;; Локальная переменная
:when (not= square "A1")] ;; Фильтр (пропускаем A1)
;; Возвращаемое значение
(keyword square)))
;; Результат: (:A2 :A3 ... :H8)В JVM нет Tail Call Optimization (TCO). Это значит, что если функция вызывает сама себя в конце, стек вызовов всё равно растет. В Java глубокая рекурсия (10 000+) приведет к StackOverflowError.
Clojure обходит это ограничение через специальный оператор recur.
loop: Создает точку входа (лейбл) и инициализирует переменные.recur: "Прыгает" обратно наloop(или на начало функции), обновляя значения переменных.- Это не вызов функции! Это
GOTOна уровне байт-кода. Стек не растет. - Правило:
recurдолжен быть строго в хвостовой позиции (последним вычисляемым выражением). Компилятор выдаст ошибку, если это не так.
- Это не вызов функции! Это
Анатомия loop:
Это замена циклу while (condition) { ... x = newValue }.
(defn find-needle [needle haystack]
;; loop определяет начальные значения "переменных цикла"
;; i = 0
;; current-items = haystack
(loop [i 0
current-items haystack]
;; Условие выхода (аналог условия в while)
(if (empty? current-items)
nil ;; Не нашли
(let [item (first current-items)]
(if (= item needle)
i ;; Нашли! Возвращаем индекс (и выходим из цикла)
;; Иначе - идем на следующий круг
;; i станет (inc i)
;; current-items станет (rest current-items)
(recur (inc i) (rest current-items)))))))while в Clojure используется редко, потому что он требует мутабельного состояния (sike-effects) или исключения для выхода. Если условие всегда true и внутри ничего не меняется, вы получите вечный цикл.
Как Senior разработчику, вам часто нужно делать повторные запросы к ненадежному API. В Java вы бы написали while с счетчиком и Thread.sleep.
Напишем идиоматичный вариант на Clojure с использованием loop/recur.
(ns my-app.retry-demo)
(defn unstable-api-call
"Имитация сбойного API.
Успешно выполняется только если случайное число > 0.8"
[]
(if (> (rand) 0.8)
"Success Data"
(throw (ex-info "Network Error" {}))))
(defn execute-with-retry
"Выполняет функцию f до max-retries раз."
[max-retries f]
;; Инициализируем цикл: attempt = 1
(loop [attempt 1]
(println "Attempt:" attempt)
;; Пробуем выполнить
(let [result (try
{:value (f)} ;; Оборачиваем успех
(catch Exception e
{:error e}))] ;; Оборачиваем ошибку
(cond
;; 1. Если успех - возвращаем значение
(:value result)
(:value result)
;; 2. Если ошибка, но попытки есть - рекурсия
(and (:error result) (< attempt max-retries))
(do
(println "Failed. Retrying...")
(Thread/sleep 500) ;; Небольшая задержка
;; Прыгаем в начало loop, attempt становится (inc attempt)
(recur (inc attempt)))
;; 3. Попытки кончились - пробрасываем ошибку
:else
(throw (ex-info "All retries failed"
{:attempts attempt}
(:error result)))))))
(comment
(execute-with-retry 5 unstable-api-call)
;; Output может быть:
;; Attempt: 1
;; Failed. Retrying...
;; Attempt: 2
;; -> "Success Data"
)Ключевые выводы для Senior Dev:
for!= цикл. Используйтеforдля генерации/преобразования данных (как SQL SELECT), аdoseq— для действий (как SQL UPDATE/INSERT).recur— это оптимизация. Это единственный способ сделать "бесконечный" цикл в Clojure без переполнения стека.- Неизменяемость в циклах: Обратите внимание, что в
loopмы не меняем переменнуюattempt. Мы передаем новое значение(inc attempt)в следующую итерацию. Это принципиально.
Итог по главе:
Вы научились думать о циклах не как о процессе изменения переменной на месте, а как о передаче обновленного состояния в следующий шаг вычислений. Это база для понимания reduce и всей функциональной парадигмы.
Clojure-коллекции — это деревья (Bit-mapped Vector Tries для векторов и Hash Array Mapped Tries для мап).
Когда вы "изменяете" вектор (добавляете элемент), Clojure не копирует весь массив.
- Создается новый узел для нового элемента.
- Создается копия пути (path copying) от корня к этому узлу.
- Все остальные ветки дерева (99.9% данных) просто ссылаются на узлы старой структуры.
-
Сложность: Операции занимают
$O(\log_{32} N)$ . Поскольку логарифм по основанию 32 растет очень медленно (для 1 млрд элементов глубина дерева < 7), на практике это считается Effective Constant Time —$O(1)$ . - Потокобезопасность: Поскольку данные неизменяемы, чтение из коллекции всегда потокобезопасно без блокировок.
Все они реализуют интерфейсы java.util.Collection (или List, Map, Set), поэтому их можно передавать в Java-код, который ожидает Read-Only коллекции.
| Тип | Литерал | Java аналог | Особенности |
|---|---|---|---|
| List | '(1 2 3) |
LinkedList |
Односвязный список. Быстрый доступ к голове. Идеален для стека или AST (код — это списки). |
| Vector | [1 2 3] |
ArrayList |
Индексированный массив (дерево). Быстрый доступ по индексу и добавление в хвост. |
| Map | {:a 1} |
HashMap |
Неупорядоченная (обычно) карта. Ключи и значения — любые объекты. |
| Set | #{1 2} |
HashSet |
Множество уникальных значений. |
Clojure использует полиморфизм. Одни и те же функции работают для разных коллекций, но с нюансами.
conj (сокращение от conjoin) добавляет элемент в наиболее эффективную позицию для данной структуры.
- List: Добавляет в начало (сдвиг ссылок дешев).
- Vector: Добавляет в конец.
;; List: добавляет в голову
(conj '(1 2) 3) ;; -> (3 1 2)
;; Vector: добавляет в хвост
(conj [1 2] 3) ;; -> [1 2 3]
;; Map: добавляет пару (или объединяет мапы)
(conj {:a 1} [:b 2]) ;; -> {:a 1 :b 2}(def v [:a :b :c])
(def m {:name "Alice" :age 30})
;; По индексу/ключу (Null-safe)
(get v 1) ;; -> :b
(get v 100) ;; -> nil (в Java был бы IndexOutOfBoundsException)
(get v 100 :default) ;; -> :default
;; Ключевые слова как функции (для Map)
(:name m) ;; -> "Alice"
;; Вложенный доступ (Deep get)
(def deep-data {:users [{:name "Bob"}]})
(get-in deep-data [:users 0 :name]) ;; -> "Bob"
;; Аналог Java: data.getUsers().get(0).getName()Помните: это возвращает новую коллекцию.
assoc(associate): Заменяет значение (или добавляет новое).update: Применяет функцию к текущему значению. Это киллер-фича.dissoc: Удаляет ключ (для Maps/Sets).
(def p {:name "Bob" :score 10})
;; assoc: просто ставим значение
(assoc p :score 50) ;; -> {:name "Bob", :score 50}
;; update: берем текущее, прибавляем 5
(update p :score + 5) ;; -> {:name "Bob", :score 15}
;; Эквивалент: (assoc p :score (+ (:score p) 5))
;; Вложенная модификация (Deep update)
;; Изменить имя первого пользователя в глубине структуры
(update-in deep-data [:users 0 :name] str/upper-case)Давайте реализуем логику корзины покупок, используя всю мощь update и векторов.
(ns my-app.collections-demo)
;; Начальное состояние корзины (Map)
(def initial-cart
{:items [{:id 1 :name "Book" :price 10 :qty 1}]
:coupons #{"WELCOME10"} ;; Set для уникальности
:meta {:created-at 12345}})
(defn add-item
"Добавляет товар или увеличивает количество, если он уже есть."
[cart item]
(let [existing-idx (first
(keep-indexed
(fn [idx it] (when (= (:id it) (:id item)) idx))
(:items cart)))]
(if existing-idx
;; Товар уже есть: обновляем количество ВНУТРИ вектора
;; update-in идет по пути: [:items индекс :qty] и применяет inc
(update-in cart [:items existing-idx :qty] inc)
;; Товара нет: добавляем в вектор items (update + conj)
(update cart :items conj (assoc item :qty 1)))))
(defn apply-coupon [cart code]
;; update + conj для Set (гарантия уникальности купона)
(update cart :coupons conj code))
(defn remove-item [cart item-id]
;; Модификация вектора через фильтрацию (создает новый вектор)
(update cart :items
(fn [items]
(filterv #(not= (:id %) item-id) items))))
(comment
(let [cart (-> initial-cart
(add-item {:id 2 :name "Pen" :price 2}) ;; Добавили ручку
(add-item {:id 1 :name "Book" :price 10}) ;; Увеличили кол-во книг
(apply-coupon "SALE2025") ;; Добавили купон
(apply-coupon "WELCOME10"))] ;; Дубликат купона (игнорируется Set-ом)
(println "Cart Items:" (:items cart))
(println "Coupons:" (:coupons cart))
;; Демонстрация Structural Sharing:
;; Метаданные не менялись, поэтому ссылка может указывать на тот же объект в памяти
(println "Same meta object?" (identical? (:meta cart) (:meta initial-cart)))))Как Java-разработчик, вы должны понимать, когда что использовать.
-
Vector (
[]): Дефолтный выбор для 95% случаев.-
$O(1)$ доступ по индексу. -
$O(1)$ добавление в конец. - Занимает чуть больше памяти, чем список.
-
-
List (
()): Используйте, если алгоритм работает как Стек (LIFO) или вы генерируете код (макросы).-
$O(1)$ добавление в начало. -
$O(N)$ доступ по индексу (нужно пройти весь список).
-
Важно: Clojure
Vectorреализуетjava.util.RandomAccess, поэтому алгоритмы JDK работают с ним быстро.
Итог по главе:
Вы узнали про Persistent Data Structures. Мы больше не "меняем" поля объекта (p.setScore(10)). Мы создаем "версию мира", в которой значение изменилось (update p :score inc), при этом старая версия мира остается доступной и валидной.
Ленивая последовательность — это объект, который знает, как получить следующий элемент, но не делает этого, пока его не попросят.
В Clojure почти все функции работы с последовательностями (map, filter, remove, take, drop) возвращают ленивые результаты.
;; range возвращает ленивую последовательность чисел
(def all-numbers (range))
;; (range) - это бесконечность.
;; Если попытаться распечатать all-numbers в REPL, он зависнет (попытается вычислить всё).
;; Но само определение (def ...) мгновенно.
;; Вычисление происходит только здесь:
(take 10 all-numbers) ;; -> (0 1 2 ... 9)В отличие от Java, где бесконечные циклы опасны, в Clojure бесконечные структуры данных — норма.
range: Числовые ряды.repeat: Повторение одного значения.(repeat "A")->("A" "A" ...).cycle: Зацикливание коллекции.(cycle [1 2])->(1 2 1 2 ...).iterate: АналогStream.iterate(seed, f).f(x),f(f(x))...
;; Powers of 2 (Степени двойки)
(def powers-of-two (iterate #(* 2 %) 1))
;; (take 5 powers-of-two) -> (1 2 4 8 16)
;; Round-robin балансировщик (абстракция)
(def servers ["srv1" "srv2" "srv3"])
(def rr-strategy (cycle servers))
;; (take 5 rr-strategy) -> ("srv1" "srv2" "srv3" "srv1" "srv2")Под капотом это работает через макрос lazy-seq, который оборачивает тело выражения в замыкание (thunk). Когда кто-то вызывает seq (пытается прочитать голову списка), замыкание выполняется.
(defn my-infinite-range [n]
;; lazy-seq откладывает выполнение тела
(lazy-seq
(cons n (my-infinite-range (inc n)))))
;; Это не вызовет StackOverflow, хотя выглядит как рекурсия без условия выхода.
(take 5 (my-infinite-range 0)) ;; -> (0 1 2 3 4)Это главный источник утечек памяти в Clojure.
Так как ленивая последовательность запоминает (кэширует) вычисленные значения, если вы держите ссылку на ПЕРВЫЙ элемент (Head), GC не может собрать ни один из вычисленных элементов.
Пример OOM:
(defn bad-practice []
;; Ссылка 'nums' указывает на начало последовательности
(let [nums (range 1e8)]
;; Мы пробегаем по всей последовательности, вычисляя её.
;; Так как 'nums' видна ниже (в println), компилятор держит ссылку на начало.
;; Вся последовательность в 100 млн Long оседает в Heap.
(println "Count:" (count nums))
;; Использование nums снова
(println "First:" (first nums))))Правильный подход (Don't hold the head):
(defn good-practice []
;; Мы не сохраняем ссылку на корень (range 1e8) в переменную.
;; count потребляет последовательность и сразу "забывает" пройденные элементы.
;; GC собирает хвосты прямо во время работы count.
(println "Count:" (count (range 1e8)))
;; Здесь мы создаем НОВУЮ последовательность.
(println "First:" (first (range 1e8))))Иногда ленивость мешает. Например, если внутри map есть сайд-эффекты (запись в БД, лог). Ленивый map просто вернет объекты, но код внутри не выполнится, пока результат не потребят.
doall: Пробегает по всей последовательности (форсирует), удерживает результаты в памяти и возвращает их.- Use case: Вы загрузили список ID, сделали
mapс запросом в БД и хотите вернуть результаты на фронтенд.
- Use case: Вы загрузили список ID, сделали
dorun: Пробегает по всей последовательности, не удерживает результаты и возвращаетnil.- Use case: Вам нужно выполнить действия (отправить 1000 писем), но результаты самих функций вам не нужны, и вы не хотите забить память.
;; ОПАСНО: Ничего не напечатается!
(map println (range 5))
;; Возвращает lazy sequence, но REPL может её напечатать, тогда код выполнится.
;; В скрипте это будет "тишина".
;; ПРАВИЛЬНО для Side-effects:
(doseq [x (range 5)] (println x)) ;; (предпочтительно)
;; ИЛИ форсирование:
(doall (map println (range 5)))Представим, что мы читаем огромный лог-файл (или поток событий). Нам нужно найти первые 5 ошибок и остановиться. В Java мы бы делали while и break. В Clojure мы строим конвейер.
(ns my-app.lazy-demo
(:require [clojure.string :as str]))
;; Эмуляция бесконечного потока строк лога
(defn random-log-stream []
(repeatedly (fn []
(let [r (rand)]
(cond
(> r 0.95) (str "ERROR: Something broke " (rand-int 1000))
(> r 0.8) "WARN: Careful"
:else "INFO: All good")))))
(defn analyze-logs []
(println "Starting analysis...")
(let [log-seq (random-log-stream) ;; Бесконечная ленивая seq
;; Строим pipeline обработки.
;; Важно: Никакой работы здесь еще не делается.
errors (->> log-seq
(map (fn [msg]
;; Имитация тяжелого парсинга
(Thread/sleep 10)
msg))
;; Фильтруем только ошибки
(filter #(str/starts-with? % "ERROR"))
;; Извлекаем ID ошибки (трансформация)
(map #(str/replace % "ERROR: " "")))]
(println "Pipeline defined. Fetching 5 errors...")
;; Вот здесь начинается pull-model.
;; take 5 тянет элементы из filter -> filter тянет из map -> map тянет из random-log-stream.
;; Как только набралось 5 штук, вычисления останавливаются.
;; Остаток бесконечного стрима никогда не будет создан.
(let [found (take 5 errors)]
(println "Found errors:" found))))
(comment
(analyze-logs)
;; Output:
;; Starting analysis...
;; Pipeline defined. Fetching 5 errors...
;; Found errors: ("Something broke 123" "Something broke 555" ...)
)Chunking (Пакетирование):
Важный нюанс: Для оптимизации производительности Clojure часто вычисляет ленивые последовательности пакетами (chunks) по 32 элемента.
Это значит, что если вы запросили (first my-lazy-seq), Clojure может реально выполнить код для первых 32 элементов. Если внутри есть тяжелые сайд-эффекты, имейте это в виду.
Итог по главе:
Ленивые последовательности позволяют описывать алгоритмы "в общем виде" (для всех чисел, для всех логов), а затем ограничивать объем работы только тем, что реально нужно (take, first). Главное правило безопасности: не держи ссылку на голову, если собираешься съесть слона целиком.
Рассмотрим классический пайплайн:
(->> (range 1000000)
(map inc) ;; Создает промежуточный список из 1 млн элементов
(filter odd?) ;; Проходит по нему, создает еще один промежуточный список
(reduce +)) ;; СворачиваетПроблемы:
- Память: Создаются временные коллекции ("мусор" для GC).
- Зацепление: Логика "что делать" (map/filter) смешана с тем "как обходить" (iterators).
Трансдьюсеры убирают промежуточные коллекции, выполняя композицию функций до того, как данные начнут поступать.
В Clojure функции map, filter, take, drop и др. имеют специальную арность (arity-1).
Если вызвать их без коллекции, они возвращают трансдьюсер.
(def xf-map (map inc)) ;; Это трансдьюсер
(def xf-filter (filter odd?)) ;; Это тоже трансдьюсерТрансдьюсеры комбинируются обычной композицией функций.
Внимание! Критическое отличие от математики: При использовании
compс трансдьюсерами порядок выполнения идет СЛЕВА НАПРАВО. Это контринтуитивно, так как обычно(comp f g)означаетf(g(x)). Но для трансдьюсеров этоdata -> f -> g -> output.
;; Определяем пайплайн обработки: +1, потом оставить нечетные, потом взять 10
(def xform
(comp
(map inc)
(filter odd?)
(take 10)))
;; xform — это просто функция. Она ничего не вычисляет.
;; Она ждет, пока её применят к процессу свертки (reducing process).Поскольку трансдьюсер не знает про данные, нам нужны инструменты, чтобы "запустить" его.
Аналог reduce, но с трансдьюсером. Не создает ленивых последовательностей. Максимально быстро.
;; (transduce xform f init coll)
;; xform - наш пайплайн
;; + - функция, которая соберет результат (reducing function)
;; 0 - начальное значение
;; data - источник
(transduce xform + 0 (range 100)) Стандартная функция into имеет перегрузку для трансдьюсеров.
;; Собрать результаты трансформации xform над вектором [1 2 3] в новый вектор
(into [] xform [1 2 3 4 5])Если нужна ленивость (например, источник бесконечен).
(sequence xform (range)) ;; Вернет ленивую последовательностьТрансдьюсеры можно вешать на каналы. Элементы будут трансформироваться при прохождении через канал (буфер).
(chan 1 xform) ;; Канал с буфером 1 и встроенной трансформациейНекоторые операции требуют памяти (например, "взять 5 штук" или "убрать дубликаты подряд"). Трансдьюсеры умеют хранить состояние внутри себя, оставаясь потокобезопасными (при правильном использовании, т.к. создаются заново при каждом запуске transduce).
take,dropdedupe(убирает последовательные дубликаты)partition-all,partition-by
Представим, что у нас есть поток транзакций. Нам нужно:
- Отфильтровать возвраты.
- Конвертировать валюту.
- Разбить на пачки (batching) для записи в БД.
- Сделать это эффективно, без лишнего мусора.
(ns my-app.transducers-demo)
(def transactions
[{:id 1 :amount 100 :currency "USD" :type :sale}
{:id 2 :amount 50 :currency "USD" :type :refund} ;; Это нужно убрать
{:id 3 :amount 200 :currency "EUR" :type :sale}
{:id 4 :amount 10 :currency "USD" :type :sale}])
;; --- 1. Описываем логику трансформации (Pure Logic) ---
;; Обрати внимание: мы не указываем источник данных.
(def conversion-rates {"USD" 1.0 "EUR" 1.1})
(def process-sales-xf
(comp
;; 1. Оставляем только продажи
(filter #(= (:type %) :sale))
;; 2. Обогащаем данными (конвертация в базовую валюту)
(map (fn [tx]
(let [rate (get conversion-rates (:currency tx) 1.0)]
(assoc tx :amount-base (* (:amount tx) rate)))))
;; 3. Извлекаем только финальную сумму
(map :amount-base)))
;; --- 2. Использование в разных контекстах ---
(defn usage-examples []
(println "--- 1. Eager calc (transduce) ---")
;; Считаем общую выручку. Память аллоцируется только под аккумулятор суммы.
(let [total (transduce process-sales-xf + 0 transactions)]
(println "Total Revenue:" total))
(println "\n--- 2. Build collection (into) ---")
;; Получаем вектор очищенных сумм
(let [amounts (into [] process-sales-xf transactions)]
(println "Clean amounts:" amounts))
(println "\n--- 3. Batching (Stateful Transducer) ---")
;; Добавим группировку пачками по 2 элемента
(let [batch-xf (comp process-sales-xf (partition-all 2))
batches (into [] batch-xf transactions)]
;; Результат будет вектором векторов: [[100.0 220.0] [10.0]]
(println "Batches for DB:" batches)))
(comment
(usage-examples)
;; Output:
;; --- 1. Eager calc (transduce) ---
;; Total Revenue: 330.0
;;
;; --- 2. Build collection (into) ---
;; Clean amounts: [100.0 220.0 10.0]
;; ...
)Ключевые выводы для Senior Dev:
- Performance: Трансдьюсеры в
transduceработают быстрее, чем->>, потому что->>вызываетseqна каждом шаге (boxing/unboxing), а трансдьюсеры работают напрямую с функцией редукции. - Reusability: Вы написали
process-sales-xfодин раз. Вы можете применить его к вектору в памяти, к ленивому файлу с диска, к каналу core.async или даже к Java Stream (через библиотеки адаптеры). - Порядок
comp: Запомните навсегда:(comp filter map)в трансдьюсерах работает слева направо (сначала фильтр, потом map). Это отличается от обычного вызова функций.
Итог по главе: Вы получили инструмент для построения эффективных конвейеров обработки данных, который отделяет суть операции от механики её выполнения.
Каждый файл Clojure начинается с макроса ns. Он определяет имя текущего пространства имен и декларирует все зависимости.
Важное отличие от Java import:
В Java вы импортируете классы. В Clojure вы загружаете и связываете другие неймспейсы.
Структура ns декларативна и поддерживает несколько секций:
:require— для загрузки других Clojure-файлов (библиотек).:import— для импорта Java-классов.:gen-class— (редко) для генерации Java-класса из этого файла (нужно для AOT-компиляции).
Есть три способа сослаться на чужой код.
А. Алиас (Alias) — :as (Рекомендуемый стандарт)
Аналог import java.util.List (но мы даем короткое имя всему пакету).
(:require [clojure.string :as str])
;; Использование: (str/upper-case "hello")Почему это хорошо: Читая код, вы всегда видите, откуда пришла функция. str/ явно говорит, что это строковая утилита.
Б. Прямой импорт функций — :refer
Аналог import static java.lang.Math.abs.
(:require [my-app.utils :refer [log-error calculate-tax]])
;; Использование: (log-error "Boom")Когда использовать: Только для очень часто используемых функций или DSL (например, в тестах is, deftest). В остальных случаях это засоряет текущий namespace.
В. Импорт всего — :refer :all (Anti-pattern)
Аналог import java.util.*.
Никогда не используйте это в продакшн-коде. Вы не знаете, какие имена придут из библиотеки, и можете случайно перекрыть (shadow) стандартные функции типа map или count.
Здесь всё просто. Вы указываете пакет и список классов.
(:import [java.util Date UUID ArrayList]
[java.io File InputStream])
;; Использование: (Date.) или (UUID/randomUUID)Это боль Java-разработчиков, приходящих в Clojure.
В Java: ClassA ссылается на ClassB, ClassB ссылается на ClassA. Компилятор счастлив.
В Clojure: Это запрещено. Namespace A не может загрузиться, пока не загружен B, а B ждет A.
Решение:
- Рефакторинг (Лучшее): Выделите общую логику в третий namespace
C.A -> C,B -> C. declare(Костыль): Объявите переменную заранее, но это работает только внутри одного файла.- Динамический резолв (Крайний случай): Использовать
resolveвнутри функции, чтобы разрешить зависимость в рантайме, а не при загрузке.
Представим файл сервиса, который работает с базой данных, делает HTTP-запросы и использует Java Date.
(ns my-app.users.service
"Документация к неймспейсу.
Описывает бизнес-логику работы с пользователями."
;; 1. REQUIRE: Подключаем Clojure-зависимости
(:require
;; Стандартные библиотеки с общепринятыми алиасами
[clojure.string :as str]
[clojure.set :as set]
;; Сторонние библиотеки (например, логирование)
[clojure.tools.logging :as log]
;; Наши внутренние модули
[my-app.db.core :as db]
[my-app.config :refer [app-config]]) ;; Импортируем только конфиг напрямую
;; 2. IMPORT: Подключаем Java-классы
(:import
[java.util Date UUID]
[java.time Instant]))
;; --- Тело файла ---
(defn create-user
"Создает пользователя, генерирует UUID и дату регистрации."
[user-data]
(log/info "Creating user:" (:email user-data)) ;; Использование алиаса log
(let [clean-email (str/trim (:email user-data)) ;; Использование алиаса str
new-id (UUID/randomUUID) ;; Java Interop
user-record (assoc user-data
:id new-id
:email clean-email
:created-at (Date.))]
;; Обращение к БД через алиас db
(db/insert! :users user-record)))
(defn get-admin-emails []
;; Пример использования clojure.set
(let [all-users (db/find-all :users)
admins (filter :is-admin all-users)]
(set/project admins [:email]))) ;; Оставляет только ключи :emailПриватные функции (defn-):
Функции, объявленные как defn-, не видны при :require из другого namespace. Это аналог private методов класса.
Ключевые слова :: (Auto-resolved keywords):
Если в этом файле написать ::status, оно развернется в :my-app.users.service/status. Это очень полезно для спецификаций и глобально уникальных ключей.
Итог по главе:
Организация кода в Clojure проще, чем в Java. Нет пакетов, модулей (в смысле Java 9), "public/protected/private class". Есть просто файлы (namespaces) и явные связи между ними через :require. Главное правило Senior-разработчика: Всегда используйте алиасы (:as) для читаемости.
Это база, которую нужно уложить в голове:
- Value (Значение): Неизменяемая константа.
42,{:name "Alice"},[1 2 3]. Оно не может "измениться", как число 42 не может стать 43. - Identity (Сущность/Имя): То, что имеет идентичность во времени. Например, "Мой банковский счет".
- State (Состояние): Значение Сущности в конкретный момент времени.
В Clojure: Мы используем Reference Types (ссылочные типы) чтобы представлять Сущности. Изменение состояния — это атомарная перестановка указателя с одного неизменяемого Значения на другое.
Рабочая лошадка (90% случаев). Используются для синхронного, независимого изменения одной сущности.
- Java аналог:
java.util.concurrent.atomic.AtomicReference. - Механизм: CAS (Compare-And-Swap). Если пока мы считали новое значение, кто-то другой обновил атом, наша функция перезапустится (spin loop).
;; Создание атома с начальным значением
(def active-users (atom 0))
;; Чтение значения (разыменование)
@active-users ;; -> 0 (или (deref active-users))
;; Изменение значения: swap!
;; Принимает функцию и аргументы.
;; Вычисляет: (inc @active-users)
;; Пытается записать. Если CAS не прошел, пробует снова.
(swap! active-users inc)
;; Сброс значения (установка без учета старого)
(reset! active-users 100)
;; Валидаторы (защита целостности)
(def account (atom 100 :validator #(>= % 0))) ;; Нельзя сделать отрицательным
;; (swap! account - 200) -> Бросит IllegalStateExceptionВажно для Senior Dev: Функция, передаваемая в
swap!, не должна иметь сайд-эффектов (например, запись в БД), потому что из-за CAS-ретраев она может выполниться несколько раз, прежде чем результат будет записан.
Используются для координированного изменения нескольких сущностей одновременно.
- Java аналог: Транзакция базы данных (ACID), но в памяти. Или сложная блокировка
synchronized(A) { synchronized(B) { ... } }, но без риска дедлоков. - Механизм: MVCC (Multi-Version Concurrency Control). Транзакция видит "снэпшот" мира на момент начала.
Основные операции работают только внутри блока dosync:
alter: Изменяет значение (какswap!у атомов).commute: Оптимистичное изменение (разрешает перестановку операций, меньше конфликтов).ref-set: Жесткая установка значения.ensure: Гарантирует, что значение не изменилось (как Read Lock), но не меняет его.
(def acct-a (ref 1000))
(def acct-b (ref 1000))
(defn transfer [from to amount]
;; Начало транзакции
(dosync
;; Если баланс from изменится другим потоком, транзакция перезапустится
(alter from - amount)
(alter to + amount)))Используются для асинхронных изменений. Вы "посылаете" функцию агенту, и он выполнит её когда-нибудь в своем потоке.
-
Java аналог: Actor model (упрощенная), Event Loop, Single Thread Executor.
-
Механизм: Очередь задач. Агенты последовательны (один агент выполняет одну задачу за раз), но сама отправка мгновенна.
-
send: Выполняет в пуле потоков (Fixed Thread Pool). Для CPU-bound задач. -
send-off: Выполняет в кэширующем пуле (Cached Thread Pool). Для IO-bound задач (запись в файл, сеть).
Киллер-фича: Если отправить действие агенту внутри STM транзакции (
dosync), оно отправится только если транзакция успешно закоммитится. Это идеально для сайд-эффектов транзакций!
Используются для Thread-Local состояния.
- По умолчанию Vars статичны (глобальны).
- Если пометить Var как
^:dynamic, его можно временно переопределять для текущего потока через макросbinding.
(def ^:dynamic *current-user* nil)
(defn print-user []
(println "User:" *current-user*))
(defn handle-request [user]
;; Переопределяем *current-user* ТОЛЬКО внутри этого блока и для этого потока
(binding [*current-user* user]
(print-user))) ;; -> User: Alice
(print-user) ;; -> User: nil (глобальное значение не изменилось)Соберем все вместе. У нас есть счета (Refs), глобальная статистика операций (Atom) и аудит-лог (Agent).
(ns my-app.bank-stm
(:require [clojure.pprint :refer [pprint]]))
;; --- State Definitions ---
;; 1. REFS: Состояние счетов. Нужна координация (списать у одного, добавить другому).
(def account-a (ref 1000 :validator pos?)) ;; Нельзя уйти в минус
(def account-b (ref 1000 :validator pos?))
;; 2. ATOM: Глобальный счетчик транзакций. Независимая метрика.
(def tx-counter (atom 0))
;; 3. AGENT: Лог операций (IO-bound). Асинхронно, не блокируем трансфер.
(def audit-log (agent []))
;; --- Helper Functions ---
(defn log-audit-entry [logs entry]
;; Имитация задержки записи на диск (100мс)
(Thread/sleep 100)
(conj logs (assoc entry :timestamp (System/currentTimeMillis))))
(defn error-handler [ag ex]
(println "Audit Log Failed:" (.getMessage ex)))
;; Устанавливаем обработчик ошибок для агента (иначе он "умрет" тихо)
(set-error-handler! audit-log error-handler)
;; --- Core Logic ---
(defn transfer! [from-ref to-ref amount]
(try
;; Запускаем STM транзакцию
(dosync
;; 1. Бизнес-логика (чистые функции над значениями)
(alter from-ref - amount)
(alter to-ref + amount)
;; 2. Сайд-эффект: Обновляем счетчик
;; Внимание: swap! внутри dosync безопасен, но может вызваться много раз при ретраях.
;; Однако, изменения атома видны сразу (он вне транзакции STM).
;; Обычно атомы не миксуют с dosync, но для счетчика попыток - ок.
(swap! tx-counter inc)
;; 3. Сайд-эффект: Отправка в лог
;; send-off отправится ТОЛЬКО после успешного commit транзакции.
(send-off audit-log log-audit-entry {:amount amount :status :success})
;; Возвращаем результат транзакции
:success)
(catch IllegalStateException e
;; Сработает валидатор (недостаточно средств)
(send-off audit-log log-audit-entry {:amount amount :status :failed-funds})
:error-funds)))
;; --- Simulation ---
(comment
;; Запускаем в разных потоках (future создает поток)
(future (transfer! account-a account-b 100))
(future (transfer! account-b account-a 50))
;; Ждем немного, пока агенты отработают
(Thread/sleep 500)
(println "Account A:" @account-a)
(println "Account B:" @account-b)
(println "Total Attempts:" @tx-counter)
;; Читаем лог агента
(pprint @audit-log)
;; Если агент упал с ошибкой, его нужно перезапустить:
;; (restart-agent audit-log [] :clear-actions true)
)- Deadlock-free: В
dosyncдедлоки невозможны. Система сама разруливает порядок блокировок или перезапускает транзакции. - Reads are Cheap: Чтение (
deref/@) всегда неблокирующее и очень быстрое. Читатели никогда не блокируют писателей. - Retry logic: Ваш код внутри
swap!иdosyncдолжен быть идемпотентным (pure), так как он может быть вызван несколько раз скрыто от вас при высокой конкуренции.
Это отдельная библиотека, не встроенная в ядро.
deps.edn: org.clojure/core.async {:mvn/version "1.6.673"}
Канал — это потокобезопасная очередь.
- Java аналог:
java.util.concurrent.BlockingQueue. - Отличие: Каналы могут быть "безразмерными" (unbuffered) для синхронизации, или иметь стратегии отбрасывания лишнего (Backpressure).
(require '[clojure.core.async :as a :refer [>! <! >!! <!! go chan buffer close! alts! timeout]])
;; 1. Unbuffered Channel (Rendezvous)
;; Писатель блокируется, пока Читатель не заберет сообщение.
(def c (chan))
;; 2. Buffered Channel (Fixed)
;; Блокируется, только когда буфер полон (10 элементов).
(def c-buf (chan 10))
;; 3. Dropping/Sliding Buffers (Backpressure strategies)
;; Никогда не блокируют Писателя!
(def c-drop (chan (a/dropping-buffer 10))) ;; Если полон, новые сообщения выкидываются.
(def c-slide (chan (a/sliding-buffer 10))) ;; Если полон, старые сообщения выкидываются.Это фундаментальное различие.
- Блокировка (
<!!,>!!): Блокирует реальный Java Thread. Нельзя использовать внутриgoблоков (иначе исчерпаете тред-пул). Используется на границах системы (в main, в HTTP хендлерах). - Парковка (
<!,>!): Работает только внутриgo. Не блокирует поток. Она "паркует" выполнение функции, освобождает поток для других задач и возобновляет выполнение, когда данные готовы.- Под капотом: Макрос
goпреобразует ваш код в конечный автомат (State Machine). Это аналогawaitв JS/C# или Virtual Threads, но на уровне библиотеки.
- Под капотом: Макрос
Создает "легковесный поток" (Go routine). Вы можете запустить 100 000 go блоков на 8 реальных ядрах.
(go
;; Этот код выполняется асинхронно в пуле core.async
(let [val (<! c)] ;; "Паркуемся" и ждем данные из канала c
(println "Got:" val)
(>! out-chan (inc val)))) ;; Кладем результатАналог select в Go или Selector в Java NIO. Позволяет ждать события из нескольких каналов одновременно. Побеждает тот, кто ответил первым.
;; Ждем либо сообщения из c1, либо из c2, либо таймаут
(let [[val port] (alts! [c1 c2 (timeout 1000)])]
(cond
(= port c1) (println "Received from C1:" val)
(= port c2) (println "Received from C2:" val)
:else (println "Timed out!")))Представим систему, которая:
- Принимает заказы.
- Обрабатывает их параллельно (воркеры).
- Имеет жесткий SLA (таймаут обработки).
- Собирает результаты.
(ns my-app.async-demo
(:require [clojure.core.async :as a :refer [>! <! >!! <!! go chan close! alts! timeout go-loop]]))
;; Канал входящих заказов (буфер 100)
(def orders-chan (chan 100))
;; Канал готовых результатов
(def results-chan (chan 100))
(defn process-order-heavy
"Имитация долгой работы."
[id]
;; ВНИМАНИЕ: Thread/sleep блокирует поток!
;; В реальном go-блоке лучше использовать (<! (timeout ms)).
;; Но для имитации CPU-bound работы оставим sleep,
;; предполагая, что пул потоков настроен верно.
(Thread/sleep (rand-int 3000))
(str "Order processed: " id))
(defn start-worker [worker-id]
(go-loop [] ;; Аналог while(true) внутри go-блока
;; Ждем заказ или сигнал остановки (если канал закроют, val будет nil)
(when-let [order-id (<! orders-chan)]
(println "Worker" worker-id "took order" order-id)
;; Паттерн: "Гонка" между работой и таймаутом
(let [work-ch (go (process-order-heavy order-id)) ;; Запускаем работу в под-процессе
timeout-ch (timeout 2000) ;; SLA 2 секунды
[result port] (alts! [work-ch timeout-ch])]
(if (= port timeout-ch)
(>! results-chan {:id order-id :status :timeout})
(>! results-chan {:id order-id :status :success :data result})))
;; Рекурсия без переполнения стека (переход в начало loop)
(recur))))
;; Запуск системы
(defn run-system []
;; 1. Запускаем 3 воркера
(dotimes [i 3] (start-worker i))
;; 2. Кидаем заказы (используем >!! так как мы в main thread)
(future
(dotimes [i 10]
(>!! orders-chan i)
(println "Submitted:" i)))
;; 3. Читаем результаты
(go-loop []
(when-let [res (<! results-chan)]
(println "RESULT:" res)
(recur))))
(comment
(run-system)
;; Закрыть канал, чтобы остановить воркеров (они получат nil и выйдут из when-let)
;; (close! orders-chan)
)- Callback Hell is gone: Код внутри
goвыглядит линейным (let [x (<! ch)] ...), хотя асинхронен. - Backpressure: Используйте буферизированные каналы. Если канал полон, продюсер "паркуется" (тормозит). Система сама балансирует нагрузку.
- Error Handling: Исключения внутри
goблока "проглатываются" (возвращают nil в канал), если их не ловить. Хорошая практика — возвращать из канала мапы{:result ...}или{:error ...}. pipeline: Вcore.asyncесть высокоуровневая функцияpipeline, которая автоматически распараллеливает обработку данных из канала в канал, сохраняя (или не сохраняя) порядок.
Итог по главе: Вы получили инструмент для построения сложных асинхронных систем (Event Driven Architecture) внутри одного процесса, используя те же примитивы, что и язык Go.
Это самый мощный и гибкий вид полиморфизма, которого нет в Java. В Java диспетчеризация идет по типу первого аргумента. В Clojure мультиметод позволяет выбирать реализацию на основе результата произвольной функции от аргументов (значения, типа, метаданных, комбинации полей).
defmulti: Объявляет имя метода и функцию диспетчеризации.defmethod: Предоставляет реализацию для конкретного значения, которое вернула функция диспетчеризации.
Представьте, что в Java вам нужно отправлять уведомления. Вы бы создали интерфейс NotificationSender и реализации EmailSender, SmsSender. А если логика зависит от содержимого сообщения? Придется писать if/else или Factory.
(ns my-app.polymorphism
(:require [clojure.string :as str]))
;; 1. Объявляем мультиметод.
;; Функция диспетчеризации принимает те же аргументы, что и сам метод.
;; Она возвращает "значение диспетчеризации" (обычно keyword).
(defmulti send-notification
(fn [user message]
;; Логика выбора реализации:
;; Если юзер админ и сообщение важное -> :urgent-admin
;; Иначе -> берем предпочтительный канал связи из профиля (:email, :sms)
(if (and (:admin? user) (:urgent? message))
:urgent-admin
(:preferred-channel user))))
;; 2. Реализация для Email
(defmethod send-notification :email
[user message]
(println "Sending EMAIL to" (:email user) ":" (:text message)))
;; 3. Реализация для SMS
(defmethod send-notification :sms
[user message]
(println "Sending SMS to" (:phone user) ":" (:text message)))
;; 4. Сложный случай (Админ + Срочно)
(defmethod send-notification :urgent-admin
[user message]
(println "ALARM! Calling admin" (:name user) "immediately! Text:" (:text message)))
;; 5. Дефолтная реализация (аналог default в switch)
(defmethod send-notification :default
[user message]
(println "Unknown channel for user" (:id user) ". Logging to file."))
;; --- Использование ---
(comment
(send-notification {:preferred-channel :email :email "a@b.com"} {:text "Hi"})
;; -> Sending EMAIL...
(send-notification {:admin? true :name "Boss"} {:urgent? true :text "Server Down"})
;; -> ALARM! Calling admin Boss...
)Ключевые слова в Clojure могут выстраиваться в иерархии (ad-hoc inheritance). Мультиметоды это понимают.
;; Говорим, что :github-login и :google-login являются потомками :oauth
(derive :github-login :oauth)
(derive :google-login :oauth)
(defmulti auth (fn [params] (:method params)))
;; Реализация для родителя
(defmethod auth :oauth [params]
(println "Performing OAuth generic logic..."))
;; Вызов с потомком сработает и вызовет родителя!
;; (auth {:method :github-login}) -> "Performing OAuth generic logic..."Если мультиметоды — это "тяжелая артиллерия" (медленнее, супер-гибко), то Протоколы — это аналог Java Interfaces, но лучше.
Они решают Expression Problem: вы можете расширить существующий тип (даже java.lang.String или nil) новой функциональностью, не владея исходным кодом типа.
- Производительность: Очень высокая (dispatch происходит по типу первого аргумента, использует JVM интерфейсы под капотом).
defprotocol: Определение контракта.extend-protocol/extend-type: Реализация.
Допустим, мы хотим, чтобы разные объекты умели превращаться в JSON-строку. В Java нам пришлось бы наследовать их от интерфейса Jsonable. Но мы не можем унаследовать java.util.Date от нашего интерфейса! В Clojure — можем.
;; 1. Определение протокола
(defprotocol JsonWrappable
(to-json [this] "Returns JSON string representation"))
;; 2. Расширение существующих Java типов
(extend-protocol JsonWrappable
;; Учим строки быть JSON
java.lang.String
(to-json [this] (str "\"" this "\""))
;; Учим числа
java.lang.Number
(to-json [this] (str this))
;; Учим NIL (Null-safety из коробки!)
nil
(to-json [_] "null")
;; Учим Java массивы (используя low-level функцию)
clojure.lang.IPersistentVector
(to-json [this]
(str "[" (str/join ", " (map to-json this)) "]")))
;; --- Использование ---
(comment
(to-json "Hello") ;; -> "Hello"
(to-json 123) ;; -> 123
(to-json nil) ;; -> null
(to-json ["a" 1]) ;; -> ["a", 1]
)Когда вам нужно создать свой тип данных, который:
- Имеет именованные поля (как класс).
- Реализует протоколы/интерфейсы.
- Ведет себя как Map (доступ по ключу, деструктуризация, работа с
assoc/update). ... вы используетеdefrecord.
;; Объявляем тип User с двумя полями
(defrecord User [id email])
;; Создание (конструкторы генерируются автоматически)
(def u (->User 1 "test@test.com"))
;; или через map->RecordName (принимает мапу, полезно для опциональных полей)
(def u2 (map->User {:id 2 :email "a@b.com" :extra "data"}))
;; Это Map!
(:email u) ;; -> "test@test.com"
(assoc u :email "new@test.com") ;; -> Возвращает новый User
;; Реализация протокола прямо в defrecord (самый быстрый вариант)
(defrecord Order [id amount]
JsonWrappable
(to-json [this] (str "{\"id\": " id ", \"amt\": " amount "}")))Важно для Senior Dev:
defrecordсоздает настоящий Java-класс под капотом. Доступ к полям внутри протоколов идет напрямую (какthis.id), минуя хеш-таблицу, что дает производительность на уровне POJO.
Макрос reify позволяет создать экземпляр анонимного класса, реализующего протокол или Java-интерфейс, во время выполнения. Это аналог new Interface() { ... } в Java.
Часто используется для создания моков в тестах или callback-ов для Java библиотек.
(defn get-mock-json-object []
;; Создаем объект "на лету"
(reify JsonWrappable
(to-json [this] "{\"mock\": true}")
;; Можно реализовать и стандартные Java интерфейсы
java.lang.Runnable
(run [this] (println "I am running!"))))| Инструмент | Java аналог | Диспетчеризация по... | Use Case |
|---|---|---|---|
| Обычная функция | Static method | Нет | 90% кода. Просто логика. |
| Multimethod | - | Значению, метаданным, чему угодно | Сложная бизнес-логика, плагины, конечно-автоматная логика. |
| Protocol | Interface | Типу первого аргумента | Полиморфизм типов данных. Расширение чужих классов. |
| Record | Class + Map | - | Доменные сущности (User, Order). |
| Reify | Anon Class | - | Адаптеры, коллбэки, моки. |
Итог по главе:
Вы узнали, как Clojure решает Expression Problem. Протоколы позволяют вам добавить метод toJSON классу java.util.Date, не перекомпилируя Java и не создавая класс-обертку. Мультиметоды позволяют строить логику ветвления любой сложности декларативно.
Суть: У вас есть значение, завернутое в "контекст" (Коллекция, Future, Result, Option). Вы хотите изменить значение, не трогая контекст.
В Java:
Optional.of(10).map(i -> i + 1); // -> Optional(11)В Clojure:
У нас нет единого интерфейса Functor в ядре, но функция map работает как функтор для последовательностей. Для других типов используются библиотеки (например, cats) или специфические функции (update для map, future-map для future и т.д.).
;; 1. Вектор как функтор
(map inc [1 2 3]) ;; -> (2 3 4)
;; 2. Карта (Map) как функтор (в значениях)
;; update изменяет значение по ключу, сохраняя структуру map
(update {:score 10} :score inc) ;; -> {:score 11}Суть: Вы хотите построить конвейер операций, где:
- Каждый шаг зависит от результата предыдущего.
- Контекст (ошибка, null, асинхронность) обрабатывается автоматически между шагами.
В Java это решает проблему if (x != null) { ... } else { return null; }.
В Clojure встроенный макрос some-> (thread-first null-safe) реализует поведение монады Maybe.
(defn get-user-id [username]
;; Имитация поиска: возвращает ID или nil
(if (= username "Alice") 101 nil))
(defn get-profile [user-id]
(if (= user-id 101) {:region "US"} nil))
(defn get-region-code [profile]
(:region profile))
;; Без монады (Java style null checks):
(let [uid (get-user-id "Bob")]
(if uid
(let [prof (get-profile uid)]
(if prof
(get-region-code prof)
nil))
nil))
;; С монадой (Clojure idiom):
;; Если любой шаг вернет nil, цепочка прервется и вернется nil.
(some-> "Bob"
get-user-id
get-profile
get-region-code)Часто нам нужно знать не просто nil, а почему произошла ошибка. В Java для этого кидают Exception, что ломает чистоту функций (referential transparency).
Давайте напишем свою простую монаду для обработки ошибок (Railway Oriented Programming).
(ns my-app.monads
(:require [clojure.string :as str]))
;; --- Определяем примитивы ---
(defn success [val] {:status :ok :result val})
(defn failure [msg] {:status :error :message msg})
;; Это наш "bind" или "flatMap"
(defn bind [wrapper f]
(if (= (:status wrapper) :ok)
;; Если успех - распаковываем значение и передаем в f
(f (:result wrapper))
;; Если ошибка - пробрасываем её дальше, не вызывая f
wrapper))
;; Макрос для удобного синтаксиса (аналог do-notation в Haskell или for-comprehension в Scala)
(defmacro m-let [bindings & body]
(let [[var-name expr & rest-bindings] bindings]
(if var-name
`(bind ~expr (fn [~var-name] (m-let ~rest-bindings ~@body)))
`(do ~@body))))
;; --- Бизнес-логика (Чистые функции) ---
(defn validate-name [name]
(if (str/blank? name)
(failure "Name cannot be empty")
(success name)))
(defn validate-age [age]
(if (or (nil? age) (< age 18))
(failure "Age must be 18+")
(success age)))
(defn save-user [name age]
;; Имитация сохранения
(if (= name "DatabaseError")
(failure "DB Connection failed")
(success {:id (rand-int 1000) :name name :age age})))
;; --- Использование ---
(defn register-user [raw-data]
;; m-let выглядит как императивный код, но это цепочка вложенных вызовов
(m-let [valid-name (validate-name (:name raw-data))
valid-age (validate-age (:age raw-data))
saved-user (save-user valid-name valid-age)]
;; Возвращаем финальный результат (обернутый в success)
(success saved-user)))
(comment
(register-user {:name "Alice" :age 20})
;; -> {:status :ok, :result {:id 123, :name "Alice", :age 20}}
(register-user {:name "" :age 20})
;; -> {:status :error, :message "Name cannot be empty"}
(register-user {:name "Alice" :age 10})
;; -> {:status :error, :message "Age must be 18+"}
)В реальной жизни контексты вкладываются. Например, Future<Option<User>> (Асинхронный результат, который может отсутствовать).
Работать с этим вручную больно: map внутри map внутри future.
В Clojure это часто решается через библиотеку core.async (каналы абстрагируют и асинхронность, и потоковость) или просто использованием исключений (try/catch внутри future), так как прагматика здесь побеждает чистоту типов.
- Не зацикливайтесь на типах: В Haskell монада — это тип. В Clojure монада — это форма поведения данных.
some->— ваш лучший друг: Это самая используемая "монада" в языке для защиты от NPE.- Railway Oriented Programming (ROP): Паттерн
Either(Success/Failure) очень популярен для бизнес-логики, чтобы избежатьtry-catchспагетти. - Макросы скрывают сложность: Как вы видели в
m-let, макросы позволяют писать линейный код для любой монадической логики.
В Clojure вызов Java-метода — это не магия, это прямая компиляция в байт-код invokevirtual или invokestatic.
| Java | Clojure | Примечание |
|---|---|---|
new Date() |
(java.util.Date.) |
Точка в конце = new. |
Math.abs(-5) |
(Math/abs -5) |
/ для статики. |
Math.PI |
Math/PI |
Статическое поле. |
x.toUpperCase() |
(.toUpperCase x) |
Точка в начале метода. |
person.name |
(.-name person) |
Доступ к публичному полю (редко нужно, обычно геттеры). |
-
doto(Builder Pattern): Используется для инициализации мутабельных Java-объектов.(doto (java.util.HashMap.) (.put "a" 1) (.put "b" 2)) ;; Возвращает САМ HashMap, а не результат .put
-
..(Chaining): АналогSystem.getProperties().get("os.name").(.. System getProperties (get "os.name"))
Clojure динамический язык. Компилятор не всегда знает, какого типа будет аргумент x в (.method x).
- Reflection (Рефлексия): Если тип неизвестен, Clojure генерирует код, который ищет метод по имени в рантайме (Reflection API). Это медленно.
- Type Hints: Вы можете подсказать компилятору тип, и он сгенерирует прямой, быстрый вызов.
Как обнаружить рефлексию:
(set! *warn-on-reflection* true) ;; Всегда включайте это в проде!Как исправить:
;; Медленно (Reflective call)
(defn len [s] (.length s))
;; Быстро (Direct call)
;; ^String перед аргументом - это тег типа
(defn len2 [^String s] (.length s))
;; Хинт возвращаемого значения (перед вектором аргументов)
(defn ^Double get-price [] ...)Полный аналог Java, плюс свои фишки.
try,catch,finally— стандартные блоки.throw— выброс Java исключения.ex-info(Clojure Exception): В Java вы часто создаете кастомные классы исключений (UserNotFoundException) только ради имени. В Clojure принято использовать стандартныйclojure.lang.ExceptionInfo, который переносит Map с данными.
(try
(throw (ex-info "Database Error" {:code 500 :table "users"}))
(catch clojure.lang.ExceptionInfo e
(println "Code:" (:code (ex-data e))))) ;; ex-data извлекает мапуВ Java вы пишете class ... implements .... В Clojure есть три уровня:
Самый частый сценарий. Аналог new Runnable() { ... }.
Создает инстанс анонимного класса, реализующего интерфейс(ы). Видит локальные переменные (замыкание).
(defn create-task [task-id]
(reify Runnable
(run [this]
(println "Running task" task-id)))) ;; task-id доступен из замыканияИспользуется, когда нужно переопределить методы конкретного класса (а не интерфейса) на лету. Медленнее reify.
;; Переопределяем метод InputStream
(proxy [java.io.InputStream] []
(read [] -1))Используется только для AOT (Ahead-of-Time) компиляции. Если вам нужно создать .class файл, который будет виден из Java-фреймворка (например, создать Servlet, JUnit тест или Main-класс для запуска через java -jar). Обычно прописывается в ns.
Иногда Java API требует примитивных массивов (byte[], int[]). Векторы Clojure здесь не подойдут.
make-array: Создать массив(make-array Integer/TYPE 10).into-array: Конвертировать коллекцию в массив.aget/aset: Доступ по индексу (быстро, но мутабельно).alength: Длина.
Напишем функцию, которая качает JSON с URL, используя java.net.http.HttpClient (Java 11+), обрабатывает потоки и использует ex-info.
(ns my-app.java-interop
(:import [java.net URI]
[java.net.http HttpClient HttpRequest HttpResponse$BodyHandlers]
[java.time Duration]))
;; Включаем предупреждения о рефлексии.
;; Если мы забудем тайп-хинт, компилятор напишет в консоль.
(set! *warn-on-reflection* true)
(defn build-client []
;; Используем Builder паттерн через цепочку вызовов (..)
(.. HttpClient newBuilder
(connectTimeout (Duration/ofSeconds 5))
build))
(defn fetch-url [^String url]
(let [client (build-client)
req (.. HttpRequest newBuilder
(uri (URI/create url))
(header "Accept" "application/json")
build)]
(try
(println "Sending request to" url)
;; Тайп-хинт для client, чтобы send был быстрым
;; В let хины ставятся перед именем переменной
(let [^HttpClient c client
resp (.send c req (HttpResponse$BodyHandlers/ofString))]
(if (= (.statusCode resp) 200)
(.body resp)
;; Выбрасываем исключение с данными
(throw (ex-info "Non-200 response"
{:status (.statusCode resp)
:url url}))))
(catch java.io.IOException e
;; Оборачиваем Java Exception в наш, добавляя контекст
(throw (ex-info "Network error" {:url url} e)))
(catch IllegalArgumentException e
(println "Invalid URL format:" (.getMessage e))))))
;; Пример реализации интерфейса Comparator для сортировки строк по длине
(def length-comparator
(reify java.util.Comparator
(compare [this o1 o2]
;; o1 и o2 приходят как Object, нужны касты или хины
(let [s1 ^String o1
s2 ^String o2]
(compare (.length s1) (.length s2))))))
(comment
(fetch-url "https://api.github.com/zen")
;; Использование Java Comparator в Clojure sort
(sort length-comparator ["Apple" "Pear" "Banana" "Kiwi"])
;; -> ("Pear" "Kiwi" "Apple" "Banana")
)*warn-on-reflection*обязателен: Без него Clojure-код может работать в 10-100 раз медленнее Java аналога в узких местах.reifyвместо анонимных классов: Это идиоматичный способ реализации интерфейсов "на месте".- Mutable Arrays: Если профайлер показывает узкое место в числодробилках — не бойтесь спускаться до Java Arrays (
long[]) и цикловareduce/loop. В этом сила Clojure: вы можете писать "как на C", не выходя из языка. try-with-resources->with-open: Макрос(with-open [r (Reader.)] ...)автоматически вызывает.close()даже при исключении.
Когда вы делаете (reduce conj [] (range 1000000)), Clojure создает 1 миллион промежуточных версий вектора. Благодаря Structural Sharing это быстро, но это всё еще миллион аллокаций узлов дерева и давление на GC.
Transient позволяет временно превратить персистентную коллекцию в мутабельную, выполнить серию изменений "на месте" (in-place) со скоростью Java ArrayList / HashMap, и "запечатать" её обратно в неизменяемую форму.
-
transient: Создание мутабельной версии из персистентной ($O(1)$). -
Мутация (
conj!,assoc!,dissoc!): Изменение структуры. Обратите внимание на!в конце. -
persistent!: Превращение обратно в неизменяемую ($O(1)$). После этого вызова transient-версия становится невалидной и использовать её нельзя.
Критически важно: Transient коллекции не потокобезопасны. Они предназначены только для локального использования внутри функции для построения результата.
В Java методы мутации (list.add(x)) обычно возвращают void или boolean.
В Clojure conj! возвращает новую ссылку на transient-коллекцию.
Почему? Потому что даже мутабельная структура может перерасти свой внутренний буфер (array resizing), и тогда старая ссылка станет невалидной, а данные переедут в новый объект.
Всегда используйте возвращаемое значение conj!, а не исходную переменную!
(defn build-vector-naive [n]
;; Медленно: создает N промежуточных векторов
(reduce conj [] (range n)))
(defn build-vector-fast [n]
;; Быстро: использует мутабельность внутри
(let [tv (transient [])] ;; 1. Создаем builder
;; 2. Наполняем (императивный стиль через loop/recur)
(loop [i 0
current-tv tv]
(if (< i n)
;; conj! возвращает ОБНОВЛЕННЫЙ transient, передаем его дальше
(recur (inc i) (conj! current-tv i))
;; 3. Запечатываем
(persistent! current-tv)))))
;; На самом деле, стандартная функция `into` уже делает это внутри!
;; (into [] (range 1000000)) работает через transients.Clojure позволяет прикрепить к любому объекту (коллекции, символу) дополнительную Map с данными — метаданные.
Это данные, ортогональные значению. Они не влияют на равенство (=).
with-meta: Создает объект с новыми метаданными.vary-meta: Обновляет существующие метаданные функцией (какupdate).meta: Чтение метаданных.^(Reader Macro): Синтаксический сахар для добавления метаданных при определении.
Использование:
- Type Hints:
^String x(мы это уже видели). - Compiler Flags:
^:const,^:dynamic. - Документация:
(defn ...)хранит docstring в метаданных Var-а. - Свои метки: Пометить данные как "грязные", "проверенные" или "секретные".
(def data [1 2 3])
(def data-with-source (with-meta data {:source "db" :timestamp 12345}))
;; Они равны по значению!
(= data data-with-source) ;; -> true
;; Но метаданные разные
(meta data) ;; -> nil
(meta data-with-source) ;; -> {:source "db" :timestamp 12345}
;; При модификации коллекции метаданные сохраняются
(meta (conj data-with-source 4)) ;; -> {:source "db" ...}Стандартная функция group-by использует transients. Давайте напишем свою версию, которая группирует данные и сразу считает количество в каждой группе, используя максимальную производительность.
(ns my-app.perf-demo)
(defn fast-frequencies-by
"Группирует элементы по ключу (f x) и считает их количество.
Аналог SQL: SELECT key, count(*) FROM items GROUP BY key"
[f coll]
;; 1. Создаем мутабельную мапу
(let [tm (transient {})]
;; 2. Бежим по коллекции (reduce - самый быстрый способ обхода)
(let [final-tm
(reduce
(fn [current-tm item]
(let [k (f item)
;; Получаем текущее значение из transient map (get работает как обычно)
old-count (get current-tm k 0)
new-count (inc old-count)]
;; Обновляем значение (assoc!)
;; Важно: возвращаем результат assoc! для следующего шага reduce
(assoc! current-tm k new-count)))
tm ;; Начальное значение аккумулятора (наш transient)
coll)] ;; Источник данных
;; 3. Финализируем результат
(persistent! final-tm))))
(comment
(def big-data (take 1000000 (cycle [:a :b :c :d :e])))
(time (frequencies big-data))
;; -> "Elapsed time: 150 msecs" (стандартная функция)
(time (fast-frequencies-by identity big-data))
;; -> "Elapsed time: 60 msecs" (наша оптимизированная transient версия)
;; Разница в 2-3 раза за счет отсутствия аллокаций промежуточных MapNode.
)- Transient = Local Mutable State. Используйте их только в узких местах ("горячие циклы"), когда профайлер показывает, что GC захлебывается от создания промежуточных коллекций.
- Safety: Никогда не возвращайте transient из функции и не передавайте его в другие потоки. Если кто-то вызовет
persistent!на нем дважды или попытается прочитать его в другом потоке — результат не определен (может упасть JVM). - Невидимость: Хороший тон — прятать transients внутри функции. Снаружи функция должна принимать immutable и возвращать immutable. Внутренняя "кухня" никого не волнует.
- Metadata: Это мощный инструмент для создания фреймворков. Вы можете вешать на данные метки
^:sensitive, и ваш логгер будет автоматически скрывать такие поля, проверяя метаданные перед печатью.
Спецификации (Specs) — это предикаты (функции, возвращающие true/false), которые можно комбинировать.
- Глобальный реестр: Спеки регистрируются в глобальном реестре через кейворды (namespaced keywords).
- Разделение: Спека отделена от данных. Вы можете применить спеку к любым данным.
- Генерация: Если вы описали, как данные выглядят, спека может генерировать примеры этих данных (для тестов).
Подключение: org.clojure/spec.alpha {:mvn/version "0.3.218"}
s/def: Регистрация спеки в реестре.s/valid?: Проверка (true/false).s/explain: Печать ошибки в человекочитаемом виде.s/and,s/or: Логическая композиция.
(require '[clojure.spec.alpha :as s])
;; 1. Простые предикаты
(s/def ::email (s/and string? #(re-matches #".+@.+\..+" %)))
(s/def ::age (s/and int? #(>= % 18)))
;; 2. Валидация
(s/valid? ::email "bad-email") ;; -> false
(s/explain ::age 10)
;; -> val: 10 fails spec: :user/age predicate: (>= % 18)В Java вы описываете класс с полями. В Clojure вы описываете, какие ключи должны (:req - required) или могут (:opt - optional) быть в мапе.
Важно: s/keys проверяет только наличие ключей. Валидация значений этих ключей происходит автоматически, если для ключа (keyword) зарегистрирована спека с таким же именем.
;; Регистрируем спеки для отдельных полей
(s/def :my.app/id uuid?)
(s/def :my.app/first-name string?)
(s/def :my.app/last-name string?)
;; Регистрируем спеку для сущности User
;; Используем ::unqualified-keys, если в мапе ключи простые (:id), а не ::id
(s/def ::user
(s/keys :req-un [:my.app/id :my.app/first-name] ;; Обязательные
:opt-un [:my.app/last-name])) ;; Опциональные
;; Пример валидной мапы
(def valid-user {:id (java.util.UUID/randomUUID)
:first-name "Alice"})Вы можете описать контракт функции:
:args— аргументы (как регулярное выражение последовательности).:ret— возвращаемое значение.:fn— связь между аргументами и результатом (например, "результат должен быть больше первого аргумента").
(defn divide [a b] (/ a b))
(s/fdef divide
:args (s/cat :numerator number?
:denominator (s/and number? #(not= % 0))) ;; Защита от деления на 0
:ret number?)Во время разработки вы можете включить проверку всех вызовов функций.
(require '[clojure.spec.test.alpha :as stest])
;; Включает валидацию аргументов для всех функций, у которых есть s/fdef
(stest/instrument)
;; (divide 10 0) -> Бросит Exception с детальным объяснением ошибки спецификацииJava-разработчик привык к Jackson (JSON) и Hibernate/JDBC (DB). В Clojure подходы отличаются фокусом на данные (Data-Oriented).
Родной формат Clojure. Это подмножество синтаксиса языка.
- Плюсы: Поддерживает множества
#{...}, кейворды:key, даты (через теги#inst), UUID#uuid. Безопасен (нет выполнения кода при чтении). - Применение: Конфигурационные файлы, обмен данными между Clojure-сервисами.
(require '[clojure.edn :as edn])
(def config (edn/read-string "{:db {:host \"localhost\" :port 5432}}"))Стандарт веба. Используйте библиотеки Cheshire (стандарт) или Jsonista (быстрее).
- Нюанс: В JSON ключи — строки
"foo", в Clojure —:foo. Нужно конвертировать. - Naming convention: В JSON
snake_caseилиcamelCase, в Clojurekebab-case. Библиотеки умеют это конвертировать автоматически.
;; deps.edn: com.github.metosin/jsonista {:mvn/version "0.3.7"}
(require '[jsonista.core :as j])
;; Запись
(j/write-value-as-string {:user-name "Alice"})
;; -> "{\"user-name\":\"Alice\"}" (по умолчанию)
;; Чтение с конвертацией ключей в keyword
(def mapper (j/object-mapper {:decode-key-fn true})) ;; true = keyword keys
(j/read-value "{\"user_name\": \"Bob\"}" mapper)
;; -> {:user_name "Bob"} (нужно настроить kebab-case отдельно, если требуется)Забудьте про Hibernate и ORM. В Clojure мы используем Data Mapper подход или просто выполняем SQL, получая в ответ Maps.
next.jdbc — это современная обертка над JDBC.
- Философия: SQL запрос — это строка (или вектор данных). Результат — это вектор мап. Никаких Entity-классов, Lazy Loading и Session Cache. Всё явно и прозрачно.
(ns my-app.db
(:require [next.jdbc :as jdbc]
[next.jdbc.result-set :as rs]))
;; 1. Datasource (Обычный javax.sql.DataSource)
;; Можно использовать HikariCP для пулинга
(def db-spec {:dbtype "postgres" :dbname "mydb" :user "postgres" :password "secret"})
(def ds (jdbc/get-datasource db-spec))
;; 2. Выполнение запросов
(defn get-users-by-role [role]
;; execute! выполняет SQL и возвращает вектор
(jdbc/execute! ds
;; Параметризованный SQL (защита от инъекций)
["SELECT * FROM users WHERE role = ?" role]
;; Опции билдера: превращаем snake_case колонки в kebab-case keywords
{:builder-fn rs/as-kebab-maps}))
;; 3. Транзакции
(defn transfer-money [from-id to-id amount]
(jdbc/with-transaction [tx ds] ;; tx - это connection внутри транзакции
(jdbc/execute! tx ["UPDATE accounts SET balance = balance - ? WHERE id = ?" amount from-id])
(jdbc/execute! tx ["UPDATE accounts SET balance = balance + ? WHERE id = ?" amount to-id])))Часто писать сырой SQL строк неудобно (нет композиции). Библиотека HoneySQL позволяет писать SQL как Clojure-структуры данных (Maps).
(require '[honey.sql :as sql])
(def query
{:select [:id :username]
:from [:users]
:where [:= :role "admin"]})
(sql/format query)
;; -> ["SELECT id, username FROM users WHERE role = ?" "admin"]Итог по главе:
- Spec дает вам контракт данных сильнее, чем типы Java, и работает в рантайме.
- EDN — для своих, JSON — для чужих.
- JDBC — это просто передача данных. SQL превращается в Maps. ORM не нужен, когда у вас есть мощные функции для работы с коллекциями.
Ring — это стандарт де-факто (как Servlet API, но проще).
- Request: Это просто Map (
{:uri "/foo" :request-method :get ...}). - Response: Это просто Map (
{:status 200 :body "OK" :headers {}}). - Handler: Это функция, принимающая Request и возвращающая Response.
Никаких мутабельных объектов, никаких response.getWriter().write(...).
(ns my-app.web
(:require [ring.adapter.jetty :refer [run-jetty]]
[ring.util.response :as resp]))
;; Простейший обработчик
(defn handler [request]
{:status 200
:headers {"Content-Type" "text/plain"}
:body (str "Hello from " (:remote-addr request))})
;; Хелперы из ring.util.response (сахар)
(defn json-handler [req]
(-> (resp/response "{\"status\":\"ok\"}") ;; body, status 200
(resp/content-type "application/json")
(resp/status 201)))Это паттерн "Декоратор" или "Chain of Responsibility" (как Servlet Filters). Мидлварь — это функция высшего порядка, которая принимает другой хендлер и возвращает новый хендлер, добавляя поведение (логирование, парсинг JSON, аутентификацию).
(defn wrap-logging [handler]
(fn [request]
(println "Incoming:" (:uri request))
;; Вызываем оригинальный хендлер
(let [response (handler request)]
(println "Status:" (:status response))
response)))
;; Композиция мидлварей (обычно через ->)
(def app
(-> handler
wrap-logging
;; Другие стандартные мидлвари:
;; wrap-json-body
;; wrap-params
))В промпте вы просили Compojure. Это классическая библиотека, использующая макросы. Однако, Senior Note: В современном Clojure мире стандартом становится библиотека Reitit. Она быстрее, полностью data-driven (маршруты — это данные, а не макросы) и проще в интроспекции (Swagger генерируется сам).
Но разберем Compojure как запрошено:
(ns my-app.routes
(:require [compojure.core :refer [defroutes GET POST context]]
[compojure.route :as route]
[ring.middleware.json :refer [wrap-json-response wrap-json-body]]))
;; Определяем маршруты
(defroutes app-routes
(GET "/" [] "<h1>Welcome</h1>")
;; Деструктуризация параметров (params)
(GET "/users/:id" [id]
(str "User ID: " id))
;; Вложенные маршруты
(context "/api" []
(POST "/data" req
;; Доступ к телу запроса (после wrap-json-body)
(let [data (:body req)]
{:status 200 :body {:received data}})))
;; Fallback (404)
(route/not-found "Not Found"))
;; Собираем приложение с мидлварями
(def app
(-> app-routes
(wrap-json-body {:keywords? true}) ;; Парсит body JSON в map
(wrap-json-response))) ;; Сериализует map ответа в JSONRing-адаптеры связывают абстракцию Ring с реальным веб-сервером.
(defn -main []
;; join? false позволяет REPL оставаться активным
(run-jetty app {:port 3000 :join? false})
(println "Server started on port 3000"))В Clojure тесты пишутся так же легко, как и код. Стандартная библиотека clojure.test покрывает 95% нужд.
deftest: Объявление теста (аналог@Test).is: Утверждение (Assertion). Принимает выражение, которое должно быть истинным.(is (= 1 1)).testing: Логическая группировка внутри теста (добавляет контекст в отчет об ошибке).
Аналоги @BeforeAll, @AfterEach.
use-fixtures принимает тип (:once или :each) и функцию, которая оборачивает вызов теста.
(ns my-app.core-test
(:require [clojure.test :refer :all]
[my-app.core :refer :all]))
;; Фикстура: Поднять БД перед тестами, опустить после
(defn db-fixture [f]
(println "Starting DB...")
(f) ;; Запуск тестов
(println "Stopping DB..."))
(use-fixtures :once db-fixture)
(deftest simple-math-test
(testing "Arithmetic"
(is (= 4 (+ 2 2)))
;; Проверка на исключение
(is (thrown? ArithmeticException (/ 1 0)))))
(deftest logic-test
(let [x 10]
(is (pos? x) "X should be positive"))) ;; Сообщение об ошибкеВ Java нужны Mockito/PowerMock. В Clojure, поскольку функции хранятся в Vars, мы можем временно переопределить Var.
Осторожно:
with-redefsменяет функцию глобально во всех потоках. Не используйте при параллельном запуске тестов (kaocha --parallel). Для протоколов лучше использоватьreify.
(defn external-call []
(throw (Exception. "Network down")))
(defn my-service []
(try (external-call) (catch Exception e "default")))
(deftest mocking-test
;; Подменяем external-call на заглушку
(with-redefs [external-call (fn [] "mocked-data")]
(is (= "mocked-data" (my-service)))))ClojureScript (CLJS) компилируется в JavaScript. Reagent — это минималистичная обертка над React.js. Она настолько проста, что многие выбирают ClojureScript только ради Reagent.
В Reagent нет JSX. HTML описывается векторами Clojure.
[:div {:class "box"} [:span "Text"]] -> <div class="box"><span>Text</span></div>
Помните стандартные atom? Reagent добавляет свои атомы (r/atom).
Магия: Когда вы разыменовываете (@state) такой атом внутри компонента (функции), Reagent автоматически подписывает компонент на изменения этого атома. Атом меняется -> компонент перерисовывается.
Никаких setState, useEffect dependency arrays или MobX.
(ns my-app.frontend
(:require [reagent.core :as r]
[reagent.dom :as d]))
;; 1. Состояние (State)
(def counter (r/atom 0))
;; 2. Компонент (Component) - это просто функция
(defn counter-component []
[:div
[:h1 "Count: " @counter] ;; Подписка на изменение
[:button
;; Обработчик событий
{:on-click #(swap! counter inc)}
"Click me"]])
;; 3. Входная точка
(defn ^:export init []
(d/render [counter-component]
(.getElementById js/document "app")))В CLJS есть глобальный namespace js.
js/window->windowjs/console.log->console.log(.-fieldName obj)-> доступ к полю.(.methodName obj args)-> вызов метода.
Конвертация типов:
clj->js: Clojure map -> JS object.js->clj: JS object -> Clojure map (используйте:keywordize-keys true).
Это самая мощная и опасная фича Lisp. В Java вы используете Annotation Processors (как Lombok), чтобы генерировать геттеры/сеттеры или билдеры во время компиляции. В Clojure вы используете Макросы.
Макрос — это функция, которая выполняется на этапе компиляции.
- Вход: AST (абстрактное синтаксическое дерево) в виде списков Clojure.
- Выход: Новый AST (код), который будет скомпилирован вместо старого.
Чтобы писать макросы, нужно уметь формировать списки кода.
- Quote (
'): Воспринимать буквально.'x— это символx, а не значение переменной. - Syntax Quote (
`): Обратный апостроф.- Квалифицирует символы (добавляет namespace):
`mapпревратится вclojure.core/map. - Позволяет делать подстановки внутри.
- Квалифицирует символы (добавляет namespace):
- Unquote (
~): "Вычислить это". Аналог${var}в строках, но для кода. - Unquote Splicing (
~@): "Распаковать список". Вставляет элементы списка, а не сам список.
В Clojure нет unless. Напишем его.
;; Обычная функция НЕ ПОДОЙДЕТ, потому что аргументы вычислятся ДО вызова.
;; (my-function (println "I ran!")) -> напечатает сразу.
(defmacro unless [condition & body]
;; Мы возвращаем список: (if (not condition) (do body...))
`(if (not ~condition)
(do ~@body)))
;; Использование
(comment
;; Макроэкспанд (что видит компилятор):
(macroexpand-1 '(unless (= 1 2) (println "Math works")))
;; -> (if (clojure.core/not (= 1 2)) (do (println "Math works")))
;; Выполнение:
(unless false (println "Hello")))Если вы создаете переменные внутри макроса (let), они могут конфликтовать с именами переменных пользователя.
Добавление # в конце имени (result#) генерирует уникальное имя (result__123__auto).
(defmacro time-execution [expr]
`(let [start# (System/nanoTime) ;; Уникальное имя для start
ret# ~expr] ;; Выполняем выражение
(println "Time:" (/ (- (System/nanoTime) start#) 1000000.0) "ms")
ret#)) ;; Возвращаем результатВ Java мире царит Spring. Он управляет Dependency Injection (DI) и жизненным циклом бинов (@PostConstruct, @PreDestroy).
В Clojure глобальные переменные (def db ...) — зло для тестирования. Нам нужна система компонентов, но явная.
Integrant — это стандарт де-факто для Data-Driven архитектуры.
Вся ваша система описывается одной хеш-мапой. Ключи — это компоненты, значения — их конфигурация.
(def config
{:adapter/jetty {:port 3000 :handler (ig/ref :handler/app)}
:handler/app {:db (ig/ref :db/postgres)}
:db/postgres {:jdbc-url "jdbc:postgresql://..."}})Обратите внимание на ig/ref. Это явное объявление зависимости. Jetty зависит от App, App зависит от DB.
Мы реализуем мультиметоды init-key (старт) и halt-key (стоп) для каждого ключа.
(require '[integrant.core :as ig])
;; 1. DB Component
(defmethod ig/init-key :db/postgres [_ config]
(println "Starting DB pool...")
(jdbc/get-datasource config))
(defmethod ig/halt-key :db/postgres [_ datasource]
(println "Closing DB pool...")
(.close datasource))
;; 2. Handler Component (принимает DB как зависимость)
(defmethod ig/init-key :handler/app [_ {:keys [db]}]
(fn [req] ;; Возвращает Ring handler
{:status 200 :body (str "DB Status: " (if db "Connected" "Error"))}))
;; 3. Server Component
(defmethod ig/init-key :adapter/jetty [_ {:keys [handler port]}]
(run-jetty handler {:port port :join? false}))
(defmethod ig/halt-key :adapter/jetty [_ server]
(.stop server))Это то, ради чего всё затевалось. Вы можете перезагрузить систему прямо в REPL, не перезапуская JVM.
(def system (ig/init config)) ;; Запуск всего графа зависимостей
;; ... работаем, меняем код ...
(ig/halt! system) ;; Остановка всего в обратном порядке
(def system (ig/init config)) ;; Перезапуск с новым кодомDatomic — это база данных, созданная автором Clojure. Она переворачивает представление о БД.
Обычно БД — это Место, где лежат данные. Вы делаете UPDATE user SET name='Bob', и старое имя стирается навсегда.
Datomic — это История фактов.
Факты хранятся как кортежи (Datoms): [Entity-ID, Attribute, Value, Transaction-ID, Added?].
Когда вы меняете имя, вы добавляете факт: [User1 :name "Bob" Tx2 true] и [User1 :name "Alice" Tx2 false] (ретракция старого). История сохраняется.
Это подмножество Prolog. Логическое программирование.
Вы описываете паттерны, которым должны соответствовать данные. Переменные начинаются с ?.
;; Найти всех пользователей, у которых email заканчивается на @gmail.com
;; и вернуть их ID и Имя.
(d/q '[:find ?e ?name ;; Что вернуть (entity id и name)
:in $ ?domain ;; Входящие аргументы (db и домен)
:where
[?e :user/email ?email] ;; Паттерн 1: У ?e есть email, кладем в ?email
[?e :user/name ?name] ;; Паттерн 2: У этого же ?e есть name
[(clojure.string/ends-with? ?email ?domain)]] ;; Предикат (фильтр)
db-value ;; Снэпшот базы данных
"@gmail.com")Так как БД — это неизменяемое значение, вы можете получить БД такой, какой она была вчера.
(def db-history (d/history db))
(def db-yesterday (d/as-of db #inst "2023-01-01"))Стандартный компилятор ClojureScript хорош, но он плохо дружит с экосистемой JavaScript (NPM). Shadow-CLJS — это инструмент сборки (Build tool), который решает эту проблему. Он работает как гибрид Maven (для Clojure либ) и Webpack (для JS либ).
Вам не нужно искать "Clojure-обертку" для React Datepicker. Вы берете оригинал.
npm install react-datepicker- В коде:
(ns my-app.view (:require ["react-datepicker" :default DatePicker] ;; ES6 import default ["moment" :as moment])) ;; CommonJS require ;; Использование в Reagent (через :>) (defn date-selector [] [:div [:> DatePicker {:selected (js/Date.) :onChange #(js/console.log %)}]])
Shadow-CLJS предоставляет самый быстрый Hot Reload. Он не перезагружает страницу, он подменяет код функций на лету, сохраняя состояние приложения (State), которое лежит в атомах Reagent.
Clojure на JVM имеет недостаток: время старта (1-2 секунды). Это убивает желание писать на нем CLI утилиты (ls, grep аналоги) или AWS Lambda.
Это нативный бинарник (написанный на GraalVM), который содержит интерпретатор Clojure и "батарейки" (http client, json, yaml, работа с процессами). Он стартует мгновенно (миллисекунды).
Пример скрипта script.clj:
#!/usr/bin/env bb
(require '[cheshire.core :as json]
'[clojure.java.shell :refer [sh]])
;; Вызов команды shell (ls -la)
(let [files (:out (sh "ls" "-la"))]
(println "Files found:" files))
;; Парсинг JSON из stdin
(let [input (json/parse-string (slurp *in*) true)]
(println "Received:" (:event input)))Вы можете скомпилировать свой полноценный Clojure проект в исполняемый файл (ELF/EXE). Он не требует установленной Java. Старт: 0.05 сек. Потребление памяти: 20-30 Мб.
Это идеально для микросервисов в Kubernetes (быстрый скейлинг) и Serverless.
Ограничения:
- Никакой динамической загрузки кода (
evalне работает). - Рефлексия должна быть известна во время компиляции.
Ты теперь обладаешь картой всей экосистемы.
- Синтаксис: Lisp прост.
(func arg1 arg2). - Данные: Map, Vector, Set, List. Неизменяемость + Persistent Structures.
- Полиморфизм: Protocols (для типов) и Multimethods (для значений).
- Конкурентность: STM (Refs), Atoms (CAS), Core.Async (Channels).
- Архитектура: Integrant (DI), Spec (Validation), Ring (Web).
- Экосистема: Interop с Java (прямой доступ), Interop с JS (shadow-cljs).