Преобразователи монад, объясненные в Javascript?

Мне трудно понять преобразователи монад, отчасти потому, что в большинстве примеров и объяснений используется Haskell.

Может ли кто-нибудь привести пример создания преобразователя для слияния фьючерсной и любой монады в Javascript и того, как его можно использовать.

Если бы вы могли использовать ramda-fantasy реализацию этих монад, было бы еще лучше.


person Marcelo Lazaroni    schedule 14.03.2017    source источник
comment
Каков будет конечный результат этого слияния? Преобразователи монад Afaict — это функции, которые принимают монаду и возвращают монаду, удовлетворяющую нескольким правилам: en.wikipedia. org/wiki/Monad_transformer#Definition   -  person Alex Pánek    schedule 14.03.2017
comment
Я использую монаду Someone для обработки проверки внутри монады Future, обрабатывающей асинхронный поток. Работать с одной монадой внутри другой не очень чисто, а объединение монад Future в цепочку становится особенно сложным. Я читал, что преобразователи монад могут дать мне более чистый API, состоящий из этих двух монад. Это правильно? Если да, то как это выглядит в Javascript?   -  person Marcelo Lazaroni    schedule 14.03.2017
comment
@AlexPánek эта статья в Википедии является примером объяснения, которое едва ли понятно разработчику JavaScript, не знающему Haskell. Я ищу объяснение, используя простой код JavaScript.   -  person Marcelo Lazaroni    schedule 14.03.2017
comment
Не могли бы вы описать, как вы пытались реализовать это до сих пор?   -  person Alex Pánek    schedule 14.03.2017
comment
@AlexPánek Я считаю, что вопрос был довольно простым. Это не конкретно мой код, а то, что такое трансформеры монад и как они работают в JavaScript.   -  person Marcelo Lazaroni    schedule 14.03.2017
comment
Этот вопрос слишком широк. В любом случае, AFAIK, преобразователи монад действительно облегчают композицию монад. Например, они могут помочь вам избежать глубоко вложенных цепных вызовов. Реализация Javascript доступна на Стране фантазий и в этот SO вопрос. Обратите внимание, что не каждая монадная композиция дает монаду, т. е. вы можете потерять некоторые из монадных законов.   -  person    schedule 14.03.2017
comment
@MarceloLazaroni Я никогда не собирался говорить, что это не так. Но я бы не знал достаточно о теме в целом, чтобы дать ответ, поэтому я спросил, что у вас есть на данный момент. Это может помочь мне лучше понять, чего вы пытаетесь достичь. :)   -  person Alex Pánek    schedule 15.03.2017
comment
это старый вопрос, но для тех, кто пересматривает его, вот видео с подробным ответом на этот вопрос! youtube.com/   -  person Alfred Young    schedule 09.07.2019


Ответы (1)


Сначала правила

Во-первых, у нас есть Закон естественной трансформации.

  • Некоторый функтор F из a, сопоставленный с функцией f, дает F из b, затем естественным образом преобразуется, дает некоторый функтор G из b.
  • Некоторый функтор F из a, естественным образом преобразованный, дает некоторый функтор G из a, затем сопоставленный с некоторой функцией f, дает G из b

Выбор любого пути (сначала сопоставить, затем преобразовать, или сначала преобразовать, затем сопоставить) приведет к одному и тому же конечному результату, G из b.

закон естественного преобразования

nt(x.map(f)) == nt(x).map(f)

Получить реальность

Хорошо, теперь давайте сделаем практический пример. Я объясню код шаг за шагом, а затем в самом конце у меня будет полный работоспособный пример.

Сначала мы реализуем Либо (используя Left и Right)

const Left = x => ({
  map: f => Left(x),
  fold: (f,_) => f(x)
})

const Right = x => ({
  map: f => Right(f(x)),
  fold: (_,f) => f(x),
})

Затем мы реализуем Task

const Task = fork => ({
  fork,
  // "chain" could be called "bind" or "flatMap", name doesn't matter
  chain: f =>
    Task((reject, resolve) =>
      fork(reject,
           x => f(x).fork(reject, resolve)))
})
Task.of = x => Task((reject, resolve) => resolve(x))
Task.rejected = x => Task((reject, resolve) => reject(x))

Теперь давайте начнем определять некоторые части теоретической программы. У нас будет база данных пользователей, где у каждого пользователя есть лучший друг (лучший друг навсегда). Мы также определим простую функцию Db.find, которая возвращает задачу поиска пользователя в нашей базе данных. Это похоже на любую библиотеку базы данных, которая возвращает Promise.

// fake database
const data = {
  "1": {id: 1, name: 'bob', bff: 2},
  "2": {id: 2, name: 'alice', bff: 1}
}

// fake db api
const Db = {
  find: id =>
    Task((reject, resolve) => 
      resolve((id in data) ? Right(data[id]) : Left('not found')))
}

Итак, есть один небольшой поворот. Наша функция Db.find возвращает Task из Either (Left или Right). Это в основном для демонстрационных целей, но также может рассматриваться как хорошая практика. То есть, мы можем не считать сценарий «пользователь не найден» ошибкой, поэтому мы не хотим reject задачу — вместо этого мы изящно обработаем ее позже, разрешив Left из 'not found'. Мы могли бы использовать reject в случае другой ошибки, такой как сбой подключения к базе данных или что-то в этом роде.


Постановка целей

Цель нашей программы — взять заданный идентификатор пользователя и найти его лучшую подругу.

Мы амбициозны, но наивны, поэтому сначала попробуем что-то вроде этого

const main = id =>
  Db.find(1) // Task(Right(User))
    .map(either => // Right(User)
      either.map(user => // User
        Db.find(user.bff))) // Right(Task(Right(user)))

Да! a Task(Right(Task(Right(User)))) ... это очень быстро вышло из-под контроля. Это будет полный кошмар, работая с таким результатом...


Естественная трансформация

А вот и наше первое естественное преобразование eitherToTask:

const eitherToTask = e =>
  e.fold(Task.rejected, Task.of)

// eitherToTask(Left(x)) == Task.rejected(x)
// eitherToTask(Right(x)) == Task.of(x)

Давайте посмотрим, что произойдет, когда мы chain преобразуем это преобразование в наш Db.find результат.

const main = id =>
  Db.find(id) // Task(Right(User))
    .chain(eitherToTask) // ???
    ...

Так что же такое ???? Что ж, Task#chain ожидает, что ваша функция вернет Task, а затем сплющит текущую задачу и только что возвращенную задачу вместе. Итак, в этом случае мы идем:

// Db.find           // eitherToTask     // chain
Task(Right(User)) -> Task(Task(User)) -> Task(User)

Ух ты. Это уже огромное улучшение, потому что оно делает наши данные более плоскими по мере прохождения вычислений. Давайте продолжим ...

const main = id =>
  Db.find(id) // Task(Right(User))
    .chain(eitherToTask) // Task(User)
    .chain(user => Db.find(user.bff)) // ???
    ...

