JavaScript中的Monad变换器解释?

4

我很难理解单子转换器,部分原因是大多数例子和解释都使用Haskell。

有人可以给出一个在JavaScript中创建将Future和Either单子合并的转换器的示例以及如何使用它的例子吗?

如果您可以使用ramda-fantasy实现这些单子,那就更好了。


那个合并的最终结果会是什么?据我所知,单子变换器是一些函数,它们接受一个单子并返回一个满足几个规则的单子:https://en.wikipedia.org/wiki/Monad_transformer#Definition - Alex Pánek
我正在使用Either Monad来处理Future Monad中的验证,以处理异步流。在一个Monad内部处理另一个Monad并不是很干净,而且链式处理Future Monad特别棘手。我读过Monad Transformers可以为我提供这两个Monad的更清晰的API组合。没错吗?如果是这样,Javascript中会是什么样子? - Marcelo Lazaroni
@AlexPánek 这篇维基百科文章是一个例子,对于不懂Haskell的JavaScript开发人员来说,它几乎无法理解。我正在寻找使用纯JavaScript代码进行解释的说明。 - Marcelo Lazaroni
你能概述一下你目前尝试实现这个的方式吗? - Alex Pánek
1
这个问题太宽泛了。不管怎样,据我所知,单子变换确实促进了单子组合。例如,它们可以帮助您避免深度嵌套的链式调用。在Fantasy Land上有一个Javascript实现,在这个SO问题中也有。请注意,并非每个单子组合都产生单子,即您可能会失去一些单子定律。 - user6445533
显示剩余3条评论
1个回答

19

首先是规则

首先我们有自然变换定律(Natural Transformation Law)

  • 对于一些从 a 映射到 b 的函子 F,使用函数 f 进行映射后,得到 Fb 类型的函子,然后进行自然变换,得到一些从 b 映射到 G 的函子。
  • 对于一些从 a 映射到 b 的函子 F,进行自然变换后得到一些从 a 映射到 G 的函子,然后使用某些函数 f 进行映射,得到 Gb 类型的函子。

选择任何一条路径(先映射再变换,或者先变换再映射)都会导致相同的最终结果,即 Gb 类型的函子。

natural transformation law

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

实现真实

好的,现在让我们来做一个实际的例子。我会逐步解释代码,然后最后给出一个完整可运行的示例。

首先,我们将实现Either(使用LeftRight)。

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))

现在让我们开始定义一个理论程序的一些部分。我们将拥有一个用户数据库,其中每个用户都有一个BFF(最好的朋友)。我们还将定义一个简单的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 函数返回一个 TaskEitherLeftRight)。这主要是为了演示目的,但也可以被认为是一个好的实践。例如,我们可能不认为用户未找到的情况是一个错误,因此我们不想 reject 任务 - 相反,我们通过解决 'not found'Left 来优雅地处理它。在遇到其他错误,比如无法连接到数据库等情况下,我们可能会使用 reject


制定目标

我们的程序目标是获取给定用户id并查找该用户的bff。

我们雄心勃勃,但很天真,所以我们首先尝试类似于这样的操作:

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)))

哎呀!一个 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)

让我们观察当我们将此转换链接到我们的Db.find结果时会发生什么

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

那么什么是????好的,Task#chain期望你的函数返回一个Task,然后将当前的Task和新返回的Task压缩在一起。所以在这种情况下,我们会这样做:

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

哇,这已经是一个巨大的改进,因为它在我们进行计算时使数据保持更加扁平。让我们继续...

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

在这一步中,???是什么?我们知道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)) // <b>Task(Right(User))</b>
    <b>.chain(eitherToTask)</b> // Task(User) !!!

热土豆!好的,我们该怎么处理呢?嗯,main接受一个Int并返回一个Task(User),所以...

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

很简单。如果Db.find解析为Right,它将被转换为Task.of(已解决的任务),这意味着结果将进入console.log - 否则,如果Db.find解析为Left,它将被转换为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)


归属

我几乎完全借鉴了Brian Lonsdorf(@drboolean)的回答。他在Egghead上有一系列名为Professor Frisby Introduces Composable Functional JavaScript的精彩视频。巧合的是,你问题中的示例(转换Future和Either)与他的视频以及我在此处的代码中使用的相同示例。

关于自然变换的两个内容如下:

  1. 使用自然变换进行原则性类型转换
  2. 在日常工作中应用自然变换

任务的替代实现

Task#chain 中有一些不太明显的魔法。

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

我提到这个作为一个旁注,因为它对于考虑上面Either到Task的自然转换并不特别重要。 Task#chain 足以进行演示,但是如果你真的想把它拆开来看看所有东西是如何工作的,那可能会感觉有点难以接近。
下面,我使用 mapjoin 推导出 chain。我会在下面放置一些类型注释,应该会有所帮助。
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,可以非常类似于我们实现的任务。当然,二者之间有很多差异,但是为了演示的目的,使用Promise而不是Task将导致代码几乎与原始示例完全相同。

主要区别如下:

  • 任务期望您的fork函数参数按(reject, resolve)排序 - Promise执行器函数参数按(resolve, reject)排序(顺序相反)
  • 我们调用promise.then而不是task.chain
  • Promises自动压缩嵌套的Promises,因此您无需担心手动展平Promise of a Promise
  • Promise.rejectedPromise.resolve不能作为一级调用 - 每个上下文需要绑定到Promise - 例如x => Promise.resolve(x)Promise.resolve.bind(Promise)而不是Promise.resolvePromise.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)


1
我认为你应该从Task中删除reject,然后将失败的可能性表示为Task<Either<A>>。那将是一个真正的单子变换器! - Bergi
@Bergi 这是一个有趣的想法。我想知道如果视频系列使用Task原封不动地呈现,是否会让学习者不被实现细节所淹没。我仍然有一些关于你的建议如何运作的问题。你能否提供一个编辑来澄清一下? - Mulan
不是自然变换,但你可以明确地编写 Either monad transformer,并将其应用于 Task。这样就会得到一个Monad,你可以直接将其chain应用于find(id)的结果。 - Bergi
所以,如果你明白我的意思,我们会使用EitherT<E, Task><A>代替Task<Either<E,A>> - Bergi
很棒。谢谢您,@naomik。但是这让我想,如果我想以不同于数据库错误的方式处理未找到用户的情况怎么办?有没有任何解决方案可以将它们分开处理? - Marcelo Lazaroni
显示剩余2条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接