Зачем Clojure Flutter

«Если вам нравятся Руби, Свифт, Дарт, Эликсир, Эльм, С++, Питон или даже С, используйте их ради бога. Но выучите Кложур, и выучите его хорошо» — Дядя Боб (твит, а также твиты: 1, 2, 3).

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

Очень краткая история

Clojure не был написан второпях (JS), не пытался захватывать рынок с многомиллионным бюджетом на маркетинг (Java). За ним не стояло огромных компаний (Go, Dart, Kotlin), он не был единственным языком для платформы (Swift, C#). Даже начать программировать на нем достаточно сложно (в отличае от Ruby или Python).

Так почему же Clojure набрал критическую массу, является самым высокооплачиваемым языком и одним из трех самых любимых языков? Зачем для него пишут порты на Java, JS, C#, Unity, Elm, Python … и, наконец, Dart?

Ответ на этот вопрос — в докладе Рича Хикки «Simple Made Easy», есть перевод на Хабре. Моя непростительно краткая и бессовестная версия: Рич подумал, как сделать хорошо, руководствуясь принципами простоты, стабильности и практичности, и сделал Clojure, не зависимый от сроков, бюджетов, погони за хайпом.

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

В этом разделе было много громких высказываний, подробнее на многие вопросы отвечаю в FAQ в конце статьи, со ссылками, пруфами и небольшим налетом «субъективщины».

Экскурс в язык

Для приемлемо-комфортного чтения статьи достаточно понимать, что структура выражения на Clojure представляет собой список, где первый элемент — функция, а последнующие элементы — аргументы:

(функция аргумент-1 аргумент-2 ... аргумент-n)

Каждый аргумент и даже сама функция могут быть такими же выражениями, например:

(функция-2 (функция-3 аргумент) аргумент-2)

Когда происходит интерпретация, сперва вычисляются аргументы, затем они применяются к функции. Выражение выше на превратится в:

(функция-2 вычисленный-аргумент аргумент-2)

Это упрощение, но для понимания тех примеров, о которых я напишу, достаточно.

Простота синтаксиса и консистентность

Примеры ниже на Dart и на Clojure, делающие одно и то же:

  • Dart: max(1, 2);

  • Clojure: (max 1 2)

  • Dart: a > b || (c > d && d > e && e >f);

  • Clojure: (or (> a b) (> c d e f))

  • Dart: if (a == 1) a else b;

  • Clojure: (if (= a 1) a b)

  • Dart: int square(int n) => n * n;

  • Clojure: (defn square [n] (* n n))

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

В Clojure скобочки — это всегда группировка выражения, где на первом месте стоит функция или макрос, а далее — аргументы.

Кроме того, на Dart не очевидно (надо знать или оборачивать в скобочки), какой оператор выполнится первым — <, || или &&, — на Clojure же все в скобочках.

  • Dart: ++i;

  • Clojure: (inc i)

В Dart используются инфиксная (1 < 2), префиксная (max(1, 2)) и постфиксная (i++) нотации, тогда как на Clojure — только префиксная.

Синтаксис Clojure значительно проще синтаксиса Dart, так как он более консистентный и описывается меньшим количеством правил. Самый простой способ показать эту разницу — сравнить Antlr-парсеры (и лексеры) Clojure и Dart.

В ссылках, что я привел, Clojure описывается короче в 5 раз короче (по количество слов). Если взять официальный парсер Dart (spec), то будет разница в 9 раз.

А вот грамматика языков (clj, dart), описанная для другого парсера — Tree-sitter. Clojure понадобилось 1.6к строк, Dart — 10k.

Элегантный и краткий

Приведу несколько примеров кода, которые показывают, почему и за счет чего код на Clojure, как правило, короче, чем на других языках. Вот пример для визуального сравнения. Еще один — physics-simulation flutter cookbook, реализованный на «кложе» в 2 раза короче.

Победа над вложенностью шестью строчками кода

Вложенные виджеты на дарте превращаются в лесенку,

Container(
  color: Colors.red,
  child: const SizedBox(
    child: Center(
      child: Padding(
        padding: EdgeInsets.all(16),
        child: Text(
          'Oh my god, how "clutter” is it!',
        ),
      ),
    ),
  ),
);

Если мы знаем, что у каждого вложенного виджета есть child, то почему бы не избавиться от дублирования, превратив код во что-то вроде:

nest(
  Container(color: Colors.red),
  SizedBox(),
  Center(),
  Padding(padding: EdgeInsets.all(16)),
  Text('Oh my god, how "flutter” is it!'));

Решить это в compile time невозможно (и как я написал выше, и с дополнительными аннотациями), так как :child некоторых виджетов — обязательный параметр. Можно попросить разработчиков компилятора сделать nest, но захотят ли они добавлять новое ключевое слово (нет)?

Решить для ограниченного количества классов виджетов в runtime можно при помощи рефлексии, но так как поля :child — final, придется копировать виджеты целиком. То есть понадобится огромное количество кода и будет работать медленно.

Решить в runtime для общего случая — невозможно, так как мы не знаем, какие кастомные виджеты нам передаст пользователь. Может, он напишет такой виджет, где сохранит переданный в :child аргумент с другим именем someTrickyName — и мы не сможем найти нужное поле). И опять же, :child может быть required.

