В программировании много абстракций, которые кажутся сложными на первый взгляд, но после практики становятся понятнее. Например - монады.
Я видел разработчиков, которые бояться использовать монады, а код с использованием монад - сжигают на месте. Основная причина - убеждение, что для этого необходимо знать теорию категорий и хаскель, да и зачем лишние абстракции в проекте. Другие не понимают зачем эту абстракцию тащить в руби. При этом, люди используют монады каждый день и не подозревают этого (ссылки). Поэтому сегодня поговорим о практическом использовании монад в руби и зачем это нужно.
Не значит, что монады - серебряная пуля, которая решит любую проблему. Так же, это не значит, что использование монад - единственно верный способ написания кода.
Поэтому, цель текста - не объяснить что такое монада, а показать, как и где начать ее использовать . Не ждите функторов, аппликативных функторов, математических выкладок и подробного объяснения зачем каждая монада нужна. Только практика и императивное объяснение. Статьи для любопытных:
- Объяснение функторов и монад в картинках
- Объяснение в HaskellWiki
- A Fistful of Monads - Learn You a Haskell for Great Good!
Код
Встречаются места когда приходится работать с данными и состояниями, например валидны данные или нет, вернула бд данные и если да - какие это данные, etc. Например:
response = http.get(url, params)
if response[:status] == :success
user_repository.create(response[:body])
end
Пример не выглядит сложным, но с бизнес логикой - вложенность выходит из под контроля:
response = http.get(url, params)
if response[:status] == :success
validation_result = validator.call(response[:body])
if validation_result.valid?
if user = user_repository.create(response[:body])
NotificationWorker.perform_async(user.id)
end
else
validation_result.errors
end
else
response
end
Вариант с гардами мне показался сложнее для восприятия
Вспоминая railway programming, было бы здорово переписать наш пример с использованием последовательных шагов:
step :get_response # returns response or Failed result
step :validate # returns payload or error message
step :persist # returns user or nothing
step :notify # calls worker with user id
В случаях, когда данные из прошлого шага влияют на последовательность логики, приходят на помощь монады. Важно запомнить, что монада - объект с общим интерфейсом, в котором лежит значение. Ближайшая абстракция - коробка. В коробке лежит все, что поместиться. При этом, не открыв коробку - значение не получить. Коробки - разные, большие, маленькие, цветные, но каждая коробка открывается одинаково - просто подними крышку и посмотри что там.
Монада - инструмент, который позволяет создавать цепочки вызовов функций или методов без лишних проверок данных, которые возвращаются в предыдущем шаге. Монад много, но рассмотрим только популярные - Maybe
, Result
и Try
.
-
Maybe
- оборачивает значение вSome
или возвращаетNone
объект без значения. -
Result
- оборачивает значение вSuccess
илиFailure
. -
Try
- оборачивает вызов кода вResult
если не было эксепшенов и вError
, если код упал с ошибкой (которая ловится)
У каждой из монад есть 3 главных функции, fmap
, bind
и способ получить данные, которые содержит в себе монада.
fmap
- выполняет блок, если значение монады соответствуетSuccess
варианту, а результат выполнения блока оборачивает в ту же монаду, у которой он вызвался. Например:
Some(1).fmap(&:to_s) # => Some('1')
None().fmap(&:to_s) # => Nothing
bind
- аналогиченfmap
, только возвращается результат выполнения блока:
Some(1).bind(&:to_s) # => '1'
Some(1).bind { |value| Success(value) } # => Success('1')
None().bind(&:to_s) # => Nothing
В руби нет монад из коробки, но существуют гемы, которые реализуют монады:
Советую dry, как единственную поддерживаемую. К тому же, при использовании dry-validation можно легко конвертировать результат валидации в монаду, воспользовавшись экстеншеном:
Dry::Validation.load_extensions(:monads)
Это минимум, который нужен, чтобы начать использовать монады в руби приложении. Для закрепления - перепишем изначальный пример с использованием монад:
http.get(url, params) # теперь клиент возвращается Result Monad
# валидация возвращает Result, который используется для следующих вызовов
.bind { |body| validator.call(body).to_result }
# сохраняем в базу, если валидация вернула Success
.bind { |payload| Maybe(user_repository.create(payload)) }
# вызываем воркер, если сохранение вернет Some
.fmap { |user| NotificationWorker.perform_async(user.id) }
Кроме использования монад в бизнес логике, попробуйте эту абстракцию для обработки результата, который возвращается из бизнес логики. Как пример - вызов operation из экшена и последующая обработка результата в этом же экшене: cookie_box/show.rb
Что делать с результатом
При использовании dry-monads
можно:
- вызывать на прямую
success?
,failed?
илиvalue_or
; - использовать
dry-matcher
; - мой любимый вариант, использовать
case
;
Минусы
- В отличии от условий (
if
,unless
, etc) нельзя просто взять и использовать монаду. Если не знать в чем смысл абстракции и что значатbind
иfmap
- будет сложно понять код, который написан; - Использование монад может сильно усложнить код. Спасает опыт, а опыт получается в практике;
- Если хотите начать использовать монады в проекте, придется прорваться через ужас в глазах коллег (причина почему я написал этот текст);
Запомнить
- Монады - абстракция для чейна вызовов функций и следованию railway programming;
- Для использования монад не нужно математическое образование. Главное понять, что монада оборачивает данные в объекты с единым интерфейсом;
- Советую начать с -
Maybe
,Result
иTry
; fmap
иbind
- методы для чейна вызовов функций- Чрезмерное использование монад усложняет код, будьте осторожны и подходите к написанию кода с умом;
Полезное
raise RuntimeError
и при этомrescue
без указания класса, тоесть он делаетrescue StandardError
, что при таком формате теста будет накладывать огромный отпечаток на скорость. Норм там все будет по скоростиПро нагрузку на разраба- опять же, если пофиг на ошибки (что для меня странно), то есть
rescue
и там уже бороться с тем, что произошло. Но мне бы все ровно в такой ситуации хотелось бы запись в лог. При этом никто не мешает все сгруппировать и отрабатывать ошибки с сетью в хелперах. Аргумент, что это тяжело для разраба тут как-то неубедителенне безопасная абстракция для работы с логикой
Никто не запрещает запилить же общий rescue на крайний случай, для избежания ситуации "ой, все". Этого случая в нормальной жизни "никогда не должно произойти, так что он не пригодится ;) но, если что-то пойдет не так, то в логах будет не только
nil
, а еще и понимание того, что было.Если взять для примера
#send_request
и аргумент, что там может потеряться одна из ошибок- то ограждающий "общий" rescue в помощь. Даже, если еще на этапе тестов проблем не возникло.Что важно, что при rescue логике ты все будешь подсвечивать логами, так, что как только ты понизишь левел до нужного уровня логи станут воистину полными. В случае с моанами или с :try это все потеряется
Да, можно сказать, что если API на внешнем сервисе изменится или будут проблемы с сетью ты по
nil
и так все поймешь или же что для многих решений это overkill. Не соглашусь, ткrescue
блок тут будет выполнять именно ту функцию, для чего создан. Он понятен и интуитивен, и сохраняет текст ошибки.связанность между логикой увеличивается
никто не мешает возвращать какой-то сорт NullObject или FailureObject. да и ошибки разные и нормально, что ох отработка даст разные результаты. Но это ведь совсем другой паттерн, более легкий.
Да и тестить тоже можно только 2 случая- успех и нет. достаточно лишь проверять был ли рейз. в случае с моанами вообще понятие ошибки исчезает, так что тут упрекать, что в тестах могут тестить не все это как-то не уместно, имхо
и еще- в твоих примерах (обоих), семантически это
raise
. это именно нечто, ввиду чего продолжение не возможноесть штатные конструкции языка, которые будут так же в игре, что бы ты не делал. и мне тут искренне не понятно, зачем нужна экстра терминология, в ДИНАМИЧЕСКИ типизированном языке. Для Haskell вопроса нет. Но тут зачем?
пока что я так и не понял, зачем это все надо в проект. но, безусловно еще прочитаю. наверное, просто я тупой и допераю туго )