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

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

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

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

简单来讲就是,作数学家作的事情:欺骗。前端

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

  1. 依赖注入,或者咱们也能够叫它问题搁置
  2. 使用 Effect 函子,咱们能够把它想象为重度拖延[2]

依赖注入

依赖注入是咱们处理反作用的第一种方法。在这种方法中,将代码中的不纯的部分放入函数参数中,而后咱们就能够把它们看做是其余函数功能的一部分。为了解释个人意思,咱们来看看一些代码:node

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

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

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

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

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

如今,你可能会想:“这样作有点傻逼。这样把问题变得更严重了,代码仍是和以前同样不纯”。你是对的。这彻底就是一个漏洞。ios

YouTube 视频连接:youtu.be/9ZSoJDUD_bUgit

这就像是在装傻:“噢!不!警官,我不知道在 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() 函数。若是一个函数知足如下条件就能够称之为纯函数:

  1. 没有明显的反作用
  2. 引用透明。也就是说,给定相同的输入,它老是返回相同的输出。

让咱们看看 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, 两个函数 fg
那么 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。因此若是你在其余地方读到的话,记住 chainflatMapbind 实际上是同一律念的引用。

结合 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}');
复制代码

咱们已经有一个模板函数了。它接收一个字符串和一个对象而且返回一个字符串。可是咱们的字符串和对象(namepattern)已经包装到 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)));
});
复制代码

注意,liftA2liftA3 历来没有提到 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]

让咱们把这些反对意见分红两个问题:

  1. 函数纯度真的重要吗?
  2. 在真实场景中何时有用?

函数纯度重要性

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

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

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

实际场景中的 Effect 模式

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

Joel Spolsky 使人信服地论证过 函数式编程能够帮助咱们解决这个问题。咱们能够编写并行运行的 mapreduce 的替代版本,而函数纯度使这成为可能。但这并非故事的结尾。固然,您能够编写一些奇特的并行处理代码。但即使如此,您的开发机器仍然只有 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。正如你看到的,它和延时函数很像。咱们提早计划好了计算。而后,一旦准备好了,发动战争。

总结

本文涉及了不少内容,可是咱们已经探索了两种方法来处理代码中的函数纯度:

  1. 依赖注入
  2. Effect 函子

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

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

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

  2. 在其余语言(如 Haskell)中,这称为 IO 函子或 IO 单子。PureScript 使用 Effect 做为术语。我发现它更具备描述性。

  3. 注意,不一样的语言对这个简写有不一样的名称。例如,在 Haskell 中,它被称为 pure。我不知道为何。

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

  5. 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…

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

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


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

相关文章
相关标签/搜索