Комментарии 28

Пишу сейчас процессинг. Море операций с файловой системой, базой данных, сторонними сервисами и программами. Пришел к похожей схеме с различием в структуре данных.
Для себя остановился на funcool/cats и монаде Either.
PS: Ваш блок let функции buy-lot заставляет моё сердце кровоточить :)

Что именно заставляет «сердце кровоточить», интересно? :)

Многословность. В моем понимании в let блок должен быть максимально лаконичным.
with-lot-fn, buy-lot-fn вынести наружу. buy-lot-fn разбить для более легкого понимания.
Лично меня начинает клинить если в функции большая вложенность.
Вложенные let блоки причиняют страдания.
Всё это разумеется ИМХО

В данном случае функции `with-lot-fn` и `buy-lot-fn` можно делать как приватными внутри функции `buy-lot` (в этом случае им доступны параметры функции `buy-lot` без прокидывания их через контекст), так и вынести их наружу (и тогда все данные нужно брать из контекста). Плюс, здесь нужно учитывать, что если функцию для каждого шага прописывать в неймспейсе, то у вас в этом неймспейсе получится адское количество таких функций, большинство которых будут использоватся только в одном месте.
Все остальное — дело вкуса, цвета и запаха :).
Посмотрите на Clojure пристально. Там наверняка есть монады.
В ФП контекст обеспечивает монада State.
Что именно в программировании с монадами будет проще/лучше, чем в контекстном программировании?
Наверное, не все, что существует нужно применять потому, что это существует => хотелось бы узнать, чем программировании с монадами проще/лучше :)
Конкретно в данном случае — вы используете монаду Either для ошибок и State для контекста. Чистая функция, которая может вернуть результат или ошибку — пишется именно с помощью Either. А until-first-error — это bind для Either (точнее, часть с матчингом) плюс костыль — обработка списка, которая решается функцией fold.
Проще говоря, вы не знаете, что монады уже умеют всё это делать, и поэтому городите велосипеды.

Плюс, если посмотреть на типичные монады Reader — Writer — State — они предназначены для ситуаций только читать(конфиги) — только писать(логи) — читать+писать(контекст). И чтобы использовать любую из них, достаточно завернуть функцию в монаду. А вам, чтобы сделать Reader+State, например, придется либо оставить конфиги изменяемыми и сделать их частью контекста(а потом искать ошибку, когда кто-то по ошибке их поменяет), либо городить еще больше велосипедов.
Сравнить контекстное программирование с программированием с монадами — действительно интересный и важный вопрос.
Любому человеку, незнакомому с контекстным программированием и монадным программированием из того, что вы написали понятно следующее:
— в контекстном программировании вводится понятие результата элементарного действия [:ok updated_context] | [:error reason] и, если это соответствует задаче, предлагается разбить логику на элементарные действия и использовать функцию `until-first-error`;
— в монадном программировании вводятся сущности: монада, типы монад (Maybe, Either, State, Reader, Writer и др.). И дальше предлагается изучить как это все готовить.
Возможно, что эта сложность монад, по мере роста масштаба приложения, в некоторый момент себя оправдает. Но совершенно очевидно, что для приложений среднего масштаба это сложность является излишней.
Ваш пример с Reader+State я пока не понял (в чем там killing feature у монад перед контекстным программированием), но еще подумаю.
На самом деле большинство людей использует монады задолго до того, как слышит слово монада. Те же самые Maybe (т.е. вернуть значение или ничего) и Either (вернуть значение или ошибку) спокойно используются без каких-либо знаний о монадах.
И как раз-таки ваше «элементарное действие» это просто функция, принимающая дополнительным параметром Context и возвращающая Either Error Context. Даже монады знать не надо, чтобы так написать.
А потом, когда вы хотите скомбинировать несколько «действий» в одно, которое бы вернуло итоговый результат или первую ошибку — вы можете написать комбинирующую функцию вручную, а можете воспользоваться тем, что Either это монада и значит её можно комбинировать с другими Either — как раз так, как вам надо. И о монадах можете продолжать не знать ничего, кроме как что они комбинируются между собой.
Плюс заметьте, что мы совсем даже не использовали State — потому что возвращаем новый контекст в результате. А что если мы хотим вернуть какой-то осмысленный результат? Конечно, вы можете возвращать пару из Context и результата. Но как потом скомбинировать это действие с другим действием, которое принимает как аргументы ваш результат и Context(не как одну пару)? Ещё больше костылей. Или же вы прочитаете про монаду State и используете её.
TL DR: вы уже написали код с монадой Either, но не знаете об этом — и поэтому не пользуетесь преимуществами монад.
Новые инструменты есть смысл применять для решения конкретных проблем/сложностей.
Мы решали проблему декомпозиции кода, чтобы не было длинных методов (и проблем, которые с этим связаны). И метод контекстного программирования эту проблему полностью решает.

