八、连接函数——管道和组合

第 7 章中,我们介绍了几种通过应用高阶函数构建新函数的方法。 在本章中,我们将深入到 FP 的核心,并学习如何创建函数调用序列,以及如何将它们组合起来,从几个简单的组件中产生一个更复杂的结果。 为此,我们将涵盖以下主题:

  • 管道:一种连接函数的方法,类似于 Unix/Linux 管道。
  • 链接:这可能被认为是流水线的一种变体,但仅限于对象。
  • :这是一个经典的运算,它起源于计算机基础理论。
  • :map/filter/reduce 操作的优化方法。

在此过程中,我们将接触相关的概念,如:

  • Pointfree 风格,常与流水线、构图结合使用
  • 调试组合或管道函数,为此我们将快速开发一些辅助工具
  • 测试组合或管道函数,不会证明是高度复杂的

有了这些技术,您将能够组合小函数来创建较大的函数,这是函数式编程的特点,并将帮助您开发更好的代码。

流水线

流水线和组合技术用于设置函数,使它们按顺序工作,以便一个函数的输出成为下一个函数的输入。 有两种方法来看待这个问题:从计算机的角度,和从数学的角度。 我们将在本节中研究这两种方法。 大多数 FP 文本以后者开始,但由于我假设大多数人会更喜欢计算机而不是数学,让我们从前者开始。

管道在 Unix / Linux

在 Unix/Linux 中,执行一个命令并将其输出作为输入传递给第二个命令,第二个命令的输出将产生第三个命令的输入,以此类推,称为管道。 正如流水线概念的创造者 Doug McIlroy 在贝尔实验室的一篇文章中所解释的那样,这是 Unix 哲学的一个非常常见的应用:

  • 让每个程序做好一件事。 要做新的工作,重新构建,而不是通过添加新的功能使旧程序复杂化。
  • 期望每个程序的输出成为另一个程序的输入,到目前为止还不知道。

Given the historical importance of Unix, I'd recommend reading some of the seminal articles describing the (then new) operating system, in the Bell System Technical Journal, July 1978, at http://emulator.pdp-11.org.ru/misc/1978.07_-_Bell_System_Technical_Journal.pdf. The two quoted rules are in the Style section, in the Foreword article.

让我们从一个简单的例子开始。 假设我想知道一个目录中有多少 LibreOffice 文本文档。 有很多方法可以做到这一点,但这个就可以了。 我们将执行三个命令,将每个命令的输出作为下一个命令的输入(这是|字符的含义)。 假设我们进入cd /home/fkereki/Documents,然后执行以下操作(请忽略美元符号,它只是控制台提示符):

$ ls -1 | grep "odt$" | wc -l
4

这意味着什么? 它是如何工作的? 我们必须一步一步地分析这个过程:

  • 管道的第一部分,ls -1,列出目录中的所有文件(按照我们的cd命令,/home/fkereki/Documents),在一个列中,每行一个文件名。
  • 第一个命令的输出作为grep "odt$"的输入提供,它只过滤(让通过)以"odt"结尾的行,"odt"是 LibreOffice Writer 的标准文件扩展名。
  • 过滤后的输出提供给计数命令wc -l,该命令计算输入中有多少行。

You can find out more about pipelines in Section 6.2, Filters, of the UNIX Time-Sharing System article by Dennis Ritchie and Ken Thompson, also in the issue of the Bell Laboratories journal that I mentioned previously.

从 FP 的角度来看,这是一个关键的概念。 我们希望从简单、单一用途、较短的函数中构建更复杂的操作。 Unix shell 使用管道来应用这一概念,它简化了执行命令、获取其输出并将其作为输入提供给另一个命令的工作。 稍后我们将在 JavaScript 的函数风格中应用类似的概念:

Figure 8.1: Pipelines in JavaScript are similar to Unix/Linux pipelines. The output of each function becomes the input for the next

顺便说一下(请放心,这不会变成一个 shell 教程!),您还可以让管道接受参数。 例如,如果我碰巧想计算我有多少文件与这个或那个扩展名,我可以创建一个函数,如cfe,代表计数的扩展名:

$ function cfe() {
    ls -1 | grep "$1\$"| wc -l
}

然后,我可以使用cfe作为命令,给它所需的扩展名作为参数:

$ cfe odt
4
$ cfe pdf
6

cfe执行我的管道,告诉我有4``odt文件(LibreOffice)和6``pdf文件; 好了! 我们还希望编写类似的参数化管道:我们不局限于在流中只有固定的函数; 我们完全可以自由选择我们想要包含的内容。 在 Linux 上工作过之后,我们现在可以回去编码了。 让我们来看看。

回顾一个例子

我们可以通过回顾前一章的一个问题来将结尾联系在一起。 还记得我们必须计算的平均纬度和经度的一些地理数据,我们看着从对象中提取数据部分第五章,编程以声明的方式——一个更好的风格吗? 基本上,我们从以下数据开始,问题是计算给定点的平均纬度和经度:

const markers = [
  {name: "AR", lat: -34.6, lon: -58.4},
  {name: "BO", lat: -16.5, lon: -68.1},
  {name: "BR", lat: -15.8, lon: -47.9},
  {name: "CL", lat: -33.4, lon: -70.7},
  {name: "CO", lat:   4.6, lon: -74.0},
  {name: "EC", lat:  -0.3, lon: -78.6},
  {name: "PE", lat: -12.0, lon: -77.0},
  {name: "PY", lat: -25.2, lon: -57.5},
  {name: "UY", lat: -34.9, lon: -56.2},
  {name: "VE", lat:  10.5, lon: -66.9},
];

根据我们所知道的,我们可以写出如下形式的解:

  • 能够从每个点提取纬度(然后是经度)
  • 用这个函数来创建一个纬度数组
  • 将得到的数组流水线到前面章节的计算一节中编写的 average 函数

为了完成第一个任务,我们可以使用第 7 章参数顺序章节中的myMap()函数transform Functions - curcurry and Partial Application。 第二个任务,我们可以将就用【显示】的getField()函数获得一个对象的一个属性的第六章,【病人】生产函数高阶函数,加上一些局部修复一些值。 最后,对于第三个任务,我们将使用即将开发的管道函数(尚未编写!)! 完全地,我们的解决方案看起来像这样:

const average = arr => arr.reduce(sum, 0) / arr.length;
const getField = attr => obj => obj[attr];
const myMap = curry(flipTwo(demethodize(array.prototype.map)));

const getLat = curry(getField)("lat");
const getAllLats = curry(myMap)(getLat);

let averageLat = pipeline(getAllLats, average);
// *and similar code to average longitudes*

当然,您总是可以屈服于一些一行的诱惑,但它会更清晰或更好吗?

let averageLat2 = pipeline(curry(myMap)(curry(getField)("lat")), average);
let averageLon2 = pipeline(curry(myMap)(curry(getField)("lon")), average);

这是否对你有意义将取决于你在 FP 方面的经验。 在任何情况下,无论您采用哪种解决方案,向工具集添加管道(以及以后的组合)都可以帮助您编写更紧凑、声明性更强、更易于理解的代码。

现在,让我们学习如何以正确的方式对函数进行流水线操作。

创建管道

我们希望能够生成一个由多个函数组成的管道。 我们可以通过两种不同的方式来实现这一点:通过手工构建管道,以特定问题的方式,或者通过寻求使用更通用的构造来应用。 让我们都来看看。

手工建造管道

让我们以 Node 为例,类似于本章前面构建的命令行管道。 在这里,我们将手工构建我们需要的管道。 我们需要一个函数来读取目录中的所有文件。 我们可以这样做(这是不推荐的,因为同步调用,这在服务器环境中通常不好):

function getDir(path) {
  const fs = require("fs");
  const files = fs.readdirSync(path);
  return files;
}

过滤odt文件非常简单。 我们从以下函数开始:

const filterByText = (text, arr) => arr.filter(v => v.endsWith(text));

这个函数接受一个数组,并过滤掉任何不以给定文本结束的元素。 所以,我们现在可以这样写:

const filterOdt = arr => filterByText(".odt", arr);

更棒的是,我们可以使用 curry 和 pointfree 的风格,如第 3 章部分所示

const filterOdt2 = curry(filterByText)(".odt");

两个版本的过滤函数是等价的; 你用哪一种取决于你的口味。 最后,要计数数组中的元素,可以简单地编写如下代码。 由于length不是一个函数,我们不能应用我们的去方法技巧:

const count = arr => arr.length;

有了这些函数,我们可以这样写:

const countOdtFiles = path => {
  const files = getDir(path);
  const filteredFiles = filterOdt(files);
  const countOfFiles = count(filteredFiles);
  return countOfFiles;
};

countOdtFiles("/home/fkereki/Documents"); // 4, *as with the command line solution*

我们实际上在做与 Linux 相同的过程:获取文件,只保留odt文件,并计算由此产生的文件数量。 如果你想摆脱所有的中间变量,你也可以使用一行代码定义,它以相同的方式完成相同的工作,尽管行数更少:

const countOdtFiles2 = path => count(filterOdt(getDir(path)));

countOdtFiles2("/home/fkereki/Documents"); // 4, *as before*

这就触及了问题的关键:文件计数函数的两种实现都有缺点。 第一个定义使用几个中间变量来保存结果,并将 Linux shell 中的一行代码变成一个多行函数。 第二种定义要短得多,但另一方面,它却很难理解,因为我们似乎是以相反的顺序来写计算步骤的! 我们的管道必须首先读取文件,然后过滤它们,最后计算它们,但是在我们的定义中,这些函数出现了而不是!

我们当然可以手工实现管道,正如我们已经看到的,但是如果我们可以采用更声明式的样式会更好。

让我们继续前进,尝试通过应用我们已经看到的一些概念,以一种更清晰、更易于理解的方式构建一个更好的管道。

使用其他结构

如果我们认为在功能方面,我们是一个列表的功能,我们要按顺序应用它们,从第一个开始,然后应用第二到第一个函数产生的结果,然后应用第三到第二个函数的结果,等等。 如果我们只是修复两个函数的管道,你可以使用以下代码:

const pipeTwo = (f, g) => (...args) => g(f(...args));

这是我们在本章前面提供的基本定义:对第一个函数求值,它的输出将成为第二个函数的输入; 很简单的! 但是,您可能会反对,这个只有两个函数的管道有点太有限了! 这并不是看起来那么没用,因为我们可以构建更长的管道——尽管我承认这需要太多的编写! 假设我们想要编写三个函数的管道(来自上一节); 我们可以用两种不同的、等价的方法来实现:

const countOdtFiles3 = path =>
  pipeTwo(pipeTwo(getDir, filterOdt), count)(path);

const countOdtFiles4 = path =>
  pipeTwo(getDir, pipeTwo(filterOdt, count))(path);

We are taking advantage of the fact that piping is an associative operation. In mathematics, the associative property is the one that says that we can compute 1+2+3 either by adding 1+2 first and then adding that result to 3, or by adding 1 to the result of adding 2+3: in other terms, 1+2+3 is the same as (1+2)+3 or 1+(2+3).

它们是如何工作的? 为什么它们是等价的? 跟踪给定调用的执行将是有用的; 这么多电话很容易让人混淆! 可以一步一步地跟随第一个实现,直到最终的结果,它符合我们已经知道的:

countOdtFiles3("/home/fkereki/Documents") ===
  pipeTwo(pipeTwo(getDir, filterOdt), count)("/home/fkereki/Documents") ===
    count(pipeTwo(getDir, filterOdt)("/home/fkereki/Documents")) ===
      count(filterOdt(getDir("/home/fkereki/Documents"))) // 4

第二个实现也得到了相同的最终结果:

countOdtFiles4("/home/fkereki/Documents") ===
  pipeTwo(getDir, pipeTwo(filterOdt, count))("/home/fkereki/Documents") ===
    pipeTwo(filterOdt, count)(getDir("/home/fkereki/Documents")) ===
      count(filterOdt(getDir("/home/fkereki/Documents"))) // 4

两个派生到达相同的最终表达我们早点手写相同,在实际中,我们现在知道,我们能做的只有一个基本的管两个高阶函数,但我们真的想能够工作在一个更短,更紧凑的方式。 第一个实现可以沿着以下路线:

const pipeline = (...fns) => (...args) => {
  let result = fns[0](...args);
  for (let i = 1; i < fns.length; i++) {
    result = fns[i](result);
  }
  return result;
};

pipeline(getDir, filterOdt, count)("/home/fkereki/Documents"); // *still* 4

这是可行的——指定文件计数管道的方法更加清晰,因为函数是按照正确的顺序给出的。 然而,pipeline()函数的实现并不是很实用,而是回到旧的、命定的、手动循环方法。 我们可以使用reduce()做得更好,就像我们在第五章编程声明式-更好的风格

If you check out some FP libraries, the function that we are calling pipeline() here may also be known as flow()—because data flows from left to right – or sequence()—alluding to the fact that operations are performed in ascending sequence—but the semantics are the same.

其思想是,从第一个函数开始计算,将结果传递给第二个函数,然后将结果传递给第三个函数,以此类推。 通过这样做,我们可以用更短的代码进行流水线:

const pipeline2 = (...fns) =>
  fns.reduce((result, f) => (...args) => f(result(...args)));

pipeline2(getDir, filterOdt, count)("/home/fkereki/Documents"); // 4

这段代码更具说明性。 然而,你可以用我们的pipeTwo()函数来写它,它以一种更简洁的方式做同样的事情:

const pipeline3 = (...fns) => fns.reduce(pipeTwo);

pipeline3(getDir, filterOdt, count)("/home/fkereki/Documents"); // *again* 4

你可以理解这段代码,因为它使用了我们前面提到的关联属性,并将第一个函数传递给第二个函数; 然后,它将结果输送给第三个函数,以此类推。

哪个版本更好? 我会说的版本指的是pipeTwo()功能是清晰的:如果你知道reduce()是如何工作的,你可以很容易地理解我们的管道穿过两个功能,从第一匹配你知道管道是如何工作的。 我们编写的其他版本或多或少都是说明性的,但不那么容易理解。

在我们讨论其他组成函数的方法之前,让我们考虑一下如何调试管道。

调试管道

现在,让我们转向一个实际问题:如何调试代码? 使用流水线,您无法真正看到从一个函数传递到另一个函数的是什么,那么该如何做呢? 对此我们有两个答案:一个(也)来自 Unix/Linux 世界,另一个(最适合这本书)使用包装器来提供一些日志。

使用三通

我们将使用的第一个解决方案意味着向管道添加一个函数,该函数将记录其输入。 我们想要实现一些类似于teeLinux 命令的东西,它可以拦截管道中的标准数据流,并将副本发送到备用文件或设备。 记住/dev/tty是通常的控制台,我们可以执行类似于下面的操作,并在屏幕上获得通过tee命令的所有内容的副本:

$ ls -1 | grep "odt$" | tee /dev/tty | wc -l

*...the list of files with names ending in odt...*
*4*

我们可以轻松地编写一个类似的函数:

const tee = arg => {
  console.log(arg);
  return arg;
};

If you are aware of the uses of the comma operator, you can be more concise and just write const tee = (arg) => (console.log(arg), arg)—do you see why? Check out https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Comma_Operator for the answer!

我们的日志函数简短而准确:它将接收一个参数,列出它,并将它传递给管道中的下一个函数。 我们可以看到它在以下代码中工作:

console.log(
  pipeline2(getDir, tee, filterOdt, tee, count)(
    "/home/fkereki/Documents"
  )
);

[...*the list of all the files in the directory*...]
[...*the list of files with names ending in odt*...]
*4*

我们可以做得更好如果我们tee()函数可以得到一个记录器函数作为参数,当我们在登录功能的方式第六章,生产函数——高阶函数; 这只是一个我们在那里设法做出的那种改变的问题。 同样好的设计理念再次被应用!

const tee2 = (arg, logger = console.log) => {
  logger(arg);
  return args;
};

Be aware that there might be a binding problem when passing console.log in that way. It would be safer to write console.log.bind(console) just as a precaution.

这个函数的工作方式与前面的tee()完全相同,尽管它允许我们在应用和测试时更加灵活。 然而,在我们的例子中,这只是一个特殊的增强。

现在,让我们考虑一个更通用的点击函数,它有更多的可能性,而不仅仅是做一点日志记录。

进入流

如果您愿意,您可以编写一个增强的tee()函数,该函数可以生成更多调试信息,可能将报告的数据发送到文件或远程服务,等等——您可以探索许多可能性。 你也可以探索一个更普遍的解决方案,tee()将只是一个特殊的情况,也将允许我们创建个性化的点击功能。 这可以从下图中看出:

Figure 8.2: Tapping allows you to apply a function so that you can inspect data as it flows through the pipeline

当使用管道时,您可能希望在其中间放置一个日志记录函数,或者您可能希望使用其他类型的侦听函数—可能用于在某处存储数据、调用服务或其他类型的副作用。 我们可以有一个通用的tap()函数,它允许我们在数据沿着管道移动时检查数据,其行为如下:

const tap = curry((fn, x) => (fn(x), x));

这可能是最具技巧代码的奖的候选人,所以让我们来解释一下。 我们希望生产函数,给出一个函数,fn(),和一个论点,x,将评估fn(x)(产生任何副作用我们可能感兴趣),但返回x(所以管道继续不受干扰)。 逗号操作符恰好具有这种行为:如果您编写类似于(a, b, c)的内容,JavaScript 将按顺序计算三个表达式,并使用最后一个值作为表达式的值。

The comma has several uses in JavaScript and you can read more about its usage as an operator at https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Comma_Operator.

现在,我们可以利用咖喱来产生几种不同的敲击功能。 我们在上一节中写的,tee(),也可以这样写:

const tee3 = tap(console.log);

顺便说一下,你也可以不用 curry 来写tap(),但是你不得不承认它失去了一些神秘感! 这在这里得到了证明:

const tap2 = fn => x => (fn(x), x);

这样做同样的工作,你就会意识到这样的局部套用我们看着的局部套用手工第七章,转换功能,局部套用和部分应用。 既然我们已经学习了如何使用管道,现在让我们通过回顾我们在前几章中看到的一些概念来学习一种不同的日志记录方法。

使用日志包装器

我们提到的第二个想法是基于我们在第 6 章Logging一节中提到的addLogging()函数Producing Functions—high - order Functions。 这个想法是用一些日志功能包装一个函数,这样,在进入时,参数将被打印出来,在退出时,函数的结果将显示出来:

pipeline2(
  addLogging(getDir), 
  addLogging(filterOdt), 
  addLogging(count))("/home/fkereki/Documents"));

entering getDir: /home/fkereki/Documents
exiting getDir: ...*the list of all the files in the directory*...
entering filterOdt: ...*the same list of files*...
exiting filterOdt: ...*the list of files with names ending in odt*...
entering count: ...*the list of files with names ending in odt*...
exiting count: 4

我们可以简单地验证pipeline()函数正在正确地执行它的操作——因此,无论函数产生什么,都会作为输入给该行中的下一个函数,我们也可以理解每次调用发生了什么。 当然,您不需要向管道中的每个函数添加日志记录:您可能会在怀疑发生错误的地方这样做。

既然我们已经了解了如何连接函数,现在让我们看看在 FP 中定义函数的一种非常常见的方法,pointfree style,这是您可能会遇到的。

Pointfree 风格

当你将函数连接在一起时,无论是管道方式还是复合方式,我们将在本章后面看到,你不需要任何中间变量来保存将成为下一个函数参数的结果:它们是隐式的。 类似地,您可以编写函数而不提及它们的参数; 这被称为 pointfree 样式。

Pointfree style is also called tacit programming and pointless programming by detractors! The term point itself means a function parameter, while pointfree refers to not naming those parameters.

定义 pointfree 函数

您可以很容易地识别一个无点函数定义,因为它不需要function关键字或=>符号。 让我们回顾一下之前我们在本章中编写的一些函数。 例如,原始文件计数函数的定义如下:

const countOdtFiles3 = path =>
  pipeTwo(pipeTwo(getDir, filterOdt), count)(path);

const countOdtFiles4 = path =>
  pipeTwo(getDir, pipeTwo(filterOdt, count))(path);

前面的代码可以重写如下:

const countOdtFiles3b = pipeTwo(pipeTwo(getDir, filterOdt), count);

const countOdtFiles4b = pipeTwo(getDir, pipeTwo(filterOdt, count));

新定义不引用新定义函数的形参。 您可以通过检查管道中的第一个函数(本例中为getDir())并查看它接收到的参数来推断这一点。 (使用类型签名,我们将在第 12 章,Building Better Containers - Functional Data Types中看到,这将对文档有很大帮助。) 同样,getLat()的定义是无点的:

const getLat = curry(getField)("lat");

等效的完整样式定义应该是什么? 您必须检查getField()函数(我们在重新访问示例部分中查看了这一点),以确定它期望一个对象作为参数。 然而,通过写以下内容来明确这种需求并没有多大意义:

const getLat = obj => curry(getField)("lat")(obj);

如果你愿意写下这一切,你可能希望坚持以下几点:

const getLat = obj => obj.lat;

那么,你完全可以不在乎咖喱!

转换为 pointfree 样式

另一方面,您最好暂停一分钟,尽量不要在无点代码中编写所有,无论它可能付出什么代价。 例如,考虑我们在第 6 章中返回的isNegativeBalance()函数,生产函数——高阶函数:

const isNegativeBalance = v => v.balance < 0;

我们能不能用一种毫无意义的方式来写这个? 是的,我们可以,我们将看到如何-但我不确定我们想要这样编码! 我们可以考虑构建一个由两个函数组成的管道:一个函数将从给定对象中提取余额,而另一个函数将检查余额是否为负数。 由于这个原因,我们将编写 balance-checking 函数的替代版本如下:

const isNegativeBalance2 = pipeline(getBalance, isNegative);

要从给定对象中提取 balance 属性,我们可以使用getField()和一点 curry,然后写如下内容:

const getBalance = curry(getField)("balance");

对于第二个函数,我们可以编写以下代码:

const isNegative = x => x < 0;

我们的零分进球去了! 相反,我们可以使用binaryOp()函数,也来自我们前面提到的同一章,再加上一些 curry,来编写以下内容:

const isNegative = curry(binaryOp(">"))(0);

我以另一种方式编写了测试(0>x而不是x<0),只是为了简化编码。 另一种选择是使用增强的功能我提到的方便实现部分第六章,生产函数高阶函数,这是一个那么复杂,如下:

const isNegative = binaryOpRight("<", 0);

最后,我们可以这样写:

const isNegativeBalance2 = pipeline(
  curry(getField)("balance"),
  curry(binaryOp(">"))(0)
);

或者,我们可以这样写:

const isNegativeBalance3 = pipeline(
  curry(getField)("balance"),
  binaryOpRight("<", 0)
);

你真的认为这是一种进步吗? 我们的新版本的isNegativeBalance()没有引用他们的论点,完全是无点的,但使用无点风格的想法应该有助于提高代码的清晰度和可读性,而不是产生混淆和不透明! 我怀疑会有人看到我们的新版本的函数,并认为它们比原始版本更有优势,因为任何可能的原因。

如果您发现您的代码变得越来越难以理解,而这仅仅是因为您打算使用无点编程,那么请停止并回滚您的更改。 记住我们在这本书中的原则:我们想要做 FP,但我们不想做得太过火——使用 pointfree 风格并不是必需的!

在本节中,我们学习了如何构建函数的管道—这是一项强大的技术。 然而,对于对象和数组,我们有另一种您可能已经使用过的特殊技术:链接。 现在让我们来看看这个。

链接和流畅的接口

当您处理对象或数组时,有另一种方法将多个调用的执行链接在一起:通过应用链接。 例如,当您使用数组时,如果您应用map()filter()方法,结果将是一个新数组,然后您可以进一步应用新的map()filter(),以此类推。 当我们在第 5 章的章节中定义range()函数时,我们使用了这样的方法。Declaratively - A Better Style:

const range = (start, stop) =>
  new Array(stop - start).fill(0).map((v, i) => start + i);

首先,我们创建了一个新数组; 然后,我们将fill()方法应用于它,这将就地更新数组(副作用)并返回更新后的数组,最后我们将map()方法应用于该数组。 后一个方法生成了一个新的数组,我们可以对它应用进一步的映射、过滤或任何其他可用的方法。

让我们看一个流畅的、链接的 api 的常见示例,然后考虑我们如何自己做到这一点。

流畅的 api 示例