Что касается Clojure, то изначальный код выглядит так:

(Container 
 :color m.Colors/red
 :child 
 (SizedBox 
  :child
  (Center 
   :child 
   (Padding 
    :padding (EdgeInsets/all 16)
    :child (Text "Oh my god, how "clutter” is it")))))

Но мы легко можем переписать его:

(nest
  (Container :color m.Colors/red)
  (SizedBox)
  (Center)
  (Padding :padding (EdgeInsets/all 16))
  (Text "Oh my god, how "flutter” is it"))

И это будет работать для общего случая с решением всех проблем, описанных выше.

Как такое возможно?

Гомоиконичность, также известная как code as data. Это когда программу, написанную на языке, можно положить в структуру данных этого же языка (и выполнить). Например, код, написанный на дарте, нельзя положить в массив этого же дарта, а с кложурой такое возможно.

Если язык гомоиконичен, то можно легко манипулировать синтаксическим деревом: писать и изменять «кложей» код на «кложе», то есть расширять компилятор. Инструмент, доступный пользователю, — макросы, и вот как выглядит макрос, позволяющий «выпрямить» вложенность:

(defmacro nest [form & forms]
  (let [[form & forms] (reverse (cons form forms))]
    `(->> ~form ~@(for [form forms] (-> form 
                                        (cond-> (symbol? form) list) 
                                        (concat  [:child]) 
                                        (with-meta (meta form)))))))

Писать этот макрос не обязательно, так как он уже есть в библиотеке (nest). И основной макрос widget тоже поддерживает «выпрямление».

Magic apply

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

Как проверить, что коллекция отсортирована?

На Dart:

bool isSorted(List list) {
  if (list.length < 2) return true;
  int prev = list.first;
  for (var i = 1; i < list.length; i++) {
    int next = list[i];
    if (prev > next) return false;
    prev = next;
  }
  return true;
}

isSorted([0, 1, 2, 3, 4, 5]); // true

var list = [for(var i=0; i<6; i+=1) i];
isSorted(list); // true

На Clojure:

(apply < [0 1 2 3 4 5]) ;;=> true
(apply < (range 6))     ;;=> true

Как это работает? Apply берет функцию и список, и передает элементы из списка в качестве аргументов. То есть выражение превращается в: (< 1 2 3 4 5). У «кложи» тут есть еще преимущество в том, что < принимает любое число агрументов.

Рассмотрим еще один, более сложный пример.

Как транспонировать матрицу?

На Dart

List> transposeList(List> input) {
  List> output = [];

  for (int i = 0; i < input[0].length; i++) {
    output.add(List.generate(input.length, (idx) => idx));
  }

  for (int i = 0; i < input.length; i++) {
    List column = input[i];
    for (int j = 0; j < input[0].length; j++) {
      int rowItem = column[j];
      output.elementAt(j).removeAt(i);
      output.elementAt(j).insert(i, rowItem);
    }
  }

  return output;
}

transposeList([[1, 2], [3, 4], [5, 6]]); // [[1, 3, 5], [2, 4, 6]]

На Clojure будет функция в одну строчку:

;; объявление функции "transpose" средствами языка, без библиотек
(defn transpose [m] (apply map list m))

;; и вызов функции
(transpose [[1 2]
            [3 4]    ;; => [[1 3 5]
            [5 6]])  ;;     [2 4 6]]

Как это работает? Функция map ожидает на вход функцию и одну или несколько коллекций. Примеры:

(map inc [1 2 3])     ;;=> (2 3 4)
(map odd? [1 2 3])    ;;=> (true false true)
(map + [1 2] [1 1])   ;;=> (2 3)
(map max [1 2] [2 1]) ;;=> (2 2)

И так выражение (apply map list [[1 2] [3 4] [5 6]]) благодаря apply превращается в:

(map list [1 2] [3 4] [5 6])

И это уже в процессе вычисления переходит в:

[(list 1 3 5) (list 2 4 6)] ;;=> [(1 3 5) (2 4 6)] 

Коллекции работают так, как надо

Тезис не только про то, что equals, compare, sort и тому подобное будет работать предсказуемо (без подключения библиотеки Collection, как это делается в Dart для сравнения структур данных).

Core коллекции в Clojure — иммутабельные и персистентные, то есть позволяют создавать копии себя за практически константное время. Например, добавление ключа в мапу не изменяет исходную мапу, но возвращает новую с добавленным ключом. И это не за О(n), а за О(Log32) (читай константа).

Память на новую коллекцию не расходуется (только на разницу между коллекциями), в глубинах реализации и новая, и старая «мапы» переиспользуют одно и то же дерево (подробнее). Это позволяет писать эффективный, thread-safe код.

Полноценный data oriented programming

Data oriented подход можно вкратце описать, как разделение кода и данных, что приводит также к разделению их иерархии и далее ведет к меньшей связности (coupling).

Скриншоты из статьи на тему:

без разделения

5df06d6f88c58d0fad94dc851267a8a3.png

с разделением

b59bc1e7bb25b3aa18327d90012b77e9.png

Так как тема обширная, и не поместится в одну статью, ссылаюсь на книгу и блог Йохонатана Шарвита, где широко описаны преимущества и недостатки такого подхода. Также привожу видео с примерами значительного упрощения кода ООП-экспертов.

Здесь же остановлюсь на одном аспекте отказа от классов.

Не нужно описывать, читать и разбираться со всем бойлерплейтом, сопряженным с созданием класса (hash, ==, toMap, fromMap, toString, copyWith), он доступен из коробки. Доступны также сотни функций для работы с коллекциями, которые используют с данными (а не изобретаются заново).

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

(def human {:name "Bob", :age 30})
(def old-human (update human :age inc)) ;=> {:name "Bob", :age 31}

В обычном ООП-подходе мы могли бы создать метод классу Human, увеличивающий возраст (increaseAge). А что, если нам понадобится уменьшить возраст? В Clojure передадим dec (вместо inc), а в Dart — создадим новый метод?

Проблема такого подхода не только в том, что нам необходимо дописывать код, но и в том, что мы постоянно работаем с новыми классами, у которых какие-то свои методы. Это особенно заметно, если сравнивать опыт знакомства с новой библиотекой на Dart (Java, Kotlin, Swift) и на Clojure. С последней работать проще как минимум за счет того, что не нужно изучать новые методы новых классов.

Сотни функций core-коллекций Clojure, о которых я написал выше, позволяют манипулировать данными любых библиотек так, как будто вы сами их писали под себя. Есть известная фраза Алана Перлза на этот счет:

It is better to have 100 functions operate on one data structure than to have 10 functions operate on 10 data structures.

Стабильность языка

Я занимался Android-разработкой около 7 лет, и за это время только в узком сегменте мнопоточности и «асинхронщины» было довольно много изменений.

Сперва люди пользовались AsyncTask, IntentService и просто голыми тредами. Затем начала набирать популярность RxJava. На нее переписывали модные библиотеки, о ней писали статьи и книги.

Помню, как уговаривал тимлида переходить на RxJava2. Он не согласился — и правильно сделал, потому что уже через год вышел RxJava3, а популярны стали корутины. WorkManager пришел на замену JobScheduler-у, пришедшему на замену AlarmManager (+ BroadcastReceiver, + Service). И я уверен, что через 2–3 года его так же заменят.

В Clojure мире 9 лет назад появилась библиотека core.async, которая популярна до сих пор. И это не единичный пример. Код на Clojure — долговечен. Вот ссылка на «A History of Clojure» Рича Хикки, где (26 страница, или этот твит) приводится сравнение со Scala на предмет того, как долго поддерживается код.

Все на одном языке

В конце концов, Clojure может быть полезен не только для Flutter-разработки. Ниже приведу все сферы, которые считаю практически применимыми:

На подходе хост на С++ — jank.

Небольшой пример. В моем последнем проекте — переводчике с Dart на Clojure — все написано на одном языке: core проекта, файлы с зависимостями, приложение для командной строки, приложение, доступное из npm и скрипт, публикующий сборки. Один и тот же Clojure-код переиспользуется и для npm-библиотеки, и для jar файла, и для нативных сборок (через GraalVM).

FAQ

Можно ли как-то подтвердить эффективность Clojure?

Если не вдаваться в субъективщину, вроде того, что мне лично так кажется, или что Clojure — третий любимый ЯП, согласно StackOverflow survey, — есть несколько подтверждений.

Вот reproduction research (повторили предыдущий) On the impact of programming languages on code quality. Вкратце: взяли проекты с гитхаба, посмотрели на количество коммитов с исправлением багов.

Несколько вырезок оттуда:

  • «Языки ассоциирующиеся с меньшим количеством багов TypeScript, Clojure, Haskell, Ruby, and Scala, тогда как C, C++, Objective-C, JavaScript, PHP, Python ассоциируются с большим».

  • Коммитов с исправлением багов в Clojure было меньше всего, в C++ — больше всего.

Другие возможные подтверждение — размер библиотек и зарплаты (2 следующих вопроса).

Почему «библиотека на Clojure меньше по размеру… в 2, в 3, в 5, в 10, в 100 раз»?

Цитата риторического вопроса из видео ниже, там же и ответ (буквально пару минут). Другой ответ — в этой самой статье.

И пара статей на английском: Статья со сравнением 24 фреймворков — Clojure на втором месте по количеству строк кода. «Любовное письмо к Кложуре» — приложение переписали с JS (1500 строк) на Cljs (500).

Сколько платят Clojure-разработчикам?

Больше всех, согласно StackOverflow survey за 2022 (и 2021). Феномен известный, попадался тред на Reddit с объяснением, что это не за язык платят много, а дорогие опытные специалисты выбирают его (пруф через другой график того же survey).

Тезис также можно подтвердить через State of Clojure 2022, вопрос 8. Более 76% разработчиков имеют более 6 лет опыта, 50% — более 11 лет опыта.

Если Lisp такой мощный диалект, то почему не популярный?

Тут есть 3 тезиса. Первый — популярность языка не обуславливается его качеством (JS).

Второй и третий тезисы завязаны на так называемое «проклятье Лиспа». Могу рассказать грубо, но постараюсь кратко, подробнее можно почитать Lisp curse.

Подразумевается, что Лисп позволяет одному разработчику делать то, для чего иначе были бы нужны целые компании. Может звучать неправдоподобно, в качестве небольшого примера напомню решению проблемы вложенности виджетов в 3 строки (ссылка на абзац).

В итоге это ведет к индивидуализму (второй тезис). Зачем собираться командой, разрабатывать стандарт и доводить продукт (будь то какая библиотека или редактор кода) до ума, решать 99% кейсов, когда можно в одного решить свои, скажем, 40% кейсов.

Возьмем пример с редактором: весь мир пишет Java в IntelliJ IDEA (2021 год, 75%), тогда как на Clojure редакторы распределены более-менее равномерно (Emacs, Idea, VSCode, Vim и менее популярные Atom, Sublime, NightCode).

Обратная сторона такой продуктивности в том же индивидуализме. Коммерческая разработка в крупных компаниях делается шаблонно на стандартных языках со стандартным подходом. Городятся горы бойлерплейта, зато разработчики становятся заменяемы (третий тезис).

Есть мнение, что Clojure, отчасти нивелирует проблемы «проклятья Лиспа» за счет своей «хостовости» (возможности использовать фреймворки и библиотеки на Java, Dart и JS).

Почему функциональное программирование менее популярно, чем ООП?

Простой ответ дать сложно, но не стоит забывать, что рынок языков программирования — это рынок. В рекламу Java, например, было вложено более 500 млн$ (пишу «более», поэтому это не единственная кампания). Почему Java — OOP? Может быть, потому что это было проще продать разработчикам С++.

Если эта тема интересна, можно посмотреть Why Isn’t Functional Programming the Norm?.

Должна ли смущать динамическая типизация?

Если вы противник динамической типизации из-за опыта с JavaScript, то возможно, что вам не понравилась неявная и слабая типизация, а не динамическая (подробнее о типизации есть ликбез на хабре.). С Clojure вам не придется разбираться с «багами» из-за неявного приведения типов, как в JS:

1 === '1';    // false
1 == '1';     // true
true === 1;   // false
true == 1;    // true
[0] == 0      // true 
{} + [] == 0  // true

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

Проблем с прозиводительностью, как в Python, нет (в большинстве случаев). Функции компилируются в методы языка, в который компилируется Clojure. Критически места можно оптимизировать, добавляя type hints, используя структуры данных платформы. Чтение кода тоже можно оптимизировать, используя REPL (который пока не поддержали в ClojureDart).

Если вы противник концептуальный, понимаете плюсы статической типизации, но не видите плюсы динамической, есть смысл обратить внимание на теорему о неполноте или посмотреть ответ Андрея Бреслава на критику Питона и динамической типизации:

Более долгое видео на тему — докла Рича Maybe not.

А если очень хочется типы?

Для начала, давайте определимся, нужны ли нам типы, или классы. Неплохой разбор отличий приводится в статье Ивана Гришаева про core.spec, который позволяет описать тип и форму данных более гибко. Подобные описания можно использовать для тестов, документации, значений в Swagger и т.д.

Также существует библиотека typedclojure, дающая возможность сделать из Clojure — статически-типизированный язык, с подсветкой типа при наведении курсора. В ClojureDart это можно будет использовать при небольших доработках библиотеки, но я привожу это просто как пример того, что любой разработчик может создать внутри «кложи» все что захочет.

Как начать c ClojureDart?

Если опыта с Clojure нет, читаем Learn X in Y minutes, решаем (идиоматично, как в ответах) хотя бы треть задачек с 4clojure. Practical.li — тоже неплохой ресурс для начала, а для книжных червей подойдет BraveClojure.

Если вы уже знакомы с Clojure и хотите сразу перейти к Flutter, посетите страницу Clojure Dart и гляньте на краткое руководство по началу работы. Есть еще статья от меня — Как начать писать приложения на ClojureDart.

Другие варианты — Clojure Dart workshop, YouTube-канал и просто примеры.

И если вдруг вам показалось, что Clojure очень сложная, требующая огромного опыта и понимания математики технология, то это совершенно не так. Язык ориентирован на практичность, о теории категорий можно не только не думать, но и вообще не знать, что это такое. Сложные книги вроде SICP прочитать полезно, но совершенно не обязательно.

Что не было упомянуто

Read Evaluate Print Loop. В Clojure REPL подключается к приложению, и вы можете редактировать код работающей программы, не теряя промежуточный стейт. Например, подключиться к хендлеру на бекенде в проде и посмотреть, какие данные через него проходят и там же поправить критичный баг без редеплоя. REPL интегрирован в workflow (и в редактор, и в приложение).

На консолях/реплах/shell в Dart, JS, Python, Swift, Kotlin, Java, Scala, Haskell такое невозможно. Их репл не связан с приложением, и скорее похож либо на дебаггер, либо на консоль, которую можно запустить в стороне и проверить, как работают какие-то кусочки кода.

REPL пока не реализован в ClojureDart, но вскоре будет.

Summary

Итак, зачем же использовать Clojure для Flutter-разработки? Чтобы получить инструменты, ускоряющие и упрощающие разработку.

Гомоиконичность для расширения языка

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

Персистентные коллекции для упрощения работы с данными

Изучение core функция для работы с данными ставит вас в позицию, когда любой код любого проекта / библиотеки становится понятен и предсказуем, потому что все используют один и тот же подход. Кроме того, писать иммутабельный код проще, когда создание новой коллекции — не линейно, а за O (Log32).

А также

Функциональное программирование, стабильные библиотеки и кодовая база, элегантный и консистентный синтаксис, отзывчивое коммьюнити (об этом было подробнее в FAQ в конце статьи).

© Habrahabr.ru