TaskEither 与 Promise
tl;dr
承诺摇滚
控制流
铁路导向型编程
承诺的问题
结论
AWS AI 直播!
tl;dr
TaskEither扩展了由 实现的强大的铁路导向范式Promise,并使其在引用方面更加透明。
承诺摇滚
Promise作为一种概念,它最早可以追溯到1976年,当时Daniel P. Friedman和David Wise提出了这个术语。这个概念在2000年代后期开始在JavaScript社区流行起来。
今年(2020 年)7 月,Sam Saccone 发表了一篇精彩的文章,详细概述了 JavaScript 的发展历程Promise。这篇文章读起来很有趣,我认为它是权威之作,我强烈推荐。我在这里做个简要概述。
在此之前Promise,有XMLHttpRequest,它使用延续传递风格(我们稍后会解释)。然后,2005 年出现了 MochiKit,Promise这是 JavaScript 中第一个实现。之后是deferredRequest2006 年 Dojo 的(Dojo是的前身), 2007 年jQueryDojo 将其解耦为,2009 年又出现了,同年又变成了,这促使其他库采用了,随后是2010年的。DeferredPromiseWaterken QQDojoPromisejQuerydeferred
如果这一切听起来一团糟,那是因为它确实如此。当时没有明确的前进方向——新的实现层出不穷,没人知道哪个最终会胜出并成为标准。然后,在2010年,许多重要的基础异步Web API开始发布(Webcrypto、fetch、IndexedDB、localStorage)。这使得原本就处于VHS/Beta阶段的局面更加复杂。
一年前,也就是 2009 年,Kris Zyp 提出了一个统一的接口,旨在涵盖大多数此类实现并允许它们互操作。这后来被称为“Promises/A”规范。Paul Chavard 在 Ember.js 库中为其编写了一个测试套件,但它存在缺陷——它未能正确描述“Promises/A”规范。Domenic Denicola 对其进行了修正,从而创建了“Promises/A+”规范,他在题为“ Boom, Promises/A+ Was Born”的演讲中对此进行了阐述。
2015 年,“Promises/A+”正式被纳入 ECMAScript 2015(也称为 ES6)。引用 Saccone 的话来说:
此次元胜利在于,多个一致的实现向 TC39 表明,社区对 Promises 的概念达成了共识……而几年前,这一概念还被认为过于深奥,不宜广泛使用。
正因如此,目前全球 96% 的浏览器Promise都原生支持 Promises 。“Promises/A+”最终将统一70 多种 Promise 实现,从而大幅降低复杂性和避免混淆。
尽管 A+ 规范不得不做出一些我们稍后会讨论的重大妥协,但我认为值得一提的是,这些妥协带来了巨大的成就:一个统一的Promise规范,使我们不必学习几种不同的标准,甚至可能挽救了 JavaScript 的概念Promise。
承诺是回拨天堂
在 Promise 出现之前,异步操作必须使用回调函数来实现。以下示例摘自Arfat Salman的论文。
queryDatabase({ username: 'Arfat' }, (err, user) => {
// handle errors database querying failure
const image_url = user.profile_img_url;
getImageByURL(`someServer.com/q=${image_url}`, (err, image) => {
// handle errors fetching failure
transformImage(image, (err, transformedImage) => {
// handle errors transformation failure
sendEmail(user.email, (err) => {
// handle errors of email failure
logTaskInFile('transformed the file and sent user an email', (err) => {
// handle errors of logging failure
})
})
}])
})
})
上面的代码使用了一种叫做延续传递风格的技术,尽管它更常被令人担忧地称为“回调地狱”。根据Colin Toh 的说法
回调地狱,又称厄运金字塔,是一种反模式……它由多个嵌套回调组成,使得代码难以阅读和调试。
Promise以下是使用另一种方式编写的相同代码。
queryDatabase({ username: 'Arfat' })
.then((user) => {
const image_url = user.profile_img_url;
return getImageByURL(`someServer.com/q=${image_url}`)
.then(image => transformImage(image))
.then(() => sendEmail(user.email))
})
.then(() => logTaskInFile('...'))
.catch(() => handleErrors()) // handle all errors
这种控制流程得到了改进。其优势在于将回调建模为一个名为 a 的值Promise。
承诺是声明性的
摘自 James Coglan 的文章《回调是必要的,承诺是功能性的》(重点为笔者所加)
如果说面向对象编程将一切都视为对象,那么函数式编程则将一切都视为值——不仅仅是函数,而是一切。
函数式编程的最佳之处在于它的声明式特性。在命令式编程中,我们编写一系列指令来告诉机器如何完成我们想要的操作。而在函数式编程中,我们描述值之间的关系来告诉机器我们想要计算什么,机器则负责推导出实现该计算的指令序列。
关于“陈述句”这个含义模糊的词,我们稍后再详细讨论。
承诺是某种意义上的单子
then允许操作按顺序执行。这是单子(也称为“可编程分号”)的必要特性。
单子有时被颇具争议地描述为容器或包装物(例如墨西哥卷饼)。Promise这种比喻非常贴切——单子Promise将一个值“包裹”在一个“容器”中,该值永远无法从容器中流出——它永远存在于该容器中,只能从内部进行更改(除非你使用async“/”作弊await——稍后会详细介绍)。在墨西哥卷饼的比喻中,单子Promise是饼皮,而值是豆子和奶酪。
Promise它实际上并不是一个单子,但它与单子非常相似,值得一提。我们稍后会讨论它们之间的区别,但如果你已经了解单子,也许现在你对它有了Promise更深的理解,反之亦然。
控制流
async以下是使用/await语法进行错误处理的示例
try {
const val = await getVal()
try {
const otherVal = await val.getOtherVal()
} catch (err: any) {
console.error('handle getOtherVal error')
}
} catch (err: any) {
console.error('handle getVal error')
}
以下是使用更简洁的控制流程的相同代码Promise:
const otherVal = getVal()
.catch(() => console.error('handle getVal error'))
.then(val => val.getOtherVal())
.catch(() => console.error('handle getOtherVal error'))
以下示例摘自 Jake Archibald 的《JavaScript Promises: an Introduction》 。
以下是使用以下方式的实现:Promise
asyncThing1()
.then(asyncThing2)
.then(asyncThing3)
.catch(asyncRecovery1)
.then(asyncThing4, asyncRecovery2)
.catch(() => console.log("Don't worry about it"))
.then(() => console.log("All done!"))
您可以想象,使用async/来实现这一点会复杂得多。简单的 Promise比/语法await具有更强大的错误处理能力。asyncawait
Promise也允许面向表达式的编程,这鼓励纯粹性,并且比面向语句的 编程更容易理解async。await
铁路导向型编程
Promise能够轻松处理这种复杂的控制流,因为它只需一个值就能模拟拒绝或解决。这使得组合器能够catch处理从先前操作隐式传递下来的值。例如:
const response = validate(request)
.then(update)
.then(send)
下图以图形方式展示了这段代码。
这张图出自斯科特·弗拉辛颇具影响力的铁路主题节目。你可以看到两条独立的“轨道”——上方是“解决”轨道,下方是“拒收”轨道。用铁路爱好者的行话来说,我们可以说,它catch可以把拒收的事故“分流”回解决轨道,而它then可以把它们直接分流回拒收轨道。
TaskEither它通过丰富的运算符将铁路编程提升到了一个新的水平。它们比以往的运算符更加具体then,catch涵盖了更多潜在功能。
reject(x) resolve(y)
\ /
: | | :
TE.map (f) : | f y : The 'map' function affects the value in
: | | : the resolution track, but if the train
: | | : would've been on the rejection track,
: | | : nothing would've happened.
: | | :
TE.chain (f) : | f y : The 'chain' function affects the value in
: | /| : the resolution track, and allowed the
: | / | : train to change tracks, unless it was
: | / | : already on the rejection track.
: |/ | :
: | | :
: | :
: - | :
: | | :
TE.alt (m) : m | : The 'alt' function replaces a train on
: |\ | : the rejection track with another one,
: | \ | : allowing it to switch tracks.
: | \ | :
: | \| :
TE.orElse (f) : f y | : The 'orElse' function is the opposite
: |\ | : of the 'chain' function, affecting the
: | \ | : rejection branch and allowing a change
: | \ | : back to the resolution track.
: | \| :
TE.fold (f, g) : f x g y : The 'fold' function affects both
: \ | : tracks, but forces the train to switch
: \ | : from the rejection track back to the
: \ | : resolution track.
: \| :
: | :
V
(这是根据 Aldwin Vlasblom 关于他的Fluture 库的文章中的铁路图改编的,该库与 fp-ts 类似。我们将Fluture在下一篇文章中深入探讨。)
承诺的问题
模型未检查的异常
由 传播的错误Promise被标记为any,这不好。
const resource: Promise<Resource> = getResource()
resource
.then((r: Resource) => ...)
.catch((error: any) => ...)
完全省略某项catch手术也是合法的。
const resource: Promise<Resource> = getResource()
resource
.then((r: Resource) => ...)
//.catch((error: any) => {}) <--- this is perfectly legal
这是因为Promise模型会处理未经检查的异常,我们在上一篇文章《Either 模式与异常处理》中已经讨论过。现代的解决方案是Either类型,这当然是模型的一部分。TaskEither
import * as TE from 'fp-ts/TaskEither'
const resource: TE.TaskEither<ResourceError, Resource> = getResourceTaskEither()
从类型签名中可以看出,resource它可能返回一个ResourceError。我们在编译时就知道可能出现哪些错误,因此我们不能Task在不处理所有Either情况的情况下调用它(或者至少我们不应该这样做)。
然后和捕获都比较模糊。
then这catch两个函数定义不明确。它们有许多看似相似但实际行为不同的用法。这里,我列出了与它们等效的操作TaskEither,这些操作更具描述性。
import { pipe } from 'fp-ts/pipeable'
import * as E from 'fp-ts/Either'
import * as T from 'fp-ts/Task'
const transformValueP = Promise.resolve(3)
.then(n => n + 3)
const transformValueTE = pipe(
TE.right(3),
TE.map(n => n + 3)
)
const chainAsyncP = Promise.resolve(3)
.then(n => Promise.resolve(n + 3))
const chainAsyncTE = pipe(
TE.right(3),
TE.chain(n => TE.right(n + 3))
)
const handleAllErrorsP: Promise<number> = Promise.reject(new Error('Illegal'))
.catch(() => 3)
const handleAllErrorsTE: T.Task<number> = pipe(
TE.left(new Error('Illegal')),
T.map(E.getOrElse(() => 3)),
)
const asyncOnErrorP: Promise<number> = Promise.reject(new Error('Illegal'))
.catch(() => Promise.resolve(3))
const asyncOnErrorTE: TE.TaskEither<Error, number> = pipe(
TE.left(new Error('Illegal')),
TE.orElse(() => TE.right(3)),
)
const handleErrorAndValueP: Promise<number> = Promise.resolve(3)
.then(
n => n + 3,
() => 3,
)
const handleErrorAndValueTE: T.Task<number> = pipe(
TE.right(3),
T.map(E.fold(
() => 3,
n => n + 3
)),
)
handleAllErrorsTE另外请注意, `and`的类型为` handleErrorAndValueTE<type>` Task<number>,而其余的为 `<type> TaskEither`——这比Promise<number>所有Promise值共享的类型更具信息量。
捕捉和然后之间没有明显的区别
then可以接受错误处理回调函数。
const example1: Promise<void> = Promise.resolve(3)
.then(console.log)
.catch(console.error)
const example2: Promise<void> = Promise.resolve(3)
.then(
console.log,
console.error,
)
then这些例子在功能上是相同的。那么,使用`catch` 语句的第二个参数有什么区别呢?
以下是一个行为不同的例子:
const example3: Promise<number> = Promise.resolve(3)
.then(() => Promise.reject(new Error('Illegal')))
.catch(() => 13)
// example3 === Promise.resolve(13)
const example4: Promise<number> = Promise.resolve(3)
.then(
() => Promise.reject('Illegal'),
() => 13,
)
// example4 === Promise.reject(Error('Illegal'))
区别在于这两个回调函数then都可以选择异步。这意味着 ` catchfrom`example3可以处理其then操作的拒绝,而 `in` 中的错误回调函数example4则没有机会处理其成功回调函数抛出的错误。
这里有一张描述问题的铁路示意图(摘自这篇关于游戏编程的文章)
以下是使用Task- 的等效方法,请注意example5,和example6具有完全不同的类型
const example5: T.Task<number> = pipe(
TE.right(3),
TE.chain(() => TE.left(new Error('Illegal'))),
T.map(E.getOrElse(() => 13)),
)
const example6: TE.TaskEither<Error, number> = pipe(
TE.right(3),
TE.fold(
() => TE.right(13),
() => TE.left(new Error('Illegal'))
),
)
类似承诺
摘自奥尔德温·弗拉斯布洛姆的《破碎的承诺》(警告:付费墙)
在制定 Promises/A+ 规范时,已经存在一些流行的 Promise 库,不同实现之间的互操作性至关重要。因此,规范决定,当用户将带有 then 函数的对象返回到 Promise 中时,该对象应被视为 Promise 对象。
不过,自动同化也存在一些缺点:
首先,对象上只要存在 then 函数,Promise 的行为就会改变,这使得用户可能意外地提供与 Promise 过于相似的对象,从而导致程序运行异常。执行以下代码即可查看 Promise 运行异常的示例:
Promise.resolve({ then: () => console.log("Hello!") }).then(() => {
console.log('this will never be run')
})
// output:
// Hello!
虽然极其困难,但也有可能无意中符合这种规范PromiseLike,正如我在编写这段示例代码时发现的那样。
参考透明度差
这里我们看到一个包含两个Promises 的数据结构。
const twoPrints: [Promise<void>, Promise<void>] = [
new Promise(res => {
console.log('vote!')
res()
}),
new Promise(res => {
console.log('vote!')
res()
})
]
// output
// vote!
// vote!
这里存在重复代码。如果我们将其提取出来呢?
const print: Promise<void> = new Promise(res => {
console.log('vote!')
res()
})
const twoPrints: [Promise<void>, Promise<void>] = [
print,
print
]
// output
// vote!
Promise已缓存结果print(即void),因此不会再次运行。
我们可以用Task……
import * as Task from 'fp-ts/Task'
const print: Task<void> = () => new Promise(res => {
console.log('vote!')
})
const twoPrintsRefactored: [T.Task<void>, T.Task<void>] = [
print,
print
]
twoPrints.map(invoke => invoke())
// output
// vote!
// vote!
(示例改编自这篇关于 Scala 的Stack Overflow 帖子Future)
这种特性被称为“引用透明性”。如果代码具有引用透明性,我们就可以重构重复代码而不会改变预期行为。引用透明性意味着任何表达式都可以被其值替换,而不会改变程序的行为。事物的本质就是它们看起来的样子——我们的意图是“透明的”。
Promise因此,它的引用透明度不如Task其他方式高——尽管它们都执行可能影响外部世界的代码,但它们都无法做到完全透明4。
这是我们在第一节中讨论的同一想法的延伸——虽然它Promise仅仅将回调模式表示为一个值,但Task(由于其惰性)它却将整个异步执行表示为一个值。
承诺并非单子
严格定义的范畴论单子符合三条不同的定律。这里提到的定律被称为“左恒等式”。
const printLine = (line: string): Promise<void> =>
new Promise(res => setTimeout(res, 0))
.then(() => console.log(line))
// Left Identity: the following two statements are equivalent
printLine('vote!')
// output
// 'vote!'
Promise.resolve('vote!').then(printLine)
// output
// 'vote!'
但是,它会将传递给它的Promise.resolve任何内容扁平化并调用它们Promise,从而破坏左侧的身份。
const printFirstThen = (next: Promise<void>): Promise<void> =>
new Promise(res => {
console.log('first')
res()
}).then(() => next)
printFirstThen(printLine('second'))
// output:
// first
// second
Promise.resolve(printLine('second')).then(printFirstThen) // type error
// Type 'void' is not assignable to type 'Promise<void>'
这是一个类型错误——它根本无法运行。但即使我们设法修改值使其能够编译,它仍然会出现意想不到的行为:
Promise.resolve(printLine('second'))
.then(output => printFirstThen(Promise.resolve(output)))
// output:
// second
// first
这是因为Promise.resolve调用了Promise返回的值printLine,并强制它在继续执行之前解析printFirstThen。
希望这个例子能说明,这种行为很奇怪,可能会导致意想不到的结果。
以下是Task传递左侧标识的方法:
const printLineT = (line: string): T.Task<void> =>
() => printLine(line)
const printFirstThenT = (next: T.Task<void>): T.Task<void> => pipe(
() => new Promise(res => {
console.log('first')
res()
}),
T.chain(() => next),
)
printFirstThenT(printLineT('second'))
// output:
// first
// second
pipe(
T.of(printLine('second')),
T.chain(printFirstThen),
invoke => invoke()
)
// output:
// first
// second
这是单子定律在实际工程中的好处的一个例子:打破左恒等式明显地使Promise行为不如 一致和可预测Task。
Promise 不能嵌套
有时我们可能需要嵌套函数Promise。例如,假设我们有以下两个函数。
const getInput = (): Promise<string> => ...
const query = (input: string): Promise<Response> => ...
将这两者结合起来的函数会很不错:
const queryFromInput = (): Promise<Promise<Response>> => ...
摘自 Promise/A+ 规范中关于可嵌套 Promise 的功能请求(最初发布于 2013 年):
这种类型非常精妙。它明确地表明这里涉及两个不同的执行线程——如果算上当前执行线程,则是三个——而且我们可以访问其中任何一个线程。事实上,我们可以从 userInput 线程内部访问响应线程,而如果 Promise 被预先扁平化,我们就无法做到这一点。
再次强调,虽然类型签名是合法的,但由于 Promise 奇怪的自动扁平化特性,这个函数无法实现。
急切使嵌套性变得复杂
即使嵌套函数可以存在,它Promise也会带来问题。因为函数创建后会Promise立即执行,所以很难预测嵌套函数的Promise行为。内部函数Promise可能在外部函数之前执行Promise,反之亦然——不查看实现细节就无从得知。
// if promises nested, we'd have a 'map' that wouldn't flatten
Promise.prototype.map = <A>(a: A): Promise<A> => ...
// this one executes first
// vvv
const outerFirst: Promise<Promise<void>> = new Promise(res => {
console.log('outer first')
}).map(() => new Promise(res => {
console.log('inner next')
}))
const inner = new Promise(res => {
console.log('inner first')
})
// this one executes first
// vvv
const innerFirst: Promise<Promise<void>> = new Promise(res => {
console.log('outer next')
}).map(() => inner)
积极求值并不排除嵌套性——ScalaFuture不幸地同时具备这两种特性。它具有上述行为,这种行为既不必要地复杂,又缺乏引用透明度。
嵌套的Tasks 总是从最内层组件向外延伸。
const inner: T.Task<void> = () => new Promise(res => {
console.log('inner')
})
const outer: T.Task<void> = () => new Promise(res => {
console.log('outer')
})
const innerFirst: T.Task<T.Task<void>> = pipe(
outer,
T.map(() => inner),
)
// output:
// inner
// outer
Task 的行为再次比 Promise 的行为更加一致。我们将在下一篇文章《TaskEither 与 Fluture》中讨论立即求值和延迟求值。
结论
Promise它非常强大。它使 JavaScript 成为异步编程的一流语言。我希望 Domenic Denicola 留下的遗产是他卓越的“Promises/A+”规范,而不是他那条臭名昭著的 GitHub 评论——批评类型化函数式编程(JavaScript 函数式编程库“fantasy-land”就是因此得名)。
虽然我不同意他的说法,但我理解他的出发点。他肩负着数十项提案的重任,也肩负着异步编程的未来——随着 JavaScript 在 Web 之外的领域(例如Node.js、React Native、Electron、Ink等)越来越受欢迎,Promises/A+ 的成功所带来的巨大影响也愈发清晰。他一定感到,任何一个错误的举动都可能导致一切功亏一篑。
并非要将所有功劳都归于 Denicola——这个想法已经有 45 年的历史了,而且“Promises/A+”的诞生凝聚了许多人的心血,经历了无数次的尝试和迭代。我只是觉得,如果Promise不提及 GitHub 上的那个讨论帖,就无法从函数式编程的角度来撰写这篇文章。如果不是因为这场辩论(双方)都如此激烈且循环往复,我会推荐大家去读一读——它完全围绕函数式编程和范畴论的实用性展开。Promise.resolve 这里也有一个类似的讨论帖——阅读需谨慎。
毋庸置疑,它已经非常出色Promise,而TaskEither它更胜一筹。它解决了上述诸多缺陷,并新增了大量功能。凭借其惰性求值、引用透明性、类型安全以及先进而简洁的控制流,TaskEither它堪称目前最佳异步编程技术的集大成者。
-
有些人不喜欢“墨西哥卷饼”这个比喻——他们正确地指出,这个比喻只适用于少数几个单子,之后引入更多单子时只会造成更多困惑。以下是一些典型的推文(1,2) 。↩
然而,论文《当我们谈论单子时我们在谈论什么》提出,除了“形式”和“实现”层面的知识之外,这种隐喻对于完全理解这一概念是必要的。
-
但你不能吃这个墨西哥卷饼,因为馅料还没进去。你唯一能做的就是告诉卷饼,等它最终被豆子和奶酪填满的时候,你想对它们进行一些改造,比如把它们捣碎之类的。当然,这意味着你得立刻扔掉第一个卷饼,然后你得到一个新的,它仍然是个空壳,但等它“完成”之后,里面的东西就会像你喜欢的那样被捣碎了 。
精彩之处就在这里——你还可以告诉墨西哥卷饼,当它最终成型时,你还有另一个完全不同的卷饼食谱,需要用到第一个卷饼的馅料——它会把你的豆子变成某种新的砂锅卷饼。这样,你就扔掉了第一个卷饼,得到了一张新的玉米饼,里面还包裹着另一张玉米饼,等待着豆子,然后等待着砂锅做好。我知道你在想什么——我可不想卷饼里有两张玉米饼!但你的卷饼非常智能,它会自动把中间那张饼皮抽出来扔掉,这样你就只剩下一张饼皮了。你可以随心所欲地把卷饼叠在一起!这种嵌套卷饼和抽出中间饼皮的能力——这才是真正卷饼的精髓所在。
不过有时候,你可能想要两层饼皮——你可能想分辨出外层墨西哥卷饼的末端和内层卷饼的起始位置。但这是非法的墨西哥卷饼,所以不管你愿不愿意,它都会把中间的饼皮扯出来!
有点像Chipotle。
-
async/await语法非常适合不需要复杂错误处理且其使用依赖于许多其他异步操作的异步操作,例如react-testing-library(尽管do 表示法也能有所帮助) 。↩ -
这就是一些人对“声明式”一词的批评之处——虽然 `
Promisea` 是回调函数的“声明式”替代方案,但它Task本身也是“声明式”的。`a`Task在引用关系上更加透明,但说它“更”是声明式的是否正确呢?这是一个含义模糊、定义不明确的词 。Peter Landin 在他 1966 年发表的开创性论文《未来 700 种编程语言》中讨论了这个问题,并在文中介绍了他的 ISWIM 语言(显然这个问题已经有 55 年的历史了)。他建议使用一个更合适的词来表达纯粹性——“指称性的”,这个词源自数学领域。
算术和代数中常用的表达式具有大多数计算机通信所缺乏的简洁性。具体来说,(a) 每个表达式都具有嵌套的子表达式结构;(b) 每个子表达式都表示某个事物(通常是数字、真值或数值函数);(c) 表达式所表示的事物,即它的“值”,仅取决于其子表达式的值,而不取决于子表达式的其他属性。
……“指称性的”一词似乎比非程序性的、陈述性的或功能性的更合适。“指称性的”反义词是“祈使性的”。
根据这个定义,“neither”和“
Promisenor”Task都不具有指称意义,因为它们会产生副作用。真是个有用的词!


