Расширяя границы: Clojure

12 августа 2010, 03:24
Clojure - это новый язык программирования, делающий упор на многопоточность и функциональный стиль. На сегодняшний день существует две реализации Clojure - для JVM и для CLR (последняя выделена в отдельный проект и называется, собственно, ClojureCLR). Тот факт, что язык базируется на широкораспространённых платформах, позволяет использовать огромное количество уже готовых библиотек, а также размещать код на известных и популярных хостингах. Синтаксически Clojure является диалектом Lisp'а. Также он разделяет философию Лиспа "код-как-данные" и поддерживает систему макросов. Однако, обо всём по порядку.

REPL

Или Read-Eval-Print-Loop. Если вы до сих пор не знаете, что это, то откройте для себя интерактивное программирование. В двух словах, REPL - это консоль, позволяющая напрямую вызывать функции и вычислять значения переменных. Например, написали вы функцию вычисления факториала, чтобы проверить её работоспособность в Java, вы, скорее всего, напишете отдельный класс с функцией main, запихнёте в неё вызов факториала с конкретными параметрами, скомпилируете и запустите получившуюся программу. Чтобы проверить функцию с другим значением, отредактируете код, снова скомпилируете и снова запустите. Каждое исправление с использованием IDE займёт секунд 20-30. С REPL'ом весь процесс проверки будет выглядеть так: user> (factorial 1) 1 user> (factorial 5) 120 user> (factorial 10) 3628800 Причём вам даже не надо будет набирать каждый раз слово "factorial" - жмём Ctrl+Up, исправляем значение и вуа-ля! Секунды 2-3, как правило. Но сам по себе такой REPL ничем не лучше консоли любой операционной системы. Однако добавьте к нему инкрементальную компиляцию (добавление в пространство имён новых объектов прямо в консоли) и горячую замену кода (замена старых объектов, например, функций, новыми без перезагрузки программы) и вы получите практически живой организм, с которым вы можете "общаться", получать информацию и изменять состояние любого "органа", не останавливая при этом его работу. Не знаю, правда или нет, но поговаривают, что NASA как-то даже управляла из лисповского REPL'а состоянием своего марсохода, дописывая на ходу его ПО.

Взаимодействие с Java

Если вы знаете Java, то, по сути, уже можете писать на Clojure. Вызывать Java-функции прямо из REPL'а не просто легко, а очень легко. Всё, что для этого нужно, - это поставить точку и записать через пробелы имя объекта, название метода и аргументы. Например, строка на Java myHashMap.put("key", "value"); на Clojure может быть записана как (. my-hash-map put "key" "value") или, следуя функциональному стилю, как (.put my-hash-map "key" "value") (Обратите внимание на отсутствие пробела между точкой и названием метода.) Существуют также специальные формы для конструкторов (Thread. arg), свойств и методов статических классов (System/out) и цепочки вызовов (.. System (getProperties) (get "os.name")).

Неизменяемые данные и многопоточность

Большинство типов данных в Clojure является неизменяемыми (immutable). Это является стандартом для языков функционального программирования и очень помогает при многопоточном программировании. Вспомним, например, что даже в Java строки являются всегда иммутабельными, поэтому различные потоки могут свободно использовать одну и ту же строку, не боясь конфликтов. Знание того, что какая-то структура не будет изменена, позволяет вводить ряд оптимизаций. Например, добавление нового элемента в Map не приведёт к полному его копированию, а лишь создаст объект с новым элементом и ссылкой на исходный Map. Однако, иногда всё-таки возникает необходимость использовать именно мутабельные объекты, и здесь Clojure предоставляет целый арсенал средств - Ссылки (Refs), Атомы (Atoms) и Агенты (Agents). Подробно о них можно прочитать в книге Programming Clojure издательства Pragmatic Bookshelf (доступна на торрентах). Отмечу только, что для многопоточности Clojure использует не стандартную схему с lock'ами, а так называемую Software Transactional Memory (STM) - аналог транзакционной схемы, используемой в базах данных. Такой тип синхронизации лишён недостатков, присущих обычным мониторам и критическим секциям, в частности, в нём попросту невозможно устроить dead-lock. Ну и, конечно, на случай, если вы всё-таки решите использовать lock-синхронизацию, Clojure оставляет её в виде макроса locking. Надо отметить, что создание многопоточных приложений на Clojure также гораздо проще и приятнее, чем на Java. Каждая функция в этом языке не только является объектом первого класса (то есть может передаваться в другую функцию как аргумент), но и реализует интерфейс Runnable, поэтому имея функцию doSmth, мы можем выделить её в отдельный поток так: (new Thread doSmth) или в сокращённом виде: (Thread. doSmth) Clojure также значительно упрощает создание самих конкурентных алгоритмов. Так, например, если мы хотим распараллелить вычисление факториала большого числа, то реализация MapReduce для этой задачи будет иметь вид: (defn partitial-factorial [bounds] (reduce * (range (first bounds) (+ (second bounds) 1)))) (defn map-reduce-factorial [bounds] (reduce * (pmap partitial-factorial bounds))) Первая функция представляет собой немного модернизированную версию факториала, которая вместо одного числа принимает вектор из двух элементов - нижней и верхней границ нужного диапозона. Вся соль здесь во второй функции. pmap - это аналог map, но автоматически выделяющий для каждого вычисляемого элемента свой поток. В результате мы можем записать user> (map-reduce-factorial '([1 1000] [1001 2000] [2001 3000])) и вычисление всех частичных факториалов (от 1 до 1000, от 1001 до 2000 и от 2001 до 3000) будет выполнятся параллельно. Намного проще, чем Hadoop, не правда ли?

