|
|
|||||||||||||||||||||||||||||
|
Почему стоит изучить Clojure?Источник: habrahabr anjensan
Clojure rocks?Предупрежу сразу - в статье не будет кусочков кода, демонстрирующих крутизну Clojure. Не будет фраз, подобных "в языке X это заняло 5 строчек а в Clojure всего 4". Это же отвратительный критерий для качества языка! В конце концов, мне совершенно все равно, смогу ли я записать Лямбдами сейчас никого не удивишь, они есть везде (ну почти, хотя обычно к 8й версии они появляются везде). Обработка коллекций (в том числе параллельная), списковые выражения, разнообразные синтаксический сахар - этого сейчас хватает во многих языках. По правде говоря, я просто обожаю такие статьи. Но подобные сравнения совершенно не годятся для сравнения качества языков! Это как измерять скорость ЯП по тому, насколько быстро программа выводит "Hello, world!". Ну, если только мы не измеряем скорость HQ9+. Если подумать, то подобные детали не столь уж и важны для больших систем. По мере роста проекта нас все меньше и меньше волнует, используем ли мы скобочки или отступы, инфиксную или префиксную запись. Лишняя строчка при нахождении суммы массива уже перестает всех заботить - на первое место выходят проблемы иного рода.
СложностьСистемы, которые мы создаем, по своей природе изменчивы. Было бы очень хорошо, если бы требования не изменялись. Просто замечательно, если бы в самом начале разработки можно было предусмотреть все ситуации наперед. Увы, в реальной жизни нам постоянно приходится доделывать, переделывать, улучшать, переписывать, заменять, оптимизировать… Самое неприятное - со временем сложность системы только растет. Постоянно, непрерывно. В начале разработки все просто и прозрачно, любое изменение делается быстро, никаких "костылей". Красота. Со временем ситуация перестает быть столь радужной и веселой. Даже малейшая правка кода потенциально может повлечь за собой лавинообразные изменения поведения системы. Приходится тщательно изучать, анализировать код, пытаться предугадать побочные эффекты от каждого изменения. Именно так, со временем мы буквально не можем досконально проанализировать все возможные последствия от наших изменений. Человек по своей природе может воспринимать в один момент времени лишь ограниченное количество информации. По мере роста проекта увеличивается и количество внутренних связей. Более того, б о льшая часть связей неявна. Нам все сложнее удержать нужное в голове. А в это время команда растет, коллектив меняется - новые люди уже не знают всего проекта. Идет разделение сфер обязанности, что может привести к еще большей запутанности. Постепенно наша система становится сложной. Как с этим бороться? Максимальное покрытие регрессионными тестами и прогон их после каждого изменения? Тесты крайне полезны, но они являются лишь страховочным тросом. Тесты не прошли - что-то нет так, у нас проблемы. Это лечение симптомов, но тесты не устраняют суть проблемы. Строгие гайдлайны и повсеместное использование паттернов? Нет, проблема ведь не в локальных сложностях. Мы просто перестаем понимать как взаимодействуют компоненты в нашем коде, неявных связей слишком много. Быть может постоянный рефакторинг? Это не панацея, сложность растет не из низкоуровневых решений. На самом деле проблема должна решаться комплексна. И одно из важный средств - правильный инструмент. Хороший язык программирования должен помогать нам писать простые и прозрачные программы.
Просто и легкоНо "просто" (simple) вовсе не означает "легко" (easy). Это разные понятия. На эту тему Рич Хики (автор Clojure) даже сделал известный доклад Simple Made Easy. На хабре опубликован перевод слайдов. Простота - понятие объективное. Это отсутствие сложности (complexity), отсутствие переплетения, спутанности, малое количество связей. С другой стороны, "легко" весьма субъективно. Легко ли управлять велосипедом? Выиграть партию в шахматы? Говорить на немецком? Я не знаю немецкого, но ведь это не повод говорить "этот язык не нужен, он слишком сложный". Он сложен для меня , да и то только потому, что я его банально не знаю. Мы все привыкли, что вызов функции записывается как
Выглядит очень… странно! Надо потратить некоторое время на изучение языка, освоение его концепций, чтобы он стал легким. Но простота (или сложность) постоянна. Если мы хорошо изучим инструмент, то количество внутренних зависимостей все равно не изменится. Он не станет сложнее или проще, хотя будет для нас легче. Привычный инструмент может дать лучшие результаты прямо сейчас, сиюминутно, но в более далекой перспективе простейшее решение показывает лучшие результаты.
Побочные эффектыКаковы источники сложности в наших программах? Один из них - побочные эффекты. Полностью обойтись без них нельзя, но мы можем их локализовать. И язык должен нам в этом помогать. Clojure - язык функциональный, он стимулирует нас писать чистые функции. Результат таких функций зависит лишь от входных параметров. Не надо ломать голову "хм, а что будет, если перед вызовом этой функции я запущу вот эту". Никаких если, есть входные данные, есть выходные. Сколько бы раз мы не запустили функцию - ее результат будет один и тот же. Это предельно упрощает тестирование. Не нужно перебирать различные порядки вызова или воссоздавать (симулировать) правильное внешнее состояние. Чистые функции проще анализировать, с ними буквально можно "поиграться", посмотреть как они ведут себя на живых данных. Проще отлаживать код. Мы всегда можем воспроизвести проблему с чистой функцией - достаточно передать ей на вход параметры, вызывающие ошибку, ведь результат функции не зависит от того, что выполнялось ранее. Чистые функции предельно просты, даже если выполняют большую работу. Разумеется, Clojure поддерживает функции высших порядков, их композицию.
Clojure не чистый язык, и функции могут иметь побочные эффекты. Например Но они (грязные функции) не обладают состоянием. Они лишь служат средством взаимодействия с внешним миром. Как мы увидим далее, Clojure отделяет состояние нашей программы при помощи опосредованных ссылок.
ИммутабельностьВсе структуры дынных в Clojure иммутабельны. Нет способа изменить элемент вектора. Все что мы можем - создать новый вектор, у которого будет изменен один элемент. Очень важный момент в том, что Clojure сохраняет алгоритмическую сложность (по времени и памяти) для всех стандартных операций над коллекциями. Ну почти, вместо O(1) для векторов мы имеем O(lg32(N). На практике, для даже коллекций из миллионов элементов lg32(N) не превышает 5. Достигается подобная сложность благодаря использованию персистентных коллекций. Идея в том, что при "изменении" структуры старая версия и новая разделяют большую часть внутренних данных. При этом старая версия остается полностью рабочей. Более того, мы имеем доступ ко всем версиям структуры. Это важный момент. Конечно, ненужные версии будут собраны сборщиком мусора.
Из коробки Clojure поддерживает односвязные списки, вектора, хеш-таблицы, красно-черные деревья. Есть реализация персистентной очереди (для стека можно использовать список или вектор). И все иммутабельно. Для повышения производительности можно создавать собственные типы-записи.
Тут мы объявляем структуру с 3 полями. Компилятор Clojure создаст объект с 5 полями (2 "лишних"). Одно поле для метаданных, в нашем случае это будет null. 3 поля для собственно данных. И еще одно поле - для дополнительных ключей. Даже если для повышения скорости в нашей программе мы объявляем структуру с явным перечислением полей, то Clojure все равно оставляет нам возможность добавлять дополнительные значения.
И да, для структур данных в Clojure есть специальный синтаксис:
СостояниеИтак, у нас есть чистые функции, они определяют бизнес-логику нашего приложения. Есть грязные функции, служащие для взаимодействия в внешними системами (сокеты, БД, web-сервер). И есть внутреннее состояние нашей системы, которое в Clojure хранится в виде опосредованных ссылок (references). Есть 4 вида стандартных ссылок:
Все глобальные переменные хранятся в
Тут мы явно указали компилятору, что переменная В тестах можно подменять целые функции.
Все ссылки в Clojure поддерживают операцию
Ячейка хранит значение (иммутабельное), но при этом сама является отдельной сущностью. Для функции
Функция
Важно, чтобы функция была чистой, поскольку она может выполнится несколько раз. Мы не можем (не должны!) писать что-то вроде:
АгентыАгенты служат для поддержки состояние, которое непосредственно связанно с побочными эффектами. Идея проста. У нас есть ячейка, к ней "прикреплена" очередь из функций. Функции поочередно применяются к значению, которое хранится в этой ячейке, результат функции становится новым значением. Все вычисляется асинхронно в отдельном пуле потоков.
Агенты обновляют свое значение асинхронно. Но мы можем в любой момент времени узнать состояние агента. Агенты могут посылать сообщения друг-другу, при посылке сообщение откладывается до того момента, когда посылающий агент обновит свое состояние. Другими словами, если в одном агенте будет брошено исключение, то посланные из него сообщения никуда не будут отправлены.
Напрашивается некая аналогия с моделью акторов. Они схожи, но есть принципиальные отличия. Состояние агентов явно, в любой момент времени можно вызывать Второе ключевое отличие в том, что агенты открыты для изменений и добавления функциональности. Единственный способ изменить поведение актора, это переписать его код. Агенты лишь ссылки, они не обладают собственным поведением. Мы можем написать новую функцию и послать ее в очередь агента. Акторы пытаются разделить состояние нашей программы на небольшие части, которые легче разнести или изолировать. Операции обновления и чтения состояния сводятся к посылке сообщений. Иногда это крайне полезно (например, при выполнении erlang-программы на нескольких узлах). Но чаще этого не требуется. Иногда даже наоборот. Так, в агентах удобно хранить большие объемы информации, которые нужно шарить между потоками: кеши, сессии, промежуточные результаты математических вычислений и т.п. Для акторов мы фиксируем множество сообщений, на которое он может отреагировать (остальные посчитает ошибочными). Порядок сообщений тоже важен, поскольку они могут потенциально повлечь за собой побочные эффекты. Это его публичный контракт. Для агента же мы фиксируем только данные, которые могут в нем хранится, их структуру. Очень важно подчеркнуть, что агенты совершенно не пытаются заменить собой акторы. Это разные концепции, и сферы их применения отличаются. Как упоминалось, агенты работают асинхронно. Мы можем выстраивать цепочки событий (посылая сообщения из агента в агент). Но при помощи одних агентов у нас не получится изменять состояние нашей программы координированно .
STMПрограммная транзакционная память - одна из ключевых фишек Clojure. Реализована посредством MVCC. И сразу пример:
Мы увеличиваем одно значение и синхронно уменьшаем другое. Если что-то пойдет не так (исключение), то вся транзакция будет отменена:
Очень похоже на привычный ACID, но только без Durability. При входе в транзакцию все ссылки словно замораживаются, их значения фиксируются на время всей транзакции. Если при чтении/записи ссылки обнаруживается, что она уже поменяла свое значение (другая транзакция завершилась и подпортила нам жизнь), то происходит перезапуск текущей транзакции. Поэтому внутри транзакции не должно быть побочных эффектов (ввод-вывод, работа с атомами). И тут как нельзя кстати оказываются агенты.
Все сообщения для агентов придерживаются вплоть то того момента, когда транзакция будет завершена. В нашем примере изменение ссылок Чтобы различные транзакции мешали другу другу как можно меньше, ссылки в Clojure хранят историю значений. По умолчанию это только последнее значение, но когда происходит конфликт (одна транзакция пишет, а другая читает), то для конкретной ссылки размер хранимой истории увеличивается на единицу (вплоть до 5 значений). Не забываем, что мы храним в ссылках персистентные структуры, которые разделяют общие структурные элементы. Поэтому хранить такую историю в Clojure очень дешево в плане потребляемой памяти. STM-транзакции не мешают нам при изменении нашего кода. Нет необходимости анализировать, можно ли использовать ту или иную ссылку в текущей транзакции. Они доступны все, и мы можем добавлять новые ссылки совершенно прозрачно для уже существующего кода. Ссылки не взаимодействуют между собой. Например, при использовании обычных локов нам надо следить за порядком блокировки/разблокировки, чтобы не вызвать deadlock. При конкурентном доступе транзакции-читатели не блокируют друг друга, примерно как при использованииReadWriteLock. Более того, транзакции-писатели не блокируют читателей! Даже если в текущий момент выполняется транзакция, которая изменяет ссылку, мы можем получить значение без блокировки. Агенты и STM-ссылки дополняют друг друга. Первые не подходят для координированного изменения состояния, вторые не позволяют работать с побочными эффектами. Но их совместное использование делает наши программы прозрачнее и проще (менее запутанными), нежели при использовании "классических" средств (мьютексы, семафоры и подобное).
МетапрограммированиеСейчас у многих языков есть те или иные средства метапрограммирования. Это AspectJ для Java, AST-трансформации для Groovy, декораторы и метаклассы для Python, различная рефлексия. Clojure, как представитель семейства Lisp, для этих целей использует макросы. С их помощью мы можем программировать (расширять) язык средствами самого языка. Макрос - "обыкновенная" функция, с тем лишь различием, что выполняется во время компиляции программы. На вход макроса передается еще не скомпилированный код, результат выполнения макроса - новый код, который компилятор уже компилирует.
Мы создали собственную управляющую конструкцию (инверсный вариант Макросы используются в Clojure весьма широко. Кстати, многие встроенные в язык операторы на самом деле являются макросами. Например, вот реализация
Даже
Недавно в Java появился try-with-resources. При этом 7ю версию Java мы ждали всего-то несколько лет. Для Clojure достаточно написать всего несколько строчек:
В других языка ситуация получше, но все равно далека от идеальной. Важно не наличие той или иной конструкции в языке, а возможность добавить свою. Поэтому неудивительно, что, скажем, паттерн-матчинг для Clojure реализован в виде отдельной подключаемой библиотеки. Просто нету необходимости включать подобные вещи в ядро языка, гораздо целесообразнее реализовывать их в виде макроса. Аналогичная ситуация с поддержкой монад, логическим програмированием, продвинутой обработкой ошибок и другими расширениями языка. Есть дажеопциональная статическая типизация! Нельзя не упомянуть и про удобство создания DSL. Для Clojure их создано очень много. Это и генерация HTML, ироутинг HTTP-запросов, и работа с реляционными базами данных, и работа с бинарными протоколами, и валидация данных… Создавать их просто и эффективно (хотя в этом деле нужно знать меру). Clojure (как и все Lisp-подобные языки) обладает очень важной особенностью - он гомоиконен. Другими словами, нету надобности в отдельном представлении для исходного кода программы, не нужно создавать лишние уровни абстракции в виде некоего дополнительного AST-дерева, программа и есть это дерево. Причем это дерево не из каких-то специальных структур, это обычные списки, векторы и символы. И мы можем работать с нашей программой точно также, как и с обычными данными.
При всей своей мощи макросы в Clojure не ухудшают читаемость программы (если, конечно, использовать их в меру). Ведь макрос всего лишь функция, а мы всегда можем однозначно определить, какая функция используется в текущем контексте. Например, если мы видим код
ПолиморфизмДля создания полиморфных функций у Clojure есть 2 механизма. Изначально язык поддерживал только мультиметоды - мощное средство, но чаще всего избыточное. Начиная с версии 1.2 (а на данный момент актуальна версия 1.5.1) в язык добавили новую концепцию - протоколы. Протоколы похожи на Java-интерфейсы, но не могут наследовать друг друга. В каждом протоколе описывается набор функций.
Этим мы объявляем 2 сущности - собственно протокол, а также функцию
Можно реализовать протокол для стороннего типа (даже встроенного).
Можно добавлять реализацию протоколов к уже существующим типам, даже если у нас нету доступа к исходным кодам. Тут не происходит никаких магических манипуляций с байткодом или подобных трюков. Clojure создает глобальную таблицу Но иногда протоколов бывает недостаточно. Например, для двойной диспетчеризации. В этом (и не только) случаях нам пригодятся мультиметоды . При объявлении мультиметода мы указываем специальную побочную функцию-диспатчер. Диспатчер получает теже аргументы, что и мультиметод. Поиск конечной реализации происходит уже по значению, которое вернул диспатчер. Это может быть тип, ключевое слово или вектор. В случае вектора происходит поиск наиболее подходящей реализации по нескольким значениями.
Тут мы объявили абстрактную функцию, реализация которой выбирается на основе типа первого аргумента и значения второго (это должен быть класс). Конечно Clojure учитывает иерархию типов при поиске подходящей реализации. Использовать типы удобно, но их иерархия строго фиксирована. Зато мы можем создавать собственные ad-hoc иерархии из ключевых слов.
Иерархий можно объявить несколько. Также как и с типами, можно проводить диспатчеризацию по нескольким значениям сразу (вектору). При задании своих иерархий можно даже смешивать ключевые слова и Java-типы!
Мы можем "унаследовать" тип от кейворда (но не наоборот). Это удобно для создания открытых для расширения групп классов. Система мультиметодов простая, но при этом чрезвыячайно мощная. Обычно для повседневных нужд хватает функциональности протоколов, но мультиметоды могут быть отличным выходом в сложных и нестандартных ситуациях.
Здравый смыслЯзык ничего не значит без инфраструктуры. Без сообщества, набора библиотек, фреймворков, различного рода утилит. Одна из сильных сторон 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 стоит. Однозначно. Даже если этот инструмент не подойдет Вам по той или иной причине, то идеи, которые в него заложены, окажутся весьма полезными. Ссылки по теме
|
|