Pull to refresh

Почему стоит изучить Clojure?

Reading time 17 min
Views 149K

Что такое хороший язык программирования? Какими качествами и характеристиками он должен обладать? Ответ дать сложно. Вот одно из возможных определений: хороший ЯП должен хорошо решать возложенные на него задачи. Ведь ЯП — лишь инструмент в руках программиста. А инструмент обязан помогать нам в работе. В конце концов, это же и есть причина его создания. Разные ЯП стараются решать разные проблемы (с переменным успехом). Цель, которая ставилась при проектировании Clojure — сделать написанные нами программы простыми. И, как следствие, ускорить их создание, тестирование. А главное, уменьшить время на их понимание, изменение и сопровождение.


Clojure rocks?


Предупрежу сразу — в статье не будет кусочков кода, демонстрирующих крутизну Clojure. Не будет фраз, подобных «в языке X это заняло 5 строчек а в Clojure всего 4». Это же отвратительный критерий для качества языка! В конце концов, мне совершенно все равно, смогу ли я записать qsort в 2 строчки, или мне придется напрячь пальцы на целых 5 — в реальной жизни я буду использовать библиотечную функцию!

Лямбдами сейчас никого не удивишь, они есть везде (ну почти, хотя обычно к 8й версии они появляются везде). Обработка коллекций (в том числе параллельная), списковые выражения, разнообразные синтаксический сахар — этого сейчас хватает во многих языках. По правде говоря, я просто обожаю такие статьи. Но подобные сравнения совершенно не годятся для сравнения качества языков! Это как измерять скорость ЯП по тому, насколько быстро программа выводит «Hello, world!». Ну, если только мы не измеряем скорость HQ9+. Если подумать, то подобные детали не столь уж и важны для больших систем. По мере роста проекта нас все меньше и меньше волнует, используем ли мы скобочки или отступы, инфиксную или префиксную запись. Лишняя строчка при нахождении суммы массива уже перестает всех заботить — на первое место выходят проблемы иного рода.

Сложность


Системы, которые мы создаем, по своей природе изменчивы. Было бы очень хорошо, если бы требования не изменялись. Просто замечательно, если бы в самом начале разработки можно было предусмотреть все ситуации наперед. Увы, в реальной жизни нам постоянно приходится доделывать, переделывать, улучшать, переписывать, заменять, оптимизировать… Самое неприятное — со временем сложность системы только растет. Постоянно, непрерывно. В начале разработки все просто и прозрачно, любое изменение делается быстро, никаких «костылей». Красота. Со временем ситуация перестает быть столь радужной и веселой. Даже малейшая правка кода потенциально может повлечь за собой лавинообразные изменения поведения системы. Приходится тщательно изучать, анализировать код, пытаться предугадать побочные эффекты от каждого изменения. Именно так, со временем мы буквально не можем досконально проанализировать все возможные последствия от наших изменений.

Человек по своей природе может воспринимать в один момент времени лишь ограниченное количество информации. По мере роста проекта увеличивается и количество внутренних связей. Более того, большая часть связей неявна. Нам все сложнее удержать нужное в голове. А в это время команда растет, коллектив меняется — новые люди уже не знают всего проекта. Идет разделение сфер обязанности, что может привести к еще большей запутанности. Постепенно наша система становится сложной.

Как с этим бороться? Максимальное покрытие регрессионными тестами и прогон их после каждого изменения? Тесты крайне полезны, но они являются лишь страховочным тросом. Тесты не прошли — что-то нет так, у нас проблемы. Это лечение симптомов, но тесты не устраняют суть проблемы. Строгие гайдлайны и повсеместное использование паттернов? Нет, проблема ведь не в локальных сложностях. Мы просто перестаем понимать как взаимодействуют компоненты в нашем коде, неявных связей слишком много. Быть может постоянный рефакторинг? Это не панацея, сложность растет не из низкоуровневых решений. На самом деле проблема должна решаться комплексно. И одно из важный средств — правильный инструмент. Хороший язык программирования должен помогать нам писать простые и прозрачные программы.

Просто и легко


