第 5 章高阶函数
子利和子思正在吹嘘他们最新程序的大小。“二十万行,”子利说,“不包括注释!”子思回应道,“切,我的程序已经接近一百万行了。”元马大师说,“我最好的程序只有五百行。”听到这话,子利和子思顿悟了。
大型程序是昂贵的程序,不仅是因为构建所需的时间。规模几乎总是伴随着复杂性,而复杂性会让程序员感到困惑。困惑的程序员反过来往往会在程序中引入错误(bug)。大型程序还为这些 bug 提供了大量的隐藏空间,使其难以查找。
让我们简要回顾一下引言中的最后两个示例程序。第一个程序是自包含的,只有六行长。
var total = 0, count = 1; while (count <= 10) { total += count; count += 1; } console.log(total);
console.log(sum(range(1, 10)));
如果我们统计 sum
和 range
定义的大小,第二个程序也相当大——甚至比第一个更大。但尽管如此,我认为它更有可能正确。
它更有可能正确,因为解决方案是用与所解决的问题相对应的词汇表达的。求一组数字的和与循环和计数器无关。它与范围和求和有关。
这个词汇(函数 sum
和 range
的定义)的定义仍然会涉及循环、计数器和其他附带细节。但由于它们表达的概念比整个程序更简单,因此更容易弄对。
抽象
在编程的背景下,这些类型的词汇通常被称为抽象。抽象隐藏了细节,并让我们能够在更高(或更抽象)的级别上讨论问题。
每人将 1 杯干豌豆放入容器中。加入水,直到豌豆完全浸没。将豌豆浸泡在水中至少 12 小时。将豌豆从水中取出,放入烹饪锅中。每人加入 4 杯水。盖上锅盖,将豌豆小火炖煮 2 小时。每人取半个洋葱。用刀切成块。加入豌豆。每人取一根芹菜。用刀切成块。加入豌豆。每人取一根胡萝卜。切成块。用刀!加入豌豆。再煮 10 分钟。
第二个食谱更短,更容易理解。但你需要理解更多与烹饪相关的词语——浸泡、炖煮、切碎,我想还有蔬菜。
在编程时,我们不能指望所有需要的词语都在词典中等待我们。因此,你可能会陷入第一个食谱的模式——逐一计算计算机必须执行的精确步骤,而忽略了它们所表达的更高层次的概念。
对于程序员来说,必须成为第二天性,注意到何时一个概念需要被抽象成一个新词语。
抽象数组遍历
到目前为止,我们所见到的普通函数是构建抽象的好方法。但有时它们还不够。
在上一章中,这种类型的 for
循环出现过几次
var array = [1, 2, 3]; for (var i = 0; i < array.length; i++) { var current = array[i]; console.log(current); }
它试图表达,“对于数组中的每个元素,将其记录到控制台”。但它使用了一种迂回的方式,涉及一个计数器变量 i
、与数组长度的比较,以及一个额外的变量声明来提取当前元素。除了有点难看,这还为潜在的错误提供了很大的空间。我们可能会意外地重复使用 i
变量,将 length
拼写成 lenght
,混淆 i
和 current
变量,等等。
嗯,很容易写出一个函数,它遍历一个数组,并对每个元素调用 console.log
。
function logEach(array) { for (var i = 0; i < array.length; i++) console.log(array[i]); }
但是如果我们想要做一些除了记录元素之外的事情呢?由于“做某事”可以用函数来表示,而函数只是值,我们可以将我们的操作作为函数值传递。
function forEach(array, action) { for (var i = 0; i < array.length; i++) action(array[i]); } forEach(["Wampeter", "Foma", "Granfalloon"], console.log); // → Wampeter // → Foma // → Granfalloon
(在某些浏览器中,以这种方式调用 console.log
并不起作用。如果此示例无法正常工作,你可以使用 alert
代替 console.log
。)
通常,你不会将预定义的函数传递给 forEach
,而是会在现场创建一个函数值。
var numbers = [1, 2, 3, 4, 5], sum = 0; forEach(numbers, function(number) { sum += number; }); console.log(sum); // → 15
这看起来很像经典的 for
循环,它的主体写在一个块下面。但是现在,主体位于函数值内部,以及 forEach
调用的括号内。这就是为什么它必须用右括号和右圆括号关闭的原因。
使用这种模式,我们可以为当前元素指定一个变量名(number
),而无需手动从数组中提取它。
事实上,我们不需要自己编写 forEach
。它作为数组上的标准方法可用。由于数组已经作为方法作用于的对象提供,forEach
只需要一个参数:要为每个元素执行的函数。
为了说明这有多么有用,让我们回顾一下上一章中的一个函数。它包含两个数组遍历循环。
function gatherCorrelations(journal) { var phis = {}; for (var entry = 0; entry < journal.length; entry++) { var events = journal[entry].events; for (var i = 0; i < events.length; i++) { var event = events[i]; if (!(event in phis)) phis[event] = phi(tableFor(event, journal)); } } return phis; }
function gatherCorrelations(journal) { var phis = {}; journal.forEach(function(entry) { entry.events.forEach(function(event) { if (!(event in phis)) phis[event] = phi(tableFor(event, journal)); }); }); return phis; }
高阶函数
对其他函数进行操作的函数,无论是将它们作为参数传入还是返回它们,都称为高阶函数。如果你已经接受了函数是普通值的事实,那么这种函数的存在就没有什么特别值得注意的地方。这个术语来自数学,在数学中,函数和其他值之间的区别更加严格。
高阶函数允许我们对操作进行抽象,而不仅仅是对值进行抽象。它们有多种形式。例如,你可以有创建新函数的函数。
function greaterThan(n) { return function(m) { return m > n; }; } var greaterThan10 = greaterThan(10); console.log(greaterThan10(11)); // → true
function noisy(f) { return function(arg) { console.log("calling with", arg); var val = f(arg); console.log("called with", arg, "- got", val); return val; }; } noisy(Boolean)(0); // → calling with 0 // → called with 0 - got false
function unless(test, then) { if (!test) then(); } function repeat(times, body) { for (var i = 0; i < times; i++) body(i); } repeat(3, function(n) { unless(n % 2, function() { console.log(n, "is even"); }); }); // → 0 is even // → 2 is even
我们在第 3 章中讨论的词法作用域规则在这种情况下对我们有利。在前面的示例中,n
变量是外部函数的参数。由于内部函数位于外部函数的环境中,因此它可以使用 n
。这种内部函数的主体可以访问它们周围的变量。它们可以发挥与普通循环和条件语句中使用的 {}
块类似的作用。一个重要的区别是,在内部函数中声明的变量不会最终出现在外部函数的环境中。这通常是一件好事。
传递参数
前面定义的 noisy
函数,它将参数包装在另一个函数中,有一个相当严重的缺陷。
function noisy(f) { return function(arg) { console.log("calling with", arg); var val = f(arg); console.log("called with", arg, "- got", val); return val; }; }
如果 f
接受多个参数,它只会获取第一个参数。我们可以向内部函数添加一堆参数(arg1
、arg2
,等等),并将它们全部传递给 f
,但目前尚不清楚需要多少个参数才足够。这种解决方案还会剥夺 f
在 arguments.length
中的信息。由于我们总是传递相同数量的参数,因此它将不知道最初传递了多少个参数。
对于这些情况,JavaScript 函数有一个 apply
方法。你可以将参数数组(或类似数组的对象)传递给它,它将使用这些参数调用函数。
function transparentWrapping(f) { return function() { return f.apply(null, arguments); }; }
这是一个无用的函数,但它展示了我们感兴趣的模式——它返回的函数将所有给定的参数(并且仅传递这些参数)传递给 f
。它是通过将自己的 arguments
对象传递给 apply
来实现的。apply
的第一个参数,在这里我们传递的是 null
,可以用来模拟方法调用。我们将在下一章中回到这一点。
JSON
以某种方式将函数应用于数组元素的高阶函数在 JavaScript 中被广泛使用。forEach
方法是最原始的此类函数。数组上还提供了一些其他变体。为了熟悉它们,让我们使用另一个数据集进行练习。
几年前,有人翻阅了大量档案,整理了一本关于我家族姓氏(Haverbeke,意为“燕麦溪”)历史的书籍。我打开书,希望找到骑士、海盗和炼金术士……但这本书主要讲的是佛兰德农民。为了娱乐,我提取了关于我的直系祖先的信息,并将它们转换成计算机可读的格式。
[ {"name": "Emma de Milliano", "sex": "f", "born": 1876, "died": 1956, "father": "Petrus de Milliano", "mother": "Sophia van Damme"}, {"name": "Carolus Haverbeke", "sex": "m", "born": 1832, "died": 1905, "father": "Carel Haverbeke", "mother": "Maria van Brussel"}, … and so on ]
这种格式称为 JSON(发音为“Jason”),代表 JavaScript 对象表示法。它在 Web 上被广泛用作数据存储和通信格式。
JSON 类似于 JavaScript 写数组和对象的语法,但有一些限制。所有属性名都必须用双引号括起来,并且只允许简单的数据表达式——不允许函数调用、变量,或任何涉及实际计算的内容。JSON 中不允许注释。
JavaScript 提供了函数 JSON.stringify
和 JSON.parse
,用于将数据转换为这种格式或从这种格式转换。第一个函数接受一个 JavaScript 值并返回一个 JSON 编码的字符串。第二个函数接受一个 JSON 编码的字符串并将其转换为它编码的值。
var string = JSON.stringify({name: "X", born: 1980}); console.log(string); // → {"name":"X","born":1980} console.log(JSON.parse(string).born); // → 1980
变量 ANCESTRY_FILE
在本章的沙盒中可用,并在网站上的可下载文件中,包含我的 JSON 文件的内容作为字符串。让我们解码它,看看它包含多少人。
var ancestry = JSON.parse(ANCESTRY_FILE); console.log(ancestry.length); // → 39
过滤数组
为了找到在 1924 年时还年轻的祖先数据集中的人,以下函数可能会有帮助。它会过滤掉不符合条件的数组元素。
function filter(array, test) { var passed = []; for (var i = 0; i < array.length; i++) { if (test(array[i])) passed.push(array[i]); } return passed; } console.log(filter(ancestry, function(person) { return person.born > 1900 && person.born < 1925; })); // → [{name: "Philibert Haverbeke", …}, …]
这使用名为 test
的参数,这是一个函数值,来填补计算中的“空白”。test
函数对每个元素进行调用,其返回值决定元素是否包含在返回的数组中。
文件中三位在 1924 年还活着且年轻的人:我的祖父、祖母和姑姑。
请注意,filter
函数不会删除现有数组中的元素,而是构建一个新的数组,其中只包含通过测试的元素。这个函数是纯函数。它不会修改传递给它的数组。
和 forEach
一样,filter
也是数组上的标准方法。这个例子只定义了该函数,是为了展示它在内部是如何工作的。从现在起,我们将这样使用它
console.log(ancestry.filter(function(person) { return person.father == "Carel Haverbeke"; })); // → [{name: "Carolus Haverbeke", …}]
使用 map 进行转换
假设我们有一个对象数组,代表人,这些对象是通过过滤 ancestry
数组获得的。但是我们想要一个名字数组,这样更容易阅读。
map
方法通过对所有元素应用一个函数并从返回值构建一个新数组来转换一个数组。新数组的长度将与输入数组相同,但其内容将被函数“映射”到一个新的形式。
function map(array, transform) { var mapped = []; for (var i = 0; i < array.length; i++) mapped.push(transform(array[i])); return mapped; } var overNinety = ancestry.filter(function(person) { return person.died - person.born > 90; }); console.log(map(overNinety, function(person) { return person.name; })); // → ["Clara Aernoudts", "Emile Haverbeke", // "Maria Haverbeke"]
有趣的是,活到至少 90 岁的人与我们之前看到的三人相同——他们在 20 世纪 20 年代还年轻,这恰好是我数据集中最晚的一代。我想医学已经取得了长足的进步。
和 forEach
和 filter
一样,map
也是数组上的标准方法。
使用 reduce 进行汇总
数组上另一种常见的计算模式是根据数组计算单个值。我们重复使用的例子,对数字集合求和,就是一个例子。另一个例子是在数据集中找到出生年份最早的人。
表示这种模式的高阶操作称为reduce(或有时称为fold)。你可以把它想象成折叠数组,每次折叠一个元素。当对数字求和时,你将从数字零开始,对每个元素,通过将它与当前的和相加来将其与当前的和结合。
除了数组之外,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
当然对应于这个函数,它有一个额外的好处。如果你的数组至少包含一个元素,你就可以省略 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, …}
可组合性
想想我们如果没有使用高阶函数会如何编写之前的例子(找到出生年份最早的人)。代码并没有糟糕太多。
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
(在 JavaScript 中,运算符不像函数那样是值,所以你不能将它们作为参数传递,因此我们必须定义 plus
作为函数有点可笑。)
我们没有将逻辑混杂在一个大循环中,而是将其整齐地组合成我们感兴趣的概念——确定性别、计算年龄和计算平均数。我们可以逐个应用这些概念来获得我们想要的结果。
这对编写清晰的代码来说棒极了。不幸的是,这种清晰度是以牺牲性能为代价的。
代价
在优雅代码和漂亮彩虹的快乐国度中,住着一个破坏气氛的怪物,叫做低效。
最优雅的处理数组的程序,是一系列清晰分离的步骤,每一步都对数组做点什么,并生成一个新的数组。但构建所有这些中间数组是相当昂贵的。
同样,将函数传递给 forEach
并让该方法为我们处理数组迭代,既方便又易于阅读。但与简单的循环体相比,JavaScript 中的函数调用是昂贵的。
许多有助于提高程序清晰度的技术也是如此。抽象在计算机正在做的事情和我们正在使用的概念之间添加了层,因此会导致机器执行更多工作。这不是铁律——有些编程语言更好地支持构建抽象而不增加低效,即使在 JavaScript 中,经验丰富的程序员也可以找到编写既抽象又快速的代码的方法。但这是一个经常出现的问题。
幸运的是,大多数计算机快得惊人。如果你正在处理一组适度的数据,或者正在做一些只需要在人类时间尺度上发生的事情(比如,每次用户点击按钮时),那么你写了一个优雅的解决方案,它需要半毫秒,还是一个超级优化的解决方案,它需要十分之一毫秒,并没有什么区别。
了解程序的某个部分将运行多少次,这一点很有帮助。如果你在一个循环里面嵌套了另一个循环(无论直接嵌套,还是通过外部循环调用最终执行内部循环的函数),内部循环中的代码将运行 N×M 次,其中 N 是外部循环重复的次数,M 是内部循环在每次外部循环迭代中重复的次数。如果内部循环中还有另一个循环,它运行了 P 次,它的循环体将运行 M×N×P 次,依此类推。这些数字可能会累加到很大,当程序运行缓慢时,问题通常可以追溯到一小部分代码,这些代码位于内部循环中。
曾曾曾曾曾……
我的祖父菲利伯特·哈弗贝克被包含在数据文件中。从他开始,我可以追踪我的血统,看看数据中最古老的人,保罗·范·哈弗贝克,是否是我的直系祖先。如果是的话,我想知道我理论上与他共享了多少 DNA。
为了能够从父母的名字找到代表这个人的实际对象,我们首先构建一个将名字与人关联的对象。
var byName = {}; ancestry.forEach(function(person) { byName[person.name] = person; }); console.log(byName["Philibert Haverbeke"]); // → {name: "Philibert Haverbeke", …}
现在,问题并不完全像按照 father
属性进行查找,并计算到达保罗需要多少步那么简单。家谱中有很多情况,人们与他们的堂兄弟结婚(都是小村庄)。这会导致家谱的分支在几个地方重新连接,这意味着我与这个人的基因共享比例超过 1/2G,其中 G 代表保罗和我之间的代数。这个公式来自于这样一种想法:每一代都会将基因库一分为二。
一个合理的思考这个问题的方法是把它看成类似于 reduce
,它通过反复地从左到右组合值来将数组压缩成单个值。在这种情况下,我们也想要将我们的数据结构压缩成单个值,但要以一种遵循家族血统的方式进行。数据的形状是家谱的形状,而不是一个扁平的列表。
我们想要减少这种形状的方式是,通过组合给定人的祖先的值来计算该人的值。这可以通过递归来完成:如果我们对人 A 感兴趣,我们必须计算 A 的父母的值,而这反过来又要求我们计算 A 的祖父母的值,等等。原则上,这将要求我们查看无限多的人,但由于我们的数据集是有限的,所以我们必须在某个地方停止。我们将允许对我们的缩减函数提供一个默认值,这个值将用于不在数据中的那些人。在我们这里,这个值 simply 零,假设不在列表中的人不与我们正在查看的祖先共享 DNA。
给定一个人、一个用于组合给定人的两位父母的值的函数,以及一个默认值,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
,它返回这个人的实际值。
然后我们可以用它来计算我的祖父与保罗·范·哈弗贝克共享的 DNA 量,并将该量除以四。
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% 的 DNA(数据集里没有重名的人),因此该函数返回 1。所有其他人的 DNA 共享比例是其父母 DNA 共享比例的平均值。
因此,从统计学角度来说,我和这个 16 世纪的人共享大约 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
为我们提供了一个有用的词汇来处理家谱数据结构。
绑定
所有函数都拥有的 bind
方法会创建一个新的函数,该函数将调用原始函数,但会将某些参数固定下来。
以下代码展示了 bind
的使用示例。它定义了一个函数 isInSet
,用于告诉我们某个人是否在给定的字符串集合中。为了调用 filter
以收集名称在特定集合中的那些人对象,我们可以编写一个函数表达式来调用 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
作为第一个参数,后面跟着传递给绑定函数的任何剩余参数。
第一个参数(示例中传递了 null
)用于方法调用,类似于传递给 apply
的第一个参数。我将在下一章中详细介绍这一点。
总结
能够将函数值传递给其他函数不仅仅是一个技巧,而是 JavaScript 的一个非常有用的方面。它允许我们将带有“间隙”的计算编写为函数,并让调用这些函数的代码通过提供描述缺失计算的函数值来填补这些间隙。
数组提供了许多有用的高阶方法:forEach
用于对数组中的每个元素执行操作,filter
用于构建一个新数组,其中过滤掉了一些元素,map
用于构建一个新数组,其中每个元素都经过了函数处理,reduce
用于将数组的所有元素合并成一个值。
函数有一个 apply
方法,可以用来用一个指定参数的数组来调用它们。它们还有一个 bind
方法,用于创建函数的部分应用版本。
练习
扁平化
结合使用 reduce
方法和 concat
方法将数组的数组“扁平化”成一个包含所有输入数组元素的单个数组。
var arrays = [[1, 2, 3], [4, 5], [6]]; // Your code here. // → [1, 2, 3, 4, 5, 6]
母子年龄差
使用本章的示例数据集,计算母子之间的平均年龄差(孩子出生时母亲的年龄)。你可以使用本章之前定义的 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
历史预期寿命
当我们查找数据集中所有活过 90 岁的人时,只有数据中最晚的一代人出现了。让我们仔细看看这种现象。
计算并输出祖先数据集中每个世纪的人的平均年龄。一个人的世纪可以通过取他们的死亡年份,除以 100,然后向上取整来确定,如 Math.ceil(person.died / 100)
。
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
为了获得额外奖励,编写一个 groupBy
函数来抽象分组操作。它应该接受一个数组和一个函数作为参数,该函数计算数组中元素的组,并返回一个将组名称映射到组成员数组的对象。
每个和一些
数组还附带标准方法 every
和 some
。它们都接受一个谓词函数,该函数在用数组元素作为参数调用时,返回真或假。就像 &&
只有在两边的表达式都为真时才返回真值一样,every
只有当谓词函数对数组的所有元素都返回真时才返回真。类似地,some
只要谓词函数对数组的任何元素返回真,就返回真。它们不会处理比必要更多的元素——例如,如果 some
发现谓词对数组的第一个元素成立,它就不会查看后面的值。
编写两个函数 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