流畅的 api 或接口中也使用这种风格的连续链接操作。 仅举一个例子,图形D3.js库(详见https://d3js.org/)经常使用这种样式。 下面的例子,摘自https://bl.ocks.org/mbostock/4063269,展示了它的作用:

 var node = svg
  .selectAll(".node")
  .data(pack(root).leaves())
  .enter()
  .append("g")
  .attr("class", "node")
  .attr("transform", function(d) { 
    return "translate(" + d.x + "," + d.y + ")"; 
  });

每个方法都在前一个对象上工作,并提供对一个新对象的访问,将来的方法调用将应用于这个新对象(如selectAll()append()方法)或更新当前对象(如attr()属性设置调用)。 这种样式并不是唯一的,其他一些知名的库(想到了 jQuery)也应用了它。

我们能自动化吗? 在这种情况下,答案是可能,但我宁愿不是。 在我看来,使用pipeline()compose()同样有效,并取得了相同的结果。 使用对象链,您只能返回新的对象或数组,或者方法可以应用到的内容。 (请记住,如果您正在处理标准类型,如字符串或数字,您不能向它们添加方法,除非您打乱了它们的原型,这是不推荐的!) 然而,使用复合,您可以返回任何类型的值; 唯一的限制是行中的下一个函数必须期望您提供的数据类型。

另一方面,如果您正在编写自己的 API,那么您可以通过让每个方法返回这个来提供一个流畅的接口——当然,除非它需要返回其他东西! 如果你是使用别人的 API,您也可以做一些欺骗通过使用一个代理,但要注意可能存在情况下,代理代码可能会失败:也许使用另一个代理,或者有些 getter 或 setter 造成问题,等等。

You may want to read up on proxy objects at https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/Proxy – they are very powerful and allow for interesting metaprogramming functionalities, but they can also trap you with technicalities and will also cause an (albeit slight) slowdown in your proxied code.

现在让我们看看如何将调用链接起来,以便将它们应用于任何类。

链接方法调用

让我们来看一个基本的例子。 我们可以有一个带有namelatlong属性的City类:

class City {
  constructor(name, lat, long) {
    this.name = name;
    this.lat = lat;
    this.long = long;
  }

  getName() {
    return this.name;
  }

  setName(newName) {
    this.name = newName;
  }

  setLat(newLat) {
    this.lat = newLat;
  }

  setLong(newLong) {
    this.long = newLong;
  }

  getCoords() {
    return [this.lat, this.long];
  }
}

这是一个带有几个方法的通用类; 一切都很正常。 我们可以像下面这样使用这个类,并提供关于我的家乡乌拉圭蒙得维的亚的详细信息:

let myCity = new City("Montevideo, Uruguay", -34.9011, -56.1645);
console.log(myCity.getCoords(), myCity.getName());
// [ -34.9011, -56.1645 ] 'Montevideo, Uruguay'

如果我们希望以一种流畅的方式处理 setter,我们可以设置一个代理来检测此类调用并提供缺失的return this。 我们怎么做呢? 如果原始方法没有返回任何东西,JavaScript 将默认包含一个return undefined语句,这样我们就可以检测该方法是否返回了return undefined,并替换return this。 当然,这是一个问题:如果我们有一个方法可以根据其语义合法地返回undefined值,我们会怎么做? 我们可以有一些例外列表来告诉我们的代理在这些情况下不要添加任何东西,但我们不讨论这个。

处理程序的代码如下所示。 每当调用对象的方法时,就会隐式调用get并捕获它。 如果要获取一个函数,则用自己的代码将其包装起来,调用原始方法,然后决定是否返回其值或对代理对象的引用。 如果我们没有得到一个函数,那么我们将返回被请求属性的值。 我们的chainify()函数将负责将处理程序分配给一个对象并创建所需的代理:

const getHandler = {
  get(target, property, receiver) {
    if (typeof target[property] === "function") {
      // *requesting a method? return a wrapped version*
      return (...args) => {
        const result = target[property](...args);
        return result === undefined ? receiver : result;
      };
    } else {
      // *an attribute was requested - just return it*
      return target[property];
    }
  },
};
const chainify = obj => new Proxy(obj, getHandler);

我们需要检查调用的get()是用于函数还是用于属性。 在第一种情况下,我们用额外的代码包装方法,以便它执行该方法,然后返回其结果(如果有的话)或对对象本身的引用。 在第二种情况下,我们只返回属性,这是预期的行为。

通过这个,我们可以链化任何对象,这样我们就有机会检查任何被调用的方法。 在我写这篇文章的时候,我现在住在印度的浦那,所以让我们来反思一下这种变化:

myCity = chainify(myCity);

console.log(myCity
  .setName("Pune, India")
  .setLat(18.5626)
  .setLong(73.8087)
  .getCoords(), 
  myCity.getName());

// [ 18.5626, 73.8087 ] 'Pune, India'

请注意以下几点:

  • 我们把myCity变成了它自己的代理版本。
  • 我们以流畅的方式调用了几个 setter,它们工作得很好,因为我们的代理负责为下面的调用提供值。
  • getCoords()getName()的调用被拦截,但没有做任何特殊的操作,因为它们已经返回了一个值。

以一种被束缚的方式工作值得吗? 这取决于你——但要记住,在某些情况下,这种方法可能会失败,所以要小心! 现在,让我们继续学习组合,这是连接函数的另一种最常见的方法。

作曲

作曲与流水线非常相似,但其根源是数学理论。 复合的概念很简单——一个函数调用的序列,其中一个函数的输出是下一个函数的输入——但是顺序与流水线中的顺序相反。 所以,如果你有一系列的函数,从左到右,当流水线操作时,第一个要应用的函数是最左边的,但当你使用复合操作时,你从最右边的开始。

让我们进一步研究这个问题。 当您定义的复合,说,三个功能(f∘g∘h),并应用这篇作文【x T6】,这相当于写【显示】f(g(【病人】h(x)))。 需要注意的是,与管道操作一样,要应用的第一个函数(实际上是列表中的最后一个函数)的属性可以是任何值,但所有其他函数必须是一元。 同时,除了不同的序列功能评估,写在《外交政策》的一个重要工具,因为它也抽象实现细节(把你专注于你所需要完成的,而不是具体实现),从而让你在更声明式的方式工作。

If it helps, you can read (f ∘ g ∘ h) as f after g after h, so that it becomes clear that h is the first function to be applied, while f is the last.

考虑到它与流水线的相似之处,实现复合并不会非常困难,这也就不足为奇了。 然而,仍然会有一些重要和有趣的细节。 在继续使用高阶函数之前,让我们看一些合成的例子,并以测试合成函数的一些考虑为结束。

一些作文的例子

您可能不会感到惊讶,但是我们已经看到了几个组合的示例—或者,至少,在这些示例中,我们实现的解决方案在功能上等同于使用组合。 让我们回顾其中一些,并使用一些新的示例。

一元操作符

逻辑否定一个函数第六章,生产函数高阶函数,我们编写了一个not()功能,给另一个函数,将逻辑转化的结果。 我们用这个函数来否定对负余额的检查; 示例代码如下:

const not = fn => (...args) => !fn(...args);

const positiveBalance = not(isNegativeBalance);

在同一章的另一节将操作转换为函数中,我留给你的挑战是编写一个unaryOp()函数,它将提供与普通 JavaScript 操作符等效的一元函数。 如果你遇到了这样的挑战,你应该能够写出如下内容:

const logicalNot = unaryOp("!");

假设存在一个compose()函数,你也可以这样写:

const positiveBalance = compose(logicalNot, isNegativeBalance);

你喜欢哪一个? 这是一个品味问题,真的——但我认为第二个版本更清楚地说明了我们试图做什么。 使用not()函数,您必须检查它做什么,以便理解通用代码。 对于组合,您仍然需要知道logicalNot()是什么,但全局结构是开放的。

再来看一个同样的例子,你可以在同一章中获得与倒置结果部分相同的结果。 回想一下,我们有一个可以根据西班牙语规则比较字符串的函数,但我们想要颠倒比较的意义,以便按降序排序:

const changeSign = unaryOp("-");

palabras.sort(compose(changeSign, spanishComparison));

这段代码产生了与前面排序问题相同的结果,但是逻辑表达得更清楚,用的代码更少:一个典型的 FP 结果! 让我们通过回顾前面讲过的另一个任务来看看更多的组合函数的例子。

计算文件

我们也可以回到我们的管道。 我们已经编写了一个单行函数来计数给定路径下的odt文件:

const countOdtFiles2 = path => count(filterOdt(getDir(path)));

忽略(至少目前)这段代码不像我们后来开发的管道版本那么清晰的观察,我们也可以用 composition 来编写这个函数:

const countOdtFiles2b = path => compose(count, filterOdt, getDir)(path);

countOdtFiles2b("/home/fkereki/Documents"); // *4, no change here*

We could have also written the function in pointfree fashion, without specifying the path parameter, with const countOdtFiles2 = compose(count, filterOdt, getDir), but I wanted to parallel the previous definition.

也可以看到以一行方式编写的:

compose(count, filterOdt, getDir)("/home/fkereki/Documents");

即使它不是一样清楚管道版本(这只是我的观点,我喜欢这可能是偏见的 Linux !),这个声明实现明确表示,我们依靠结合三个不同的函数得到的结果很容易看到和适用于构建大型解决方案的想法更简单的代码片断。

让我们来看另一个示例,它被设计成尽可能多地组合函数。

寻找独特的单词

最后,让我们来看另一个例子,我同意,它也可以用于管道。 假设您有一些文本,并想从其中提取所有独特的单词:您将如何进行这一操作? 如果你按照步骤来考虑(而不是尝试着在一个单一的步骤中创建一个完整的解决方案),你可能会得到一个类似这样的解决方案:

  1. 忽略所有非字母字符
  2. 所有的字都用大写

  3. 把课文分成单词

  4. 创造一组单词

Why a set? Because it automatically discards repeated values; check out https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/Set for more on this. By the way, we will be using the Array.from() method to produce an array out of our set; see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/from for more information.

现在,使用 FP,让我们来解决每个问题:

const removeNonAlpha = str => str.replace(/[^a-z]/gi, " ");
const toUpperCase = demethodize(String.prototype.toUpperCase);
const splitInWords = str => str.trim().split(/\s+/);
const arrayToSet = arr => new Set(arr);
const setToList = set => Array.from(set).sort();

使用这些函数,结果可以写成:

const getUniqueWords = compose(
  setToList,
 arrayToSet,
 splitInWords,
 toUpperCase,
 removeNonAlpha
);

由于您不需要看到任何组合函数的参数,因此实际上也不需要显示getUniqueWords()的参数,因此在这种情况下使用 pointfree 样式是很自然的。

现在,让我们来测试函数。 要做到这一点,让我们先将这个函数应用到两个句子的亚伯拉罕·林肯在葛底斯堡的地址(我们已经用于一个例子在映射和压扁- flatMap第五章,编程以声明的方式——一个更好的风格),打印出 43 个不同的单词(相信我, 我数过了!)

const GETTYSBURG_1_2 = `Four score and seven years ago 
our fathers brought forth on this continent, a new nation, 
conceived in liberty, and dedicated to the proposition 
that all men are created equal. Now we are engaged in a 
great civil war, testing whether that nation, or any 
nation so conceived and so dedicated, can long
endure.`;

console.log(getUniqueWords(GETTYSBURG_1_2));
[ 'A',
'AGO',
'ALL',
'AND',
'ANY',
'ARE',
'BROUGHT',
'CAN',
'CIVIL',
.
.
.
'TESTING',|
'THAT',
'THE',
'THIS',
'TO',
'WAR',
'WE',
'WHETHER',
'YEARS' ]

当然,您可以用更短的方式编写getUniqueWords(),但我要说的是,通过几个更短的步骤组成您的解决方案,您的代码会更清晰,更容易理解。 然而,如果你想说管道解决方案似乎更好,那么这只是一个意见问题!

现在,我们已经看了许多函数组合的例子,但是还有另一种方法来管理它——使用高阶函数。

与高阶函数组合

很明显,手工编写可以很容易地以类似于流水线的方式完成。 例如,我们之前编写的唯一的单词计数函数可以用简单的 JavaScript 风格编写:

const getUniqueWords1 = str => {
  const str1 = removeNonAlpha(str);
  const str2 = toUpperCase(str1);
  const arr1 = splitInWords(str2);
  const set1 = arrayToSet(arr1);
  const arr2 = setToList(set1);
  return arr2;
};

或者,它可以用一行风格写得更简洁(也更晦涩!):

const getUniqueWords2 = str =>
  setToList(arrayToSet(splitInWords(toUpperCase(removeNonAlpha(str)))));

console.log(getUniqueWords2(GETTYSBURG_1_2));
// [ 'A', 'AGO', 'ALL', 'AND', ... 'WAR', 'WE', 'WHETHER', 'YEARS' ]

这很好,但就像我们在学习流水线时做的那样,让我们去寻找一个更通用的解,它不需要在每次我们想要组合其他函数时都写一个特殊的函数。

组合两个函数是相当容易的,需要对我们的pipeTwo()函数做一个小的改变,我们在本章早些时候看过:

const pipeTwo = (f, g) => (...args) => g(f(...args));
const composeTwo = (f, g) => (...args) => f(g(...args));

唯一的区别是,使用管道时,首先应用最左边的函数,而使用组合时,首先应用最右边的函数。 这一变化表明,我们可以使用第 7 章参数阶一节中的flipTwo()高阶函数——curry 和 Partial Application。 清晰吗? 以下是代码:

const composeTwoByFlipping = flipTwo(pipeTwo);

在任何情况下,如果我们想组合两个以上的函数,我们也可以利用结合律来写如下内容:

const getUniqueWords3 = composeTwo(
  setToList,
  composeTwo(
    arrayToSet,
    composeTwo(splitInWords, composeTwo(toUpperCase, removeNonAlpha))
  )
);

console.log(getUniqueWords3(GETTYSBURG_1_2));
// [ 'A', 'AGO', 'ALL', 'AND', ... 'WAR', 'WE', 'WHETHER', 'YEARS' ] *OK again*

即使这样可行,我们还是要寻找一个更好的解决方案——我们至少可以提供两个。 第一种方法与流水线和组合工作的相反有关。 在流水线操作时从左到右应用函数,在组合时从右到左应用函数。 因此,我们可以通过反转函数的顺序并执行流水线操作来实现与组合相同的结果; 一个非常实用的解决方案,我真的很喜欢! 具体情况如下:

const compose = (...fns) => pipeline(...(fns.reverse()));

console.log(
  compose(
    setToList,
    arrayToSet,
    splitInWords,
    toUpperCase,
    removeNonAlpha
  )(GETTYSBURG_1_2)
);

/*
 [ 'A', 'AGO', 'ALL', 'AND', ... 'WAR', 'WE', 'WHETHER', 'YEARS' ] 
 *OK once more* */

唯一棘手的部分是在调用pipeline()之前使用扩频运算符。 在对fns数组进行反转之后,为了正确地调用pipeline(),必须将其元素展开。

另一种解决方案(声明性更少)是使用reduceRight(),这样我们就可以颠倒处理函数的顺序,而不是颠倒函数列表:

const compose2 = (...fns) => fns.reduceRight(pipeTwo);

console.log(
  compose2(
    setToList,
    arrayToSet,
    splitInWords,
    toUpperCase,
    removeNonAlpha
  )(GETTYSBURG_1_2)
);

/*
 [ 'A', 'AGO', 'ALL', 'AND', ... 'WAR', 'WE', 'WHETHER', 'YEARS' ] 
 *still OK* */

为什么/如何这样做? 让我们跟踪这个调用的内部工作。 为了更清楚,我们可以将pipeTwo()的定义替换为:

const compose2b = (...fns) => 
 fns.reduceRight((f,g) => (...args) => g(f(...args)));

让我们仔细看看:

  • 由于没有提供初始值,f()removeNonAlpha()g()toUpperCase(),所以第一个中间结果是一个函数(...args) => toUpperCase(removeNonAlpha(...args)); 让我们称之为step1()
  • 第二次,f()是上一步的step1()g()splitInWords(),所以新的结果是一个函数(...args) => splitInWords(step1(...args))),我们可以调用step2()
  • 第三次,以同样的方式,我们得到(...args) => arrayToSet(step2(...args)))),我们称之为step3()
  • 最后,得到一个函数(...args) => setToList(step3(...args)); 我们叫它step4()

