Как перестать бояться и полюбить Clojure
Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті
Привет, DOU, меня зовут Илья Дозоренко, работаю в IT 11 лет, на данный момент я Clojure Lead в компании Freshcode.
В этой статье я расскажу о знакомстве и начале своей работы с языком Clojure и о сферах его применения. Статья предназначена для новичков в Clojure и для разработчиков, интересующихся функциональным программированием.
Цель статьи — рассказать об особенностях языка и, по возможности, дать мотивацию для изучения и использования Clojure.
История Clojure
Rich Hickey
Автором Clojure является Рич Хики (Rich Hickey) — гик-программист и культовая личность в среде Clojure-разработчиков. До создания Clojure Рич Хики преподавал C++ в Нью-Йоркском университете и работал в сфере информационных систем, сталкиваясь на практике со стандартными проблемами («в модели данных принадлежит ли песня исполнителю, или исполнитель — песне?»). В 2005м году Хики взял творческий отпуск для работы над личными проектами, а через два года опубликовал первую версию Clojure. На данный момент Рич Хики и компания Cognitect предоставляют коммерческую поддержку для Clojure.
Rationale, Reasonability
Рич Хики (Rich Hickey) дает такое краткое обоснование Clojure:
Зачем я создал еще один язык программирования? Мне просто был нужен:
— Лисп
— для Функционального Программирования
— реализованный на стабильной платформе
— с поддержкой параллельных вычислений
С подробной историей Clojure можно познакомиться в статье самого автора языка History Of Clojure.
В Clojure намного меньше новых и уникальных идей, чем кажется на первый взгляд. Конечно, в сравнении с Java Clojure выглядит мягко говоря экспериментальным языком, тем не менее Рич Хики разработал спецификацию языка полагаясь только на проверенные временем идеи и подходы.
Clojure является диалектом языка Lisp. В свою очередь, в основе Lisp лежат концепции лямбда-исчисления — использование функций- объектов первого класса и функций высшего порядка. Другими словами, функции рассматриваются как значения, могут принимать функции в качестве параметров и возвращать функции в качестве результата.
Одной из основных идей Lisp является работа с кодом, как с данными (code as data). Это возможно благодаря тому, что код на языке Lisp состоит из s-выражений (symbolic expressions):
(A (B 3) (C D) (()))
s-выражение — это просто список, который состоит из значений или других списков. Для записи s-выражения используют префиксную нотацию, те оператор предшествует операндам, например, (+ 1 2 3).
Программа на языке Lisp идентична по форме её синтаксическому дереву (AST), такая особенность языка называется гомоиконичностью.
Концепция s-выражений открывает дополнительные возможности для мета-программирования — фактически, программный код на Lisp может изменяться во время его выполнения.
Clojure также привносит в Lisp некоторые крупные нововведения для актуализации языка для real-world задач, например, к списку добавляются новые структуры данных (maps, vectors, sets).
Немногим позже в реализацию Lisp были добавлена концепция макросов (macros) в виде дополнительной фазы преобразования кода (macro-expansion) перед интерпретацией. Макросы максимально упростили расширение синтаксиса Lisp для разработчиков.
Однако, мощь и простота Lisp одновременно является и его «проклятием» (The Lisp Curse), которое заключается в разрозненности Lisp-сообщества, обилии реализаций и отсутствии единых стандартов. С момента создания Lisp появилось множество диалектов языка (в частности, наиболее популярный — Common Lisp), которые тем не менее не получили широкого распространения по сравнению с современными скриптовыми языками вроде Python или Ruby.
Важнейшей задачей Clojure является именно популяризация Lisp, в первую очередь благодаря использованию платформы JVM и поддержке и развитию сообщества разработчиков.
Этапы изучения Clojure
В предисловии книги Clojure Applied Рас Олсен (Russ Olsen) говорит, что процесс изучения Clojure по большей части проходит в три этапа. Начальный этап — это изучение базового синтаксиса и принципов — как расставить скобки, зачем нужны квадратные скобки, чем отличается список (list) от вектора (vector) и тп. На втором этапе мы разбираемся, как это всё связать вместе — как собрать все функции высшего порядка в рабочий код, или как работать с неизменяемыми структурами данных. На третьем этапе вы начинаете исследовать экосистему Clojure — библиотеки и приложения — и пользоваться полученными знаниями чтобы разбираться в чужих решениях, тогда и начинается настоящий фан!
Знакомство с Clojure
Selenium тесты
Впервые я познакомился с Clojure в 2013, во время работы над Java EE проектом в сфере телеком (> 300K LOC). Основное веб-приложение только мигрировали на Java 7, в которой уже появился try-with-resources и NIO, но еще не было стрелочных функций (arrow functions), Java Stream API и jshell.
Не секрет, что Java считается языком с довольно избыточным (verbose) синтаксисом, который с переменным успехом помогают скрывать IDE, и упрощают добавлением новых API и синтаксического сахара в новых версиях. На данный момент актуальная версия Java 15, но согласно JetBrains в 2020 году 3/4 Java-разработчиков продолжают регулярно использовать Java 8, эта же версия используется в более чем 80% production приложений по версии New Relic.
Также Java плохо подходит для скриптовых задач, поэтому когда появилась задача покрытия функционала Selenium-тестами была предложена идея использования Clojure, по следующим причинам:
- стабильная и знакомая платформа JVM
- интеграция с Java — возможность переиспользования кода во избежание дублирования логики
- Наличие REPL, возможность «набрасывать тесты на лету» в консоли
- Простой синтаксис (например, в сравнении с Scala или даже с Java) должен позволить QA-инженерам самим править тест кейсы и добавлять новые
С последним пунктом мнения разделились: высказывались опасения, что переход на функциональный стиль Clojure будет сложен даже для разработчиков. Но энтузиазм команды был довольно высокий, а руководство относилось толерантно к выбору доп. технологий, поэтому решили рискнуть.
Сперва с помощью clj-webdriver были реализованы вспомогательные функции для навигации по веб-приложению и управления компонентами, после чего началась работа над тест-кейсами. Сейчас этим уже не удивишь, но в то время использование REPL серьезно ускорило написание тестов: тест-кейс можно было «набрасывать» на ходу прямо в консоли, без необходимости перезапускать сценарий, и это поверх JVM!
Ниже пример одного из тест-кейсов:
(ns project.tests (:use project.utils)) (defcase standart-create-qos "Создание политики качества" (select-main-menu "Управление SLA" "Политики качества") (press-button "Создание новой политики качества") (input "Название" "test_qos_2") (select-option "Класс обслуживания" "Class G") (select-option "Тип профиля" "test_profile") (set-quality-rule "В течение 15 минут" "коэффициент потери пакетов" "в прямом направлении" "менее" "10") (wait-response (press-button "Сохранить")) (check-exists-row "test_qos_2"))
Итоговые тест-кейсы на Clojure читались довольно просто, и выглядели привычно и.. императивно! Полученный DSL покрывал до ¾ кейсов, которые писали QA инженеры, для остальных случаев приходилось привлекать разработчиков.
Моя роль при написании тестов была небольшой и заключалась именно в помощи QA-инженерам и правке вспомогательных функций.
Первый этап изучения Clojure довольно простой, и в этом заслуга минимального синтаксиса Clojure. Страхи и неудобство от скобок, которыми «пугают» новичков на самом деле сильно преувеличены. Для всех основных редакторов и IDE существуют paredit-плагины, позволяющие «балансировать» количество скобок (добавлять скобки парами и избегать случайных удалений) и поддерживающих structural editing (сочетания клавиш для манипуляции s-выражениями). Structural editing можно использовать как непосредственно для Clojure-кода:
так и для Hiccup-разметки:
Взаимодействовать с Java-кодом несложно, но нужно запомнить несколько синтаксических правил, самое главное — вызов функции всегда должен находиться на первом месте в списке. Например, следующий код на Java
public ByteArrayInputStream toInputStream (String s, String charset) throws UnsupportedEncodingException { return new ByteArrayInputStream(s.getBytes(charset)); }
можно описать так в Clojure:
(defn string->stream [s charset] (-> s (.getBytes charset) (ByteArrayInputStream.)))
Для большинства Java-библиотек и модулей есть соответствующие библиотеки-«обвязки» в Clojure, поэтому можно переписать этот же код без Java-вызовов с использованием byte-streams:
(defn string->stream [s encoding] (to-input-stream s {:encoding encoding}))
Говоря о интеграции, стоит упомянуть и о том, что весь JVM-инструментарий будет работать и с Clojure, например, вы можете использовать YourKit и для профайлинга Clojure-приложений.
Первый этап был пройден, синтаксис и основные конструкции усвоены, но когда Clojure «зацепил», то становится сложно остановиться, и приходится разбираться дальше!
Промежуточный этап
Сборники задач
Обычно, второй этап изучения — самый сложный. Должен сказать, что переход с императивного стиля Java на Lisp может быть непростым. Несмотря на минимальный синтаксис, поначалу часто приходилось «ломать голову» над различными конструкциями. На помощь здесь приходит большое количество ресурсов и интерактивных учебников, и конечно опыт и поддержка сообщества.
Своей элегантной простотой Clojure напоминает игру-головоломку вроде кубика Рубика, которая сперва смущает сложностью, но в которой приятно разбираться, когда ты понимаешь её принцип.
Дать пищу для размышлений и пройти этот этап мне очень помогли сборники задач вроде project euler, 4clojure и advent of code. Есть и более продвинутые учебные ресурсы, с игрофикацией процесса, красивыми «ачивками» и real-world задачами, но лично для меня главной мотивацией стал интерес при работе с Clojure, который заставлял искать ответы на вопросы и разбирать новые примеры.
Функциональный подход
Первое, на что обращаешь внимание при создании модели данных в Clojure — отсутствие привычного ООП. В Java мы привыкли описывать данные в полях классов, а логику работы с объектами — в методах. В Clojure функции не принадлежат данным (как методы — классу в Java), они обрабатывают данные. В качестве единицы инкапсуляции рассматриваются namespaces, которые соответствуют отдельным файлам.
Clojure разрешает использование нечистых (impure) функций, но поощряет функциональный подход, в частности использование функций высшего порядка (например для работы с коллекциями — map, reduce, filter, remove), а также композиции функций (partial, comp, juxt).
Неизменяемые структуры данных
В Clojure используются неизменяемые (immutable) структуры данных — это значит, что невозможно поменять значение в коллекции, можно только создать новую коллекцию с новым значением. При этом сохраняется алгоритмическая сложность доступа к объектам, например, доступ к вектору или hash-map — O(log32(N)), а для хранения используется практически константная память за счет разделения большей части внутренних данных между всеми версиями измененной структуры.
Концепция равенства объектов (equality) приобретает новый смысл — если две структуры равны, то они будут равны всегда, а не только в определенный момент времени. Этот подход серьезно облегчает работу с многопоточными приложениями, так как отсутствует необходимость использования thread-safe конструкций и локов (вспоминаем synchronized блоки и коллекции в Java).
Для хранения состояния приложения используются потокобезопасные ссылки (thread-safe references) — var, atom, agent, ref. Также в Clojure реализован механизм Software Transaction Memory, но это тема для отдельной статьи.
Ленивые вычисления
В Clojure используются ленивые (lazy) коллекции, это значит, что элементы коллекции не доступны заранее, а могут быть получены после вычисления.
Ленивые коллекции также позволяют описывать бесконечные коллекции. Для примера обратимся к задаче № 10 из сборника Project Euler: необходимо найти сумму простых чисел в интервале до двух миллионов.
(def primes (filter prime? (iterate inc 1))) (defn solve [] (->> primes (take-while #(< % 2E6)) (reduce +)))
Здесь для краткости было опущено определение функции prime?. По-сути primes определяет всё множество простых чисел, тк iterate и filter — ленивые функции, и значение primes не будет вычислено до тех пор, пока мы не начнем обращаться непосредственно к элементам коллекции (если ввести primes в REPL, он зависнет на стадии evaluation). take-while — тоже ленивая функция, и только reduce непосредственно запускает вычисление при нахождении суммы.
Конечно, эту задачу можно решить и без использования ленивых бесконечных коллекций, и вы вправе выбрать например, подход с использованием for, но согласитесь, такая запись выглядит красиво и наглядно!
Data-oriented подход
В то время, как в Java декларативный стиль кода в WEB-разработке обеспечивает Spring и IoC, в Clojure декларативность частично обязана особенностям языка (гомоиконичность, неизменяемые структуры, макросы), а частично — сообществу, которое активно поощряет data-oriented подход в библиотеках.
В отрывке лекции «Clojure, Made Simple» Рич Хики объясняет преимущества data-oriented подхода в Clojure.
Для любого типа данных в Java используется промежуточный слой в виде интерфейса (например, class accessors/mutators или же getters/setters). Частично поиск нужных методов облегчается автокомплитом в IDE, плагин Project Lombok позволял автоматизировать их генерацию с помощью аннотаций в классах, а совсем недавно подобный подход (Records) был имплементирован в Java 14. Однако речь идет о базовых библиотеках, которые используются до сих пор, взгляните, например, на методы класса javax.servlet.http.HttpServletRequest:
Здесь разными цветами обозначены интерфейсы для доступа к трем внутренним полям, обратите внимание, они специфичны для каждого поля: разная логика названий — getParameterMap vs getHeaders, методы remove и set используются для attributes, в то время как для parameters они отсутствуют, и тд. Вместе с разнородным и громоздким интерфейсом получаем невозможность переиспользования логики манипуляции данными и сложности создания заглушек для тестирования (вспоминаем builders).
Clojure противопоставляет этому data-based подход, где объекты — это просто ассоциативные коллекции (maps) с открытым доступом ко всем полям:
Все функции Clojure по манипуляции данными можно использовать для работы с этим объектом. Потоковый макрос (threading macros) ->> позволяет компоновать функции так, как если бы вы использовали Stream API в Java, например:
(->> request :headers (filter #(str/starts-with? % "my-header")))
На практике использование небольшого сета структур данных в связке с функциональным подходом колоссально облегчает процесс манипуляции данными и, следовательно, упрощает написание и чтение программ.
Продвинутый этап
Web разработка на Clojure
Спустя некоторое время у меня появилась возможность попробовать Clojure в веб-разработке, и таким образом перейти к третьему этапу изучения. Преимущественно я использую Clojure при создании классических информационных систем в небольших командах (до 10 человек), на мой взгляд небольшая команда позволяет полноценно использовать преимущества языка, одним из которых является быстрое прототипирование.
Ring. Быстрое прототипирование
Ring — это стандарт веб-разработки в Clojure. Аналогами Ring для Python и Ruby являются соответственно WSGI и rack. Репозиторий библиотеки Ring содержит спецификации request/response/middleware и базовый код для работы с ними, а также основные middleware, запуск Jetty-сервера и документацию. Все современные Clojure-фреймворки придерживаются совместимости со стандартом Ring, это позволяет без изменений запускать приложение на разных платформах — Jetty, http-kit, Immutant и др.
Простота работы с Ring практически стала откровением. Идиоматический подход с использованием концепции Middleware на основе функций используется сейчас во многих языках и фреймворках (например, Express в NodeJS). На тот момент у меня было несколько лет опыта работы с Java Servlets и Spring, поэтому в первую очередь сравнивал middleware-подход с Servlet Filters и Spring Boot.
К примеру, сравните пример базового REST-сервиса на Spring с аналогичным кодом на Clojure.
Мой первый REST API на Clojure выглядел фактически как в примере выше, разве что с использованием библиотеки compojure вместо более продвинутого compojure-api. В комбинации с REPL использование Ring позволяет очень быстро создавать рабочие приложения.
Compojure — это библиотека роутинга поверх стандарта Ring. Она позволяет описать функцию-обработчик Ring в привычном для нас и легко читаемом виде списка роутов:
(def my-routes (routes (GET "/foo" [] "Hello Foo") (GET "/bar/:id" [id] (str "Hello " id)))))
Библиотека темплейтинга Hiccup предоставляет DSL для описания HTML:
(html [:span {:class "foo"} "bar"]) ;; => <span class="foo">bar</span>
Удобство Hiccup состоит в том, что мы можем манипулировать Hiccup-деревом так же, как и обычными структурами данных Clojure, без необходимости заключать вычисляемые выражения в фигурные скобки.
(html [:ul (for [x (range 1 4)] [:li x])]) ;; => <ul><li>1</li><li>2</li><li>3</li></ul>
Этот же формат используется в популярной ClojureScript библиотеке Reagent (аналог ReactJS).
Web-фреймворки
Комьюнити предпочитает использовать композицию популярных библиотек вместо классических фреймворков «всё-в-одном» для облегчения разработки и для большей гибкости. Тем не менее для web-разработки в Clojure доступны как популярные темплейты, так и более продвинутые фреймворки:
compojure-api — compojure «на стероидах» для работы с RESTful-сервисами, в классический compojure добавлена поддержка clojure.spec и Swagger.
Liberator — библиотека для работы с RESTful-сервисами, использующая подход Erlang фреймворка webmachine. Позволяет описывать обработчики запросов с помощью концепции ресурсов, которые выполняются после прохождения запросом определенного графа решений (decision graph). Такой подход является более строгим и упрощает обеспечение совместимости со стандартом HTTP RFC 2616.
yada — как и Liberator, позволяет обеспечивать совместимость с HTTP-стандартом. Включает в себя Swagger, Websockets (aleph) и другие сервисы. Интересно, что yada в целом совместима с Ring, но не использует Ring Middleware, предлагая data-driven подход.
Pedestal — веб-фреймворк от Cognitect, в целом достаточно неплохой (кроме документации), но довольно opinionated (напр., использует свой роутер), отличается хорошей оптимизацией и совместимостью с веб-серверами (напр, возможность деплоя в AWS Lambda).
luminus — project template с определенным набором библиотек для работы с REST сервисами (Reitit, Swagger), Websockets (зависит от выбранного сервера), БД (HugSQL + Migratus), темплейтингом (Hiccup, Selmer), i18n (Tempura) и др. Активно поддерживается в настоящее время.
Jetty (включен в релизацию Ring) и http-kit — базовые веб-серверы для Clojure приложений, также поддерживают работу с websockets. Это standalone-библиотеки, те они будут упакованы в один JAR-файл с приложением.
Immutant — комплект, включающий веб-сервер Undertow и библиотеки для месседжинга (HornetQ), кэширования (Infinispan) и планирования задач (Quartz). Может быть использован как standalone-библиотека, так и в контейнере WildFly для кластеризации. Поддерживается JBoss Community.
Наиболее актуальный список библиотек, сгруппированных по назначению, доступен в Clojure Toolbox.
Расширяемость
Говоря о разработке, необходимо отметить расширяемость языка, так как множество популярных и необходимых в повседневной разработке особенностей Clojure реализовано не в ядре языка, а во внешних библиотеках при помощи макросов.
Так, например, pattern matching реализован в отдельной библиотеке core.match. Можно сравнивать с образцом любые структуры данных Clojure:
(let [x {:a 1 :b 1}] (match [x] [{:a _ :b 2}] :a0 [{:a 1 :b 1}] :a1 [{:c 3 :d _ :e 4}] :a2 :else nil)) ;=> :a1
Или расширить сравнение для других типов данных:
(matchm [(java.util.Date. 2010 10 1 12 30)] [{:year 2009 :month a}] a [{:year (:or 2010 2011) :month b}] b :else :no-match)
core.async — библиотека, реализующая асинхронное взаимодействие с помощью каналов (очередей) и паттернов thread pool и inversion of control. Подход напоминает каналы в Golang, с тем основным отличием, что в Clojure каналы реализованы не в ядре языка, а в отдельной библиотеке.
(defonce log-chan (chan)) (defn loop-worker [msg] (println (str "hello, " msg "!"))) (go-loop [] (let [msg (<! log-chan)] (loop-worker msg) (recur))) (>!! log-chan "world") ; => hello, world! (>!! log-chan "core.async") ; => hello, core.async!
Блок go (или go-loop, который совмещает в себе макрос go и функцию loop) — объявляет код, который будет выполняться асинхронно, в отдельном потоке, и сразу же возвращает управление.
Функция осуществляет парковку потока в рамках блока go-loop, то есть при получении сообщения выполняется функция-обработчик loop-worker, а затем блок приостанавливает выполнение, ожидая следующего сообщения.
С помощью блокирующей функции >!! в канал log-chan передаются строки, которые обработчик loop-worker выводит в консоль.
Для go-блоков core.async по умолчанию предоставляет фиксированный Thread pool размером в 8 потоков.
При этом, core.async активно используется и в ClojureScript. Реализация не содержит блокирующих операций и предоставляет идиоматический для Clojure подход к решению проблемы callback-hell.
clojure.spec и динамическая типизация
Динамической типизации Clojure в первую очередь обязан Lisp’у. Рич Хики также является сторонником использования динамической типизации по умолчанию.
О типизации годами ведутся долгие и сложные дискуссии, поэтому я опишу здесь только личные впечатления. Действительно, с использованием динамической типизации проще «выстрелить себе в ногу», но с другой стороны, становится гораздо проще стрелять в целом :) Отсутствие «статики» при web-разработке в Clojure компенсируется возможностью тестирования работы отдельных компонентов и всей системы «на лету» в REPL и покрытием функционала тестами, а также возможностью описания схемы данных и валидации с помощью core.spec.
core.spec использует data-oriented подход Clojure — для описания схемы данных используются структуры данных языка. Clojure делает смелое предположение о том, что во многих кейсах вам не понадобится прослеживание типов от начала и до конца выполнения модуля или цепочки функций. Вместо этого specs могут затем быть использованы в необходимый момент для проверки данных на соответствие протоколу, например, перед сабмитом веб-формы, или для проверки входных данных модуля/функции на корректность.
В примере ниже spec используется для валидации request/response и для преобразования типов (а также для генерации swagger docs, см. полный пример):
(s/def ::id int?) (s/def ::name string?) (s/def ::user (s/keys ::req-un [::id ::name])) (def app (api {:coercion :spec} (GET "/users/:id" [] :path-params [id :- ::id] :return ::user (ok (users-by-id id)))))
После нескольких лет использования статической типизации в Java, я поначалу чувствовал себя неуверенно при работе с гибкой информационной моделью в Clojure. Не буду лукавить, у статической типизации есть свои бесспорные преимущества — например, intellisense и оптимизации компилятора. Однако когда речь заходит об «отлавливании» ошибок в реальных рабочих кейсах, покрытие кода тестами и использование clojure.spec работает.
Действительно, иногда возникает обоснованная необходимость добавить типизацию, например, для сложного алгоритма или определенного модуля. Здесь на помощь снова приходит расширяемость Clojure: например, библиотека core.typed реализует полноценную статическую типизацию, которую можно добавить как для всей программы, так и для выбранных участков кода.
Другие возможности
Clojure активно используют не только в web и мобильной разработке, но и в сфере Data Science и DL/ML (см. например incanter, scicloj). Мне пока не приходилось непосредственно работать в этих областях, но я смог оценить элегантность Clojure в сфере работы с DSL.
DSL
DSL (domain specific language) — это язык с достаточно высоким уровнем абстракции, предназначенный для решения узкого класса проблем. В Clojure часто DSL описывают в терминах структур данных Clojure, как например Hiccup или honeysql:
{:select [:a :b :c] :from [:foo] :where [:= :f.a "baz"]}
Более кастомизируемым способом является использование парсеров произвольных грамматик.
Приведу реальный пример: при работе над Java-проектом, где для манипуляции фильтрами над датасетами в ElasticSearch использовался GUI, аналогичный searchkit, передо мной встала задача реализации «продвинутого» режима фильтрации. Этот режим позволил бы расширить возможности GUI фильтров с помощью SQL выражений и добавить более сложные логические конструкции, которые GUI не позволял использовать. Например, все условия в GUI фильтрах соединялись исключительно с помощью конъюнкции (AND) или дизъюнкции (OR), таким образом, в GUI было невозможно комбинировать операторы или расставлять приоритеты:
условие_1 AND (условие_2 OR условие_3).
Соответственно, задача состояла в преобразовании SQL-выражений такого типа в довольно сложный, но валидный запрос к ElasticSearch в формате edn, например, фильтр
NOT (project.amount <= 1000 AND project.name LIKE «%startup%»)
должен транслироваться в следующую форму:
{:query {:bool {:must_not {:bool {:should [{:nested {:path "tuples" :filter {:bool {:must [{:term {:tuples.field_name_raw "project.name"}} {:multi_match {:query "startup" :operator "and" :lenient true :fields ["tuples.str_value_e_ngram" "tuples.str_value_ngram"]}}]}}}} {:nested {:path "tuples" :filter {:bool {:must [{:term {:tuples.field_name_raw "project.amount"}} {:range {"tuples.long_value" {:lte 1000}}}]}}}}]}}}}}
Основной запрос к ElasticSearch генерировался динамически и, как можно заметить, был довольно специфичен из-за структуры хранения данных, поэтому описать его с помощью SQL и выполнить с использованием ElasticSearch SQL JDBC driver было бы весьма затруднительно. Также требовалось реализовать автодополнение SQL выражения в UI (например, в зависимости от контекста отображать в выпадающем списке доступные поля датасета или операции над определенными типами данных — LIKE для строк и BETWEEN для numeric полей) и подсветку ошибок.
Для этой задачи как нельзя лучше подошел Clojure, особенностями которого являются гомоиконичность и простота работы с коллекциями. Идея состояла в том, чтобы (1) «распарсить» SQL-выражение в AST в виде структуры данных Clojure, которое затем (2) преобразовать в изоморфную структуру запроса к ElasticSearch.
Для первого шага была использована библиотека Instaparse, которая предназначена для создания парсеров произвольных грамматик описанных при помощи BNF: на вход подается выражение и BNF спецификация и строка с выражением , а на выходе — AST в формате hiccup (по-сути, вложенные Clojure-коллекции), при этом каждый узел дерева содержит также полезные мета-данные (например, соответствие строке/колонке исходного выражения), которые можно использовать для реализации автодополнения. При ошибке парсинга Instaparse также предоставляет указатель на строку и колонку с ошибкой и даже ожидаемые термы.
На втором этапе используется pattern matching вместе с функцией обхода полученного на первом шаге AST-дерева.
Для демонстрации подхода я подготовил упрощенный gist для вычисления арифметических операций. Разница с вышеописанным кейсом состоит в том, что после получения AST для математического выражения, мы преобразуем его непосредственно в код Clojure, который можно выполнить и получить результат. Для большей наглядности использование контекста и переменных в выражении пришлось опустить.
На моей практике к подобному подходу с использованием Instaparse приходилось обращаться не один раз, но к сожалению его подробное рассмотрение выходит за рамки данной статьи.
ClojureScript
На данный момент существует большое количество сфер, в которых JavaScript является доминирующей технологией (браузеры), либо одной из наиболее широко используемых (мобильные устройства).
ClojureScript — компилятор Clojure в JavaScript, использующий Google Closure compiler; термин также используется в качестве названия языка (иногда cljs). По-сути, cljs сохраняет все достоинства Clojure — структуры данных, функциональный подход, макросы и др., и отличается только нюансами взаимодействия с платформой, при этом предлагая production-ready экосистему и библиотеки. Использование связки Clojure backend + cljs frontend позволяет переиспользовать общий код, а shadow-cljs обеспечивает бесшовную интеграцию с пакетами npm. Наконец, cljs используется для создания мобильных приложений на React Native.
И о недостатках
Как и у любой другой технологии, у Clojure есть свои недостатки и компромиссы.
Из-за особенностей реализации персистентные структуры менее производительны, чем их аналоги в Java. В Clojure доступны и более производительные изменяемые (transient) аналоги для таких структур данных, как vector, map и set, но они не предназначены для конкурентного доступа. Также в Clojure можно использовать все возможности хост-платформы (например, структуры данных и библиотеки Java), тем не менее типичный код на Clojure вероятно будет выполняться медленней, чем типичный код Java или Scala. Это не критично для большинства приложений, но может стать решающим фактором при выборе технологии для специфических высокопроизводительных систем или модулей.
Новички часто указывают на достаточно высокий порог входа, но связано это как правило не с функциональным подходом, а с взаимодействием с платформой. Это не должно стать проблемой, если вы только не собрались сходу переносить на Clojure готовый Java-проект.
Тем не менее, я считаю, что один из самых серьезных недостатков Clojure — это скромный маркетинг. Clojure — простой язык с минимальным синтаксисом, продуманной экосистемой и большим потенциалом, он способен вас приятно удивить! Тем не менее, его публичный образ, по-видимому, унаследованный от Lisp, часто бывает связан с «мудрёностью» и отсутствием стандартов, хотя дела обстоят ровным счетом наоборот.
Как следствие, в изучение Clojure(Script) не решаются инвестировать новички, хотя на практике именно они овладевают языком быстрее остальных.
Статистика использования Clojure
Результаты опроса Stack Overflow Developer Survey 2019 свидетельствуют о том, что доля Clojure в общем рейтинге языков программирования (Programming, Scripting, and Markup Languages) составляет порядка 1.5%, что является неплохим результатом среди функциональных языков программирования (для сравнения, у Scala — 3.8%, F# не представлен в списке). Clojure находится на первой строчке рейтинга уровня зарплат, опережая F#, Go и Scala, при этом Clojure пользуется популярностью у наиболее опытных разработчиков.
Результаты исследования State of Clojure 2020 показывают стабильный рост процента компаний и разработчиков, использующих Clojure для рабочих проектов, и в частности для enterprise приложений.
Что делать новичку
- Как и с любой другой технологией — попробовать её на практике. Если вы новичок в функциональном программировании, начните с тренировки навыков на специализированных ресурсах или сборниках задач вроде Project Euler. В свое время я также использовал Clojure для реализации небольших пет-проектов, и Clojure придал этим проектам большой стимул.
- Учиться у других — обращаться за помощью к сообществу, литературы по Clojure и Lisp также масса (в конце указано несколько источников), встречаются и по-хорошему сумасшедшие вещи.
- Посмотреть лекции Хики, он доступно и интересно рассказывает об особенностях языка и о причинах тех или иных инженерных решений
Итоги
- Clojure — интересный, активно развивающийся и доступный вариант функционального языка программирования для разработчиков (в том числе для Java-специалистов), которые ищут баланс между производительностью и fun-фактором
- Clojure — универсальный язык, который используется как для full stack web и мобильной разработки, так и для создания скриптов или работы с DSL
- Clojure, как потомок Lisp, нравится гикам, которые любят ставить перед собой сложные задачи и решать их. Но его роль более глобальна — он был создан для того, чтобы сделать разработку проще, и по-моему мнению с этой задачей он справляется отлично.
Полезные материалы
Почему стоит изучить Clojure?
Clojurians — Slack Clojure-сообщества с каналами для новичков (beginners, clojure, clojurescript) и поиском работы (jobs, remote-jobs)
/r/Clojure — Clojure сабреддит с анонсами и обсуждениями библиотек/лекций и др.
Clojure Toolbox — актуальный справочник Clojure(Script)-библиотек
Rich Hickey fanclub — коллекция лекций и интервью Рича Хики
На clojure.org перечислены все известные книги по Clojure, ниже несколько книг по изучению языка, которые я могу порекомендовать:
Programming Clojure — хорошая вводная книга, недавно вышло обновленное третье издание (не путать с Clojure Programming — тоже прекрасная книга-справочник, но уже немного устаревшая)
Clojure Applied — более продвинутая книга для тех, кто уже знаком с Clojure и FP
Mastering Clojure Macros — для тех, кто желает совершенствовать свои навыки написания макросов
58 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів