欢迎访问 生活随笔!

生活随笔

当前位置: 首页 > 前端技术 > javascript >内容正文

javascript

[译] 如何使用纯函数式 JavaScript 处理脏副作用

发布时间:2025/6/15 javascript 49 豆豆
生活随笔 收集整理的这篇文章主要介绍了 [译] 如何使用纯函数式 JavaScript 处理脏副作用 小编觉得挺不错的,现在分享给大家,帮大家做个参考.
  • 原文地址:HOW TO DEAL WITH DIRTY SIDE EFFECTS IN YOUR PURE FUNCTIONAL JAVASCRIPT
  • 原文作者:James Sinclair
  • 译文出自:掘金翻译计划
  • 本文永久链接:github.com/xitu/gold-m…
  • 译者:Gavin-Gong
  • 校对者:huangyuanzhen, AceLeeWinnie

如何使用纯函数式 JavaScript 处理脏副作用

首先,假定你对函数式编程有所涉猎。用不了多久你就能明白纯函数的概念。随着深入了解,你会发现函数式程序员似乎对纯函数很着迷。他们说:“纯函数让你推敲代码”,“纯函数不太可能引发一场热核战争”,“纯函数提供了引用透明性”。诸如此类。他们说的并没有错,纯函数是个好东西。但是存在一个问题……

纯函数是没有副作用的函数。[1] 但如果你了解编程,你就会知道副作用是关键。如果无法读取 ? 值,为什么要在那么多地方计算它?为了把值打印出来,我们需要写入 console 语句,发送到 printer,或其他可以被读取到的地方。如果数据库不能输入任何数据,那么它又有什么用呢?我们需要从输入设备读取数据,通过网络请求信息。这其中任何一件事都不可能没有副作用。然而,函数式编程是建立在纯函数之上的。那么函数式程序员是如何完成任务的呢?

简单来说就是,做数学家做的事情:欺骗。

