Clojure – язык программирования, спроектированный для выполнения на JVM (компилируется "на лету" либо в .class-файлы Java); предназначен для написания крупномасштабных проектов с большим количеством вычислений и/или сложным параллелизмом. Появился относительно недавно (2007) после длительного и вдумчивого проектирования, и ориентирован на эффективное решение современных проблем IT-индустрии; сочетает динамичность Lisp'ов и лаконичность функциональных языков. Помимо основной реализации на JVM имеются варианты на .NET/Mono (ClojureCLR) и на JavaScript (ClojureScript; ввиду особенностей платформы у этой реализации есть некоторые отличия).
Документацию по языку можно найти здесь; также имеются шпаргалки по API (Clojure, ClojureScript) и онлайн-REPL (Clojure, ClojureScript). Для обучения советую учебник Clojure for the Brave and True и сайты: 4Clojure (упражнения/задачи), Clojure Koans (упражнения по языку и функциональному программированию – скачивать здесь), ClojureScript Koans (набор упражнений в браузере).
Главным недостатком Clojure является медленный запуск – около секунды для библиотеки языка, и несколько секунд для Leiningen (основной инструмент, применяемый для разработки проектов); по крайней мере в случае библиотеки причиной проблемы являются особенности процесса загрузки JVM. На данный момент этот недостаток может вызывать проблемы с тестированием на DL. Однако, на ClojureScript это распространяться не должно; так что при проблемах с Clojure решения можно тестировать на нём.
На DL установлен Clojure-1.8.0, решения запускаются как скрипты; вместо компиляции вызывается спеллчекер. Также установлен ClojureScript-1.9.229, компилируемый в JavaScript с помощью скрипта (оптимизация по размеру отключена, поэтому результат компиляции занимает ~1Mb) и запускаемый под PhantomJS 1.9.8 (см. JavaScript).
Lisp
Для начала – описание синтаксиса, характерного для диалектов Lisp.
;; режим кода ("формы")
(command arg1 arg2 arg3 arg4) ; command(arg1, arg2, arg3, arg4)
;; режим данных (команда quote)
(value1 value2 value3 value4) ; [value1, value2, value3, value4]
;; идентификатор (symbol) также является корректным значением (уникальная строка)
Основная структура данных Lisp - односвязный список (Lisp расшифровывается как LISt Processing).
(= (quote (1 2 3)) (list 1 2 3)) ; [1, 2, 3] <=> (1, (2, (3, nil)))
В Lisp есть функция reader, умеющая читать данные в формате Lisp. Любая программа на Lisp может быть прочтена этой функцией; т.е. Lisp-код является корректным набором данных на Lisp.
Всего в Lisp три вида "форм" (команд) – встроенные операторы, функции и макросы. Макросом (macros, не macro) в Lisp называется функция, вызываемая в процессе компиляции кода; она получает в качестве аргументов код на Lisp как данные и выдаёт также код на Lisp, обрабатываемый далее компилятором. Например:
;; предположим, нам неудобно писать математические выражения в польской нотации,
;; и мы написали макрос "math-expr", который преобразует выражения в польскую запись
(math-expr x + 2 * y + (3 - z) / 2 = 7)
;; этот вызов будет скомпилирован как следующий код (выровнено для удобства чтения):
(= (+ x
(* 2 y)
(/ (- 3 z)
2))
7)
Также есть reader macros, вызываемые на этапе обработки reader'ом определённых символов. (К примеру, символ ' обрабатывается как оператор quote.)
(= '(1 2 3) (list 1 2 3)) ; [1, 2, 3] = list(1, 2, 3)
Другими словами, макросы позволяют добавлять в язык новый синтаксис и новые синтаксические конструкции (и это возможно на любом количестве уровней). Например, CLOS – одна из самых развитых ООП-систем, нативно поддерживающая мультиметоды (множественный runtime-полиморфизм) – реализована в Common Lisp как внешняя библиотека, не зависящая от конкретной реализации.
Clojure
Clojure – диалект Lisp, который компилируется на JVM (обычно) и рассчитан на работу с ним. Он имеет следующие (встроенные) типы данных:
;; JVM
3 ; Long, целое (тж. BigInteger, Integer, Short, Byte)
1. 0.1 1e-20 ; Double, дробное (тж. Float)
"some string" ; String, строка
\a \newline \space ; Character, символ (текстовый)
true false ; Boolean, логический тип
nil ; nil, отсутствие значения (null)
#"\d+" ; regex.Pattern, регулярное выражение
;; взято из других Lisp'ов
2/9 ; Ratio, точная дробь
'some-name ; Symbol, идентификатор
:some-name ; Keyword, идентификатор-ключ (используется вместо enum'ов)
() '(1 2 3 4) ; односвязный список; добавление в начало
;; новое
3N ; BigInt - длинное целое (оптимизировано)
[] [1 2 3 4] ; вектор: [1, 2, 3, 4]; добавление в конец
#{} #{1 2 3 4} ; множество: {1, 2, 3, 4}; не упорядочено по значению
(sorted-set 1 2 3 4) ; упорядоченное множество (сбалансированное дерево)
{} {1 2, 3 4} ; словарь: {1: 2, 3: 4}; не упорядочен по ключам
(sorted-map 1 2 3 4) ; упорядоченный словарь (сбалансированное дерево)
;; cимвол "," в синтаксисе Clojure считается пробельным и используется для удобства чтения
Все структуры данных в Clojure персистентны. Это значит, что при попытке изменить структуру (добавить/удалить/заменить элемент/элементы) мы получаем желаемое изменение в виде "копии", в то время как исходная структура остаётся неизменной; при этом на самом деле копируется только "изменённая" часть структуры – остальная часть берётся напрямую из старой, что делает стоимость операции заметно меньше ожидаемой при полном копировании. (Подход к оптимизированию кода с персистентными структурами несколько отличается от оного при использовании "мутируемых"; после оптимизации его производительность будет в худшем случае в ~log N раз меньше, но нередко от разницы можно избавиться полностью.)
За редким исключением, значения переменных в Clojure неизменимы; при получении новых данных им выдаётся новое название (либо же они используются на месте). Циклы, как и в обычном Lisp, оформляются как рекурсивные функции (правда, в Clojure такие рекурсивные вызовы выполняются отдельной командой).
;; глобальные значения
(def name "value") ; глобальная константа
(def add2 (fn [a b] (+ a b))) ; функция add2(a, b), сохранённая в глобальную константу
(defn add2 [a b] (+ a b)) ; то же, но записанное с макросом defn
(def inc #(+ 1 %)) ; сокращённая запись функции
(def add2 #(+ %1 %2)) ; сокращённая функция с >1 аргументами
;; локальные значения
(let [x value ; блок со значениями:
y (+ 1 2)] ; { x = value, y = 1 + 2;
; .... ; (println (+ x y))) ; println(x+y); }
(letfn [(twice [n] (+ n n)) ; макрос для локальных функций
(! [n] (if (< n 2) ; факториал:
1 ; (1 if n < 2 else
(* n (! (dec n)))))] ; n*(n-1)!)
(println (twice (! 10)))) ; 2*10!
(binding [*in* fin, *out* fout] ; макрос для временного изменения "динамических" переменных
(println (read-line)))
(with-open [fin (io/reader "1.in")] ; with open("1.in") as fin:
(doall (line-seq fin))) ; return fin.readlines() # в смысле, результат выражения
;; условные конструкции (condition выполняется, если его значение не false и не nil)
(if condition a b) ; (a if condition else b)
(if-not condition a b) ; (b if condition else a)
(if (< a b) ; if a < b:
(do-stuff) ; do_stuff()
(do ; else:
(other-stuff) ; other_stuff()
(something-else))) ; something_else()
(when condition ; if condition:
(stuff) ; stuff()
(other-stuff)) ; other_stuff()
(when-not condition ; if not condition:
(stuff) ; stuff()
(other-stuff)) ; other_stuff()
(if-let [x (calc)] ; x = calc()
(do ; if x is not None:
(stuff x) ; stuff(x)
(more x)) ; more(x)
; else:
(other)) ; other()
(when-let [x (calc)] ; x = calc()
; if x is not None:
(stuff x) ; stuff(x)
(more x)) ; more(x)
(cond
(< n 0) :negative ; (NEGATIVE if n < 0 else
(> n 0) :positive ; POSITIVE if n > 0 else
:else :zero) ; ZERO)
(condp instance? x
Number (* 2 x) ; (2*x if isinstance(x, int) else
String (* 2 (count x))) ; 2*len(x) if isinstance(x, str) else
; raise IllegalArgumentException(...))
(condp some s ; # первый элемент, входящий во множество (или его значение по ключу словаря)
#{\a \b \c} :>> #(str % \z) ; next([c+'z' for c in s if c in {'a', 'b', 'c'}] +
#{\x \y \z} :>> #(str \a %) ; ['a'+c for c in s if c in {'x', 'y', 'z'}],
"") ; "")
(case computer-type
:laptop a ; (a if computer_type is LAPTOP else
:desktop b ; b if computer_type is DESKTOP else
:tower c ; c if computer_type is TOWER else
d) ; d)
;; циклы (за исключением doseq выдают lazy-seq по 16 элементов за раз; doall/dorun принуждает досчитать всё сразу)
(map inc (range 5)) ; (0 1 2 3 4) -> (1 2 3 4 5)
(reduce + (range 1 6)) ; (1 2 3 4 5) -> 15
(take-while #(< % 20) ; (0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 ...) ->
(filter odd? (range))) ; (1 3 5 7 9 11 13 15 17 19)
(take 7 (remove #(< % 5) (range))) ; (5 6 7 8 9 10 11)
(take 4 (iterate #(* % %) 2)) ; 2 -> (2 4 16 256)
(doseq [x (range 5 0 -1)] ; for x in [5, 4, 3, 2, 1]:
(println x)) ; print(x)
(while (not-finished?) ; while is_not_finished():
(do-stuff)) ; do_stuff()
(for [x (range 6) ; [y for x in range(6)
:let [y (* x 3)] ; for y in [x*3]
:when (even? y)] ; if y % 2 == 0]
y)
(loop [n (count l), i 0] ; for (int i = 0, n = l.length; i < n; i++)
(println (nth l i)) ; System.out.println( l[i] );
(when (< i n)
(recur n (inc i))))
(loop [xs (range 10)] ; for x in range(10):
(if [[x & xs'] (seq xs)]
(println x) ; print(x)
(recur xs')))
В Clojure имеется довольно обширная библиотека, модули которой можно подключать (наиболее часто используемые функции загружены по умолчанию). Кроме того, Clojure поддерживает простое взаимодействие с host-VM'ом (однако, использование interop'а привязывает код к конкретному VM'у).
;; namespace необходим в файле ClojureScript, но его имя не принципиально для скрипта из одного файла
(ns thats.how.you.declare-namespace ; package thats.how.you; class declare_namespace
(:require [clojure.string :as s])) ; подключение модуля Clojure
(require '[clojure.string :as s]) ; оно же без ns
(import java.nio.file.Paths) ; import java.nio.file.Paths;
(Integer. "5") ; new Integer("5"); (Math/sin (/ Math/PI 2)) ; Math.sin(Math.PI / 2); (js/parseInt "5") ; parseInt("5"); (.println System/stdout "a" b) ; System.stdout.println("a", b);(s/join " " (range 5)) ; "0 1 2 3 4"
(s/split " a bc d " #" +") ; ["", "a", "bc", "d"]
Макросы
Макросы позволяют расширять синтаксис языка, добавляя в него желаемые конструкции. Помимо CLOS-подобной системы ООП, в стандартной библиотеке Clojure реализовано множество вспомогательных макросов, включая т.н. threading-макросы:
(-> x .toString .getBytes .-length (> n)) ; x.toString.getBytes().length > n
(->> s Integer. (/ n 2) (+ 3)) ; 3 + n / 2 / new Integer(s)
(as-> [:foo :bar] v ; (.substring (first (map name
(map name v) ; [:foo :bar]))
(first v) ; 1)
(.substring v 1))
(.. System getProperties (get "os.name")) ; System.getProperties().get("os.name");
(doto (new java.util.HashMap) ; HashMap $tmp21313 = new HashMap();
(.put "a" 1) ; $tmp21313.put("a", 1);
(.put "b" 2)) ; $tmp21313.put("b", 2);
; return $tmp21313;
Хотя новые макросы (для отдельно взятой программы) добавляют сравнительно редко (как часто вам хочется изменить синтаксис языка, на котором вы пишете?), но сама возможность это сделать нередко значительно облегчает многие сложные задачи (скажем, в большинстве языков реализовать DSL вроде SQL настолько трудоёмко, что в большинстве случаев такая идея даже не рассматривается; однако в Lisp'ах эта задача практически тривиальна, и опытный Lisp-программист способен без особых проблем добавить в язык те возможности, которые ему нужны, вместо того чтобы симулировать программно).
Как пример, рассмотрим макрос with-open. Он есть в Clojure, но ClojureScript реализован на JavaScript, и не имеет интерфейса java.io.Closeable, используемого JVM-реализацией. Но сам по себе он довольно удобен, так что попробуем реализовать его (в упрощённом варианте) для работы с файловыми объектами PhantomJS.
;; первый аргумент - вектор из двух элементов, остальные берём как один список
(defmacro with-open [[file name] & body]
;; quasiquote (`) позволяет подставлять, unquote (~) подставляет значение
;; splice-unquote (~@) подставляет все элементы списка
;; result# подставляет сгенерированный symbol, начинающийся с "result"
`(let [~file ~name
result# (do ~@body)]
(.close ~file)
result#))
;; тестируем
(macroexpand
'(with-open [fin (fs.open infile "r")]
(read-line)
(read-line))))
;; результат вызова macroexpand будет выглядеть примерно так:
;; (let* [fin (fs.open infile "r")
;; result__218__auto__ (do (read-line)
;; (read-line))]
;; (.close fin)
;; result__218__auto__)
(Внимание: компилятор ClojureScript работает не на целевой VM – JavaScript, и отличается от полноценного Lisp-компилятора; поэтому для использования макросов они должны быть описаны во внешнем Clojure-файле и импортированы командой require-macros.)
Примеры (Clojure)
(require '[clojure.string :as s])
(defn read-all [fin]
(binding [*in* fin]
(let [to-int #(Integer. %)]
(map to-int (s/split (read-line) #" ")))))
(defn print-all [fout res]
(binding [*out* fout]
(println res)))
(defn calc [[a b]] (+ a b))
(print-all *out* (calc (read-all *in*)))
или
(let [[a, b] (.split (read-line) " ")]
(println (+ (Integer. a)
(Integer. b))))
Примеры (ClojureScript)
(ns a-plus-b
(:require [clojure.string :as s]))
(def sys (js/require "system"))
(defn read-all [fin]
(let [to-int #(js/parseInt %)
[a b] (map to-int (s/split (.readLine fin) #" "))]
{:a a, :b b}))
(defn print-all [fout res]
(.writeLine fout res))
(defn calc [{:keys [a, b]]
(+ a b))
(print-all sys.stdout (calc (read-all sys.stdin)))
или
(ns a+b)
(def sys (js/require "system"))
(let [[a, b] (.split (.readLine sys.stdin) " ")]
(js/console.log (+ (js/parseInt a)
(js/parseInt b))))
В ClojureScript можно использовать функции вывода Clojure (prn, println и т.п.), но для этого нужно установить *print-fn* (это можно сделать командой (enable-console-print!)).
Производительность (Clojure vs ClojureScript)
При запуске программы на Clojure JVM тратит около секунды (~750мсек по замерам разработчиков языка) на инициализацию библиотеки, вне зависимости от того, какая часть этой библиотеки используется самой программой. В ClojureScript инициализируется только та часть, которая используется на практике; поэтому простые программы отрабатывают почти без задержки. Однако сложные вычисления нередко затрагивают немалую часть библиотеки; в таком случае на её инициализацию может уйти даже больше времени, чем на JVM, из-за различий в реализации (особенности движка JavaScript).
При написании решения на ClojureScript следует помнить об этом различии; код, который неплохо отрабатывал на JVM безо всяких оптимизаций, в JS может внезапно начать провисать. Например, в одной задаче решение замедлилось в несколько раз из-за различий в производительности функции last на векторах.
Подход к оптимизация кода берём примерно следующий:
- Находим функцию, которая вызывает проблему (с помощью функции time)
- Выбираем операцию, которую можем заменить более быстрой альтернативой
- Комментируем текущую реализацию функции, пишем оптимизированный вариант
- Сравниваем производительность до и после
- Если есть заметное улучшение, оставляем новую реализацию
- Если заметного улучшения нет, убираем изменённый вариант и пробуем что-нибудь другое
Помимо однострочного комментария (;) в Clojure имеется "макрос пропуска" (#_), заменяющий при компиляции свой аргумент на nil; аналогичный эффект имеет макрос-форма comment. Их использование упрощает работу с кодом (при сравнении производительности разных реализаций функции).