Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Аппликативное программирование в Ruby: секретные архивы тайного общества адептов raleway–программирования

Аппликативное программирование в Ruby: секретные архивы тайного общества адептов raleway–программирования

Dmitry Tsepelev

November 16, 2022
Tweet

More Decks by Dmitry Tsepelev

Other Decks in Programming

Transcript

  1. Аппликативное программирование в Ruby Секретные архивы тайного общества адептов railway–

    программирования Дмитрий Цепелев
  2. DmitryTsepelev RUBYRUSSIA’22 2 @dmitrytsepelev 🌎 dmitrytsepelev.dev

  3. Программирование в стиле Railway

  4. DmitryTsepelev RUBYRUSSIA’22 Что хотим сделать 4 • списать деньги со

    счета; • проверить наличие товара; • обновить статус заказа; • если что–то не получается — все отменить.
  5. DmitryTsepelev RUBYRUSSIA’22 Реализация сервиса (а ля dry-monads) 5 class ProcessOrder

    include Dry : : Monads[:result] def initialize(order) = @order = order def perform result = ApplicationRecord.transaction do deduct_from_user_account.bind { prepare_shipment.bind { update_order_status } }.tap { |result| raise ActiveRecord : : Rollback.new(result.failure) if result.failure? } end end … end начинаем транзакцию отменяем если что–то пошло не так попадем внутрь блока если прошлая операция успешна
  6. DmitryTsepelev RUBYRUSSIA’22 6 class ProcessOrder … private def deduct_from_user_account if

    @order.user.balance > @order.amount @order.user.deduct_amount(@order.amount) Right() else Left("cannot deduct # { @order.amount}, user has # { @order.user.balance}") end end def prepare_shipment @order.item_id = = 42 ? Success() : Failure("not enough items in warehouse") end def update_order_status @order.processed! Left() end end успех что–то пошло не так Реализация сервиса (а ля dry-monads)
  7. DmitryTsepelev RUBYRUSSIA’22 Программирование в стиле Railway • пока забудем про

    dry-monads; • выполнение нескольких функций последовательно; • функция может вернуть только контейнер; • в зависимости от контейнера дальнейшие шаги могут не вызываться. 7
  8. DmitryTsepelev RUBYRUSSIA’22 Те самые rails (но не те которые вы

    подумали 🙂) 8 class ProcessOrder def perform ApplicationRecord.transaction do deduct_from_user_account.bind { prepare_shipment.bind { update_order_status } }.tap { |result| raise ActiveRecord : : Rollback.new(result.failure) if result.failure? } end end end Success Failure
  9. DmitryTsepelev RUBYRUSSIA’22 Контейнер Either 9 class Either class Left <

    Either attr_reader :error def initialize(error) = @error = error def deconstruct = [@error] end class Right < Either attr_reader :value def initialize(value) = @value = value def deconstruct = [@value] end end • Right — «всё хорошо», в контейнере значение; • Left — «что–то пошло не так», в контейнере ошибка; • похоже на Maybe, но с объяснением, что случилось.
  10. DmitryTsepelev RUBYRUSSIA’22 Работа со значением в контейнере 10 def fetch_email(user_id)

    if user_id = = 42 Either : : Right.new("[email protected]") else Either : : Left.new("User # { user_id} not found") end end def format_email(either_email) case either_email in Either : : Right(email) then Either : : Right.new("Email: # { email}") in left then left end end format_email(fetch_email(42)) # = > #<Either : : Right:… @value="Email: [email protected]"> format_email(fetch_email(1)) # = > #<Either : : Left:… @error="User 1 not found"> ⚠ Такого кода будет много! Можно не перепаковывать?
  11. Функторы и аппликативные функторы

  12. DmitryTsepelev RUBYRUSSIA’22 Интерфейс Functor 12 • преобразовывает значение в коробке

    с учетом типа контейнера; • обязательная функция — fmap. fmap a m b m
  13. DmitryTsepelev RUBYRUSSIA’22 Реализация Functor для Either 13 module Functor def

    fmap(&_fn) = raise NotImplementedError end class Either class Left # . . . class Right # . . . include Functor def fmap(&fn) case self in Either : : Right(value) then Either : : Right.new(fn.(value)) in left then left end end end • если значение Right — происходит распаковка, применение функции и запаковка; • Left остается без изменений.
  14. DmitryTsepelev RUBYRUSSIA’22 Реализация Functor для Either 14 def fetch_email(user_id) if

    user_id = = 42 Either : : Right.new("[email protected]") else Either : : Left.new("User # { user_id} not found") end end def format_email(either_email) = either_email.fmap { |email| "Email: # { email}" }
  15. DmitryTsepelev RUBYRUSSIA’22 Аппликативные функторы • fmap принимает функцию с одним

    аргументом; • curry: все функции могут принимать меньшее число аргументов и возвращать новые функции; • что будет, если передать функцию с двумя аргументами? 15
  16. DmitryTsepelev RUBYRUSSIA’22 Аппликативные функторы 16 def sum(x, y) = x

    + y Either : : Right.new(42).fmap(&method(:sum)) # = > #<Either : : Right: . . . @value=#<Proc: . . . (lambda) > > Either : : Right.new(42).fmap(&method(:sum)).value.(1) # = > 43 ^ a - > b - > c m b - > c m a m
  17. DmitryTsepelev RUBYRUSSIA’22 Applicative • позволяет удобно применять функцию в контейнере

    к значению в контейнере; • pure заворачивает значение в минимально простой контейнер; • ^ достает функцию из контейнера слева и применяет к значению в контейнере справа, затем кладет всё в исходный контейнер (если сможет!). 17 module Applicative include Functor def self.included(klass) klass.extend(Module.new do def pure(_value) = raise NotImplementedError end) end def pure(value) = self.class.pure(value) def ^(_other) = raise NotImplementedError end
  18. DmitryTsepelev RUBYRUSSIA’22 Реализация Applicative Functor для Either 18 class Either

    # . . . include Applicative def self.pure(value) = Right.new(value) def ^(other) case self in Right(fn) then other.fmap(&fn) in left then left end end end • pure — кладет значение в Right; • ^ вернет Left если Left будет слева; • иначе — обычный fmap.
  19. DmitryTsepelev RUBYRUSSIA’22 Реализация Applicative Functor для Either 19 def format_email(either_email)

    add_label = lambda { |label, email| " # { label} : # { email}" } Either.pure(add_label) ^ Either : : Right.new("Email") ^ either_email end 🌎 https: / / cutt.ly/FCtfSd7
  20. DmitryTsepelev RUBYRUSSIA’22 Чем круто? • безопасное каррирование; • можно создавать

    функции, работающие только с «хорошим сценарием»; • плохой сценарий будет обработан реализацией аппликативного функтора для используемого типа. 20
  21. Монады — расширение Applicative и дают больше возможностей

  22. DmitryTsepelev RUBYRUSSIA’22 Monad 22 bind m b m • return

    заворачивает значение в монаду (обычно так же как pure); • >>= (bind) берет монаду и функцию, преобразующую текущее значение в другое, применяет функцию и заворачивает результат в монаду; • отличие от Applicative — цепочка bind позволяет видеть все предыдущие результаты. a b a - >
  23. DmitryTsepelev RUBYRUSSIA’22 Модуль Monad 23 module Monad include Applicative def

    self.included(klass) klass.extend(Module.new do def returnM(value) = pure(value) end) end def bind(&fn) = raise NotImplementedError end
  24. DmitryTsepelev RUBYRUSSIA’22 Реализация Monad для Either 24 class Either include

    Monad def bind(&fn) case self in Right(value) then fn.(value) in left then left end end end
  25. DmitryTsepelev RUBYRUSSIA’22 Monad 25 def fetch_email(user_id) case user_id when 42

    then Right("[email protected]") when 666 then Right("invalid") else Left("User # { user_id} not found") end end def validate(email) = email.include?(“@") ? Either : : returnM(email) : Left("invalid email") def format_email(email) = Right("Email: # { email}") def fetch_validate_and_format(user_id) fetch_email(user_id).bind { |email| validate(email).bind { |validated_email| format_email(validated_email) } } end fetch_validate_and_format(42) # = > #<Either : : Right:… @value="Email: [email protected]"> fetch_validate_and_format(666) # = > #<Either : : Left:… @error="invalid email"> fetch_validate_and_format(1) # = > #<Either : : Left:… @error="User 1 not found">
  26. И зачем тогда?

  27. DmitryTsepelev RUBYRUSSIA’22 Еще есть поведение! • самые популярные контейнеры —

    Result и подобные; • в функциональных языках контейнеров гораздо больше; • аппликативных функторов больше, чем монад; • если реализация (аппликативного) функтора следует законам — можно делать интересные надстройки. 27
  28. DmitryTsepelev RUBYRUSSIA’22 Реализация Functor/Applicative для списка 28 class ApplicativeArray <

    Array include Functor def fmap(&fn) = map(&fn.curry) include Applicative class < < self def pure(x) = ApplicativeArray.new([x]) end def ^(other) ApplicativeArray.new(flat_map { |fn| other.fmap(&fn) }) end end 🌎 https: / / cutt.ly/ECtge4W
  29. DmitryTsepelev RUBYRUSSIA’22 ApplicativeArray 29 def plus(x, y) = x +

    y def mult(x, y) = x * y array_with_functions = ApplicativeArray.new([method(:plus), method(:mult)]) array_with_args = ApplicativeArray.new([2, 7]) array_with_args_2 = ApplicativeArray.new([3, 5]) array_with_functions ^ array_with_args ^ array_with_args_2 # = > [5, 7, 10, 12, 6, 10, 21, 35] 🌎 https: / / cutt.ly/ECtge4W
  30. DmitryTsepelev RUBYRUSSIA’22 А можно поэлементно? 30 plus = lambda {

    |x, y| x + y } mult = lambda { |x, y| x * y } functions = ZipList.new([plus, mult]) args = ZipList.new([2, 7]) args_2 = ZipList.new([3, 5]) (functions ^ args ^ args_2).list # = > [5, 35] # pure – бесконечный список functions = ZipList : : pure(plus) args = ZipList : : pure(2) args_2 = ZipList.new([4, 6, 8]) (functions ^ args ^ args_2).list.eager.to_a # = > [6, 8, 10] 🌎 https: / / cutt.ly/TCtf865
  31. DmitryTsepelev RUBYRUSSIA’22 Аппликативный парсер 31 parser = ( Parser.pure(lambda {

    |a, b, c| a + b + c }.curry) ^ (Parser.char('A') | Parser.char('B')) ^ Parser.char('C') ^ Parser.string("42") ).fmap(&:downcase) parser.parse("AC42D") # = > #<Either : : Right:… @value=#<Pair:… @fst="D", @snd="ac" > > parser.parse("BC42D") # = > #<Either : : Right:… @value=#<Pair:… @fst="D", @snd="bc" > > parser.parse("DCB") # = > #<Either : : Left:… @error="unexpected D"> 🌎 https: / / cutt.ly/iCtdAiU
  32. DmitryTsepelev RUBYRUSSIA’22 Traversable 32 module Traversable def traverse(traversable_class, &_fn) =

    raise NotImplementedError end t a b a - > f traverse b t f Traversable Applicative
  33. DmitryTsepelev RUBYRUSSIA’22 Traversable для списка 33 class ApplicativeArray < Array

    include Traversable def traverse(applicative_class, &fn) return applicative_class : : pure([]) if empty? x, * xs = self applicative_class : : pure(lambda { |ta, rest| [ta] + rest }) ^ fn.(x) ^ ApplicativeArray.new(xs).traverse(applicative_class, &fn) end end 🌎 https: / / cutt.ly/zCtfXFq Вытаскивает первый Left наружу либо всё значения Right обернутые в Right
  34. DmitryTsepelev RUBYRUSSIA’22 Traversable для списка 34 increment = lambda {

    |maybe_value| maybe_value.fmap { |value| value + 1 } } rights = ApplicativeArray.new([Right(1), Right(3), Right(5)]) rights_and_lefts = ApplicativeArray.new([Right(1), Left("error")]) rights.traverse(Either, &increment) # = > #<Either : : Right: . . . @value=[2, 4, 6]> rights_and_lefts.traverse(Either, &increment) # = > #<Either : : Left: . . . @error=“error"> 🌎 https: / / cutt.ly/zCtfXFq Вытаскивает первый Left наружу либо всё значения Right обернутые в Right
  35. Делаем Service Object на базе Applicative в Railway– стиле

  36. DmitryTsepelev RUBYRUSSIA’22 Как было 36 class ProcessOrder include Dry :

    : Monads[:result] def initialize(order) = @order = order def perform ApplicationRecord.transaction do deduct_from_user_account.bind { prepare_shipment.bind { update_order_status } }.tap { |result| raise ActiveRecord : : Rollback.new(result.failure) if result.failure? } end end private def deduct_from_user_account; … end def prepare_shipment; … end def update_order_status; … end end
  37. DmitryTsepelev RUBYRUSSIA’22 Как стало 37 class ProcessOrder < MultiStepService def

    initialize(order) = @order = order add_step :deduct_from_user_account add_step :prepare_shipment add_step :update_order_status def deduct_from_user_account; … end def prepare_shipment; … end def update_order_status; … end end 🌎 https: / / cutt.ly/CCtfMFn def identity(value) = value def Right(value = method(:identity)) = Either : : Right.new(value) def Left(error = method(:identity)) = Either : : Left.new(error) class MultiStepService class < < self def add_step(step) = steps < < step def steps = @steps | | = [] end def perform ApplicationRecord.transaction do self.class.steps.reduce(Right()) { |result, step| result ^ send(step) }.on_error { |error| raise ActiveRecord : : Rollback.new(error) } end end end
  38. DmitryTsepelev RUBYRUSSIA’22 Выводы • монады круто подходят для Railway–стиля, но

    они могут гораздо больше; • монады могут быть реализованы на базе аппликативных функторов; • реализации аппликативных функторов могут давать другое интересное поведение. 38
  39. DmitryTsepelev RUBYRUSSIA’22 Куда пойти дальше • весь код который мы

    сегодня видели и который не успели посмотреть (https:/ /github.com/DmitryTsepelev/applicative-rb); • моя статья про функторы в Haskell (https:/ /dmitrytsepelev.dev/haskell- adventures-functors); • не моя научпоп–статья про функторы, аппликативные функторы и монады в картинках (https:/ /adit.io/posts/2013-04-17- functors,_applicatives,_and_monads_in_pictures.html); • не моя хардкорная статья про аппликативные функторы (https:/ / www.sta ff .city.ac.uk/~ross/papers/Applicative.pdf). 39
  40. Спасибо! @dmitrytsepelev 🌎 dmitrytsepelev.dev