Но «просто» (simple) вовсе не означает «легко» (easy). Это разные понятия. На эту тему Рич Хики (автор Clojure) даже сделал известный доклад Simple Made Easy. На хабре опубликован перевод слайдов. Простота — понятие объективное. Это отсутствие сложности (complexity), отсутствие переплетения, спутанности, малое количество связей. С другой стороны, «легко» весьма субъективно. Легко ли управлять велосипедом? Выиграть партию в шахматы? Говорить на немецком? Я не знаю немецкого, но ведь это не повод говорить «этот язык не нужен, он слишком сложный». Он сложен для меня, да и то только потому, что я его банально не знаю.

Мы все привыкли, что вызов функции записывается как f(x, y). Нам привычно программировать в рамках ООП. Это обыденно. Но на самом деле легкое не обязательно просто. Мы лишь привыкаем к сложности некоторых вещей, начинаем ее игнорировать, воспринимать как данность. Пример функции:

(defn select
  "Returns a set of the elements for which pred is true"
  {:added "1.0"}
  [pred xset]
    (reduce (fn [s k] (if (pred k) s (disj s k)))
            xset xset))


Выглядит очень… странно! Надо потратить некоторое время на изучение языка, освоение его концепций, чтобы он стал легким. Но простота (или сложность) постоянна. Если мы хорошо изучим инструмент, то количество внутренних зависимостей все равно не изменится. Он не станет сложнее или проще, хотя будет для нас легче.

Привычный инструмент может дать лучшие результаты прямо сейчас, сиюминутно, но в более далекой перспективе простейшее решение показывает лучшие результаты.

Побочные эффекты


Каковы источники сложности в наших программах? Один из них — побочные эффекты. Полностью обойтись без них нельзя, но мы можем их локализовать. И язык должен нам в этом помогать.

Clojure — язык функциональный, он стимулирует нас писать чистые функции. Результат таких функций зависит лишь от входных параметров. Не надо ломать голову «хм, а что будет, если перед вызовом этой функции я запущу вот эту». Никаких если, есть входные данные, есть выходные. Сколько бы раз мы не запустили функцию — ее результат будет один и тот же. Это предельно упрощает тестирование. Не нужно перебирать различные порядки вызова или воссоздавать (симулировать) правильное внешнее состояние.

Чистые функции проще анализировать, с ними буквально можно «поиграться», посмотреть как они ведут себя на живых данных. Проще отлаживать код. Мы всегда можем воспроизвести проблему с чистой функцией — достаточно передать ей на вход параметры, вызывающие ошибку, ведь результат функции не зависит от того, что выполнялось ранее. Чистые функции предельно просты, даже если выполняют большую работу.

Разумеется, Clojure поддерживает функции высших порядков, их композицию.

((juxt dec inc) 1)            ; => [0 2]
((comp str *) 1 2 3)          ; => "6"
(map (partial * 10) [1 2 3])  ; => [10 20 30]
(map (comp inc inc) [1 2 3])  ; => [3 4 5]


Clojure не чистый язык, и функции могут иметь побочные эффекты. Например, println — это вызов функции, действие. Важно то, что сама суть подобных функций заключается во взаимодействии с внешним миром. Вывести значение в файл, отправить HTTP запрос, выполнить SQL — все эти действия лишены смысла в отрыве от создаваемого ими побочного эффекта. Поэтому очень полезно такие функции (чистые и грязные) разделять.

Но они (грязные функции) не обладают состоянием. Они лишь служат средством взаимодействия с внешним миром. Как мы увидим далее, Clojure отделяет состояние нашей программы при помощи опосредованных ссылок.

Иммутабельность


Все структуры данных в Clojure иммутабельны. Нет способа изменить элемент вектора. Все что мы можем — создать новый вектор, у которого будет изменен один элемент. Очень важный момент в том, что Clojure сохраняет алгоритмическую сложность (по времени и памяти) для всех стандартных операций над коллекциями. Ну почти, вместо O(1) для векторов мы имеем O(lg32(N)). На практике, для даже коллекций из миллионов элементов lg32(N) не превышает 5.

Достигается подобная сложность благодаря использованию персистентных коллекций. Идея в том, что при «изменении» структуры старая версия и новая разделяют большую часть внутренних данных. При этом старая версия остается полностью рабочей. Более того, мы имеем доступ ко всем версиям структуры. Это важный момент. Конечно, ненужные версии будут собраны сборщиком мусора.