最后的结果是一个函数,它接收(...args),然后开始应用removeNonAlpha(),然后应用toUpperCase(),以此类推,最后应用setToList()

令人惊讶的是,我们也可以用reduce()来实现这一功能——你知道为什么吗? 其原因与我们之前所做的类似,所以我们将把这个留给你们作为练习:

const compose3 = (...fns) => fns.reduce(composeTwo);

After working out how compose3() works, you might want to write a version of pipeline() that uses reduceRight(), just for symmetry, to round things out!

在本节结束时,我们将提到,在测试和调试方面,我们可以应用与流水线相同的思想; 然而,记住作文是反方向! 提供更多的同类示例不会给我们带来任何好处,因此让我们考虑一种使用对象时链接操作的通用方法,并看看它是否有利,鉴于我们不断增长的 FP 知识和经验。

测试由函数

在本章的最后,让我们考虑一下管道或组合函数的测试。 鉴于这两种操作的机制是相似的,我们将查看这两种操作的示例。 它们没有区别,除了由于从左到右或从右到左的函数求值顺序而导致的逻辑差异。

当谈到管道时,我们可以从如何测试pipeTwo()函数开始,因为设置将类似于pipeline()。 我们需要创建一些间谍,检查他们是否被调用了正确的次数,是否每次都收到了正确的论点。 我们会安排间谍让他们提供一个已知的答案。 通过这样做,我们可以检查一个函数的输出是否成为管道中下一个函数的输入:

var fn1, fn2;

describe("pipeTwo", function() {
  beforeEach(() => {
    fn1 = () => {};
    fn2 = () => {};
  });

  it("works with single arguments", () => {
    spyOn(window, "fn1").and.returnValue(1);
    spyOn(window, "fn2").and.returnValue(2);

    const pipe = pipeTwo(fn1, fn2);
    const result = pipe(22);

    expect(fn1).toHaveBeenCalledTimes(1);
    expect(fn2).toHaveBeenCalledTimes(1);
    expect(fn1).toHaveBeenCalledWith(22);
    expect(fn2).toHaveBeenCalledWith(1);
    expect(result).toBe(2);
  });

  it("works with multiple arguments", () => {
    spyOn(window, "fn1").and.returnValue(11);
    spyOn(window, "fn2").and.returnValue(22);

    const pipe = pipeTwo(fn1, fn2);
    const result = pipe(12, 4, 56);

    expect(fn1).toHaveBeenCalledTimes(1);
    expect(fn2).toHaveBeenCalledTimes(1);
    expect(fn1).toHaveBeenCalledWith(12, 4, 56);
    expect(fn2).toHaveBeenCalledWith(11);
    expect(result).toBe(22);
  });
});

