Привет!
Ключевые слова: coroutine, continuation, generators, async/await, project loom, free monad, algebraic effects.
Я хочу сделать алгебраические эффекты для Clojure(Script). Я уже сделал библиотеку https://github.com/darkleaf/effect/blob/doc-2/README.md Но есть моменты, которые вызывают у меня вопросы.
Если очень кратко, то лично мне эффекты нужны, чтобы:
- удобно тестировать бизнес логику
- писать один код для фронтенда и бэкенда, cljc.
Сейчас нужно использовать внедрение зависимостей, что вынуждает делать стабы/моки/шпионы. Они stateful, сложно проверить порядок их вызовов. В ридми выше я показываю как можно тестировать иммутабельным сценарием.
JVM и V8 имеют разную модель ввода-вывода - блокирующую и не блокирующую соответственно. Если для валидации сущности нужно сходить в БД или дернуть запрос, то код для V8 будет имень принципиально другую структуру - с коллбэками, промисами или async/await. Таким образом невозможно написать один код под две платформы. В ридми есть описание этого случая.
Сейчас я использую библиотеку cloroutine.
Вопросы и мысли, не отсортированные по важности.
1. Я изначально хотел сделать multi-shot континуации. Но все реализации, вроде project loom, js generators - one-shot. multi-shot означает, что вызов континуации не меняет ее состояние и можно сделать "fork". Да, можно из one-shot сделать multi-shot путем клонирования, но например js так не умеет. И видимо лучше и мне остановиться на one-shot реализации.
Можете придумать зачем может быть нужно mulit-shot?
Мне на ум приходит только спекулятивные вычисления, но без haskell подобного undefined
вряд ли получится сделать что-то полезное.
Например, можно в рантайме анализировать эффекты, собирать статистику и убирать N+1.
2. Мне не нравится cloroutine.
- нет api, чтобы узнать что корутина закончилась и нужно ответ заворачивать в свой специальный объект-обертку, вроде reduced из clj.
- нет api, чтобы передать значение. В примерах ипользуются динамические переменные (dynamic var), но я сделал бенчмарк и где-то треть времени тратится на прокидывание значения в динамическую переменную.
- макрос использует &env. И дебагер из cider не может работать с таким кодом. Можно его заставить, но практической пользы от этого нет. И дебажить нужно через prn. По той же причине не работает сбор покрытия кода тестами.
- я не понимаю как она работает
Может быть это не такие и большие проблемы?
3. особый синтаксис вызова эффектов/функций с эффектами.
(let [x (! (effect ...))]
(! (effect-fn ...))
...)
В итоге не рабоатают стрелочные макросы вроде ->
, ->>
, ...
Была идея ставить метаданные вроде
(let [x (^:break effect ...))]
(^:break effect-fn ...))
...)
Но тут тоже не все просто, т.к. нужно сначала раскрыть все макросы. А у macroexpand-all
тоже есть свои проблемы.
Да, функции становятся "цветными" и стандартные map, reduce в любом случае не будут работать.
4. Я внезапно вспомнил, что в js есть генераторы и это именно то, что мне нужно. Спасибо cljs-async-await за озарение. Но в js проблема с выражениями (expression) и do из clojure компилирется в немедленно вызываемое замыкание (IIFE), а там yield/await уже не работает. И у меня есть идея, как это обойти. Можно не реализовывать весь cljs синтаксис, а ограничиться подмножеством. Хватает же хаскелистам do нотации:
;; edo - Effect's do
(edo [:catch js/Error1 e :return-wrong ;; catch можно только вначале объявить
:catch js/Error2 e e
x (effect :state/get)
:let [a 1] ;; обычный let
_ (effect :state/put x)]
;; тут может быть только одна форма
(inc x))
Вроде как тут не будет ситуации, когда возникает IIFE. А yield прячется в edo
.
edo
можно вкладывать друг в друга:
(edo [x (effect :state/get)
y (if (= 0 x) (edo ...) (edo ...))]
...)
Плюсы в том, что это можно сделать на очень простом макросе и небольшом расширении cljs компилятора. Минусы - новый синтаксис вместо привычных форм.
Да, тут не получится внутри edo форму обернуть в try/catch, т.к. опять возникает IIFE, можно только весь код в edo завернуть в try.
Норм синтаксис?
5. Для JVM есть проекты, которые добавляют stackless генераторы или континуации. Они переписывают на лету байткод. Но нужно подключать jvm-агент при запуске. https://github.com/offbynull/coroutines
Я их еще не смотрел, но кажется, что дебагер, покрытие и т.п. должны с ним работать.
Но, тут уже нет IIFE и можно делать "нормальный" синтаксис без edo
. И добавить edo
синтаксис только для cljc файлов.
;; "нормальный" синтаксис
(let [x (! (effect ...))]
(! (effect-fn ...))
...)
Два синтаксиса ок?
6. когда-нибудь допилят Project loom и будут stackful континуации в JVM. Т.е. yield/! можно будет вызывать в фукнции выше по стеку, в том числе в анонимных функциях. И стандартные map, reduce автоматически заработают с функциями с эффектами.
Но в js нет даже признака на подобные проекты. И опять, чтобы писать переносимый код придется в jvm иметь 2 синтаксиса, один для loom, а второй для cljc файлов.
7. если делать свой компилятор clj-to-clj, то с все плохо с инструментами. Есть https://github.com/clojure/tools.analyzer. Там есть разные обход и изменение AST. Есть преобразование AST в код.
НО. https://github.com/clojure/tools.analyzer.js заброшен, а в компиляторе clojurescript нет преобразования AST-to-cljs.
core.async и cloroutine как-то сами геренируют код.
И если выбирать, то я бы выбрал не делать столь сложную трансформацию clj-to-clj, а воспользовался бы инструментами платформы, но clojurescript/js все ломает, т.к. генераторы не работают в IIFE, а clojurescript использует IIFE, т.к. в js еще не завезли do expressions.
Может быть не так и сложно сделать надежный clj-to-clj компилятор? И что бы дебагер и покрытие работали.
8. в haskell есть free monad. Но clj не haskell, и например, хочется обработки исключений:
(let [x (try
(! (effect ...))
(catch Throwable e ...))]
...)
т.е. do нотация определенно такого не позволит.
Я хочу данные обрабатывать и использовать jvm библиотеки, а не функторы реализовывать. Но почему бы не позаимствовать идеи из haskell?
9. В haskell есть инструмент для написания своих микро языков (eDSL). Т.е. уже есть do нотация, (free) монады и остается самому реализовать только суть своего языка. В clojure же всего этого нет и чтобы сделать core.async ребята запилили свой компилятор. И никто его не обобщил.
Нужно ли для clojure какие-то обобщенные интсруменны для написания eDSL?