1
0
mirror of https://github.com/apachecn/eloquent-js-3e-zh.git synced 2025-05-23 20:02:20 +00:00
wizardforcel b7e3ed3f20 5.
2018-05-02 09:39:29 +08:00

25 KiB
Raw Blame History

五、高阶函数

开发大型程序通常需要耗费大量财力和物力这绝不仅仅是因为构建程序所花费时间的问题。大型程序的复杂程度总是很高而这些复杂性也会给开发人员带来不少困扰而程序错误或缺陷bug往往就是这些时候引入的。大型程序为这些缺陷提供了良好的藏身之所因此我们更加难以在大型程序中找到它们。

让我们简单回顾一下前言当中的两个示例。其中第一个程序包含了6行代码并可以直接运行。

let total = 0, count = 1;
while (count <= 10) {
  total += count;
  count += 1;
}
console.log(total);

第二个程序则依赖于外部函数才能执行,且只有一行代码。

console.log(sum(range(1, 10)));

哪一个程序更有可能含有缺陷呢?

如果算上sum和range两个函数的代码量显然第二个程序的代码量更大。不过我仍然觉得第二个程序包含缺陷的可能性比第一个程序低。

之所以这么说的原因是,第二个程序编写的代码很好地表达了我们期望解决的问题。对于计算一组数字之和这个操作来说,我们关注的是计算范围和求和运算,而不是循环和计数。

sum和range这两个函数定义的操作当然会包含循环、计数和其他一些操作。但相比于将这些代码直接写到一起这种表述方式更为简单同时也易于避免错误。

5.1 抽象

在程序设计中,我们把这种编写代码的方式称为抽象。抽象可以隐藏底层的实现细节,从更高(或更加抽象)的层次看待我们要解决的问题。

举个例子,比较一下这两份豌豆汤的食谱:

按照每人一杯的量将脱水豌豆放入容器中。倒水直至浸没豌豆然后至少将豌豆浸泡12个小时。将豌豆从水中取出沥干倒入煮锅中按照每人四杯水的量倒入水。将食材盖满整个锅底并慢煮2个小时。按照每人半个的量加入洋葱用刀切片然后放入豌豆中。按照每人一根的量加入芹菜用刀切片然后放入豌豆当中。按照每人一根的量放入胡萝卜用刀切片然后放入豌豆中。最后一起煮10分钟以上即可。

第二份食谱:

一个人的量:一杯脱水豌豆、半个切好的洋葱、一根芹菜和一根胡萝卜。

将豌豆浸泡12个小时。按照每人四杯水的量倒入水然后用文火煨2个小时。加入切片的蔬菜煮10分钟以上即可。

相比第一份食谱,第二份食谱更简短且更易于理解。但你需要了解一些有关烹调的术语:浸泡、煨、切片,还有蔬菜。

在编程的时候,我们不能期望所有功能都是现成的。因此,你可能就会像第一份食谱那样编写你的程序,逐个编写计算机需要执行的代码和步骤,而忽略了这些步骤之上的抽象概念。

在编程时,注意您的抽象级别什么时候过低,是一项非常有用的技能。

5.2 重复的抽象

我们已经了解的普通函数就是一种很好的构建抽象的工具。但有些时候,光有函数也不一定能够解决我们的问题。

程序以给定次数执行某些操作很常见。 你可以为此写一个for循环,就像这样:

for (let i = 0; i < 10; i++) {
  console.log(i);
}

我们是否能够将“做某件事N次”抽象为函数? 编写一个调用console.log N次的函数是很容易的。

function repeatLog(n) {
  for (let i = 0; i < n; i++) {
    console.log(i);
  }
}

但如果我们想执行打印数字以外的操作该怎么办呢?我们可以使用函数来定义我们想做的事,而函数也是值,因此我们可以将期望执行的操作封装成函数,然后传递进来。

function repeat(n, action) {
  for (let i = 0; i < n; i++) {
    action(i);
  }
}

repeat(3, console.log);
// → 0
// → 1
// → 2