Так что же такое ??? на этом шаге? Мы знаем, что Db.find возвращает Task(Right(User), но мы делаем chain, так что мы знаем, что вместе сожмем как минимум два Task. Это значит, что мы идем:

// Task of Db.find         // chain
Task(Task(Right(User))) -> Task(Right(User))

И посмотрите, у нас есть еще один Task(Right(User)), который мы уже умеем сглаживать. eitherToTask!

const main = id =>
  Db.find(id) // Task(Right(User))
    .chain(eitherToTask) // Task(User)
    .chain(user => Db.find(user.bff)) // Task(Right(User))
    .chain(eitherToTask) // Task(User) !!!

Горячий картофель! Итак, как мы будем работать с этим? Итак, main принимает Int и возвращает Task(User), так что...

// main :: Int -> Task(User)
main(1).fork(console.error, console.log)

Это действительно так просто. Если Db.find разрешает право, оно преобразуется в Task.of (решенная задача), то есть результат переходит к console.log, иначе, если Db.find разрешает левое, оно преобразуется в Task.rejected (отклоненная задача), что означает результат пойдет на console.error


Выполняемый код

// Either
const Left = x => ({
  map: f => Left(x),
  fold: (f,_) => f(x)
})

const Right = x => ({
  map: f => Right(f(x)),
  fold: (_,f) => f(x),
})

// Task
const Task = fork => ({
  fork,
  chain: f =>
    Task((reject, resolve) =>
      fork(reject,
           x => f(x).fork(reject, resolve)))
})

Task.of = x => Task((reject, resolve) => resolve(x))
Task.rejected = x => Task((reject, resolve) => reject(x))

// natural transformation
const eitherToTask = e =>
  e.fold(Task.rejected, Task.of)

// fake database
const data = {
  "1": {id: 1, name: 'bob', bff: 2},
  "2": {id: 2, name: 'alice', bff: 1}
}

// fake db api
const Db = {
  find: id =>
    Task((reject, resolve) => 
      resolve((id in data) ? Right(data[id]) : Left('not found')))
}

// your program
const main = id =>
  Db.find(id)
    .chain(eitherToTask)
    .chain(user => Db.find(user.bff))
    .chain(eitherToTask)

// bob's bff
main(1).fork(console.error, console.log)
// alice's bff
main(2).fork(console.error, console.log)
// unknown user's bff
main(3).fork(console.error, console.log)


Атрибуция

Почти всем этим ответом я обязан Брайану Лонсдорфу (@drboolean). У него есть фантастическая серия статей об Egghead под названием Профессор Фрисби представляет компонуемый функциональный JavaScript. . Совершенно случайно пример в вашем вопросе (преобразование будущего и любого) — это тот же пример, который используется в его видео и в этом коде в моем ответе здесь.

Два о естественных преобразованиях

  1. Преобразование основных типов с естественными преобразованиями
  2. Применение естественных преобразований в повседневной работе

Альтернативная реализация задачи

В Task#chain есть немного волшебства, которое не сразу бросается в глаза

task.chain(f) == task.map(f).join()

Я упоминаю об этом в качестве примечания, потому что это не особенно важно для рассмотрения естественного преобразования Либо в Задачу выше. Task#chain достаточно для демонстрации, но если вы действительно хотите разобрать его, чтобы посмотреть, как все работает, он может показаться немного неприступным.

Ниже я вывожу chain, используя map и join. Я помещу пару аннотаций типа ниже, которые должны помочь

const Task = fork => ({
  fork,
  // map :: Task a => (a -> b) -> Task b
  map (f) {
    return Task((reject, resolve) =>
      fork(reject, x => resolve(f(x))))
  },
  // join :: Task (Task a) => () -> Task a
  join () {
    return Task((reject, resolve) =>
      fork(reject,
           task => task.fork(reject, resolve)))
  },
  // chain :: Task a => (a -> Task b) -> Task b
  chain (f) {
    return this.map(f).join()
  }
})

// these stay the same
Task.of = x => Task((reject, resolve) => resolve(x))
Task.rejected = x => Task((reject, resolve) => reject(x))

Вы можете заменить определение старой задачи на это новое в приведенном выше примере, и все будет работать по-прежнему ^_^


Переход на родной язык с Promise

ES6 поставляется с промисами, которые могут функционировать очень похоже на реализованную нами задачу. Конечно, есть куча отличий, но для целей этой демонстрации использование Promise вместо Task приведет к коду, почти идентичному исходному примеру.

Основные отличия:

  • Задача ожидает, что ваши параметры функции fork будут упорядочены как (reject, resolve) - параметры функции исполнителя обещания упорядочены как (resolve, reject) (обратный порядок)
  • мы называем promise.then вместо task.chain
  • Промисы автоматически сжимают вложенные промисы, поэтому вам не нужно беспокоиться о ручном выравнивании промисов промисов.
  • Promise.rejected и Promise.resolve нельзя назвать первоклассными — контекст каждого из них должен быть привязан к Promise — например, x => Promise.resolve(x) или Promise.resolve.bind(Promise) вместо Promise.resolve (то же самое для Promise.reject)

// Either
const Left = x => ({
  map: f => Left(x),
  fold: (f,_) => f(x)
})

const Right = x => ({
  map: f => Right(f(x)),
  fold: (_,f) => f(x),
})

// natural transformation
const eitherToPromise = e =>
  e.fold(x => Promise.reject(x),
         x => Promise.resolve(x))

// fake database
const data = {
  "1": {id: 1, name: 'bob', bff: 2},
  "2": {id: 2, name: 'alice', bff: 1}
}

// fake db api
const Db = {
  find: id =>
    new Promise((resolve, reject) => 
      resolve((id in data) ? Right(data[id]) : Left('not found')))
}

// your program
const main = id =>
  Db.find(id)
    .then(eitherToPromise)
    .then(user => Db.find(user.bff))
    .then(eitherToPromise)

// bob's bff
main(1).then(console.log, console.error)
// alice's bff
main(2).then(console.log, console.error)
// unknown user's bff
main(3).then(console.log, console.error)

person Mulan    schedule 14.03.2017
comment
Я думаю, вам следует убрать reject из Task, а затем представить возможность неудачи как Task<Either<A>>. Вот это был бы настоящий трансформер монад! - person Bergi; 15.03.2017
comment
@ Берги, это интересная идея. Интересно, использовалась ли в серии видеороликов задача как есть, чтобы не перегружать учащихся деталями реализации. У меня все еще есть несколько вопросов о том, как ваше предложение будет работать. Как вы думаете, вы могли бы предоставить редактирование для уточнения? - person Mulan; 15.03.2017
comment
@Bergi, в частности, если сбой представлен как Task(Left(someErr)), есть ли у нас еще основания для преобразований? Или нам понадобится совершенно отдельная программа-пример? - person Mulan; 15.03.2017
comment
Нет, это не естественное преобразование, но вы можете явно указать Either монадный преобразователь и примените его к Task. И получается монада, chain которой вы можете напрямую применить к find(id) результатам. - person Bergi; 15.03.2017
comment
Таким образом, вместо Task<Either<E,A>> у нас будет EitherT<E, Task><A>, если вы понимаете, о чем я. - person Bergi; 15.03.2017
comment
Отлично. Спасибо за это @naomik. Но это заставило меня задуматься, а что, если я хочу обработать сценарий «пользователь не найден» иначе, чем сценарий ошибки базы данных? Есть ли какое-нибудь решение, которое не группировало бы их вместе? - person Marcelo Lazaroni; 15.03.2017
comment
Этот ответ - золото. - person Alex Pánek; 15.03.2017