本文要点
函数式编程技术通过以可组合的方式构造关注点来帮助我们编写代码。
可扩展效果灵活地分离了效果及其实现。
在纯函数语言论坛中,可扩展效果是函数式编程领域的一个热门话题,它的支持者声称这是构造程序的正确方法。
前端开发越来越多地采用函数式编程实践,更多地依赖于纯函数、promises、流和其他构造。可扩展效果正在进入 JavaScript 开发领域。
William Heslam 介绍了在 JavaScript 中如何使用可扩展效果有趣又有益。
William Heslam 是 Ecotricity 的高级 JavaScript 开发人员。最近,他在 Lambda Days 2020 上做了一场 演讲,内容是关于利用可扩展效果进行副作用建模以及由此带来的测试方面的好处。
Heslam 的演讲围绕着一个具体的例子展开,那是一个处理待办列表的应用程序后端(即 Todo MVC基准应用程序),下面是相应的 API:
在对如何应用标准测试技术(如模仿和依赖注入)进行 API 测试做了一个快速的回顾之后,Heslam 利用 freer monad引入了可扩展效果来表达这个 API:
api.put 返回一个 etchRune 函数生成的数据结构,该函数来自 Heslam 的可扩展效果库:
update 变量是一个数据结构,它可以对一个程序进行有效地编码(就像一个 抽象语法树(AST)),通过提供一个解释器,我们就可以对这个程序做各种处理。Heslam 提供了下面这个解释器的例子,它将逐步记录 update 程序的执行情况:
Heslam 最后提供了一个解释器,它将随机输入生成与执行跟踪生成相结合,为基于属性的测试提供支持。
InfoQ 采访了 Heslam,以了解可扩展效果在 JavaScript 开发中的适用范围和好处。
InfoQ:请给我们介绍下你的经历,以及如何对函数式编程产生兴趣的?
William Heslam: 我从事软件开发大约有 10 年了,主要是 Web 开发,我的业余爱好是 GPU 编程。
我学过 Java 和 PHP 编程,我对那种编程方式感到非常沮丧:面向对象、满是 getter 和 setter 的深度继承的类层次结构。
幸运的是,随着 2015 年 ES6 的发布,我接触到了函数式编程。我认为这不是巧合,得益于箭头函数的简洁,JS 中的 FP 变得更容易为人所接受。
也是在那个时候,我学会了 React,它真正展示了纯函数的强大功能,并在 Web 开发人员中普及了函数式编程。
函数式编程为我提供了一个词汇表,用于思考和讨论状态变化、数据结构和计算模型,这些都是我以前没有的。最重要的是,它真的很有趣!
InfoQ:在演讲中,你展示了一个应用程序的 API,说其实现效果可以利用 可扩展效果实现,并提到了你正在开发的可扩展效果库,其灵感来自 freer monad。通俗地讲,freer monad 是什么?那些可扩展效果又是什么?
Heslam :广义地说,可扩展效果就是你可以在代码中分离“什么”和“如何”。
将效果表示为不包含内部实现细节的“标记(token)”,你就可以编写完全不知道它们最终将如何与环境交互的程序。
稍后,将每个标记转换为你选取的特定动作,就可以“解释”这些效果。
这些效果可以是一般化的,比如“发送网络请求”,或者是领域特有的,比如“注销用户”——这取决于你。
如果你不了解 monads,你可以将这项技术理解为软件 API 调用的依赖注入。你可以对接口编程,并根据情况提供不同的实现。
可扩展效果是通过 Freer monad 实现的。这是一个初始效果或值的嵌套数据结构,以及将前一个效果的结果转换为下一个效果的函数序列。
当应用到一个将效果转换为所选目标 monad 的解释器函数,它会“从里向外”展开——第一个效果被转换成目标 monad,后者会映射到下一个包含效果的 Freer monad。然后是清洗和重复,像折纸一样折叠和展开,直到只剩下目标 monad。
Freer monads 的推出是为了修复 Free monads 的一些问题,如 减少声明效果的样板代码,缓解 性能问题。由于类型缺失以及 Haskell 和 JS 之间差异巨大的执行语义,所以很多东西无法延续,也不会改变这里的任何想法!
InfoQ:在过去的几年中,可扩展效果似乎是许多 Haskell 论坛上的一个热门话题,因为 Haskell 开发人员经常比较构造程序的正确方法。JavaScript 开发人员并不一定会对可扩展效果感兴趣——构造程序的正确方法通常会涉及到对前端框架的讨论。如何向 JavaScript 开发人员(或其他不使用纯函数式语言的开发人员)推销可扩展效果?
Heslam :你是对的,那些允许你破坏规则的语言通常不愿意采用显式的副作用建模;尤其是动态类型语言,它们对于浏览复杂数据结构,如嵌套 monads 或 monad 转换,没有提供多少帮助。
另一方面,现代前端开发实际上已经包含了用于应用程序状态管理的纯函数,而 缺少了副作用这块拼图。
Node 和浏览器中的很多 IO 都是通过 Promises 实现的,所以开发人员对将计算“提升”到一个以后执行或者根本不执行的上下文这一思想已经很熟悉了。
鉴于此,我在推销时将把可扩展效果作为该思想的延续:每个 API 调用都可以表示为你创建和传递的一个值,而不仅仅是返回 Promises。程序与外部世界之间的交互非常明确。
这在抽象实现细节时非常有用。例如,你可以创建自己的“get”和“set”存储效果,然后在一个浏览器或安全上下文中将它们解释为 cookie,在另一个浏览器或安全上下文中将它们解释为本地存储——甚至是一个异步网络请求。
事实上,一些 API 调用可能会返回一个 Promise,而有一些对你的程序而言已不再重要。它不必停在这里:效果可能会引入人为的延迟,或者有些效果可以故意忽略,例如模拟只读存储或缺少权限。
为什么不创建一个“日志解释器”,这样你的程序就可以记录所有的外部 API 调用,而不必改变你的业务逻辑,从而引入一种面向方面的编程形式呢?
它在测试与第三方交互的代码时尤其出色。
例如,你的程序发送信息吗?我希望你的测试工具已经去掉了正确的 API 调用,否则你的持续集成服务器可能会无意中给人们发短信!
有许多用于模拟依赖关系的传统工具都是模拟封装,因为你必须提前知道代码将会表现出哪些副作用。
这就需要改变全局变量,在激活 API 调用之前小心地把它们换出来,劫持 Node 所需的机制,或者费力地在每个调用站点中线程化依赖项。
相比之下,可扩展效果有意地将这些副作用具体化,把猜测变成肯定。
它通过分离业务逻辑与实现细节来鼓励业务逻辑的重用,并使其更便于测试,这就是那些不熟悉纯函数编程的人应该考虑使用可扩展效果的原因。
对于那些已经熟悉 Promise 或 Observable 代码的人来说,这就像是对他们已经在做的事情的一个自然而言地延伸。
InfoQ:与注入效果处理程序相比,可扩展效果的优势是什么(例如, ReaderT设计模式做的事情就类似于依赖注入)?
Heslam :我自己还没有用过 ReaderT 设计模式。显然,在 Haskell 中, Freer和ReaderT之间存在联系,但是由于 JS 缺少内联和优化,还不清楚这种联系是否也适用于 JS。
根据我的经验,如果你已经在使用一个具体的 monad,你可能会用 ReaderT 封装它。然后就可以为每个操作提供不同的实现。
可扩展效果的优势是,你也可以推迟选择 monad,直到程序即将要执行的时候,这可以保证你的代码完全上下文无关,而不仅仅是操作无关的。
关于这一点,有一个有趣的例子,就是一个程序抽取 3 个随机数求最终的和。
将“get number”效果的目标 monad 从一个 IO 或一个数字的 Future,替换为一个包含 5 个数字的数组,运行程序的最终结果是一个包含 15 个值的数组,每一个是 3X5 个不同选择的结果!
这种分析需要一个完全不同的、只产生一个结果的执行上下文,比如,一个 Promise 或一个 Either。
在可扩展效果库中经常看到的一个特性是单独解释每个效果的能力。如果你的程序有 3 种效果:Foo、Bar 和 Baz,那么你可以先解释 Foo 和 Baz,把 Bar 留到以后再解释——据我所知,ReaderT 做不到这一点!
在确定可扩展效果之前,我尝试了一些其他技术和方法。我看过 tagless final,,这是一个非常相似的概念。长期以来,在 Scala 开发者中间,这一直存在争议,但它还是很有指导意义的,因为 Scala 和 JS 都很严格;两种语言具有相同的偏好。
它更快,有很多不错的属性,但它需要 将效果解释作为参数传递。
Scala 的隐式参数缓解了这种尴尬,但 JS 没有这样的语法糖,这就是它不吸引我的原因。
InfoQ:反过来说,可扩展效果有什么缺点或限制吗?Sandy Maguire 有 一篇文章介绍了纯函数式语言上下文中的一些限制。这些限制是否适用于 JavaScript 上下文?
Heslam :那篇文章中提出的一些批评,如性能问题、无用的错误消息和无人维护的库,无疑也适用于 JS。
(一般来说)没有类型系统、宏或编译器来弥补这些问题。
对于函数式 JS 来说,性能通常都是个问题,因为缺少不可变保证,所以运行时很难安全地优化抽象和消除冗余分配。
Monad 实现中的命名不一致意味着,如果你在使用多个库,就需要做许多手工工作把它们粘在一起,如果你漏了什么,就会看到令人困惑的运行时错误信息!
一个附带的结果是,在解释的时候,不管你的目标 monad 使用了什么命名约定,你可能都不得不提供一个翻译,如链接到绑定或 flatMap 等。
我正在我的可扩展效果库 Runic 中做这方面的改进。
缺乏对高阶效果的支持,例如,我还没有引入以可配置的方式解释错误处理。
目前,Runic 有一个相当固定的错误处理概念,它类似于 Promises 和 Either。你不能重新解释它的错误处理(例如,支持回滚),因为那是由你的目标 monad 决定的;Runic 按线性次序应用 bimap/orElse。
一个与之相关的问题是并发性,这对 JavaScript 来说至关重要……在默认情况下,如果你放弃了原始的 Promises,你就会失去这些。这是高阶效果可以帮助解决的问题!
InfoQ:有许多可扩展效果库是用 JavaScript 编写的吗?能列举一些吗?Runic 库设计目标是什么?想要包含什么特性?每个特性在什么时间点完成?
Heslam :实际上,可扩展效果和 free/freer monads 有一堆不同的实现:
其中很多都是试验性的,或者开发并不活跃,但仍然值得一试!
Luke Westby 还写了一篇很棒的 博文,介绍了如何实现一个简单、可工作的示例,而不会陷入术语泥潭。
Runic 的设计目标是简洁、易于调试以及方便与更广泛的 FP 生态系统集成。
将依赖项作为参数注入自然会更快,但这会使代码膨胀,破坏 JS 的乐趣!
相反,我在简化语法方面做过了头,但鉴于 JS 对静态分析如此抗拒,你要为这种抽象付出性能代价。
同样,另一种平衡方法是支持 TS 类型推断,同时允许引用透明性。
类型推断经常需要直接定位函数的位置——可能需要多次编写相同的内联代码,这违背了一次性定义函数并按名称引用的简便性。
内置集成现有的单体库(如 RxJS和 Crocks)将减少人们必须自己编写的样板文件的数量,特别是如果它兼容 fantasy-land规范的话。
我想在下个月前后推出 Runic 的首个版本,但恰当的功效学设计将非常重要。
InfoQ:在演讲中,你使用 Runic 库和可扩展效果测试了一个示例 API。这样做的原因是,一旦你有一个 free® monad 程序,你就可以附加选择的任何解释器来进行程序计算。解释器可以在程序中执行这些效果。出于测试目的,建议使用另一种解释器,它可以生成程序的随机输入,进行程序计算,跟踪程序计算过程,包括计算期间发生的错误。我理解的对吗?在解释器中处理或模拟错误有什么好处?
Heslam :是的,这是一个很好的总结!
错误处理是一个有趣的问题。正如你之前提到的,这是 现代化free monad Haskell库Polysemy着眼的主题。
Aaron Levin 发表过一篇很棒的 文章,对使用 Free monads 时的错误进行了归类,建议在效果的结果类型中将“语义错误”编码为 Either。
Runic 这个名字是在效果层面,提升到 Freer monad 层面则是“Rune”,它采用的方法稍微有些不同。它没有像效果结果那样返回 Either,而是将 Either 整合到它的签名中:你可以像往常一样映射和链接,但是它也支持使用 otherwise/orElse 从错误中恢复,类似于 Folktale的Result,并实现了 bimap 和 bichain,类似于 Crock的Either。
这与 Scala ZIO表示错误的方式非常相似。
它依赖于目标 monad 表示左侧的状态,所以如果你的目标 monad 是 State 或 Array,那么你只要沿着这条幸福的路走下去就行了,你的效果实现不会传播错误!
如果你的目标 monad 需要存储状态并表示失败,你可能需要 fp-ts的StateReaderTaskEither。
在测试期间模拟错误对于证明代码的健壮性非常有用。反之亦然,通过在解释器中提供故障回退和重试,可以有效地规避程序的复杂性,如果你想这样做的话。
与强类型语言不同,JS 很容易在最意想不到的时候意外抛出异常。在 Rune 的映射或链接函数里抛出异常实际上是出现在解释期间目标 monad 的上下文里,所以是由那个 monad 决定如何处理异常。
InfoQ:基于属性的测试如何与解释器机制一起工作?
Heslam :如果经典的“基于示例”的单元测试是对给定输入(给定输入 A,期望得到 B)做具体的断言,那么基于属性的测试则是要证明你的断言对给定范围的输入有效。
这个范围可以是无限的,例如每个自然数,也可以非常大,例如一个长度为 5 的 ASCII 字符串的每一种排列。因此,你在测试的时候需要限制最大尝试次数。
(对于属性测试,我推荐 fast-check,它令人印象深刻,维护良好且易于使用!)
在 monadic 风格中,你可以通过“Arbitraries”(随机值生成器)与映射、链接以及许多组合子(combinator)的组合来指定输入空间。
属性测试的一个经典示例是检查排序是否稳定:生成一个随机大小的随机数数组,对其进行一次排序,然后对排序后的结果进行排序,并确保两次排序的结果相同。这个属性应该适用于所有可能的数字数组,如果它不适用,属性测试库将自动识别不适用的最小失败用例。
在这种情况下,输入值生成器、测试代码和属性测试就可以明确分开。
我意识到,因为你可以在 monadic 风格中链接 Arbitraries,所以可以将其作为解释器的目标 monads,就像一个 Array 或 State monads 那样。
现在,本质上,输入值生成器和测试代码就是一个东西。
Arbitraries 不是创造了随机的数字数组,它们现在是生成了效果结果的随机组合。你的程序本质上是一连串的 Arbitraries。
在我的 Lambda Days 演讲中,我演示了使用不可靠的数据库处理和存储一些信息的程序的健壮性,该数据库可能只存储记录的子集,或者完全失败。
按照传统方式进行测试可能会非常令人沮丧,因为你在编写一些测试变体后,就会厌烦!
相反,我展示了如何将数据库写入表示为一种效果,并将其解释为一个完全成功、部分成功或失败的 Arbitrary。
然后,我设计了一个属性测试,断言我的代码将总是重试失败的写操作,直到每个记录都成功保存。
运行该属性测试将在所有可能失败的数据库写操作的“相空间”中尝试随机变化,搜索潜在的断言不成立的点。
你可以进一步扩展这个思路:通过将读取系统时间表示为一个效果,并将其解释为一个 Arbitrary(可能封装在一个 State 转换器中),模拟一个随机跳跃的系统时间,从而检查指数退避(exponential backoffs)是否如预期的那样工作。
在演讲中,我展示了如何使用 Runic 的 ArbitraryStateEither,它在 Arbitraries 中增加了状态和错误。它非常适合在日志中记录所有产生的效果,让你可以证明自己的程序遵守某些规则。
例如:连续执行相同的效果不超过 5 次,或者在没有“登录”之前从不尝试“注销”。
InfoQ:除了演讲之外,是否有机会在真实的程序中使用可扩展效果?如果有的话,能详细说明下使用的背景以及在这种背景下获得的好处吗?
Heslam :我还没有在交付的项目中使用过这种方法,但它的灵感来自于我在无服务器微服务中使用 AWS DynamoDB 时遇到的问题。DynamoDB 有意限制了读/写能力,它会报告无法插入的记录,并期望你 重试失败的操作。
测试局部不可用的边缘情况是相当困难的,需要谨慎区分可重试和不可重试错误,特别是在集成非幂等的第三方时!
我尝试创建 Arbitraries 来生成失败的 API,我可以用传统的依赖注入来换入,但是我发现,这非常麻烦。更糟糕的是,对于特定的测试迭代,生成器不知道是否要使用某个 API 调用,因此,它们将产生大量冗余的排列。
为了寻找更简洁的答案,我学习了可扩展效果的知识,通过将程序解释为一系列 Arbitraries,我意识到,测试只会针对重要的数据库失败生成排列。程序变成了排列!
InfoQ:你认为如何才能增强可扩展效果对 JavaScript 开发人员的吸引力,无论是在前端还是后端?
Heslam :一套针对 Node 和浏览器 API 的预定义效果将帮助人们更快地提高工作效率。例如, Fetch效果以及现成的生产解释器和测试解释器。
这也将有助于记录实际的例子,这块通常是缺失的!
许多成功的 JS 库都一直推广那些重要的概念,如 observables、 sagas和代数效应( algebraic effects),它们会持续关注实际的好处,采纳 JS 的古怪语义,留下一些或好或坏的理论和学术术语供进一步阅读。
在我看来,这是一个很好的平衡,因为大多数 Web 开发人员都容易被新奇的东西吸引(或者勉强可以忍住),但同时又保持着很强的实用主义倾向。我认为,与那些默认用户已经具备函数式编程背景的库相比,这可能有利于其被更广泛的采用。
对于库来说,导出 类型定义有很大的压力,而可扩展效果则由于对 TypeScript 的坚实支持而更有吸引力。
如果效果是程序与其环境之间的自然边界,那么它自然也是使用类型修饰的地方(不像内部实现细节那样),注解在这里可能是一种负担。
接着,在 Runic 中,我一直在探索效果类型约束的概念。
与数据库交互的程序可能有以下类型:
Rune<Write | Read | Delete, Error, boolean>
。如果你想未一个给定的 API 端点指定 Read 访问权限,而不是 Write 或 Delete 访问权限,则所需的程序类型
Rune<Read, Error, boolean>
会将不兼容的情况标记为类型错误。当然,TS 有类型断言和 escape hatch,所以它更像是一种交互式文档,而不是真正的安全措施!
TS 的类型系统有了很大改进,但它并不总是可以直接表示那些在 Haskell 中自然存在的数据结构。
对 higher kinded types的支持会很有帮助,你可有看下 fp-ts,这是一个很好的尝试。
对我来说,可扩展效果的主要吸引力在于它可以避开 API 模拟和传统依赖注入中一些令人厌恶的问题,它们使得测试副作用代码变得棘手。我的目的是借助 Runic 尽可能地凸显这种差别:有更好的方法!
受访者介绍 :
William Heslam 全栈 Web 开发人员和国际演讲者。他感兴趣的是如何通过借鉴函数式编程领域令人兴奋的思想来简化和改进软件开发。他喜欢就各种主题发表演讲,从复杂的测试技术到并行生物模拟。Heslam 的爱好包括寻找 20 世纪 80 年代被遗忘的范例和 3D 图形编程。他还喜欢伯爵茶、花椒和德国泡菜,但不是同时吃。
原文链接:
Extensible Effects in JavaScript for Fun and Profit - Q&A with William Heslam
评论