>И как раз-таки ваше «элементарное действие» это просто функция, принимающая дополнительным параметром Context и возвращающая Either Error Context.

Элементарное действие принимает контекст единственным параметром. И как правило, это не просто какая-то функция, а функция в который вызывается бизнес функция (как правило, это запрос к бизнес-сервису), результат которой сохраняется в текущий контекст. Цель элементарного действия: сделать бизнес вызов и обслужить контекст, с результатом этого вызова.

>А что если мы хотим вернуть какой-то осмысленный результат? Конечно, вы можете возвращать пару из Context и результата.

Результат нужно возвращать после применения элементарных действий над текущим контекстом. В этот момент контекст сделал свою функцию и возвращать его дальше никуда не нужно.
Поэтому про какие костыли тут речь, пока не очень понятно.
В бизнес-сервисах часто появляется State. Но, контекст — он шире State: при реализации логики State сервиса, как правило, включается в контекст, в нем обрабатывается и результирующий State в конце извлекается из контекста.

Является ли контекстное программирование заменой монадного программирование и решает ли он те же задачи, что и монады?
Конечно, не является и не решает. Я думаю, что мы даже и не ставили те же задачи, для которых были разработаны монады :).
Может быть, решает, да. Но с моей точки зрения вы просто переизобрели велосипед — монаду Either.

> Элементарное действие принимает контекст единственным параметром.
Не вижу сложностей. Просто не делайте других параметров. И кстати, как вы тогда будете реализовывать действие «вернуть первые N записей»? Требовать, чтобы N было в вашем контексте под каким-то именем? Согласитесь, это же некрасиво. Особенно если это действие — первое в списке из нескольких. Потому что более никому ненужный параметр N так в контексте и останется.

> Результат нужно возвращать после применения элементарных действий над текущим контекстом.
Извиняюсь, не заметил, что промежуточные значения вы просто кладете в контекст. (Впрочем, until-first-error так и так возвращает updated-context, а не результат.) Только вот это порождает сразу несколько проблем. Как минимум, ломается изоляция — последующие функции видят результат всех предыдущих, если его явно не удалили из контекста. А удалять — значит это вы рассчитываете, что после функции a будет в цепочке функций всегда b, которая удалит результат a из контекста. Но тогда эти две функции разумно объединить.

>Является ли контекстное программирование заменой монадного программирование и решает ли он те же задачи, что и монады?
Не является. И не решает. Но все костыли вашего контекстного программирования заменяются использованием монады Either, даже не требуя глубокого понимания «что такое монады».
>А потом, когда вы хотите скомбинировать несколько «действий» в одно, которое бы вернуло итоговый результат или первую ошибку — вы можете написать комбинирующую функцию вручную, а можете воспользоваться тем, что Either это монада и значит её можно комбинировать с другими Either — как раз так, как вам надо. И о монадах можете продолжать не знать ничего, кроме как что они комбинируются между собой.