这里没有太多要测试的,因为我们的函数总是接收两个函数作为参数。 这两个测试之间的唯一区别是,一个显示了应用于单个参数的管道,而另一个显示了应用于多个参数的管道。

接下来看pipeline(),测试将非常相似。 但是,我们可以添加一个单函数管道测试(边界情况!)和一个包含四个函数的测试:

describe("pipeline", function() {
  beforeEach(() => {
    fn1 = () => {};
    fn2 = () => {};
    fn3 = () => {};
    fn4 = () => {};
  });

  it("works with a single function", () => {
    spyOn(window, "fn1").and.returnValue(11);

    const pipe = pipeline(fn1);
    const result = pipe(60);

    expect(fn1).toHaveBeenCalledTimes(1);
    expect(fn1).toHaveBeenCalledWith(60);
    expect(result).toBe(11);
  });

  // we omit here tests for 2 functions,
  // which are similar to those for pipeTwo()

  it("works with 4 functions, multiple arguments", () => {
    spyOn(window, "fn1").and.returnValue(111);
    spyOn(window, "fn2").and.returnValue(222);
    spyOn(window, "fn3").and.returnValue(333);
    spyOn(window, "fn4").and.returnValue(444);

    const pipe = pipeline(fn1, fn2, fn3, fn4);
    const result = pipe(24, 11, 63);

    expect(fn1).toHaveBeenCalledTimes(1);
    expect(fn2).toHaveBeenCalledTimes(1);
    expect(fn3).toHaveBeenCalledTimes(1);
    expect(fn4).toHaveBeenCalledTimes(1);
    expect(fn1).toHaveBeenCalledWith(24, 11, 63);
    expect(fn2).toHaveBeenCalledWith(111);
    expect(fn3).toHaveBeenCalledWith(222);
    expect(fn4).toHaveBeenCalledWith(333);
    expect(result).toBe(444);
  });
});

最后,对于组合,样式是相同的(除了函数求值的顺序相反),所以让我们看看一个测试,在这里,我只是在前面的测试中改变了函数的顺序:

describe("compose", function() {
  beforeEach(() => {
    fn1 = () => {};
    fn2 = () => {};
    fn3 = () => {};
    fn4 = () => {};
  });

  // other tests omitted...

  it("works with 4 functions, multiple arguments", () => {
    spyOn(window, "fn1").and.returnValue(111);
    spyOn(window, "fn2").and.returnValue(222);
    spyOn(window, "fn3").and.returnValue(333);
    spyOn(window, "fn4").and.returnValue(444);

    const pipe = compose(fn4, fn3, fn2, fn1);
    const result = pipe(24, 11, 63);

    expect(fn1).toHaveBeenCalledTimes(1);
    expect(fn2).toHaveBeenCalledTimes(1);
    expect(fn3).toHaveBeenCalledTimes(1);
    expect(fn4).toHaveBeenCalledTimes(1);

    expect(fn1).toHaveBeenCalledWith(24, 11, 63);
    expect(fn2).toHaveBeenCalledWith(111);
    expect(fn3).toHaveBeenCalledWith(222);
    expect(fn4).toHaveBeenCalledWith(333);
    expect(result).toBe(444);
  });
});

最后,为了测试chainify()函数,我选择使用我之前创建的City对象——我不想与 mock、stub、spies 等混淆; 我想确保代码在正常情况下工作:

class City {
    // *as above*
}

var myCity;

describe("chainify", function() {
  beforeEach(() => {
    myCity = new City("Montevideo, Uruguay", -34.9011, -56.1645);
    myCity = chainify(myCity);
  });

  it("doesn't affect get functions", () => {
    expect(myCity.getName()).toBe("Montevideo, Uruguay");
    expect(myCity.getCoords()[0]).toBe(-34.9011);
    expect(myCity.getCoords()[1]).toBe(-56.1645);
  });

  it("doesn't affect getting attributes", () => {
    expect(myCity.name).toBe("Montevideo, Uruguay");
    expect(myCity.lat).toBe(-34.9011);
    expect(myCity.long).toBe(-56.1645);
  });

  it("returns itself from setting functions", () => {
    expect(myCity.setName("Other name")).toBe(myCity);
    expect(myCity.setLat(11)).toBe(myCity);
    expect(myCity.setLong(22)).toBe(myCity);
  });

  it("allows chaining", () => {
    const newCoords = myCity
      .setName("Pune, India")
      .setLat(18.5626)
      .setLong(73.8087)
      .getCoords();

    expect(myCity.name).toBe("Pune, India");
    expect(newCoords[0]).toBe(18.5626);
    expect(newCoords[1]).toBe(73.8087);
  });
});

所有这些测试的最终结果可以在下面的截图中看到:

Figure 8.3: A successful run of testing for composed functions

我们可以看到,我们所有的测试都成功通过了; 好!

在这里,我们已经了解了通过使用管道、链接和组合来构建函数可以使用的重要方法。 这工作得很好,但我们将看到,在一个特殊的情况下,您的代码的性能可能会受到影响,我们将需要一个新的方法来处理合成:转导

电转换

现在,让我们考虑一个 JavaScript 中的性能问题,当我们处理大型数组并应用几个 map/filter/reduce 操作时,会发生这个问题。 如果您从一个数组开始并应用这样的操作(通过链接,正如我们在本章前面看到的),您将得到所需的结果,但许多中间数组会被创建、处理和丢弃——这会导致延迟。 如果你处理短阵列,额外的时间不会产生影响,但如果你处理更大的数组(如一个大数据的过程,也许在节点,你处理大型数据库查询的结果),那么你将有理由去寻找一些优化。 我们将通过学习一种新的功能组合工具:转导来做到这一点。

首先,让我们创建一些函数和数据。 我们将使用一个基本无意义的例子,因为我们不关注实际操作,而是关注一般的过程。 我们将从一些过滤函数和映射开始:

const testOdd = x => x % 2 === 1;
const testUnderFifty = x => x < 50;
const duplicate = x => x + x;
const addThree = x => x + 3;

现在,让我们将这些映射和过滤器应用到一个数组中。 首先,去掉偶数,重复保留的奇数,去掉大于 50 的结果,最后给所有结果加 3:

const myArray = [22, 9, 60, 24, 11, 63];

const a0 = myArray
  .filter(testOdd)
  .map(duplicate)
  .filter(testUnderFifty)
  .map(addThree);

/*
[ 21, 25 ]
*/

下图显示了这一系列操作的工作原理:

Figure 8.4: Chaining map/filter/reduce operations causes intermediate arrays to be created and later discarded

在这里,我们可以看到将几个 map/filter/reduce 操作链接在一起会导致创建中间数组(本例中是三个),然后丢弃——对于大型数组,这可能会变得很麻烦。

我们如何优化它? 这里的问题是,处理将第一个转换应用到输入数组; 然后,将第二个变换应用于得到的数组; 然后是第三个,以此类推。 另一种解决方案是取输入数组的第一个元素,并依次对其应用所有转换。 然后,您需要获取输入数组的第二个元素并对其应用所有转换,然后获取第三个元素,以此类推。 在一种伪代码中,以下方案之间的区别:

for each transformation to be applied:
    for each element in the input list:
        apply the transformation to the element

根据这个逻辑,我们进行一个又一个转换,将其应用到每个列表并生成一个新的列表。 这将需要编制若干中间清单。 备选方案如下:

for each element in the input list:
    for each transformation to be applied:
        apply the transformation to the element

在这种变体中,我们逐个元素地对其应用所有的转换,这样我们就得到了最终的输出列表,而没有中间的任何一个。

现在的问题是如何对变换进行转置; 我们怎么做呢? 我们在第 5 章Programming Declaratively - A Better Style中看到了这个关键概念,我们可以根据reduce()定义map()filter()。 通过使用这些定义,而不是一系列不同的函数,我们将在每一步应用相同的操作(reduce),这就是秘诀! 如下图所示,我们通过组合所有的转换来改变求值顺序,这样它们就可以一次性应用,而不需要任何中间数组:

Figure 8.5: By applying transducers, we will change the order of evaluation but get the same result

与其应用第一个 reduce 操作,将其结果传递给第二个,将其结果传递给第三个,等等,我们将把所有的 reduce 函数组合成一个单独的函数! 让我们来分析。

作曲还原剂

本质上,我们想要的是将每个函数(testOdd()duplicate()等等)转换为一个将调用下面的 reducer 的还原操作。 几个高阶函数会有帮助; 一个用于映射函数,另一个用于过滤函数。 这样,一个操作的结果将被传递给下一个操作,从而避免了中间数组:

const mapTR = fn => reducer => (accum, value) => reducer(accum, fn(value));

const filterTR = fn => reducer => (accum, value) =>
  fn(value) ? reducer(accum, value) : accum;

这两个转换函数是转换器:接受一个还原函数并返回一个新的还原函数的函数。

The word transduce comes from Latin, meaning transform, transport, convert, change over, and is applied in many different fields, including biology, psychology, machine learning, physics, electronics, and more.

我们如何使用这些传感器? 我们可以编写如下代码,尽管稍后我们需要一个更抽象、更通用的版本:

const testOddR = filterTR(testOdd);
const testUnderFiftyR = filterTR(testUnderFifty);
const duplicateR = mapTR(duplicate);
const addThreeR = mapTR(addThree);

我们最初的四个函数都进行了转换,因此它们将计算结果并调用减数函数进一步处理。 例如,addThreeR()将在其输入上增加 3,并将增加的值传递给下一个 reducer,在本例中是addToArray()。 这将构建最终的结果数组。 现在,我们可以把整个变换写成一个步骤:

const addToArray = (a, v) => {
  a.push(v);
  return a;
};

const a1 = myArray.reduce(
  testOddR(duplicateR(testUnderFiftyR(addThreeR(addToArray)))),
  []
);

/*
[ 21, 25 ]
*/

这很拗口,但很有效! 然而,我们可以通过使用compose()函数来简化代码:

const makeReducer1 = (arr, fns) =>
  arr.reduce(compose(...fns)(addToArray), []);

const a2 = makeReducer1(myArray, [
  testOddR,
 duplicateR,
 testUnderFiftyR,
 addThreeR,
]);

/*
[ 21, 25 ]
*/

代码是相同的,但是要特别注意compose(...fns)(addToArray)表达式:我们组合了所有的映射和过滤函数(最后一个是addToArray)来构建输出。 然而,这并不像我们想要的那样普遍:为什么我们必须创建一个数组? 为什么我们不能有一个不同的最终约化函数? 我们可以再一般化一点。

对所有约化子的推广

为了能够使用各种各样的减震器并产生它们所创造的任何结果,我们需要做一些小的改变。 想法很简单:让我们修改我们的makeReducer()函数,以便它接受累加器的最终减速器和起始值:

const makeReducer2 = (arr, fns, reducer = addToArray, initial = []) =>
  arr.reduce(compose(...fns)(reducer), initial);

const a3 = makeReducer2(myArray, [
  testOddR,
  duplicateR,
  testUnderFiftyR,
  addThreeR,
]);

/*
[ 21, 25 ]
*/

为了使这个函数更有用,我们指定了数组构建函数(并将[]作为累加器的起始值),这样如果跳过这两个参数,就会得到一个生成数组的减速器。 现在,让我们看看另一个选项:我们不使用数组,而是计算所有映射和过滤后的结果数的和:

const sum = makeReducer2(
  myArray,
  [testOddR, duplicateR, testUnderFiftyR, addThreeR],
  (acc, value) => acc + value,
 0
);

/*
46
*/

通过使用传感器,我们已经能够优化 map/filter/reduce 操作序列,从而输入数组被处理一次并直接产生输出结果(无论是数组还是单个值),而无需创建任何中间数组; 一个好收获!

总结

在本章中,我们学习了如何通过流水线和组合的不同方式连接其他函数来创建新函数。 我们还研究了流畅的接口,它应用了链接和转换,这是一种组成 reducer 以获得更高速度的转换序列的方法。 有了这些方法,您就能够从现有的函数中创建新的函数,并以我们喜欢的声明式方式进行编程。

第 9 章设计函数-递归中,我们将继续学习函数设计和递归的用法,递归是函数编程的基本工具,允许非常干净的算法设计。

问题

8.1。 标题大写:让我们定义标题样式的大写,这样可以确保一个句子除了每个单词的第一个字母外都是小写的。 (这种风格的真正定义更复杂,所以让我们简化一下这个问题。) 编写一个headline(sentence)函数,它将接收一个字符串作为参数,并返回一个适当大写的版本。 空间独立的单词。 通过组合更小的函数来构建这个函数:

 console.log(headline("Alice's ADVENTURES in WoNdErLaNd")); 
     // Alice's Adventures In Wonderland

8.2。 待处理任务:web 服务返回如下结果,显示每个人分配的所有任务。 任务可能已经完成(done===true)或正在等待(done===false)。 您的目标是为给定的人员生成一个包含待处理任务 id 的数组,该数组由名称标识,应该与responsible字段匹配。 通过使用组合或管道来解决这个问题:

const allTasks = {
         date: "2017-09-22",
         byPerson: [
             {
                 responsible: "EG",
                 tasks: [
                     {id: 111, desc: "task 111", done: false},
                     {id: 222, desc: "task 222", done: false}
                 ]
             },
             {
                 responsible: "FK",
                 tasks: [
                     {id: 555, desc: "task 555", done: false},
                     {id: 777, desc: "task 777", done: true},
                     {id: 999, desc: "task 999", done: false}
                 ]
             },
             {
                 responsible: "ST",
                 tasks: [{id: 444, desc: "task 444", done: true}]
             }
         ]
     };

例如,如果您正在寻找的人没有出现在 web 服务结果中,请确保您的代码不会抛出异常!

In the last chapter of this book, Chapter 12, Building Better Containers – Functional Data Types, we will look at a different way of solving this by using Maybe monads. This greatly simplifies the problem of dealing with possibly missing data.

8.3。 :假设你正在浏览一些旧的代码,你发现一个类似下面的函数。 (我将名称保持模糊和抽象,以便您可以关注结构而不是实际的功能)。 你能把它转换成无点风格吗?

function getSomeResults(things) {
    return sort(group(filter(select(things))));
};

8.4。 未检出杂质? 你注意到我们写的addToArray()函数实际上是不纯的吗? (如果你不相信,请查看第 4 章参数突变章节!) 我们这样写是不是更好? 我们应该试试吗?

const addToArray = (a, v) => [...a, v];

8.5。 无用转导? 我们使用传感器来简化任何序列的映射和滤波操作。 如果你只有map()操作,你会需要这个吗? 如果你只有filter()操作会怎样?*