您不必将预定义的函数传递给repeat。 通常情况下,您希望原地创建一个函数值。

let labels = [];
repeat(5, i => {
  labels.push(`Unit ${i + 1}`);
});
console.log(labels);
// → ["Unit 1", "Unit 2", "Unit 3", "Unit 4", "Unit 5"]

这个结构有点像for循环 - 它首先描述了这种循环,然后提供了一个主体。 但是,主体现在写为一个函数值,它被包裹在repeat调用的括号中。 这就是它必须用右小括号和右大括号闭合的原因。 在这个例子中,主体是单个小表达式,你也可以省略大括号并将循环写成单行。

5.3 高阶函数

如果一个函数操作其他函数,即将其他函数作为参数或将函数作为返回值,那么我们可以将其称为高阶函数。因为我们已经看到函数就是一个普通的值,那么高阶函数也就不是什么稀奇的概念了。高阶这个术语来源于数学,在数学当中,函数和值的概念有着严格的区分。

我们可以使用高阶函数对一系列操作和值进行抽象。高阶函数有多种表现形式。比如你可以使用高阶函数来新建另一些函数。

function greaterThan(n) {
  return m => m > n;
}
let greaterThan10 = greaterThan(10);
console.log(greaterThan10(11));
// → true

你也可以使用高阶函数来修改其他的函数。

function noisy(f) {
  return (...args) => {
    console.log("calling with", args);
    let result = f(...args);
    console.log("called with", args, ", returned", result);
    return result;
  };
}
noisy(Math.min)(3, 2, 1);
// → calling with [3, 2, 1]
// → called with [3, 2, 1] , returned 1

你甚至可以使用高阶函数来实现新的控制流。

function unless(test, then) {
  if (!test) then();
}
repeat(3, n => {
  unless(n % 2 == 1, () => {
    console.log(n, "is even");
  });
});
// → 0 is even
// → 2 is even

有一个内置的数组方法,forEach,它提供了类似for/of循环的东西,作为一个高阶函数。

["A", "B"].forEach(l => console.log(l));
// → A
// → B

脚本数据集

数据处理是高阶函数表现突出的一个领域。 为了处理数据,我们需要一些真实数据。 本章将使用脚本书写系统的数据集,例如拉丁文,西里尔文或阿拉伯文。

请记住第 1 章中的 Unicode该系统为书面语言中的每个字符分配一个数字。 大多数这些字符都与特定的脚本相关联。 该标准包含 140 个不同的脚本 - 81 个今天仍在使用59 个是历史性的。

虽然我只能流利地阅读拉丁字符,但我很欣赏这样一个事实,即人们使用其他至少 80 种书写系统来编写文本,其中许多我甚至不认识。 例如,以下是泰米尔语手写体的示例。

示例数据集包含 Unicode 中定义的 140 个脚本的一些信息。 本章的编码沙箱中提供了SCRIPTS绑定。 该绑定包含一组对象,其中每个对象都描述了一个脚本。

{
  name: "Coptic",
  ranges: [[994, 1008], [11392, 11508], [11513, 11520]],
  direction: "ltr",
  year: -200,
  living: false,
  link: "https://en.wikipedia.org/wiki/Coptic_alphabet"
}

这样的对象会告诉您脚本的名称,分配给它的 Unicode 范围,书写方向,(近似)起始时间,是否仍在使用以及更多信息的链接。 方向可以是从左到右的"ltr",从右到左的"rtl"(阿拉伯语和希伯来语文字的写法),或者从上到下的"ttb"(蒙古文的写法)。

ranges属性包含 Unicode 字符范围数组,每个数组都有两元素,包含下限和上限。 这些范围内的任何字符代码都会分配给脚本。 下限是包括的(代码 994 是一个科普特字符),并且上限排除在外(代码 1008 不是)。

5.6 数组过滤

为了找到数据集中仍在使用的脚本,以下函数可能会有所帮助。 它过滤掉数组中未通过测试的元素:

function filter(array, test) {
  let passed = [];
  for (let element of array) {
    if (test(element)) {
      passed.push(element);
    }
  }
  return passed;
}

console.log(filter(SCRIPTS, script => script.living));
// → [{name: "Adlam", …}, …]

该函数使用名为test的参数(一个函数值)填充计算中的“间隙” - 决定要收集哪些元素的过程。

需要注意的是filter函数并没有从当前数组中删除元素而是新建了一个数组并将满足条件的元素存入新建的数组中。这个函数是一个“纯函数”因为该函数并未修改给定的数组。

与forEach一样filter函数也是标准的数组方法。本例中定义的函数只是用于展示内部实现原理。今后我们会使用以下方法来过滤数据

console.log(SCRIPTS.filter(s => s.direction == "ttb"));
// → [{name: "Mongolian", …}, …]

5.7 使用map函数转换数组

假设我们已经通过某种方式过滤了SCRIPTS数组,生成一个用于表示脚本的信息数组。但我们想创建一个包含名称的数组,因为这样更加易于检查。

map方法可以对数组中的每个元素调用函数然后利用返回值来构建一个新的数组实现转换数组的操作。新建数组的长度与输入的数组一致但其中的内容却通过对每个元素调用的函数“映射”成新的形式。

function map(array, transform) {
  let mapped = [];
  for (let element of array) {
    mapped.push(transform(element));
  }
  return mapped;
}

let rtlScripts = SCRIPTS.filter(s => s.direction == "rtl");
console.log(map(rtlScripts, s => s.name));
// → ["Adlam", "Arabic", "Imperial Aramaic", …]

与forEach和filter一样map也是标准的数组方法。

5.8 使用reduce进行数据汇总

另外还有一种常用的数组计算操作,即根据整个数组计算出一个值。我们在前面讲到循环的时候,有个示例是对数字集合执行求和操作,这正是这类任务的一个实例。这里将给出另一个例子,查找数据集中出生最早的人。

我们将这种高阶操作称为reduce操作或fold操作。你可以把这个过程看成折叠数组的操作每次操作一个元素。在计算所有数字之和时先将结果初始化为0然后遍历各个元素并累加到当前的总和之中。

reduce函数包含三个参数数组、执行合并操作的函数和初始值。该函数没有filter和map那样直观因此使用时需多加小心。

function reduce(array, combine, start) {
  var current = start;
  for (var i = 0; i < array.length; i++)
    current = combine(current, array[i]);
  return current;
}

console.log(reduce([1, 2, 3, 4], function(a, b) {
  return a + b;
}, 0));
// → 10

数组中有一个标准的reduce方法当然和我们上面看到的那个函数一致可以简化合并操作。如果你的数组中包含多个元素在调用reduce方法的时候忽略了start参数那么该方法将会使用数组中的第一个元素作为初始值并从第二个元素开始执行合并操作。

要使用reduce方法来找出我的家族中最早出生的人我们可以编写如下代码

console.log(ancestry.reduce(function(min, cur) {
  if (cur.born < min.born) return cur;
  else return min;
}));
// → {name: "Pauwels van Haverbeke", born: 1535, …}

5.9 可组合性

考虑一下,我们怎样才可以在不使用高阶函数的情况下,实现以上示例(找出最早出生的人)当中相同的功能?代码看起来也还不错。

var min = ancestry[0];
for (var i = 1; i < ancestry.length; i++) {
  var cur = ancestry[i];
  if (cur.born < min.born)
    min = cur;
}
console.log(min);
// → {name: "Pauwels van Haverbeke", born: 1535, …}

这段代码中多了一些变量,虽然多了两行代码,但代码逻辑还是很容易让人理解的。

当你遇到需要组合函数的情况时,高阶函数的价值就突显出来了。举个例子,我们编写一段代码,找出数据集中男人和女人的平均年龄。

function average(array) {
  function plus(a, b) { return a + b; }
  return array.reduce(plus) / array.length;
}
function age(p) { return p.died - p.born; }
function male(p) { return p.sex == "m"; }
function female(p) { return p.sex == "f"; }

console.log(average(ancestry.filter(male).map(age)));
// → 61.67
console.log(average(ancestry.filter(female).map(age)));
// → 54.56

略显麻烦的是我们必须定义一个plus函数因为在JavaScript中运算符不能像函数那样作为参数值传递给函数。

这段代码并没有将逻辑放到整个循环体中,而是将逻辑巧妙地组合成了我们所关注的几个方面:判断性别、计算年龄和计算平均数。我们可以依次使用这些函数来获得所需结果。

我们可以采用这种方式编写出逻辑清晰的代码。不过,编写这样的代码也是有代价的。

5.10 性能开销

在一片饱含优雅代码和漂亮彩虹的乐土上,住着一只名为“低效”的怪兽。

编写处理数组的程序时,最优雅的方式是将程序描述成清晰分离的步骤序列,每一步完成一些任务,并生成一个新数组。但构造这些中间数组却需要很高的性能开销。

同样地将函数传递给forEach来处理数组迭代任务的确十分方便而且易于阅读。但JavaScript中函数调用却比简单的循环结构代价更高。

许多能够改善代码清晰程度的技术都会对性能产生影响。抽象在计算机操作与我们使用的概念之间增加了中间层因此计算机在处理高层抽象的时候也要执行更多的代码。但并非所有技术都存在性能损耗的问题有些编程语言提供了很好的构建抽象的支持而且可以避免性能损耗的问题。甚至在JavaScript中有经验的开发人员也可以编写出高性能的抽象代码。不过我们仍然会经常碰到性能问题。

幸运的是绝大多数计算机的运行速度都非常快。如果你处理的数据集合中的数量适中或者需要人为触发某项操作比如每次用户点击一个按钮那么你编写的是结构层次清晰但执行过程需要消耗0.5毫秒的代码还是经过优化而且执行过程仅消耗0.1毫秒的代码,其实就不那么重要了。

粗略地记录一下你的程序的执行频率是很有帮助的。如果一个循环中嵌套了一个循环无论是直接执行内部循环还是在外部循环中调用函数最终执行内部循环最后会执行N×M次内部循环代码其中N是外部循环次数M是外部循环每次迭代时的内部循环次数。如果内部循环又嵌套了一层循环循环次数为P次那么最内层循环的循环体将会执行M×N×P次以此类推。这些代码的运行次数可能最终会累计成很大的数字。当程序执行很慢的时候问题往往只是嵌套最深的循环体中的一小部分代码引起的。

5.11 曾曾曾曾……祖父

我的祖父Philibert Haverbeke的信息也在数据文件中。我可以从他开始追溯我的家族中最早的祖先Pauwels van Haverbeke他是我的直系祖先。如果他是的话我想知道从理论上来说我与他有多少DNA是相同的。

为了能够根据祖辈的名字找出表示该人的实际对象,我们先要构建一个对象,将祖辈的姓名与表示人的对象关联起来。

var byName = {};
ancestry.forEach(function(person) {
  byName[person.name] = person;
});

console.log(byName["Philibert Haverbeke"]);
// → {name: "Philibert Haverbeke", …}

现在的问题可比依据father属性来追溯Pauwels要复杂了许多。在家谱树中有许多人和他们的兄妹结婚这在小村庄中非常普遍。因此家谱树中的分支会在某些地方重新合并到家谱树中这意味着我从Pauwels继承的基因数量超过了其基因数量的1/2G其中G表示Pauwels和我相差几代。该公式假定每一代都会将自己一半的基因遗传给下一代。

我们可以将这个问题与reduce方法进行类比该方法会从左到右遍历数组中的元素然后反复对元素进行合并最终提炼出一个值。在本例中我们也想从数据结构中提炼出一个值只不过是根据家系进行遍历的。这里的数据集合更像是一个家族树而非简单的列表。

我们希望通过合并一个人的祖先的基因比例得到该人的基因比例。我们可以使用递归来实现如果想获得A这个人的基因组成比例我们就必须计算出A的父母的基因组成比例也就需要计算出A的爷爷奶奶的基因组成比例依此类推。理论上这个计算过程会无限执行下去不过我们的数据集是一定的因此计算过程总会执行到某个地方停下来。归纳函数可以接受一个默认参数如果我们需要计算某人的基因比例时该人不存在于数据中我们将默认值作为该人的基因比例。我们的例子将默认值设置为0即假设不在列表中的人不会从祖先遗传任何基因。

下面编写reduceAncestors函数用于从家谱树中提炼出一个值。该函数的参数分别是需要计算的人、用于合并父母基因组成比例的函数以及默认值。

function reduceAncestors(person, f, defaultValue) {
  function valueFor(person) {
    if (person == null)
      return defaultValue;
    else
      return f(person, valueFor(byName[person.mother]),
                       valueFor(byName[person.father]));
  }
  return valueFor(person);
}

内部函数即valueFor用于计算一个人的基因组成比例。借助于递归提供强大功能该函数只需要调用自身来处理待计算者父亲的数据与母亲的数据即可。递归调用的计算结果连同待计算者对象都会作为参数传递给f由f函数返回这个人的基因组成比例。

接下来我们就可以使用该函数来计算我的祖父遗传了Pauwels van Haverbeke的DNA比例然后将计算结果除以4。

function sharedDNA(person, fromMother, fromFather) {
  if (person.name == "Pauwels van Haverbeke")
    return 1;
  else
    return (fromMother + fromFather) / 2;
}
var ph = byName["Philibert Haverbeke"];
console.log(reduceAncestors(ph, sharedDNA, 0) / 4);
// → 0.00049

很明显Pauwels van Haverbeke这个人和Pauwels van Haverbeke共享了100%的基因数据集中没有重名的人。因此函数在遍历到他时直接返回了1。而其他所有人的相应基因组成比例是其父母相应数值之和的平均数。

那么从统计学角度来看我从这个十六世纪的人身上遗传了大约0.05%的DNA比例。需要注意的是这个计算结果只是统计学上的近似结果并非确切的数字。这个数字很小但考虑到人类携带的基因数量大约30亿个碱基对如此之庞大因此在我的身上还是有可能找到一些来自Pauwels的特征。

我们也可以不使用reduceAncestors函数来计算一个人的基因比例。但是把特定的计算计算遗传的DNA比例抽象成通用的计算方法从家谱树中提炼一个值可以提高代码的清晰程度我们也可以使用程序的抽象方法来计算其他内容。下面的代码用于找出满足特定条件的祖先比例比如可以查找年龄超过70岁的人

function countAncestors(person, test) {
  function combine(current, fromMother, fromFather) {
    var thisOneCounts = current != person && test(current);
    return fromMother + fromFather + (thisOneCounts ? 1 : 0);
  }
  return reduceAncestors(person, combine, 0);
}
function longLivingPercentage(person) {
  var all = countAncestors(person, function(person) {
    return true;
  });
  var longLiving = countAncestors(person, function(person) {
    return (person.died - person.born) >= 70;
  });
  return longLiving / all;
}
console.log(longLivingPercentage(byName["Emile Haverbeke"]));
// → 0.129

由于我们的数据集合当中所包含的人的信息比较随意因此这些计算结果也就不那么准确了。但我们还是可以从这段代码看出reduceAncestors函数可以满足各种有关家谱数据结构的计算需求。

5.12 绑定

每个函数都有一个bind方法该方法可以用来创建新的函数称为绑定函数。在调用新创建的函数时就会调用原来的那个函数只不过其中一些参数是预先就绑定好的。

下面的代码展示了bind的使用方法。代码中定义了isInSet函数用于判断某个人是否在给定的字符串集合中。为了收集姓名在特定集合中的person对象我们可以编写一个函数表达式直接调用isInSet并将特定集合作为第一个参数传递进去也可以把isInSet函数的部分参数预先确定下来。

var theSet = ["Carel Haverbeke", "Maria van Brussel",
              "Donald Duck"];
function isInSet(set, person) {
  return set.indexOf(person.name) > -1;
}

console.log(ancestry.filter(function(person) {
  return isInSet(theSet, person);
}));
// → [{name: "Maria van Brussel", …},
//    {name: "Carel Haverbeke", …}]
console.log(ancestry.filter(isInSet.bind(null, theSet)));
// → … same result

调用bind会返回一个新的函数该函数调用isInSet时会将theSet作为第一个参数并将传递给该函数的剩余参数一起传递给isInSet。

与之前的apply一样我们传递给bind的第一个参数是null该参数是给方法调用使用的。我们会在下一章当中对这个问题进行详细的解释。

5.13 本章小结

将函数类型的值传递给其他函数不仅十分有用而且还是JavaScript中一个重要的功能。我们可以在编写函数的时候把某些特定的操作预留出来并在真正的函数调用中将具体的操作作为函数传递进来实现完整的计算过程。

数组中提供了很多实用的高阶函数其中forEach用于遍历数组元素实现某些特定的功能。filter用于过滤掉一些元素构造一个新数组。map会构建一个新数组并通过一个函数处理每个元素将处理结果放入新数组中。reduce则将数组元素最终归纳成一个值。

函数对象有一个apply方法我们可以通过该方法调用函数并使用数组来指定函数参数。另外还有一个bind方法它用于创建一个新函数并预先确定其中一部分参数。

5.14 习题

5.14.1 数组降维

结合使用reduce与concat方法将输入的二维数组数组的数组中的元素提取出来并存放到一个一维数组当中。

var arrays = [[1, 2, 3], [4, 5], [6]];
// Your code here.
// → [1, 2, 3, 4, 5, 6]

5.14.2 计算母子年龄差

使用本章中提供的数据集计算母亲与孩子之间的平均年龄差也就是孩子出生时母亲的年龄。你可以使用本章前面定义的average函数。

需要注意的是并非数据中所有的父元素都在数组中存在。这里byName对象可以帮助我们很容易地根据人名找出其对应的对象。

function average(array) {
  function plus(a, b) { return a + b; }
  return array.reduce(plus) / array.length;
}

var byName = {};
ancestry.forEach(function(person) {
  byName[person.name] = person;
});

// Your code here.

// → 31.2

5.14.3 计算平均寿命

当我们查找数据集中寿命90岁以上的人时结果只有数据中最年轻的一代。让我们深入分析一下这个问题。

计算并输出每个世纪祖先的平均寿命。每个人属于其死亡的那个世纪即将每个人的去世年份除以100并向上取整如Math.ceilperson.died/100

作为额外练习编写一个groupBy函数对这类分组操作进行抽象。该函数接受两个参数一个数组和一个处理数组中每个元素的函数并返回一个对象建立小组名称与小组编号之间的映射关系。

function average(array) {
  function plus(a, b) { return a + b; }
  return array.reduce(plus) / array.length;
}

// Your code here.

// → 16: 43.5
//   17: 51.2
//   18: 52.8
//   19: 54.8
//   20: 84.7
//   21: 94

5.14.4 使用every和some方法

数组中还有两个标准方法分别是every和some。这两个方法均接受一个预测函数以数组元素作为参数并返回true或false。every函数很像&&运算符:&&运算符只有在两侧表达式均为true时才返回true而every函数只有对所有数组元素调用预测函数返回值均为true时才返回true。类似地只要对任一元素调用预测函数返回truesome函数就返回true。

编写两个函数every和some除了需将待处理数组作为第一个参数外其他行为都与上述方法一致。

// Your code here.

console.log(every([NaN, NaN, NaN], isNaN));
// → true
console.log(every([NaN, NaN, 4], isNaN));
// → false
console.log(some([NaN, 3, 4], isNaN));
// → true
console.log(some([2, 3, 4], isNaN));
// → false