Ага, тут чёрт в деталях. А деталей тут как минимум две:
1) Что вернет ваша комбинирующая функция, в случае exception? И что вам делать, если внутри действий вы начинаете некоторую транзакцию?
Насколько я понимаю, этот exception просто убежит за пределы этой комбинирующей функции и в этот момент мы потеряете значение контекста, который был до exception. Чтобы вам сделать rollback в этом случае, вам как раз придется придумывать велосипеды (обертывать ваши действия в логику, которая будет сохранять контекст в кастомном exception и доставать этот контекст в catch).
В нашей реализации есть опциональная параметр-функция `on-result-fn` для `until-first-error`, в которой вы можете сделать rollback без всякого оверхеда.
2) А что вам делать, когда вы захотите сделать retry для этой операции (причем не для всех операций, а только для неуспешной)? Поскольку у вас нет контекста, а есть просто комбинирование, то вам придется делать для этого очередной велосипед.
Только вот вы эти проблемы тоже не решаете.
1) Вы передаёте в on-result-fn контекст, каким он был до выполнения кинувшей exception функции. Ни что это за функция, ни какой был exception — вы не узнаёте. Более того, вы даже не знаете, on-result-fn вызывается после успешного выполнения или из-за ошибки, если не впихнёте это в context.
Если вам достаточно такого странного — можно сделать свой instance монады и доопределить bind. Если нет, вам тоже придется придумывать тот же самый велосипед.
2) А вам что делать? У вас нет «продолжить отсюда». Вы можете только стартовать все операции с начала, и пропускать внутри те, которые уже были выполнены — проверяя внутри это контест. Так и я могу. Или же как-то допилить ваш велосипед, дополнив его еще парой костылей.
К тому же мы сделали rollback согласно пункту 1, то операции в любом случае нужно выполнять опять.
Через контекст эти задачи можно решать достаточно гибко, в зависимости от особенностей задачи.

>Ни что это за функция, ни какой был exception — вы не узнаёте.

Цель функции on-result-fn — обслужить завершение контекста, для логики, которая не зависит от результата. Например, если в элементарных действиях делается lock на ресурс, то ее реквизиты нужно сохранить в контексте и в функции on-result-fn этот lock нужно освободить.
При этом ничто не мешает определить успешность или неуспешность выполненных действий по содержимому контента.

>Более того, вы даже не знаете, on-result-fn вызывается после успешного выполнения или из-за ошибки, если не впихнёте это в context.

Если для какой-то логики для завершения контекста результат важен, то ее нужно добавлять после получения результата (результирующий контекст или описание ошибки).

>А вам что делать? У вас нет «продолжить отсюда». Вы можете только стартовать все операции с начала, и пропускать внутри те, которые уже были выполнены — проверяя внутри это контест. Так и я могу.

А как вы сможете это сделать только через комбинирование действий, если у вас нет начального контекста?
>Например, если в элементарных действиях делается lock на ресурс, то ее реквизиты нужно сохранить в контексте и в функции on-result-fn этот lock нужно освободить.

Допустим, вы берете лок и потом кидаете exception (в одном элементарном действии). Вопрос: откуда вы узнаете, что был взят лок? Ведь у вас есть только контекст до начала действия?

>А как мы сможете это сделать только через комбинирование действий, если у вас нет начального контекста?

Начальный контекст это то, что мы передали как параметр первому(скомбинированному) действию. Этот параметр в вызывающей функции никуда не делся, и мы можем просто использовать его еще раз.
>Допустим, вы берете лок и потом кидаете exception (в одном элементарном действии). Вопрос: откуда вы узнаете, что был взят лок?

Элементарное действие для лока будет иметь вид:
(defn get-lock [{:keys [value_a ...] :as context}]
(util/with-result-or-error
#(lock-service/create-lock value_a ... )
:lock
context))

Если exception возник в lock-service/create-lock, то значит лок не был создан и в context ничего сохранять не нужно, а если lock-service/create-lock вернул успешный результат, то он сохранится в контекст (первый же тест это покажет и если он успешный, то сохранение лока в контекст всегда будет успешным).
А, вот оно как. То есть если мы взяли лок, то в этом элементарном действии больше exception кидать нельзя.
Сравнить контекстное программирование с программированием с монадами — действительно интересный и важный вопрос.
Вы так пишете, будто «контекстное программирование» — это прямо какая-то отдельная новая парадигма :)

