$30 off During Our Annual Pro Sale. View Details »

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

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

Dmitry Tsepelev

November 16, 2022
Tweet

More Decks by Dmitry Tsepelev

Other Decks in Programming

Transcript

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

    View Slide

  2. DmitryTsepelev
    RUBYRUSSIA’22 2
    @dmitrytsepelev
    🌎 dmitrytsepelev.dev

    View Slide

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

    View Slide

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


    • проверить наличие товара;


    • обновить статус заказа;


    • если что–то не получается — все отменить.

    View Slide

  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
    начинаем транзакцию
    отменяем если что–то


    пошло не так
    попадем внутрь блока если


    прошлая операция успешна

    View Slide

  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)

    View Slide

  7. DmitryTsepelev
    RUBYRUSSIA’22
    Программирование в стиле Railway
    • пока забудем про dry-monads;


    • выполнение нескольких функций последовательно;


    • функция может вернуть только контейнер;


    • в зависимости от контейнера дальнейшие шаги могут не
    вызываться.
    7

    View Slide

  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

    View Slide

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

    View Slide

  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)) #
    = >
    #: :
    Right:… @value="Email: [email protected]">


    format_email(fetch_email(1)) #
    = >
    #: :
    Left:… @error="User 1 not found">
    ⚠ Такого кода будет много! Можно не перепаковывать?

    View Slide

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

    View Slide

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


    • обязательная функция — fmap.
    fmap
    a
    m
    b
    m

    View Slide

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

    View Slide

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

    View Slide

  15. DmitryTsepelev
    RUBYRUSSIA’22
    Аппликативные функторы
    • fmap принимает функцию с одним аргументом;


    • curry: все функции могут принимать меньшее число аргументов и
    возвращать новые функции;


    • что будет, если передать функцию с двумя аргументами?
    15

    View Slide

  16. DmitryTsepelev
    RUBYRUSSIA’22
    Аппликативные функторы
    16
    def sum(x, y) = x + y


    Either
    : :
    Right.new(42).fmap(&method(:sum))


    #
    = >
    #: :
    Right:
    . . .
    @value=#. . .
    (lambda)
    > >

    Either
    : :
    Right.new(42).fmap(&method(:sum)).value.(1) #
    = >
    43
    ^
    a
    - >
    b
    - >
    c
    m
    b
    - >
    c
    m
    a
    m

    View Slide

  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

    View Slide

  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.

    View Slide

  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

    View Slide

  20. DmitryTsepelev
    RUBYRUSSIA’22
    Чем круто?
    • безопасное каррирование;


    • можно создавать функции, работающие только с «хорошим
    сценарием»;


    • плохой сценарий будет обработан реализацией аппликативного
    функтора для используемого типа.
    20

    View Slide

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

    View Slide

  22. DmitryTsepelev
    RUBYRUSSIA’22
    Monad
    22
    bind
    m
    b
    m
    • return заворачивает значение в монаду (обычно так же как pure);


    • >>= (bind) берет монаду и функцию, преобразующую текущее значение в
    другое, применяет функцию и заворачивает результат в монаду;


    • отличие от Applicative — цепочка bind позволяет видеть все предыдущие
    результаты.
    a
    b
    a
    - >

    View Slide

  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

    View Slide

  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

    View Slide

  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) #
    = >
    #: :
    Right:… @value="Email: [email protected]">


    fetch_validate_and_format(666) #
    = >
    #: :
    Left:… @error="invalid email">


    fetch_validate_and_format(1) #
    = >
    #: :
    Left:… @error="User 1 not found">

    View Slide

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

    View Slide

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


    • в функциональных языках контейнеров гораздо больше;


    • аппликативных функторов больше, чем монад;


    • если реализация (аппликативного) функтора следует законам — можно
    делать интересные надстройки.
    27

    View Slide

  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

    View Slide

  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

    View Slide

  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

    View Slide

  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") #
    = >
    #: :
    Right:… @value=#> >

    parser.parse("BC42D") #
    = >
    #: :
    Right:… @value=#> >

    parser.parse("DCB") #
    = >
    #: :
    Left:… @error="unexpected D">
    🌎 https:
    / /
    cutt.ly/iCtdAiU

    View Slide

  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

    View Slide

  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

    View Slide

  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) #
    = >
    #: :
    Right:
    . . .
    @value=[2, 4, 6]>


    rights_and_lefts.traverse(Either, &increment) #
    = >
    #: :
    Left:
    . . .
    @error=“error">


    🌎 https:
    / /
    cutt.ly/zCtfXFq
    Вытаскивает первый Left наружу либо всё значения Right обернутые в Right

    View Slide

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

    View Slide

  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

    View Slide

  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

    View Slide

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


    • монады могут быть реализованы на базе аппликативных функторов;


    • реализации аппликативных функторов могут давать другое
    интересное поведение.
    38

    View Slide

  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

    View Slide

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

    View Slide