(def a [1 2 3 4 5 6 7 8])
; a -> [1 2 3 4 5 6 7 8]
(def b (assoc a 3 999))
;b -> [1 2 3 999 5 6 7 8]


Из коробки Clojure поддерживает односвязные списки, вектора, хеш-таблицы, красно-черные деревья. Есть реализация персистентной очереди (для стека можно использовать список или вектор). И все иммутабельно. Для повышения производительности можно создавать собственные типы-записи.

(defrecord Color [red green blue])
(def a (Color. 0.5 0.6 0.7)
; a => {:red 0.5, :green 0.6, :blue 0.7}


Тут мы объявляем структуру с 3 полями. Компилятор Clojure создаст объект с 5 полями (2 «лишних»). Одно поле для метаданных, в нашем случае это будет null. 3 поля для собственно данных. И еще одно поле — для дополнительных ключей. Даже если для повышения скорости в нашей программе мы объявляем структуру с явным перечислением полей, то Clojure все равно оставляет нам возможность добавлять дополнительные значения.

(defrecord Color [red green blue])
(def b (assoc a :alpha 0.1))
; b => {:alpha 0.1, :red 0.5 :green 0.6, :blue 0.7} 


И да, для структур данных в Clojure есть специальный синтаксис:

; Вектор
[1 2 3]
; Хеш-таблица
{:x 1, :y 2}
; Множество
#{"a" "b" "c"}


Состояние


Итак, у нас есть чистые функции, они определяют бизнес-логику нашего приложения. Есть грязные функции, служащие для взаимодействия в внешними системами (сокеты, БД, web-сервер). И есть внутреннее состояние нашей системы, которое в Clojure хранится в виде опосредованных ссылок (references).

Есть 4 вида стандартных ссылок:
  • var — аналог thread-local переменных, служат для задания контекстных данных: текущее соединение с БД, текущий HTTP-запрос, параметры точности для математических выражений и подобное;
  • atomатомарная ячейка, позволяет обновлять состояние синхронно, но не координированно;
  • agent — легковесный аналог для actor (хотя, в некотором смысле они являются антиподами, об этом ниже), служат для асинхронной работы с состоянием;
  • ref — ячейки транзакционной памяти, предоставляет синхронную и координированную работу с состоянием.


Все глобальные переменные хранятся в var (включая функции). Поэтому их можно переопределять «локально».

(def ^:dynamic *a* 1)
(println a) ; => 1
(binding [a 42] (println a)) ; => 42


Тут мы указали компилятору, что переменная a должна быть динамической, т.е. хранится внутри ThreadLocal. Использование ThreadLocal несколько уменьшает производительность, поэтому не применяется для всех var-ячеек по умолчанию. Но, если понадобится, то любую var-ячейку можно сделать динамической уже после создания (что часто используется в тестах).

В тестах можно подменять целые функции.

; тут происходит работа с БД, сокетами и т.п.
(defn some-function-with-side-effect [x] ...)

; а эту функцию мы хотим протестировать
(defn another-function [x] ...)  

(deftest just-a-test
  ...
    (binding [some-function-with-side-effect (fn [x] ...)]   ; вешаем mock-функцию
      (another-function 123))
  ...)


Все ссылки в Clojure поддерживают операцию deref (получить значение). Для var-ячеек это выглядит так:

; создаем ячейку #'a
(def a 123)
(println a)            ; => 123
(println #'a)          ; => #'user/a
(println (deref #'a))  ; => 123


Ячейка хранит значение (иммутабельное), но при этом сама является отдельной сущностью. Для функции deref введен специальный синтаксис (да-да, это всего лишь сахар). Вот пример использования atom.

(let [x (atom 0)]
  (println @x)   ; => 0
  (swap! x inc)  ; CAS-операция
  (println @x))  ; => 1


Функция swap! принимает атом и «мутирующую» функцию. Последняя принимает текущее значение атома, и должна вернуть новое. Тут очень кстати оказываются персистентные структуры данных. Например, мы можем хранить в атоме вектор из миллиона элементов, но «мутирующая» функция будет выполнятся достаточно быстро для CAS (мы помним, что сложность операций над персистентными коллекциями такая же, как и у обычных, мутабельных). Или мы можем обновить пару полей у хеш-таблицы:

(def user (atom {:login "theuser" :email "theuser@example.com"}))
(swap! account assoc :phone "12345")
; эквивалентно такому коду
(swap! account (fn [x] (assoc x :phone "12345")))


Важно, чтобы функция была чистой, поскольку она может выполнится несколько раз. Мы не можем (не должны!) писать что-то вроде:

(swap! x (fn [x] (insert-new-record-to-db x) (inc x)))


Агенты


Агенты служат для поддержки состояния, которое непосредственно связано с побочными эффектами. Идея проста. У нас есть ячейка, к ней «прикреплена» очередь из функций. Функции поочередно применяются к значению, которое хранится в этой ячейке, результат функции становится новым значением. Все вычисляется асинхронно в отдельном пуле потоков.

(def a (agent 0))  ; начальное значение

(send a inc)
(println @a)  ; => 1

(send a (fn [x] (Thread/sleep 100) (inc x)))
(println @a)  ; => 1

; спустя 100 мс
(println @a)  ; => 2


Агенты обновляют свое значение асинхронно. Но мы можем в любой момент времени узнать состояние агента. Агенты могут посылать сообщения друг другу, при посылке сообщение откладывается до того момента, когда посылающий агент обновит свое состояние. Другими словами, если в одном агенте будет брошено исключение, то посланные из него сообщения никуда не будут отправлены.

(def a (agent 0))
(def b (agent 0))

(send a (fn [x]
               (send b inc)  ; посылаем сообщение в b
               (throw (Exception. "Error"))))

(println @b)
; -> 0, сообщение так и не дошло


Напрашивается некая аналогия с моделью акторов. Они схожи, но есть принципиальные отличия. Состояние агентов явно, в любой момент времени можно вызывать deref и получить значение агента. Это противоречит идее акторов, где мы можем узнать состояние только опосредованно, путем посылки и приема сообщений. В случае с акторами мы даже не можем быть уверены, что опросив его состояние мы «случайно» не изменим его. Агент абсолютно надежен в этом смысле — его состояние можно поменять только функциями send и send-off (которые между собой отличаются лишь тред-пулом, в котором будет обрабатываться наше сообщение).

Второе ключевое отличие в том, что агенты открыты для изменений и добавления функциональности. Единственный способ изменить поведение актора, это переписать его код. Агенты лишь ссылки, они не обладают собственным поведением. Мы можем написать новую функцию и послать ее в очередь агента.

Акторы пытаются разделить состояние нашей программы на небольшие части, которые легче разнести или изолировать. Операции обновления и чтения состояния сводятся к посылке сообщений. Иногда это крайне полезно (например, при выполнении erlang-программы на нескольких узлах). Но чаще этого не требуется. Иногда даже наоборот. Так, в агентах удобно хранить большие объемы информации, которые нужно шарить между потоками: кеши, сессии, промежуточные результаты математических вычислений и т.п.

Для акторов мы фиксируем множество сообщений, на которое он может отреагировать (остальные посчитает ошибочными). Порядок сообщений тоже важен, поскольку они могут потенциально повлечь за собой побочные эффекты. Это его публичный контракт. Для агента же мы фиксируем только данные, которые могут в нем хранится, их структуру. Очень важно подчеркнуть, что агенты совершенно не пытаются заменить собой акторы. Это разные концепции, и сферы их применения отличаются.

Как упоминалось, агенты работают асинхронно. Мы можем выстраивать цепочки событий (посылая сообщения из агента в агент). Но при помощи одних агентов у нас не получится изменять состояние нашей программы координированно.

STM


Программная транзакционная память — одна из ключевых фишек Clojure. Реализована посредством MVCC. И сразу пример:

(def account1 (ref 100)
(def account2 (ref 0))

(dosync 
  (alter account1 - 30)
  (alter account2 + 30))


Мы увеличиваем одно значение и синхронно уменьшаем другое. Если что-то пойдет не так (исключение), то вся транзакция будет отменена:

(println @account1)  ; => 70
(println @account2)  ; => 30

(dosync
  (alter account1 * 0)
  (alter account2 / 0))  ;  => ArithmeticException

; значения не изменились
(println @account1)  ; => 70
(println @account2)  ; => 30


Очень похоже на привычный ACID, но только без Durability. При входе в транзакцию все ссылки словно замораживаются, их значения фиксируются на время всей транзакции. Если при чтении/записи ссылки обнаруживается, что она уже поменяла свое значение (другая транзакция завершилась и подпортила нам жизнь), то происходит перезапуск текущей транзакции. Поэтому внутри транзакции не должно быть побочных эффектов (ввод-вывод, работа с атомами). И тут как нельзя кстати оказываются агенты.

(def a (ref 0))
(def b (ref 0))
(def out-agent (agent nil))

(dosync
  (println "transaction") 
  (alter a inc)  ; может привести к рестарту транзакции
  (let [a-value @a
        b-value @b]
    (send-off out-agent (fn [_] (println "a" a-value "b" b-value))))
  (alter b dec)) ; также может привести к рестарту


Все сообщения для агентов придерживаются вплоть то того момента, когда транзакция будет завершена. В нашем примере изменение ссылок a и b может повлечь рестарт транзакции, слово «transaction» может быть напечатано несколько раз. Но код внутри агента будет выполнен ровно один раз, и уже после того, как транзакция завершится.

Чтобы различные транзакции мешали другу другу как можно меньше, ссылки в Clojure хранят историю значений. По умолчанию это только последнее значение, но когда происходит конфликт (одна транзакция пишет, а другая читает), то для конкретной ссылки размер хранимой истории увеличивается на единицу (вплоть до 5 значений). Не забываем, что мы храним в ссылках персистентные структуры, которые разделяют общие структурные элементы. Поэтому хранить такую историю в Clojure очень дешево в плане потребляемой памяти.

STM-транзакции не мешают нам при изменении нашего кода. Нет необходимости анализировать, можно ли использовать ту или иную ссылку в текущей транзакции. Они доступны все, и мы можем добавлять новые ссылки совершенно прозрачно для уже существующего кода. Ссылки не взаимодействуют между собой. Например, при использовании обычных локов нам надо следить за порядком блокировки/разблокировки, чтобы не вызвать deadlock.

При конкурентном доступе транзакции-читатели не блокируют друг друга, примерно как при использовании ReadWriteLock. Более того, транзакции-писатели не блокируют читателей! Даже если в текущий момент выполняется транзакция, которая изменяет ссылку, мы можем получить значение без блокировки.

Агенты и STM-ссылки дополняют друг друга. Первые не подходят для координированного изменения состояния, вторые не позволяют работать с побочными эффектами. Но их совместное использование делает наши программы прозрачнее и проще (менее запутанными), нежели при использовании «классических» средств (мьютексы, семафоры и подобное).

Метапрограммирование


Сейчас у многих языков есть те или иные средства метапрограммирования. Это AspectJ для Java, AST-трансформации для Groovy, декораторы и метаклассы для Python, различная рефлексия.

Clojure, как представитель семейства Lisp, для этих целей использует макросы. С их помощью мы можем программировать (расширять) язык средствами самого языка. Макрос — «обыкновенная» функция, с тем лишь различием, что выполняется во время компиляции программы. На вход макроса передается еще не скомпилированный код, результат выполнения макроса — новый код, который компилятор уже компилирует.

(defmacro unless [pred a b]
  `(if (not ~pred)
    ~a
    ~b))

(unless (> 1 10)
  (println "1 > 10. ok")
  (println "1 < 10. wat"))


Мы создали собственную управляющую конструкцию (инверсный вариант if). Все что для этого нужно сделать — написать функцию!

Макросы используются в Clojure весьма широко. Кстати, многие встроенные в язык операторы на самом деле являются макросами. Например, вот реализация or:

(defmacro or
  ([] nil)
  ([x] x)
  ([x & next]
      `(let [or# ~x]
         (if or# or# (or ~@next)))))


Даже defn всего лишь макрос, разворачивающийся в def и fn. Кстати, деструктуризация тоже реализована при помощи макросов.

(let [[a b] [1 2]]
  (+ a b))

; развернется во что-то вроде...

(let* [vec__123 [1 2]
       a (clojure.core/nth vec__123 0 nil)
       b (clojure.core/nth vec__123 1 nil)] 
  (+ a b))


Недавно в Java появился try-with-resources. При этом 7ю версию Java мы ждали всего-то несколько лет. Для Clojure достаточно написать всего несколько строчек:

(defmacro with-open [[v r] & body]
  `(let [~r ~v]
    (try
      ~@body
      (finally
        (.close ~v)))))


В других языка ситуация получше, но все равно далека от идеальной. Важно не наличие той или иной конструкции в языке, а возможность добавить свою. Поэтому неудивительно, что, скажем, паттерн-матчинг для Clojure реализован в виде отдельной подключаемой библиотеки. Просто нету необходимости включать подобные вещи в ядро языка, гораздо целесообразнее реализовывать их в виде макроса. Аналогичная ситуация с поддержкой монад, логическим програмированием, продвинутой обработкой ошибок и другими расширениями языка. Есть даже опциональная статическая типизация!

Нельзя не упомянуть и про удобство создания DSL. Для Clojure их создано очень много. Это и генерация HTML, и роутинг HTTP-запросов, и работа с реляционными базами данных, и работа с бинарными протоколами, и валидация данных… Создавать их просто и эффективно (хотя в этом деле нужно знать меру).

Clojure (как и все Lisp-подобные языки) обладает очень важной особенностью — он гомоиконен. Другими словами, нету надобности в отдельном представлении для исходного кода программы, не нужно создавать лишние уровни абстракции в виде некоего дополнительного AST-дерева, программа и есть это дерево. Причем это дерево не из каких-то специальных структур, это обычные списки, векторы и символы. И мы можем работать с нашей программой точно также, как и с обычными данными.

(defn do2 [x]
  (list 'do x x))

(do2 '(println 1)) 
; => '(do (println 1) (println 1))
;   что эквивалентно
; => (list 'do (list 'println 1) (list 'println 1))


При всей своей мощи макросы в Clojure не ухудшают читаемость программы (если, конечно, использовать их в меру). Ведь макрос всего лишь функция, а мы всегда можем однозначно определить, какая функция используется в текущем контексте. Например, если мы видим код (dosomething [a b] c), то легко узнать, что же скрывается за именем dosomething, достаточно лишь взглянуть в начало файла (где происходит импорт других модулей). Если это макрос — то его семантика постоянна и известна. Нам не нужны продвинутые IDE, чтобы разобраться в таком коде. Хотя, конечно, продвинутые среды разработки умеют «развернуть» макрос на месте, позволяя посмотреть, во что превратит нашу программу компилятор.

Полиморфизм


Для создания полиморфных функций у Clojure есть 2 механизма. Изначально язык поддерживал только мультиметоды — мощное средство, но чаще всего избыточное. Начиная с версии 1.2 (а на данный момент актуальна версия 1.5.1) в язык добавили новую концепцию — протоколы.

Протоколы похожи на Java-интерфейсы, но не могут наследовать друг друга. В каждом протоколе описывается набор функций.

(defprotocol IShowable
  (show [this]))
; ...
(map show [1 2 3])


Этим мы объявляем 2 сущности — собственно протокол, а также функцию show. Это обычная Clojure-функция, которая при своем вызове ищет наиболее подходящую реализацию на основе типа первого аргумента. Отдельно мы объявляем нужные структуры данных, и указываем для них реализацию протокола.

(defrecord Color [red green blue]
  IShowable
  (show [this] 
    (str "<R" (:red this) " G" (:green this) " B" (:blue this))))


Можно реализовать протокол для стороннего типа (даже встроенного).

(extend-protocol IShowable

  String
  (show [this] (str "string " this))

  clojure.lang.IPersistentVector
  (show [this] (str "vector " this))

  Object
  (show [this] "WAT"))

(show "123")    ; => "string 123"
(show [1 2 3])  ; => "vector [1 2 3]"
(show '(1 2 3)) ; => "WAT"


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

Но иногда протоколов бывает недостаточно. Например, для двойной диспетчеризации. В этом (и не только) случаях нам пригодятся мультиметоды. При объявлении мультиметода мы указываем специальную побочную функцию-диспатчер. Диспатчер получает те же аргументы, что и мультиметод. Поиск конечной реализации происходит уже по значению, которое вернул диспатчер. Это может быть тип, ключевое слово или вектор. В случае вектора происходит поиск наиболее подходящей реализации по нескольким значениями.

(defmulti convert
  (fn [obj target-type] [(class obj) target-type]))

(defmethod convert [String Integer] [x _] (Integer/parseInt x))
(defmethod convert [String Long] [x _] (Long/parseLong x))
(defmethod convert [Object String] [x _] (.toString x))
(defmethod convert [java.util.List String] [x _] (str "V" (vec x)))

(convert "123" Integer)   ; -> 123
(convert "123" Long)      ; -> 123
(convert 123 String)      ; -> "123"
(convert [1 2 3] String)  ; -> "V[1 2 3]"


Тут мы объявили абстрактную функцию, реализация которой выбирается на основе типа первого аргумента и значения второго (это должен быть класс). Конечно, Clojure учитывает иерархию типов при поиске подходящей реализации. Использовать типы удобно, но их иерархия строго фиксирована. Зато мы можем создавать собственные ad-hoc иерархии из ключевых слов.

; задаем связи "ребенок-родитель"
(derive ::rect ::shape)
(derive ::square ::rect)
(derive ::circle ::shape)
(derive ::triangle ::shape)

(defmulti perimeter :type)  
; тут мы сократили код за счет того, что  :type ~ (fn [x] (:type x))

(defmethod perimeter ::rect [x] (* 2 (+ (:h x) (:w x))))
(defmethod perimeter ::triangle [x] (reduce + ((juxt :a :b :c) x)))
(defmethod perimeter ::circle [x] (* 2 Math/PI (:r x)))

(perimeter {:type ::rect, :h 10, :w 3})           ; -> 26
(perimeter {:type ::square, :h 10, :w 10})        ; -> 40
(perimeter {:type ::triangle, :a 3, :b 4, :c 5})  ; -> 12
(perimeter {:type ::shape})                       ; -> throws IllegalArgumentException


Иерархий можно объявить несколько. Также как и с типами, можно проводить диспатчеризацию по нескольким значениям сразу (вектору). При задании своих иерархий можно даже смешивать ключевые слова и Java-типы!

(derive java.util.Map ::collection)
(derive java.util.Collection ::collection)
(derive ::tag .java.lang.Iterable) ; -> ClassCastException


Мы можем «унаследовать» тип от кейворда (но не наоборот). Это удобно для создания открытых для расширения групп классов.

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

Здравый смысл


Язык ничего не значит без инфраструктуры. Без сообщества, набора библиотек, фреймворков, различного рода утилит. Одна из сильных сторон Clojure — использование платформы JVM. Интеграции с Java (в обоих направлениях) крайне проста. Ни для кого не секрет, что существует просто громандное количество библиотек для Java (не будем обсуждать их качество). Их все можно напрямую использовать из Clojure. Хотя и количество нативных библиотек достаточно велико (и постоянно растет).

Активно развиваются плагины для Eclipse и IDEA. Для сборки проектов уже давно стандартом де-факто стала утилита leiningen, используемая всем сообществом. Имеются разнообразные фреймворки, как для создания WEB приложений, так и асинхронных серверов

Разработан сервер приложений Immutant (обертка для JBoss AS7). Immutant предоставляет интерфейсы для работы с Ring (HTTP стек для Clojure), асинхронными сообщениями, распределенным кешированием, выполнению задач по расписанию, распределенным транзакциям, кластеризации и прочим вещам. При этом развертывать и настраивать Immutant крайне просто.

У Clojure есть и альтернативные реализации, например порт под .Net CLR. Но, по правде говоря, больше всего внимания заслуживает ClojureScript, порт для JavaScript. Конечно, там нет средств многопоточности, и, как следствие, транзакционной памяти и агентов. Но все остальные средства языка доступны, включая персистентные структуры, макросы, протоколы и мультиметоды. А интеграция между ClojureScript и JavaScript настолько же хороша и проста, как между Clojure и Java (а местами даже лучше).

Что дальше?


А дальше все просто. У нас есть инструмент. Рабочий, надежный. Не серебрянная пуля, но достаточно универсальный. Простой. Да, возможно, придется потратить некоторое время для его освоения. Многое может показаться непривычным и странным. Но это лишь дело привычки, быстро понимаешь — вся красота языка в его органичности, тонкой стыковке отдельных элементов в единое целое.

Познакомиться с Clojure стоит. Однозначно. Даже если этот инструмент не подойдет Вам по той или иной причине, то идеи, которые в него заложены, окажутся весьма полезными.
Tags:
Hubs:
+104
Comments 55
Comments Comments 55

Articles