По сути вы написали свою реализацию монады StateT Either.
Примерно как, например, java.util.Optional — это реализация монады Maybe.

Совершенно ничего плохо в этом нету. Наоборот, это правильное дело!
Но сравнивать «контекстное программирование» с монадами неуместно.
А еще вводить новый термин там, где можно сказать «State+Either», немного излишне.
Допустим, что у меня есть список функций — элементарных действий и начальный контекст. Можете дать ссылку на библиотеку с реализацией монады StateT Either / State+Either / what ever, чтобы завести мою балалайку (в которую я мог бы передать этот список функций и начальный контекст)?
Можете дать ссылку на библиотеку с реализацией монады StateT Either / State+Either / what ever
Да без проблем :)
Правда у вас на самом деле просто Either, StateT даже не нужен.

Вот пример, как можно написать аналоги ваших until-first-error и with-result-on-error.

(use
 '[monads.core]
 '[monads.error :as e]
 '[monads.types :only [either]])

(defn maybe-inc [x]
  (println "call" `(maybe-inc ~x))
  (if (> x 10)
    (fail "Too big")
    (return (inc x))))

(defn errorm->vec [m]
  (either (partial vector :fail) (partial vector :ok) m))

(defn until-first-error [fs init]
  (run-monad e/m (reduce >>= (return init) fs)))

(defn with-result-on-error [f key ctx]
  (>>= (f) #(return (assoc ctx key %))))

(errorm->vec
  (until-first-error
   (repeat 5 maybe-inc)
   9))

(errorm->vec
  (until-first-error
    [(partial with-result-on-error #(return 0) :x)
     (partial with-result-on-error #(return 1) :y)
     (partial with-result-on-error #(return 2) :z)]
    {}))


А еще можно пользоваться встроенными в язык монадами either — исключениями =)
do-something-elementary(val) -> updated_val | (throw e)

Функция until-first-error становится не нужна, вместо нее можно использовать ->, ->> или даже as->. А если список функций динамичен, тогда "((reduce comp fs) init-value)".

А функцию with-result-or-error можно реализовать вот так ;)
(defn with-result-or-error [f k c]
  (assoc c k (f))
Если для задачи достаточно использовать исключения, то, конечно, ни контекстный метод не нужен, ни монады :).
Для простых задач действительно достаточно -> и try/catch на верхнем уровне.
В нашем случае мы используем Optlike библиотеку, в которой exception = смерть процесса, поэтому обычный -> + try/catch нам не подходит.
Спасибо за код.
Тут есть пару моментов, один — мелкий и еще один — крупный.

1) Элементарные функции в моем примере — это обычные функции. В вашем примере это — инструментированные монадами функции. Оба варианта имеют свои преимущества и недостатки.

2) Преимущество контекстного метода программирования в полную раскрывается при тестировании и при реализации retry фичи для бизнес-действия. Для этого мы используем until-first-error функцию, которая возвращает последнее состояние контекста до появления ошибки:
until-first-error(fs, init_context) -> [:ok updated_context] | [:error [reason last_context]]
Для retry фичи достаточно научить элементарные действия использовать существующие данные в контексте и завернуть until-first-error в цикл по количеству повторов:

;; [:ok updated_ctx] | [:error [last_reason last_ctx]]
(defn with-retry [ctx_fn max_attempts ctx & [ on-result-fn ]]
  (loop [attempt     1
         last_reason nil
         ctx         ctx]
    (if (> attempt max_attempts)
      [:error [last_reason ctx]]
      (match (ctx_fn ctx on-result-fn)

             [:ok updated_ctx]
             [:ok updated_ctx]

             [:error [reason updated_ctx]]
             (recur  (inc attempt) reason updated_ctx)))))

Пример вызова:
(with-retry (partial util/until-first-error fs) 10 {})

В случае с монадами вам нужно еще немного инструментировать элементарные действия?
1. Мне не совсем понятно, что вы имеете ввиду под «инструментированные монадами». В данном случае это просто функции, возвращающие monads.types.Either. При желании это может быть defrecord или даже обычный вектор (см. мой комментарий ниже).
2. Фокус в том, что при работе с монадами (в нашем случае Either) никто не заставляет вас использовать только >>= и return. В Можно точно также написать with-retry, она даже может выглядеть 1-в-1 :)

Давайте переключимся на clojure.algo.monads.
Она стандартная и ощутимо проще. Правда не содержит реализации Either.
Но это очень легко поправить!
(use 'clojure.algo.monads)
(defmonad either-m
  [m-bind (fn [[z v :as m] f] (if (= z :right) (f v) m))
   m-result (fn [v] [:right v])])

И это буквально все, я серьезно =)
(defn maybe-inc [x]
  (println "call" `(maybe-inc ~x))
  (if (> x 9)
    [:left "Too big"]
    [:right (inc x)]))

(with-monad either-m
  ((m-chain  ;; склеивает функции при помощи m-bind
    (repeat 10 maybe-inc))
   0))

Но m-bind не подходит для обработки ошибок, поэтому объявим его зеркального брата (при желании даже можно оформить в виде зеракальной монады either-inv-m):
(defn e-bind-left [[z v :as m] f] (if (= z :left) (f v) m)

Ну и пример использования:
(with-monad either-m
  (-> [:right 10]     ;; init
      (m-bind      (m-chain (repeat 5 maybe-inc)))
      (e-bind-left #(do (println "Handle error" %) [:right 0]))
      (m-bind      (m-chain (repeat 5 maybe-inc)))))

Ну и аналог вашего with-retry выраженный через e-bind-left:
(defn e-with-retry [f]
  (if (<= attempts 0)
    (fn [v] (e-bind-left (f v)
                (fn [e] [:left [e v]])))
    (fn [v] (e-bind-left (f v) 
                (fn [_] ((e-with-retry f (dec n)) v))))))


Упс, последнюю функцию неправильно скопировал. Должно быть
(defn e-with-retry [f n]
  (if (<= n 0)
    (fn [v] (e-bind-left (f v)
                (fn [e] [:left [e v]])))
    (fn [v] (e-bind-left (f v) 
                (fn [_] ((e-with-retry f (dec n)) v))))))


Вообще монада Either в данном случае — это по сути паттерн.
Как всем известны паттерны для ООП (адаптер, мост, декоратор и т.п.), так и для ФП можно вывести шаблонные подходы. Достаточно сказать новому члену команды «а вот тут у нас своя реализация Either с блекджеком и шлюхами». И все, не нужно объяснять что такое «контекст», путатьтся с другими «контекстами» (уж очень популярный термин), подробно расписывать документацию и т.п.
Этих двух сущностей: элементарное действие и контекст, достаточно, чтобы эффективно реализовать бОльшую часть бизнес логики.
Те, кому нравятся монады — используют монады.
Те, кому не нравятся монады, могут использовать эту простую методологию.
until-first-error — какой кошмар, думаю одной этой функцией можно было бы закончить статью, сейчас копаюсь в подобном коде, это какой-то ужас, люди, никогда не делайте так, чтобы другим не было мучительно больно, функции с бизнес-логикой должны быть названы с точки зрения бизнес-логики и использовать термины бизнес-логики, не надо абстракции абстракциями погонять, это дорога в мир ужасов разработчика.
Не надо перекладывать с больной головы на здоровую ;).
Контекстный подход успешно решает:
1) проблему декомпозиции кода (когда код метода не влезает в один экран);
2) упрощает тестирование и, как результат, ускоряет разработку;
3) предлагает альтернативный вариант к гибкой реализации retry фичи, когда можно делать retry не всей операции полностью, а только тех шагов, которые нужны.
А, и да, самое главное:
Disclaimer: этот метод не улучшает качество кода криворукого разработчика, а наоборот ухудшает его экспоненциально :).
Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.