Slide 1

Slide 1 text

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

Slide 2

Slide 2 text

DmitryTsepelev RUBYRUSSIA’22 2 @dmitrytsepelev 🌎 dmitrytsepelev.dev

Slide 3

Slide 3 text

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

Slide 4

Slide 4 text

DmitryTsepelev RUBYRUSSIA’22 Что хотим сделать 4 • списать деньги со счета; • проверить наличие товара; • обновить статус заказа; • если что–то не получается — все отменить.

Slide 5

Slide 5 text

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 начинаем транзакцию отменяем если что–то пошло не так попадем внутрь блока если прошлая операция успешна

Slide 6

Slide 6 text

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)

Slide 7

Slide 7 text

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

Slide 8

Slide 8 text

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

Slide 9

Slide 9 text

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, но с объяснением, что случилось.

Slide 10

Slide 10 text

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)) # = > # format_email(fetch_email(1)) # = > # ⚠ Такого кода будет много! Можно не перепаковывать?

Slide 11

Slide 11 text

Функторы и аппликативные функторы

Slide 12

Slide 12 text

DmitryTsepelev RUBYRUSSIA’22 Интерфейс Functor 12 • преобразовывает значение в коробке с учетом типа контейнера; • обязательная функция — fmap. fmap a m b m

Slide 13

Slide 13 text

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 остается без изменений.

Slide 14

Slide 14 text

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}" }

Slide 15

Slide 15 text

DmitryTsepelev RUBYRUSSIA’22 Аппликативные функторы • fmap принимает функцию с одним аргументом; • curry: все функции могут принимать меньшее число аргументов и возвращать новые функции; • что будет, если передать функцию с двумя аргументами? 15

Slide 16

Slide 16 text

DmitryTsepelev RUBYRUSSIA’22 Аппликативные функторы 16 def sum(x, y) = x + y Either : : Right.new(42).fmap(&method(:sum)) # = > # > Either : : Right.new(42).fmap(&method(:sum)).value.(1) # = > 43 ^ a - > b - > c m b - > c m a m

Slide 17

Slide 17 text

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

Slide 18

Slide 18 text

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.

Slide 19

Slide 19 text

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

Slide 20

Slide 20 text

DmitryTsepelev RUBYRUSSIA’22 Чем круто? • безопасное каррирование; • можно создавать функции, работающие только с «хорошим сценарием»; • плохой сценарий будет обработан реализацией аппликативного функтора для используемого типа. 20

Slide 21

Slide 21 text

Монады — расширение Applicative и дают больше возможностей

Slide 22

Slide 22 text

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

Slide 23

Slide 23 text

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

Slide 24

Slide 24 text

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

Slide 25

Slide 25 text

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) # = > # fetch_validate_and_format(666) # = > # fetch_validate_and_format(1) # = > #

Slide 26

Slide 26 text

И зачем тогда?

Slide 27

Slide 27 text

DmitryTsepelev RUBYRUSSIA’22 Еще есть поведение! • самые популярные контейнеры — Result и подобные; • в функциональных языках контейнеров гораздо больше; • аппликативных функторов больше, чем монад; • если реализация (аппликативного) функтора следует законам — можно делать интересные надстройки. 27

Slide 28

Slide 28 text

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

Slide 29

Slide 29 text

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

Slide 30

Slide 30 text

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

Slide 31

Slide 31 text

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") # = > # > parser.parse("BC42D") # = > # > parser.parse("DCB") # = > # 🌎 https: / / cutt.ly/iCtdAiU

Slide 32

Slide 32 text

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

Slide 33

Slide 33 text

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

Slide 34

Slide 34 text

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) # = > # rights_and_lefts.traverse(Either, &increment) # = > # 🌎 https: / / cutt.ly/zCtfXFq Вытаскивает первый Left наружу либо всё значения Right обернутые в Right

Slide 35

Slide 35 text

Делаем Service Object на базе Applicative в Railway– стиле

Slide 36

Slide 36 text

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

Slide 37

Slide 37 text

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

Slide 38

Slide 38 text

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

Slide 39

Slide 39 text

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

Slide 40

Slide 40 text

Спасибо! @dmitrytsepelev 🌎 dmitrytsepelev.dev