Skip to content

Instantly share code, notes, and snippets.

@davydovanton
Created March 19, 2018 09:22
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save davydovanton/bb504c423e958470ca639dbc086ad2dc to your computer and use it in GitHub Desktop.
Save davydovanton/bb504c423e958470ca639dbc086ad2dc to your computer and use it in GitHub Desktop.
Pepegramming: Monads

Monads

В программировании много абстракций, которые кажутся сложными на первый взгляд, но после практики становятся понятнее. Например - монады.

Я видел разработчиков, которые бояться использовать монады, а код с использованием монад - сжигают на месте. Основная причина - убеждение, что для этого необходимо знать теорию категорий и хаскель, да и зачем лишние абстракции в проекте. Другие не понимают зачем эту абстракцию тащить в руби. При этом, люди используют монады каждый день и не подозревают этого (ссылки). Поэтому сегодня поговорим о практическом использовании монад в руби и зачем это нужно.

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

Поэтому, цель текста - не объяснить что такое монада, а показать, как и где начать ее использовать . Не ждите функторов, аппликативных функторов, математических выкладок и подробного объяснения зачем каждая монада нужна. Только практика и императивное объяснение. Статьи для любопытных:

Код

Встречаются места когда приходится работать с данными и состояниями, например валидны данные или нет, вернула бд данные и если да - какие это данные, 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 можно:

Минусы

  1. В отличии от условий (if, unless, etc) нельзя просто взять и использовать монаду. Если не знать в чем смысл абстракции и что значат bind и fmap - будет сложно понять код, который написан;
  2. Использование монад может сильно усложнить код. Спасает опыт, а опыт получается в практике;
  3. Если хотите начать использовать монады в проекте, придется прорваться через ужас в глазах коллег (причина почему я написал этот текст);

Запомнить

  • Монады - абстракция для чейна вызовов функций и следованию railway programming;
  • Для использования монад не нужно математическое образование. Главное понять, что монада оборачивает данные в объекты с единым интерфейсом;
  • Советую начать с - Maybe, Result и Try;
  • fmap и bind - методы для чейна вызовов функций
  • Чрезмерное использование монад усложняет код, будьте осторожны и подходите к написанию кода с умом;

Полезное

@davydovanton
Copy link
Author

  1. @kvokka, я не фанат rescue флоу

Причин много, основные:

медлено
http://blog.honeybadger.io/benchmarking-exceptions-in-ruby-yep-theyre-slow/

похоже на GOTO

увеличенная конгнетивная нагрузка на разработчика

Давай посмотрим на пример, который ты написал. #some_action обрабатывает все ошибки, которые получает при вызове метода. Следовательно, тебе надо помнить, что метод #get! возвращает результат ИЛИ бросает ошибку, которую еще поймать надо. К тому же, "resque flow" чаще всего приводит к тому, что появляется метод, который ловит 3+ ошибки из других методов. А из этого следует еще 2 пробемы:

не безопасная абстракция для работы с логикой

Чем выше конгнетивная нагрузка - тем выше шанс ошибиться (говорю сейчас за себя). Следовательно вывод, которого стараюсь придерживаться - чем меньше сайд эфектов у кода, который я использу, тем меньше конгнетивная нагрузка и меньше шанс ошибиться.

Как пример: тебе нужно сделать либо гет, либо пост запрос в зависимости от параметра (беру из головы пример), ты делаешь 3 метода:

def get!(*a) 
  response = http.get(*a)
  response[:status] == :success ? response : raise FailedGetResponse(response: response) 
end

def post!(*a) 
  response = http.post(*a)
  response[:status] == :success ? response : raise FailedPostResponse(response: response) 
end

def send_request(payload)
  if payload[:method] == :post
    get!(payload[:options])
  else
    post!(payload[:options])
  end
end

что получаешь в итоге, метод #send_request, который возвращает хеш с результатом, а может выкинуть 2 ошибки одна из которых забудется и не поймается. В следствии у тебя упадет все приложение в корректном условии выполнения кода.