Ленивые коллекции

Большинство диалектов Lisp'а придерживаются философии "Всё есть список" (Everything is a list). Clojure обобщает этот принцип до "Всё есть последовательность" (Everything is a sequence). Кроме списков (которые, если кто не в курсе, являются также и основой всех конструкций Lisp'а) в Clojure есть векторы (имеющие специальный синтаксис в виде квадратных скобок - []), наборы (sets, специальный синтаксис - {}) и мапы (записываются как #{}). Также к последовательностям автоматически преобразуются все коллекции Java, поэтому теперь нет необходимости писать кучу циклов всего лишь для преобразования из одной коллекции в другую - все объекты обрабатываются единообразно. Но и это ещё не всё! Clojure активно использует ленивые последовательности. Ленивые последовательности, как и вообще ленивые вычисления (delayed evaluation, lazy evaluation), конечно же, заслуживают отдельной, и не маленькой статьи, однако здесь я ограничусь лишь небольшим примером. Допустим, что вам необходимо разложить некую функцию в ряд Тейлора и взять заранее неизвестное количество элементов из этого ряда (типичная задача во многих математических приложениях). В Java вы скорее всего будете использовать цикл и хранить вычисленную часть списка в локальной переменной. И всё бы хорошо, только вот отдать такой полувычисленный список в вызывающую функцию вы не сможете - локальное состояние потеряется, и вычислять придётся заново. Выход здесь в том, чтобы написать генератор элементов, то есть объект, который будет хранить локальное состояние вычисленных элементов и выдавать их по запросу, а также при необходимости вычислять и новые элементы. Такие генераторы встречаются довольно часто - в Java это и Reader'ы, и Stream'ы, и класс Random и ещё два десятка объектов. Ленивые последовательности унифицируют все эти типы, позволяя создавать как конечные, так и бесконечные списки элементов с единым интерфейсом. То есть для любого "генератора" открывается возможность использовать ВЕСЬ набор функций, определённых над последовательностями. Функция partial-factorial из примера выше является докозательством продуктивности такого подхода: используя генератор range мы получили ленивый список целых чисел из диапозона bounds, а затем просто "свернули" его с помощью стандартной функции reduce. К слову, большинство функций работы со списками, такие как map, reduce, filter, distinct, take и многие другие в Clojure сами по себе пораждают ленивые коллекции, поэтому вам никогда не придётся хранить в памяти больше, чем необходимо в данный момент.

Отптимизация рекурсии

Широкое использование последовательностей открывает двери для множества рекурсивных алгоритмов. Это и алгоритмы над графами, и различные типы поиска, и переборы и прочее и прочее. Рост стека в таких алгоритмах огромен. В ряде функциональных языков, таких как Scheme или Haskell, эта проблема решается посредством Tail Call Optimization (TCO) - оптимизации хвостовых вызовов. К сожалению, JVM до сих пор не научилась делать её (в CLR, кстати, такой проблемы нет), однако в Clojure есть специальная инструкция recur, которая позволяет раскрутить хвостовые вызовы рекурсивных алгоритмов в цикл. Для примера, рассмотрим функцию факториала: (defn factorial-recur [n res] (if (= n 1) res (recur (- n 1) (* n res)))) Вместо того, чтобы накапливать результаты в стеке, мы вводим в функцию ещё один параметр - аккумулятор. Роста стека не происходит, вместо этого просто изменяется значение параметра res, при этом запись остаётся краткой и понятной. Кроме recur в Clojure также есть макрос trampoline, позволяющий раскручивать взаимную рекурсию (взаимная рекурсия - это когда функция a() вызывает функцию b(), а та, в свою очередь, снова вызывает a() и так далее).

Макросы

Когда я начинал работать с Java JDBC, мой код меня ужасал: для того, чтобы выполнить один запрос к СУБД, необходимо было написать 17 строк дополнительного кода. Создание объектов, открытие сессии, закрытие сессии, try, catch и в нём ещё два try, и оргомная куча скобок. Функции всегда отличались ровно одной строчкой... ну и, может быть, заголовком. Однако как-то вынести повторяющийся код в отдельную синтаксическую единицу не представлялось возможным, ведь код был и перед различающейся строкой, и за ней. Как и в любом Lisp'е в Clojure такая затруднительная ситуация элементарно решается посредствам макросов. Макрос - это именованный кусок произвольного кода, который к тому же может принимать параметры. Если вы сейчас вспомнили макросы в Си, забудьте - в отличие от Си в Лиспе эти "куски кода" не являются константами, а вычисляются перед компиляцией функции (в так называемый macroexpand-time), а значит, позволяют "дописывать" программу на лету. Примеров использования макросов множество. Захотели новый условный оператор - пожалуйста! Хотите обернуть функцию в кусок кода - легко! Не хватает какой-то конструкции языка - так напишите её сами! Именно макросы являются той фичей, которая позволяет Лиспам раз за разом перерождаться, а также адаптироваться к новым течениям и парадигмам. Удобная синхронизация потоков в Java потребовала введения нового ключевого слова - synchronized. В Clojure для этого понадобилось всего лишь написать макрос. Ещё раньше в старом добром Common Lisp исключительно на макросах была реализована поддержка ООП. Какой ещё язык может таким похвастаться?

Мультиметоды

Кстати про ООП. Вас никогда не смущало, что методы в Java диспетчеризируются исключительно на основании типа объекта? Никогда не хотелось выбирать нужный метод по значению или каким-то другим атрибутам? Ну, то есть красиво, не через switch-case, а чтобы на каждое значение или атрибут свой метод? Clojure заботится и об этом. Мультиметоды - это обобщённый полиморфизм, выбор метода в котором происходит по произвольному признаку, указанному в объявлении мультиметода. Для стандартной диспетчеризации по типу код будет выглядеть так: (defmulti do-smth type) type в данном случае - это имя встроенной функции, определяющей тип объекта. Разумеется, вместо неё может быть использована любая другая. Сами методы объявляются так: (defmethod do-smth String [obj] ;; ...) (defmethod do-smth Reader [obj] ;; ...) и так далее. Clojure также поддерживает метаатрибуты для объектов, что позволяет ввести свою собственную типизацию, диспетчеризацию и всё прочее. К слову, сам по себе тип объекта хранится в одном из метаатрибутов и может быть добавлен в качестве подсказки компилятору, что позволяет элементарно ввести статическую типизацию.

Производительность и применимость

К сожалению, несмотря на все старания разработчиков, Clojure по прежнему сильно отстаёт по производительности от самой Java. В среднем Clojure-код выполняется в 3-10 раз медленнее, и даже при всех оптимизациях - в 1.5-2 раза. Тем не менее, скриптовые языки, гибкостью которых (если не большей) обладает Clojure, работают до 50 раз медленнее по сравнению с той же Java, что ставит данный диалект Лиспа во вполне выгодную позицию. Кроме того, не стоит забывать, что этот язык разрабатывался для многопоточного программирования, и именно в многопоточных задачах он проявляет себя с лучшей стороны. На мой взгляд, Clojure целесообразно применять в программах для многоядерных систем и комплексов, а также в качестве языка для быстрого прототипирования (отдельные модули можно постепенно переписывать на Java или C#). Кроме того, благодаря очень приятной работе со множествами я использую его при многих математических вычислениях, а также в качестве средства тестирования, когда юнит тесты не могут адекватно оценить результат выполнения функции (например, при проверке поискового движка).

Заключение

Данная статья не претендует ни звание туториала, ни на полное описание языка, а носит скорее "рекламный" характер. Я надеюсь, она поможет читателям немного расширить своё представление о языках программирования, а также ознакомиться если не с самим Clojure, то хотя бы с принципами, положенными в его основу. Более подробно и с принципами, и с самим языком можно ознакомится здесь. За полноценным учебником по этому языку отсылаю к уже упомянутому Programming Clojure издательства Pragmatic Bookshelf. Ставлю ящик пива плюс тому кто до сюда дочитает!
Обсуждение