说他们欺骗吧,技术上又遵守规则。但是他们发现了这些规则中的漏洞,并加以利用。有两种主要的方法:

  • 依赖注入,或者我们也可以叫它问题搁置
  • 使用 Effect 函子,我们可以把它想象为重度拖延[2]
  • 依赖注入

    依赖注入是我们处理副作用的第一种方法。在这种方法中,将代码中的不纯的部分放入函数参数中,然后我们就可以把它们看作是其他函数功能的一部分。为了解释我的意思,我们来看看一些代码:

    // logSomething :: String -> () function logSomething(something) {const dt = new Date().toIsoString();console.log(`${dt}: ${something}`);return something; } 复制代码

    logSomething() 函数有两个不纯的地方:它创建了一个 Date() 对象并且把它输出到控制台。因此,它不仅执行了 IO 操作, 而且每次运行的时候都会给出不同的结果。那么,如何使这个函数变纯?使用依赖注入,我们以函数参数的形式接受不纯的部分,因此 logSomething() 函数接收三个参数,而不是一个参数:

    // logSomething: Date -> Console -> String -> () function logSomething(d, cnsl, something) {const dt = d.toIsoString();cnsl.log(`${dt}: ${something}`);return something; } 复制代码

    然后调用它,我们必须自行明确地传入不纯的部分:

    const something = "Curiouser and curiouser!"; const d = new Date(); logSomething(d, console, something); // ⦘ Curiouser and curiouser! 复制代码

    现在,你可能会想:“这样做有点傻逼。这样把问题变得更严重了,代码还是和之前一样不纯”。你是对的。这完全就是一个漏洞。

    YouTube 视频链接:youtu.be/9ZSoJDUD_bU

    这就像是在装傻:“噢!不!警官,我不知道在 cnsl 上调用 log() 会执行 IO 操作。这是别人传给我的。我不知道它从哪来的”,这看起来有点蹩脚。

    这并不像表面上那么愚蠢,注意我们的 logSomething() 函数。如果你要处理一些不纯的事情, 你就不得不把它变得不纯。我们可以简单地传入不同的参数:

    const d = {toISOString: () => "1865-11-26T16:00:00.000Z"}; const cnsl = {log: () => {// do nothing} }; logSomething(d, cnsl, "Off with their heads!"); // ← "Off with their heads!" 复制代码

    现在,我们的函数什么事情也没干,除了返回 something 参数。但是它是纯的。如果你用相同的参数调用它,它每次都会返回相同的结果。这才是重点。为了使它变得不纯,我们必须采取深思熟虑的行动。或者换句话说,函数依赖于右边的签名。函数无法访问到像 console 或者 Date 之类的全局变量。这样所有事情就很明确了。

    同样需要注意的是,我们也可以将函数传递给原来不纯的函数。让我们看一下另一个例子。假设表单中有一个 username 字段。我们想要从表单中取到它的值:

    // getUserNameFromDOM :: () -> String function getUserNameFromDOM() {return document.querySelector("#username").value; }const username = getUserNameFromDOM(); username; // ← "mhatter" 复制代码

    在这个例子中,我们尝试去从 DOM 中查询信息。这是不纯的,因为 document 是一个随时可能改变的全局变量。把我们的函数转化为纯函数的方法之一就是把 全局 document 对象当作一个参数传入。但是我们也可以像这样传入一个 querySelector() 函数:

    // getUserNameFromDOM :: (String -> Element) -> String function getUserNameFromDOM($) {return $("#username").value; }// qs :: String -> Element const qs = document.querySelector.bind(document);const username = getUserNameFromDOM(qs); username; // ← "mhatter" 复制代码

    现在,你可能还是会认为:“这样还是一样傻啊!” 我们所做只是把不纯的代码从 getUsernameFromDOM() 移出来而已。它并没有消失,我们只是把它放在了另一个函数 qs() 中。除了使代码更长之外,它似乎没什么作用。我们两个函数取代了之前一个不纯的函数,但是其中一个仍然不纯。

    别着急,假设我们想给 getUserNameFromDOM() 写测试。现在,比较一下不纯和纯的版本,哪个更容易编写测试?为了对不纯版本的函数进行测试,我们需要一个全局 document 对象,除此之外,还需要一个 ID 为 username 的元素。如果我想在浏览器之外测试它,那么我必须导入诸如 JSDOM 或无头浏览器之类的东西。这一切都是为了测试一个很小的函数。但是使用第二个版本的函数,我可以这样做:

    const qsStub = () => ({value: "mhatter"}); const username = getUserNameFromDOM(qsStub); assert.strictEqual("mhatter", username, `Expected username to be ${username}`); 复制代码

    现在,这并不意味着你不应该创建在真正的浏览器中运行的集成测试。(或者,至少是像 JSDOM 这样的模拟版本)。但是这个例子所展示的是 getUserNameFromDOM() 现在是完全可预测的。如果我们传递给它 qsStub 它总是会返回 mhatter。我们把不可预测转性移到了更小的函数 qs 中。

    如果我们这样做,就可以把这种不可预测性推得越来越远。最终,我们将它们推到代码的边界。因此,我们最终得到了一个由不纯代码组成的薄壳,它包围着一个测试友好的、可预测的核心。当您开始构建更大的应用程序时,这种可预测性就会起到很大的作用。

    依赖注入的缺点

    可以以这种方式创建大型、复杂的应用程序。我知道是 因为我做过。 依赖注入使测试变得更容易,也会使每个函数的依赖关系变得明确。但它也有一些缺点。最主要的一点是,你最终会得到类似这样冗长的函数签名:

    function app(doc, con, ftch, store, config, ga, d, random) {// 这里是应用程序代码 }app(document, console, fetch, store, config, ga, new Date(), Math.random); 复制代码

    这还不算太糟,除此之外你可能遇到参数钻井的问题。在一个底层的函数中,你可能需要这些参数中的一个。因此,您必须通过许多层的函数调用来连接参数。这让人恼火。例如,您可能需要通过 5 层中间函数传递日期。所有这些中间函数都不使用 date 对象。这不是世界末日,至少能够看到这些显式的依赖关系还是不错的。但它仍然让人恼火。这还有另一种方法……

    懒函数

    让我们看看函数式程序员利用的第二个漏洞。它像这样:“发生的副作用才是副作用”。我知道这听起来神秘的。让我们试着让它更明确一点。思考一下这段代码:

    // fZero :: () -> Number function fZero() {console.log("Launching nuclear missiles");// 这里是发射核弹的代码return 0; } 复制代码

    我知道这是个愚蠢的例子。如果我们想在代码中有一个 0,我们可以直接写出来。我知道你,文雅的读者,永远不会用 JavaScript 写控制核武器的代码。但它有助于说明这一点。这显然是不纯的代码。因为它输出日志到控制台,也可能开始热核战争。假设我们想要 0。假设我们想要计算导弹发射后的情况,我们可能需要启动倒计时之类的东西。在这种情况下,提前计划如何进行计算是完全合理的。我们会非常小心这些导弹什么时候起飞,我们不想搞混我们的计算结果,以免他们意外发射导弹。那么,如果我们将 fZero() 包装在另一个只返回它的函数中呢?有点像安全包装。

    // fZero :: () -> Number function fZero() {console.log("Launching nuclear missiles");// 这里是发射核弹的代码return 0; }// returnZeroFunc :: () -> (() -> Number) function returnZeroFunc() {return fZero; } 复制代码

    我可以运行 returnZeroFunc() 任意次,只要不调用返回值,我理论上就是安全的。我的代码不会发射任何核弹。

    const zeroFunc1 = returnZeroFunc(); const zeroFunc2 = returnZeroFunc(); const zeroFunc3 = returnZeroFunc(); // 没有发射核弹。 复制代码

    现在,让我们更正式地定义纯函数。然后,我们可以更详细地检查我们的 returnZeroFunc() 函数。如果一个函数满足以下条件就可以称之为纯函数:

  • 没有明显的副作用
  • 引用透明。也就是说,给定相同的输入,它总是返回相同的输出。
  • 让我们看看 returnZeroFunc()。有副作用吗?嗯,之前我们确定过,调用 returnZeroFunc() 不会发射任何核导弹。除非执行调用返回函数的额外步骤,否则什么也不会发生。所以,这个函数没有副作用。

    returnZeroFunc() 引用透明吗?也就是说,给定相同的输入,它总是返回相同的输出?好吧,按照它目前的编写方式,我们可以测试它:

    zeroFunc1 === zeroFunc2; // true zeroFunc2 === zeroFunc3; // true 复制代码

    但它还不能算纯。returnZeroFunc() 函数引用函数作用域外的一个变量。为了解决这个问题,我们可以以这种方式进行重写:

    // returnZeroFunc :: () -> (() -> Number) function returnZeroFunc() {function fZero() {console.log("Launching nuclear missiles");// 这里是发射核弹的代码return 0;}return fZero; } 复制代码

    现在我们的函数是纯函数了。但是,JavaScript 阻碍了我们。我们无法再使用 === 来验证引用透明性。这是因为 returnZeroFunc() 总是返回一个新的函数引用。但是你可以通过审查代码来检查引用透明。returnZeroFunc() 函数每次除了返回相同的函数其他什么也不做。

    这是一个巧妙的小漏洞。但我们真的能把它用在真正的代码上吗?答案是肯定的。但在我们讨论如何在实践中实现它之前,先放到一边。先回到危险的 fZero() 函数:

    // fZero :: () -> Number function fZero() {console.log("Launching nuclear missiles");// 这里是发射核弹的代码return 0; } 复制代码

    让我们尝试使用 fZero() 返回的零,但这不会发动热核战争(笑)。我们将创建一个函数,它接受 fZero() 最终返回的 0,并在此基础上加一:

    // fIncrement :: (() -> Number) -> Number function fIncrement(f) {return f() + 1; }fIncrement(fZero); // ⦘ 发射导弹 // ← 1 复制代码

    哎呦!我们意外地发动了热核战争。让我们再试一次。这一次,我们不会返回一个数字。相反,我们将返回一个最终返回一个数字的函数:

    // fIncrement :: (() -> Number) -> (() -> Number) function fIncrement(f) {return () => f() + 1; }fIncrement(zero); // ← [Function] 复制代码

    唷!危机避免了。让我们继续。有了这两个函数,我们可以创建一系列的 '最终数字'(译者注:最终数字即返回数字的函数,后面多次出现):

    const fOne = fIncrement(zero); const fTwo = fIncrement(one); const fThree = fIncrement(two); // 等等… 复制代码

    我们也可以创建一组 f*() 函数来处理最终值:

    // fMultiply :: (() -> Number) -> (() -> Number) -> (() -> Number) function fMultiply(a, b) {return () => a() * b(); }// fPow :: (() -> Number) -> (() -> Number) -> (() -> Number) function fPow(a, b) {return () => Math.pow(a(), b()); }// fSqrt :: (() -> Number) -> (() -> Number) function fSqrt(x) {return () => Math.sqrt(x()); }const fFour = fPow(fTwo, fTwo); const fEight = fMultiply(fFour, fTwo); const fTwentySeven = fPow(fThree, fThree); const fNine = fSqrt(fTwentySeven); // 没有控制台日志或热核战争。干得不错! 复制代码

    看到我们做了什么了吗?如果能用普通数字来做的,那么我们也可以用最终数字。数学称之为 同构。我们总是可以把一个普通的数放在一个函数中,将其变成一个最终数字。我们可以通过调用这个函数得到最终的数字。换句话说,我们建立一个数字和最终数字之间映射。这比听起来更令人兴奋。我保证,我们很快就会回到这个问题上。

    这样进行函数包装是合法的策略。我们可以一直躲在函数后面,想躲多久就躲多久。只要我们不调用这些函数,它们理论上都是纯的。世界和平。在常规(非核)代码中,我们实际上最终希望得到那些副作用能够运行。将所有东西包装在一个函数中可以让我们精确地控制这些效果。我们决定这些副作用发生的确切时间。但是,输入那些括号很痛苦。创建每个函数的新版本很烦人。我们在语言中内置了一些非常好的函数,比如 Math.sqrt()。如果有一种方法可以用延迟值来使用这些普通函数就好了。进入下一节 Effect 函子。

    Effect 函子

    就目的而言,Effect 函子只不过是一个被置入延迟函数的对象。我们想把 fZero 函数置入到一个 Effect 对象中。但是,在这样做之前,先把难度降低一个等级

    // zero :: () -> Number function fZero() {console.log("Starting with nothing");// 绝对不会在这里发动核打击。// 但是这个函数仍然不纯return 0; } 复制代码

    现在我们创建一个返回 Effect 对象的构造函数

    // Effect :: Function -> Effect function Effect(f) {return {}; } 复制代码

    到目前为止,还没有什么可看的。让我们做一些有用的事情。我们希望配合 Effetct 使用常规的 fZero() 函数。我们将编写一个接收常规函数并延后返回值的方法,它运行时不触发任何效果。我们称之为 map。这是因为它在常规函数和 Effect 函数之间创建了一个映射。它可能看起来像这样:

    // Effect :: Function -> Effect function Effect(f) {return {map(g) {return Effect(x => g(f(x)));}}; } 复制代码

    现在,如果你观察仔细的话,你可能想知道 map() 的作用。它看起来像是组合。我们稍后会讲到。现在,让我们尝试一下:

    const zero = Effect(fZero); const increment = x => x + 1; // 一个普通的函数。 const one = zero.map(increment); 复制代码

    嗯。我们并没有看到发生了什么。让我们修改一下 Effect,这样我们就有了办法来“扣动扳机”。可以这样写:

    // Effect :: Function -> Effect function Effect(f) {return {map(g) {return Effect(x => g(f(x)));},runEffects(x) {return f(x);}}; }const zero = Effect(fZero); const increment = x => x + 1; // 只是一个普通的函数 const one = zero.map(increment);one.runEffects(); // ⦘ 什么也没启动 // ← 1 复制代码

    并且只要我们愿意, 我们可以一直调用 map 函数:

    const double = x => x * 2; const cube = x => Math.pow(x, 3); const eight = Effect(fZero).map(increment).map(double).map(cube);eight.runEffects(); // ⦘ 什么也没启动 // ← 8 复制代码

    从这里开始变得有意思了。我们称这为函子,这意味着 Effect 有一个 map 函数,它 遵循一些规则。这些规则并不意味着你不能这样做。它们是你的行为准则。它们更像是优先级。因为 Effect 是函子大家庭的一份子,所以它可以做一些事情,其中一个叫做“合成规则”。它长这样:

    如果我们有一个 Effect e, 两个函数 f 和 g
    那么 e.map(g).map(f) 等同于 e.map(x => f(g(x)))。

    换句话说,一行写两个 map 函数等同于组合这两个函数。也就是说 Effect 可以这样写(回顾一下上面的例子):

    const incDoubleCube = x => cube(double(increment(x))); // 如果你使用像 Ramda 或者 lodash/fp 之类的库,我们也可以这样写: // const incDoubleCube = compose(cube, double, increment); const eight = Effect(fZero).map(incDoubleCube); 复制代码

    当我们这样做的时候,我们可以确认会得到与三重 map 版本相同的结果。我们可以使用它重构代码,并确信代码不会崩溃。在某些情况下,我们甚至可以通过在不同方法之间进行交换来改进性能。

    但这些例子已经足够了,让我们开始实战吧。

    Effect 简写

    我们的 Effect 构造函数接受一个函数作为它的参数。这很方便,因为大多数我们想要延迟的副作用也是函数。例如,Math.random() 和 console.log() 都是这种类型的东西。但有时我们想把一个普通的旧值压缩成一个 Effect。例如,假设我们在浏览器的 window 全局对象中附加了某种配置对象。我们想要得到一个 a 的值,但这不是一个纯粹的运算。我们可以写一个小的简写,使这个任务更容易:[3]

    // of :: a -> Effect a Effect.of = function of(val) {return Effect(() => val); }; 复制代码

    为了说明这可能会很方便,假设我们正在处理一个 web 应用。这个应用有一些标准特性,比如文章列表和用户简介。但是在 HTML 中,这些组件针对不同的客户进行展示。因为我们是聪明的工程师,所以我们决定将他们的位置存储在一个全局配置对象中,这样我们总能找到它们。例如:

    window.myAppConf = {selectors: {"user-bio": ".userbio","article-list": "#articles","user-name": ".userfullname"},templates: {greet: "Pleased to meet you, {name}",notify: "You have {n} alerts"} }; 复制代码

    现在使用 Effect.of(),我们可以很快地把我们想要的值包装进一个 Effect 容器, 就像这样

    const win = Effect.of(window); userBioLocator = win.map(x => x.myAppConf.selectors["user-bio"]); // ← Effect('.userbio') 复制代码

    内嵌 与 非内嵌 Effect

    映射 Effect 可能对我们大有帮助。但是有时候我们会遇到映射的函数也返回一个 Effect 的情况。我们已经定义了一个 getElementLocator(),它返回一个包含字符串的 Effect。如果我们真的想要拿到 DOM 元素,我们需要调用另外一个非纯函数 document.querySelector()。所以我们可能会通过返回一个 Effect 来纯化它:

    // $ :: String -> Effect DOMElement function $(selector) {return Effect.of(document.querySelector(s)); } 复制代码

    现在如果想把它两放一起,我们可以尝试使用 map():

    const userBio = userBioLocator.map($); // ← Effect(Effect(<div>)) 复制代码

    想要真正运作起来还有点尴尬。如果我们想要访问那个 div,我们必须用一个函数来映射我们想要做的事情。例如,如果我们想要得到 innerHTML,它看起来是这样的:

    const innerHTML = userBio.map(eff => eff.map(domEl => domEl.innerHTML)); // ← Effect(Effect('<h2>User Biography</h2>')) 复制代码

    让我们试着分解。我们会回到 userBio,然后继续。这有点乏味,但我们想弄清楚这里发生了什么。我们使用的标记 Effect('user-bio') 有点误导人。如果我们把它写成代码,它看起来更像这样:

    Effect(() => ".userbio"); 复制代码

    但这也不准确。我们真正做的是:

    Effect(() => window.myAppConf.selectors["user-bio"]); 复制代码

    现在,当我们进行映射时,它就相当于将内部函数与另一个函数组合(正如我们在上面看到的)。所以当我们用 $ 映射时,它看起来像这样:

    Effect(() => window.myAppConf.selectors["user-bio"]); 复制代码

    把它展开得到:

    Effect(() => Effect.of(document.querySelector(window.myAppConf.selectors['user-bio']))) ); 复制代码

    展开 Effect.of 给我们一个更清晰的概览:

    Effect(() =>Effect(() => document.querySelector(window.myAppConf.selectors["user-bio"])) ); 复制代码

    注意: 所有实际执行操作的代码都在最里面的函数中,这些都没有泄露到外部的 Effect。

    Join

    为什么要这样拼写呢?我们想要这些内嵌的 Effect 变成非内嵌的形式。转换过程中,要保证没有引入任何预料之外的副作用。对于 Effect 而言, 不内嵌的方式就是在外部函数调用 .runEffects()。 但这可能会让人困惑。我们已经完成了整个练习,以检查我们不会运行任何 Effect。我们会创建另一个函数做同样的事情,并将其命名为 join。我们使用 join 来解决 Effect 内嵌的问题,使用 runEffects() 真正运行所有 Effect。 即使运行的代码是相同的,但这会使我们的意图更加清晰。

    // Effect :: Function -> Effect function Effect(f) {return {map(g) {return Effect(x => g(f(x)));},runEffects(x) {return f(x);}join(x) {return f(x);}} } 复制代码

    然后,可以用它解开内嵌的用户简介元素:

    const userBioHTML = Effect.of(window).map(x => x.myAppConf.selectors["user-bio"]).map($).join().map(x => x.innerHTML); // ← Effect('<h2>User Biography</h2>') 复制代码

    Chain

    .map() 之后紧跟 .join() 这种模式经常出现。事实上,有一个简写函数是很方便的。这样,无论何时我们有一个返回 Effect 的函数,我们都可以使用这个简写函数。它可以把我们从一遍又一遍地写 map 然后紧跟 join 中解救出来。我们这样写:

    // Effect :: Function -> Effect function Effect(f) {return {map(g) {return Effect(x => g(f(x)));},runEffects(x) {return f(x);}join(x) {return f(x);}chain(g) {return Effect(f).map(g).join();}} } 复制代码

    我们调用新的函数 chain() 因为它允许我们把 Effect 链接到一起。(其实也是因为标准告诉我们可以这样调用它)。[4] 取到用户简介元素的 innerHTML 可能长这样:

    const userBioHTML = Effect.of(window).map(x => x.myAppConf.selectors["user-bio"]).chain($).map(x => x.innerHTML); // ← Effect('<h2>User Biography</h2>') 复制代码

    不幸的是, 对于这个实现其他函数式语言有着一些不同的名字。如果你读到它,你可能会有点疑惑。有时候它被称之为 flatMap,这样起名是说得通的,因为我们先进行一个普通的映射,然后使用 .join() 扁平化结果。不过在 Haskell 中,chain 被赋予了一个令人疑惑的名字 bind。所以如果你在其他地方读到的话,记住 chain、flatMap 和 bind 其实是同一概念的引用。

    结合 Effect

    这是最后一个使用 Effect 有点尴尬的场景,我们想要在一个函数中组合两个或者多个函子。例如,如何从 DOM 中拿到用户的名字?拿到名字后还要插入应用配置提供的模板里呢?因此,我们可能有一个模板函数(注意我们将创建一个科里化版本的函数)

    // tpl :: String -> Object -> String const tpl = curry(function tpl(pattern, data) {return Object.keys(data).reduce((str, key) => str.replace(new RegExp(`{${key}}`, data[key]),pattern); }); 复制代码

    一切都很正常,但是现在来获取我们需要的数据:

    const win = Effect.of(window); const name = win.map(w => w.myAppConfig.selectors['user-name']).chain($).map(el => el.innerHTML).map(str => ({name: str}); // ← Effect({name: 'Mr. Hatter'});const pattern = win.map(w => w.myAppConfig.templates('greeting')); // ← Effect('Pleased to meet you, {name}'); 复制代码

    我们已经有一个模板函数了。它接收一个字符串和一个对象并且返回一个字符串。但是我们的字符串和对象(name 和 pattern)已经包装到 Effect 里了。我们所要做的就是提升我们 tpl() 函数到更高的地方使得它能很好地与 Effect 工作。

    让我们看一下如果我们在 pattern Effect 上用 map() 调用 tpl() 会发生什么:

    pattern.map(tpl); // ← Effect([Function]) 复制代码

    对照一下类型可能会使得事情更加清晰一点。map 的函数声明可能长这样:

    _map :: Effect a ~> (a -> b) -> Effect b_ 复制代码

    这是模板函数的函数声明:

    _tpl :: String -> Object -> String_ 复制代码

    因此,当我们在 pattern 上调用 map,我们在 Effect 内部得到了一个偏应用函数(记住我们科里化过 tpl)。

    _Effect (Object -> String)_ 复制代码

    现在我们想从 pattern Effect 内部传递值,但我们还没有办法做到。我们将编写另一个 Effect 方法(称为 ap())来处理这个问题:

    // Effect :: Function -> Effect function Effect(f) {return {map(g) {return Effect(x => g(f(x)));},runEffects(x) {return f(x);}join(x) {return f(x);}chain(g) {return Effect(f).map(g).join();}ap(eff) {// 如果有人调用了 ap,我们假定 eff 里面有一个函数而不是一个值。// 我们将用 map 来进入 eff 内部, 并且访问那个函数// 拿到 g 后,就传入 f() 的返回值return eff.map(g => g(f()));}} } 复制代码

    有了它,我们可以运行 .ap() 来应用我们的模板函数:

    const win = Effect.of(window); const name = win.map(w => w.myAppConfig.selectors["user-name"]).chain($).map(el => el.innerHTML).map(str => ({ name: str }));const pattern = win.map(w => w.myAppConfig.templates("greeting"));const greeting = name.ap(pattern.map(tpl)); // ← Effect('Pleased to meet you, Mr Hatter') 复制代码

    我们已经实现我们的目标。但有一点我要承认,我发现 ap() 有时会让人感到困惑。很难记住我必须先映射函数,然后再运行 ap()。然后我可能会忘了参数的顺序。但是有一种方法可以解决这个问题。大多数时候,我想做的是把一个普通函数提升到应用程序的世界。也就是说,我已经有了简单的函数,我想让它们与具有 .ap() 方法的 Effect 一起工作。我们可以写一个函数来做这个:

    // liftA2 :: (a -> b -> c) -> (Applicative a -> Applicative b -> Applicative c) const liftA2 = curry(function liftA2(f, x, y) {return y.ap(x.map(f));// 我们也可以这样写:// return x.map(f).chain(g => y.map(g)); }); 复制代码

    我们称它为 liftA2() 因为它会提升一个接受两个参数的函数. 我们可以写一个与之相似的 liftA3(),像这样:

    // liftA3 :: (a -> b -> c -> d) -> (Applicative a -> Applicative b -> Applicative c -> Applicative d) const liftA3 = curry(function liftA3(f, a, b, c) {return c.ap(b.ap(a.map(f))); }); 复制代码

    注意,liftA2 和 liftA3 从来没有提到 Effect。理论上,它们可以与任何具有兼容 ap() 方法的对象一起工作。 使用 liftA2() 我们可以像下面这样重写之前的例子:

    const win = Effect.of(window); const user = win.map(w => w.myAppConfig.selectors['user-name']).chain($).map(el => el.innerHTML).map(str => ({name: str});const pattern = win.map(w => w.myAppConfig.templates['greeting']);const greeting = liftA2(tpl)(pattern, user); // ← Effect('Pleased to meet you, Mr Hatter') 复制代码

    那又怎样?

    这时候你可能会想:“这似乎为了避免随处可见的奇怪的副作用而付出了很多努力”。这有什么关系?传入参数到 Effect 内部,封装 ap() 似乎是一项艰巨的工作。当不纯代码正常工作时,为什么还要烦恼呢?在实际场景中,你什么时候会需要这个?

    函数式程序员听起来很像是中世纪的僧侣似的,他们禁绝了尘世中的种种乐趣并且期望这能使自己变得高洁。

    —John Hughes [5]

    让我们把这些反对意见分成两个问题:

  • 函数纯度真的重要吗?
  • 在真实场景中什么时候有用?
  • 函数纯度重要性

    函数纯度的确重要。当你单独观察一个小函数时,一点点的副作用并不重要。写 const pattern = window.myAppConfig.templates['greeting']; 比写下面这样的代码更加快速简单。

    const pattern = Effect.of(window).map(w => w.myAppConfig.templates("greeting")); 复制代码

    如果代码里都是这样的小函数,那么继续这么写也可以,副作用不足以成问题。但这只是应用程序中的一行代码,其中可能包含数千甚至数百万行代码。当你试图弄清楚为什么你的应用程序莫名其妙地“看似毫无道理地”停止工作时,函数纯度就变得更加重要了。如果发生了一些意想不到的事,你试图把问题分解开来,找出原因。在这种情况下,可以排除的代码越多越好。如果您的函数是纯的,那么您可以确信,影响它们行为的唯一因素是传递给它的输入。这就大大缩小了要考虑的异常范围。换句话说,它能让你少思考。这在大型、复杂的应用程序中尤为重要。

    实际场景中的 Effect 模式

    好吧。如果你正在构建一个大型的、复杂的应用程序,类似 Facebook 或 Gmail。那么函数纯度可能很重要。但如果不是大型应用呢?让我们考虑一个越发普遍的场景。你有一些数据。不只是一点点数据,而是大量的数据 —— 数百万行,在 CSV 文本文件或大型数据库表中。你的任务是处理这些数据。也许你在训练一个人工神经网络来建立一个推理模型。也许你正试图找出加密货币的下一个大动向。无论如何, 问题是要完成这项工作需要大量的处理工作。

    Joel Spolsky 令人信服地论证过 函数式编程可以帮助我们解决这个问题。我们可以编写并行运行的 map 和 reduce 的替代版本,而函数纯度使这成为可能。但这并不是故事的结尾。当然,您可以编写一些奇特的并行处理代码。但即便如此,您的开发机器仍然只有 4 个内核(如果幸运的话,可能是 8 个或 16 个)。那项工作仍然需要很长时间。除非,也就是说,你可以在一堆处理器上运行它,比如 GPU,或者整个处理服务器集群。

    要使其工作,您需要描述您想要运行的计算。但是,您需要在不实际运行它们的情况下描述它们。听起来是不是很熟悉?理想情况下,您应该将描述传递给某种框架。该框架将小心地负责读取所有数据,并将其在处理节点之间分割。然后框架会把结果收集在一起,告诉你它的运行情况。这就是 TensorFlow 的工作流程。

    TensorFlow™ 是一个高性能数值计算开源软件库。它灵活的架构支持从桌面到服务器集群,从移动设备到边缘设备的跨平台(CPU、GPU、TPU)计算部署。Google AI 组织内的 Google Brain 小组的研究员和工程师最初开发 TensorFlow 用于支持机器学习和深度学习领域,其灵活的数值计算内核也应用于其他科学领域。

    —TensorFlow 首页[6]

    当您使用 TensorFlow 时,你不会使用你所使用的编程语言中的常规数据类型。而是,你需要创建张量。如果我们想加两个数字,它看起来是这样的:

    node1 = tf.constant(3.0, tf.float32) node2 = tf.constant(4.0, tf.float32) node3 = tf.add(node1, node2) 复制代码

    上面的代码是用 Python 编写的,但是它看起来和 JavaScript 没有太大的区别,不是吗?和我们的 Effect 类似,add 直到我们调用它才会运行(在这个例子中使用了 sess.run()):

    print("node3: ", node3) print("sess.run(node3): ", sess.run(node3)) #⦘ node3: Tensor("Add_2:0", shape=(), dtype=float32) #⦘ sess.run(node3): 7.0 复制代码

    在调用 sess.run() 之前,我们不会得到 7.0。正如你看到的,它和延时函数很像。我们提前计划好了计算。然后,一旦准备好了,发动战争。

    总结

    本文涉及了很多内容,但是我们已经探索了两种方法来处理代码中的函数纯度:

  • 依赖注入
  • Effect 函子
  • 依赖注入的工作原理是将代码的不纯部分移出函数。所以你必须把它们作为参数传递进来。相比之下,Effect 函子的工作原理则是将所有内容包装在一个函数后面。要运行这些 Effect,我们必须先运行包装器函数。

    这两种方法都是欺骗。他们不会完全去除不纯,他们只是把它们推到代码的边缘。但这是件好事。它明确说明了代码的哪些部分是不纯的。在调试复杂代码库中的问题时,很有优势。

  • 这不是一个完整的定义,但暂时可以使用。我们稍后会回到正式的定义。

  • 在其他语言(如 Haskell)中,这称为 IO 函子或 IO 单子。PureScript 使用 Effect 作为术语。我发现它更具有描述性。

  • 注意,不同的语言对这个简写有不同的名称。例如,在 Haskell 中,它被称为 pure。我不知道为什么。

  • 在这个例子中,采用了 Fantasy Land specification for Chain 规范。

  • John Hughes, 1990, ‘Why Functional Programming Matters’, Research Topics in Functional Programming ed. D. Turner, Addison–Wesley, pp 17–42, www.cs.kent.ac.uk/people/staf…

  • TensorFlow™:面向所有人的开源机器学习框架, www.tensorflow.org/,12 May 2018。

    • [欢迎通过 Twitter 交流](twitter.com/share?url=h… to deal with dirty side effects in your pure functional JavaScript%E2%80%9D+by+%40jrsinclair)
    • 通过电子邮件系统订阅最新资讯

    如果发现译文存在错误或其他需要改进的地方,欢迎到 掘金翻译计划 对译文进行修改并 PR,也可获得相应奖励积分。文章开头的 本文永久链接 即为本文在 GitHub 上的 MarkDown 链接。


    掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 Android、iOS、前端、后端、区块链、产品、设计、人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划、官方微博、知乎专栏。

    《新程序员》:云原生和全面数字化实践50位技术专家共同创作,文字、视频、音频交互阅读

    总结

    以上是生活随笔为你收集整理的[译] 如何使用纯函数式 JavaScript 处理脏副作用的全部内容,希望文章能够帮你解决所遇到的问题。

    如果觉得生活随笔网站内容还不错,欢迎将生活随笔推荐给好友。