связанность между логикой увеличивается

использование монад говорит о том, что есть какой-то метод (#send_request) и он вернет объект. этот объект всегда будет одинаковый для любой ошибки. Тебе не важно знать, что конкретно там упало и какая из ошибок вернулась. Что бы не произошло, у тебя будет либо Success результат, либо Failure. При тестировании, тебе не нужно будет проверять, что каждая из ошибок будет ловиться, достаточно написать 2 теста и все.

пара ссылок в довесок:
http://rmosolgo.github.io/blog/2016/11/23/raising-exceptions-is-bad/
https://softwareengineering.stackexchange.com/questions/189222/are-exceptions-as-control-flow-considered-a-serious-antipattern-if-so-why

  1. Если так случилось, и тебе приходится использовать код, который бросает ошибку (например DB вызовы), что тебе мешает оборачивать эту ошибку как можно быстрее и не выносить ее дальше метода, где код был вызван? Для этого есть Try монада

  2. никто не заставляет тебя переписывать весь код, который есть прямо сейчас. К тому же, благодаря данной абстракции ты можешь подумать о Result object и посмотреть на http://dry-rb.org/gems/dry-transaction/, https://github.com/collectiveidea/interactor или на любой другой способ как организовать вызов бизнес логики в проекте

@kvokka
Copy link

kvokka commented Mar 19, 2018

  1. Про медленно- тест не корректен про exception. Автор raise RuntimeError и при этом rescue без указания класса, тоесть он делает rescue StandardError, что при таком формате теста будет накладывать огромный отпечаток на скорость. Норм там все будет по скорости

Про нагрузку на разраба- опять же, если пофиг на ошибки (что для меня странно), то есть rescue и там уже бороться с тем, что произошло. Но мне бы все ровно в такой ситуации хотелось бы запись в лог. При этом никто не мешает все сгруппировать и отрабатывать ошибки с сетью в хелперах. Аргумент, что это тяжело для разраба тут как-то неубедителен

не безопасная абстракция для работы с логикой

Никто не запрещает запилить же общий rescue на крайний случай, для избежания ситуации "ой, все". Этого случая в нормальной жизни "никогда не должно произойти, так что он не пригодится ;) но, если что-то пойдет не так, то в логах будет не только nil, а еще и понимание того, что было.

Если взять для примера #send_request и аргумент, что там может потеряться одна из ошибок- то ограждающий "общий" rescue в помощь. Даже, если еще на этапе тестов проблем не возникло.

Что важно, что при rescue логике ты все будешь подсвечивать логами, так, что как только ты понизишь левел до нужного уровня логи станут воистину полными. В случае с моанами или с :try это все потеряется

Да, можно сказать, что если API на внешнем сервисе изменится или будут проблемы с сетью ты по nil и так все поймешь или же что для многих решений это overkill. Не соглашусь, тк rescue блок тут будет выполнять именно ту функцию, для чего создан. Он понятен и интуитивен, и сохраняет текст ошибки.

связанность между логикой увеличивается

никто не мешает возвращать какой-то сорт NullObject или FailureObject. да и ошибки разные и нормально, что ох отработка даст разные результаты. Но это ведь совсем другой паттерн, более легкий.

Да и тестить тоже можно только 2 случая- успех и нет. достаточно лишь проверять был ли рейз. в случае с моанами вообще понятие ошибки исчезает, так что тут упрекать, что в тестах могут тестить не все это как-то не уместно, имхо

и еще- в твоих примерах (обоих), семантически это raise. это именно нечто, ввиду чего продолжение не возможно

  1. есть штатные конструкции языка, которые будут так же в игре, что бы ты не делал. и мне тут искренне не понятно, зачем нужна экстра терминология, в ДИНАМИЧЕСКИ типизированном языке. Для Haskell вопроса нет. Но тут зачем?

  2. пока что я так и не понял, зачем это все надо в проект. но, безусловно еще прочитаю. наверное, просто я тупой и допераю туго )

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