Skip to content

Instantly share code, notes, and snippets.

@darkleaf
Last active April 15, 2023 08:34
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save darkleaf/f0cbfe38eaad82cb44758aef1228287f to your computer and use it in GitHub Desktop.
Save darkleaf/f0cbfe38eaad82cb44758aef1228287f to your computer and use it in GitHub Desktop.
Algebraic effects для Clojure.

Привет!

Ключевые слова: coroutine, continuation, generators, async/await, project loom, free monad, algebraic effects.

Я хочу сделать алгебраические эффекты для Clojure(Script). Я уже сделал библиотеку https://github.com/darkleaf/effect/blob/doc-2/README.md Но есть моменты, которые вызывают у меня вопросы.

Если очень кратко, то лично мне эффекты нужны, чтобы:

  1. удобно тестировать бизнес логику
  2. писать один код для фронтенда и бэкенда, 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.

  1. нет api, чтобы узнать что корутина закончилась и нужно ответ заворачивать в свой специальный объект-обертку, вроде reduced из clj.
  2. нет api, чтобы передать значение. В примерах ипользуются динамические переменные (dynamic var), но я сделал бенчмарк и где-то треть времени тратится на прокидывание значения в динамическую переменную.
  3. макрос использует &env. И дебагер из cider не может работать с таким кодом. Можно его заставить, но практической пользы от этого нет. И дебажить нужно через prn. По той же причине не работает сбор покрытия кода тестами.
  4. я не понимаю как она работает

Может быть это не такие и большие проблемы?

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?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment