「译」JavaScript高阶函数(Eloquent JavaScript 第五章)
此文翻译《Eloquent Javascript》中第五章,Higher-Order Functions,侵删。
该书有中文译本出版。此译文仅作交流学习之用。
Tzu-li和Tzu-ssu在互相吹嘘他们最近程序的代码行数,Tzu-li说他写了20万行,而且注释不算在内,Tzu-ssu说,嘘,我的已经接近一百万行了。Yuan-Ma老师说,我最好的程序只有五百行。听到这里,Tzu-li和Tzu-ssu恍然大悟。
———— Master Yuan-Ma, The Book of Programming软件设计的路有两条:一条是设计简单,使其显然没有缺陷;另一条是设计复杂,使其没有明显的缺陷。
———— C.A.R. Hoare, 1980 ACM Turing Award Lecture
一个大的程序是非常消耗资源的,不是简单的因为搭建程序所消耗的时间。程序的大小总是和它的复杂性相关,太复杂的程序会让程序员们更加困惑。复杂的程序往往会带来更多的错误。足够大的程序,同样有足够大的空间去隐藏各种各样难以调试的错误。
我们先简单回顾一下序章中的两个代码段,第一段代码的含义显而易见,共6行:
1 | var total = 0, count = 1; |
第二段代码依赖于两个外部函数,只有一行:
1 | console.log(sum(range(1, 10))); |
哪个代码段更有可能含有错误呢?
如果我们把sum
和range
函数也算在内,第二段代码甚至会比第一段代码更长。但依旧,第二段代码的正确率更高。
第二段代码正确率高的原因在于,它在一句话内就包含了相应问题的解决方式。某个范围之内的求和问题本身,并不包括循环和计数器。它只包括数字范围和数字求和。
这句代码的定义中(sum
和range
函数),还是会包含循环,计数器,还有别的必要的细节。但是分开表达的每个函数,都是很简单的概念,这就比融合在一起的一个程序,出错的概率小很多。
抽象
在编程环境中,这些表达式通常被称为抽象。抽象能够隐藏掉细节,把问题抽象到一个更高的层次,更有利于探讨问题本身。
在编程过程中,我们不能总希望所有的“简单功能”(sum
,range
等)都已经存在。所以,有些人可能会按照自然计算的细节一步一步的让计算机实现某个功能。
对一个程序员来说,问题的解决需要需要变得“不那么自然”,需要意识到,把一个新的概念
抽象为一个新的功能
。
抽象字符串遍历
我们已经见过了很多次的常见函数,就是做抽象化很好的例子。但是有时候也具有一些不足。
在前面的章节,这种类型的for
循环已经出现过很多次:
1 | var array = [1, 2, 3]; |
这段代码的意思是说,在console
中打印字符串中的每个元素。但是在这段代码中,又引入了计数器i
,控制打印次数的循环,还有一个额外的用于打印的变量current
增加了复杂度。除了看起来不够整齐,还为潜在的错误提供了更多的空间。我们有可能会不小心错误的复用了变量i
,length
拼写错为lenght
,混淆了i
和current
,或者别的错误。
那么你能想一下,怎么样把这个过程抽象为一个函数呢?
首先,创建一个遍历数组中每个元素,并且同时调用console.log
的函数并不难。
1 | function logEach(array) { |
但是如果除了遍历数组之外我们还想做点变的事情呢?既然“做某件事”可以被表示为一个函数,函数就相当于一个值,那我们就可以将要做的事变成函数值。
1 | function forEach(array, action) { |
(在某些浏览器中,并不可以这样调用console.log
,可以用alert
替代console.log
)
通常情况下,你并不会传给forEach
函数一些预定义函数,而是传入自己写的一些函数。
1 | var numbers = [1, 2, 3, 4, 5], sum = 0; |
这段代码函数体单独占一块,看起来很类似经典的for
循环。但是,现在这个函数体是位于函数值之内,作为forEach
函数的一个参数。这也是为什么函数以括号和分号结尾的原因。
在这个模型下,我们可以为现在的元素(数字)指定变量名,就比手动一个一个从数组中读出来要好。
事实上,我们不需要自己去写forEach
函数。在数组中forEach
是一个标准方法。因为数组已经默认提供了方法所要的所有元素,那么forEach
就只需要一个变量:每个变量需要执行的函数。
为了说明以上非常有用,我们先来回看前面章节的一个函数。这个函数包含两个数组遍历的循环。
1 | function gatherCorrelations(journal) { |
使用forEach
函数会让代码更简洁清爽。
1 | function gatherCorrelations(journal) { |
高阶函数
基于别的函数实现的函数,一种是把其他函数作为参数传入,另一种是返回一个函数,我们把这样的函数叫做高阶函数
。如果你已经把函数当作一个普通的值,那么高阶函数的存在也没有什么特殊意义可言。这个术语来自于数学,数学中对函数和数值的概念有很大差别。
高阶函数是我们不仅能够抽象数值,还可以抽象‘动作’。高阶函数有几种不同的表现方式,比如,它可以用作创建新的函数。
1 | function greaterThan(n) { |
它还可以用作改变别的函数。
1 | function noisy(f) { |
甚至它可以用来改写函数的控制流。
1 | function unless(test, then) { |
在以上情境中,我们在第三章中讨论过的词汇作用域规则会对我们很有帮助。在前面的例子中,变量n
就是外部函数的一个参数。因为内部函数在外部函数的作用域之内,所以它可以使用n
。内部函数可以调用其对应外部函数的参数,相当于是普通循环和条件判断的{}
块的作用。还有一个重要的规则,内部函数中声明的变量,并不会在外部函数中结束其生命周期。这是一件非常有利的事。
参数传递
之前我们定义的noisy
函数,包含了其他函数的参数,是一个很大的设计缺陷。
1 | function noisy(f) { |
如果f
传进的参数大于1,那么这里只能获得第一个参数。我们可以给它的内部函数添加一系列参数(arg1, arg2, 等等),然后都传给f
,但一共设置多少个参数比较合适我们并不知道。这个解决方法,同时会导致argument.length
这个方法失效。所以我们最好是每次传递同样个数的参数,却并不知道原本有几个参数。
为了解决这类问题,JavaScript提供了apply
方法。我们给它传入数组(或类数组)作为参数,它会调用相应参数的函数。
1 | function transparentWrapping(f) { |
以上是一个没有任何实际用途的函数,但是清晰的展示了我们感兴趣的解决办法,这个函数是根据传入的每一个参数,返回f
对应的方法。具体就是通过传递每个argument
到apply
来实现。第一个传给apply
的参数,我们这里传的是null
,模拟了一个会被调用的方法。我们在下一章中会作说明。
JSON
在JavaScript中,高阶函数经常被用于处理数组中的各个元素。forEach
方法的应用就是最典型的例子。数组中还有很多类似的方法。我们使用另一个数据集,来熟悉这些方法。
几年前,有人翻越了很多史料,编著了一本关于家族姓名的史书。我翻了一下希望找到有关骑士, 海盗和炼金术士的故事,但大部分都是讲弗拉明村民。只是自己感兴趣,我提取了自己直系祖先的信息,并把它制成一个计算机可读的文件。
文件如下:
1 | [ |
这种格式的数据叫做JSON(JavaScript Object Notation),被广泛用作网络数据的存储和传输格式。
JSON很类似JavaScript中数组和对象的书写方式,当然也有一些限制。所有属性名称必须在双引号内部,而且只允许使用简单的数据表达式:不支持函数,变量,或者任何形式的计算。JSON中不能出现注释。
JavaScript中提供了相应的函数,JSON.stringify
和JSON.parse
,可以用作数据的格式转换。第一个函数是把JavaScript作为参数,返回JSON格式编码的字符串。第二个函数传入这样的字符串,解析为原本包含的信息。
1 | var string = JSON.stringify({name: "X", born: 1980}); |
变量ANCESTRY_FILE
,包含了JSON格式的字符串,可以在网上下载。我们来看一下如何解码这个文件,里面包含了多少人。
1 | var ancestry = JSON.parse(ANCESTRY_FILE); |
数组过滤
为了找出数据集中,在1924年的年轻人,以下的方法也许会有帮助。它剔除了数组中不能满足条件的人。
1 | function filter(array, test) { |
这段代码使用test
作为函数值的名称,填补了代码中的‘空隙’。数组中的每一个元素,都调用了test
函数,它的返回值决定了该值是否继续留在数组内。
这里需要强调,是‘filter’函数建了一个能通过其过滤条件的新数组,而不是在已有的数组中删除元素。这个函数很纯净,不会修改原数组。
就像forEach
,filter
也是数组的标准方法之一。例子只是为了说明它内部的运作机制。从现在开始,我们应该这样使用:
1 | console.log(ancestry.filter(function(person) { |
使用Map函数转换
假定我们现在手上有一组人名数据,从‘祖先’数组中过滤而来。但我们现在要的是只含有名字的数组,更方便读写。map
方法是对数组中每个元素执行一个函数,并为返回值创建一个新的数组。新的数组和原数组长度相等,但是数组的每一个元素内容已经被执行的函数更新。
1 | function map(array, transform) { |
有趣的是,岁数超过90岁的名单,和我们之前见过的名单(1920s的年轻人)一样,这恰恰是我的数据集中离我们最近的一代。我猜是医学已经发展了。
就像filter
和forEach
函数,map
也是数组中的一个标准方法。
Reduce函数的总结
另一个常见的数组计算,就是从数组中得到一个值。我们一直在用的一个例子,数字求和就是一个实例。另一个例子就是找到数据集中最早出生的那个人。
高阶函数中这种计算模式叫做reduce
。你可以把它当作是对数组的一种折叠,每次执行一个元素。当对数字求和的时候,最好是从0开始,对每一个元素进行操作,把每一个元素和当前的和进行加和。reduce
函数的参数,除了数组、组合方法,还需要一个开始位置。这个方法稍微比filter
和map
复杂一点,所以多注意一下。
1 | function reduce(array, combine, start) { |
数组的标准方法reduce
如上所示,还有一个方便的用途。如果你的数组至少包含两个元素,可以允许不写开始位置。该方法默认从数组第一个数值开始计算。
使用reduce
从文件中找到年纪最大的祖先,可以使用代码段如下:
1 | console.log(ancestry.reduce(function(min, cur) { |
可组合性
考虑到我们之前的例子是用高级函数找到出生最早的人,代码还不至于这么差:
1 | var min = ancestry[0]; |
下面的代码增加了几个变量,代码行数虽然超过两行,但仍容易理解。
高阶函数的的好处就体现在当你想要组合几个函数的时候。举个例子,我们查找一下数据集中男人和女人的平均年龄。
1 | function average(array) { |
(我们还需要自己定义plus
有点蠢,但是JavaScript中的算子不是函数,不是某个值,所以我们不能像传递参数一样传递加号。)
在这里我们把各个函数组合起来(确定性别,计算平均数),而不是把所有的运算都糅合进一个大循环。我们可以一个一个执行,最终找到到问题的解决办法。
这对书写简洁优雅的代码非常重要,但是这样清晰的结构也有所代价。
成本
在简洁优雅的代码生存的乐土里,还有一片乌云叫做低效
。
程序处理数组的过程被优雅的分割为一系列小步骤,而且每次都计算出一个新的数组。但是创建这些中间数组非常耗资源。
比如说,给forEach
传递函数去处理数组,字面上非常方便也容易理解。但是JavaScript中调用方程比简单的循环体更消耗资源。
还有很多的技巧帮我们提高代码的清晰度。抽象
会给原始数据和我们想要做的计算中添加一个中间层,导致机器需要处理更多的工作。这也并不是一个铁律,还有很多语言可以支持构建抽象的同时不增加开销,甚至在JavaScript中,有经验的工程师可以构建出运行更快的抽象
。但是这个问题很常见。
幸运的是,大部分计算机都快到飞起。如果你处理的是一个中等大小的数据集,或者是计算时间以人的时间为标准(用户单击鼠标的时间),那写一个需要运行半毫秒的解决方案,和写一个非常棒棒的只需要十分之一毫秒计算时间的解决方案,并没有太大区别。
持续跟踪程序中每一小块被调用的频率,会非常有帮助。如果有嵌套的循环(不管是直接嵌套,或者是外层循环调用一个函数,最终在内层函数中结束计算的),内层的代码段会被执行NM次,在这里N表示外层循环次数,M表示内层循环次数。如果内层循环还包含别的需要运行P次的循环,整个代码段就会执行NM*P次,以此类推。这样可能导致一个很长的运行时间,当一个程序非常慢的时候,问题通常会被追溯到一块非常小的代码段,通常位于一个内层循环中。
曾曾曾曾…(祖父)
我的祖父菲力贝尔·哈伯贝克也在数据集中。从他开始往上,按血统我可以追溯到名单里年纪最大的人鲍维思·凡·哈伯贝克是不是我的直系祖先。如果他是的话,我很想知道理论上我有多少DNA来源于他。
为了能通过父母的名字获得代表这个人的对象,我们首先建立一个能把人和名字联系起来的对象。
1 | var byName = {}; |
现在,问题就不止简单是从father
这个属性里,往上数到鲍维思一共有多少个人。在这个族谱中还有一些人和他们的表兄弟结婚。这会导致家族中的基因有些地方出现重合,说明我遗传的基因数会多过$1/2^G$,G代表鲍维思和我之间的间隔代数。这个公式基于每一代都把基因稀释为1/2。
思考这个问题的合理方式,就是把他当作是一种reduce
,不断重复的把一个数组从左至右压缩到,成为一个单个的值。在本问题中,我们也同样是要把数据结构沿着族谱压缩至一个值。这个数据的形式是一个家族树,而不是一个单一的列表。
这里我们的压缩方式,就是找出某个给定的人的祖先。可以通过递归完成:假设给定的人为A,我们先计算出A的父母,然后计算出A的祖父母,一次类推。原则上讲,我们需要找无限个人,但实际上我们的数据集是有限的,所以总会在某个地方停下来。我们需要给压缩函数设置一个阈值,用做判断不在目标列表里的人。在本例中,这个值就是0,表示这个人和我们给定的人不享有同源DNA。
给定一个人,一个查找父母的函数,一个阈值,reduceAcestors
就会从族谱中计算出一个值。
1 | function reduceAncestors(person, f, defaultValue) { |
内部函数valueFor
处理单个人,但通过递归,这个函数就可以调用自己计算这个人的父母的父母。结果是,通过这个人的对象,传值给f
,最终把实际的值返回给这个人。
我们通过这个计算我祖父继承了多少鲍维思·凡·哈克贝尔的基因,并把这个数字除以4。
1 | function sharedDNA(person, fromMother, fromFather) { |
很显然名叫鲍维思·凡·哈克贝尔的人,和鲍维思·凡·哈克贝尔(数据集中只有一个人叫这个名字)基因的相似度是100%,所以该函数返回1。所有其他人都只享有从父母处继承的一半的鲍维思·凡·哈克贝尔的基因。
统计学角度来讲,我和这位16世纪的人的基因有0.05%的相似度。需要提醒的是这只是统计估计,并不是真实的数据。虽然这只是一个很小的数字,但是考虑到每个人携带30亿个碱基对,在生物学上,我有些部分就源自于鲍维思。
我们也可以不通过reduceAncestor
来计算。通过把成块的计算方法(压缩族谱)分解为小函数(计算基因相似度),可以提高代码的清晰度,增加代码的复用度。举个例子,下面的代码能够找出某个人祖先中年龄超过70岁的比例(根据血统查找,所以有些人可能被重复计算)。
1 | function countAncestors(person, test) { |
对这个结果不要太认真,我们的数据集没有普遍性。但是这段代码说明了reduceAncestor
提供给我们一个计算族谱这种数据结构的很好用的代码段。
捆绑
捆绑(binding)
是所有函数都有的一个方法,它会哈村建一个可以调用原函数的新函数,但是有一些参数已经被修改。
下面的代码展示了捆绑
在例子中的应用。它定义了一个函数isinSet
来判断一个人是否在一个给定的字符串之内。为了找出那些在特定数据集的人名对象,我们需要调用filter
。我们可以写一个函数,isInSet
作为其中一个参数,或者部分使用isInSet
函数。
1 | var theSet = ["Carel Haverbeke", "Maria van Brussel", |
调用bind
返回的是以theSet
作为第一个参数的isInSet
函数,然后是任何绑定给函数的参数。
第一个参数,这个例子中传递的是null
,用作调用方法的参数,类似于apply
的第一个参数。我会在下一章中给出详细的解释。
总结
能够把函数作为值传给别的函数,不仅仅是一个小花招,而是JavaScript中非常实用的一个方面。这个设计能够让我们在写代码的时候留有一些‘空隙’,然后用函数值来填补这些‘空隙’。
数组中提供了很多高阶函数,forEach
是针对数组中每个元素进行操作;filter
会根据过滤的元素创建一个新数组;map
用作给每个元素执行一个函数,并把结果创建为一个新的数组;reduce
用于把所有元素合并为一个单一的值。
函数都有apply
方法,用于对不同的参数调用不同的计算方式。同时还有一个bind
方法,用于去创建某个部分使用另一个函数的函数。
练习
过滤
结合reduce
和concat
方法,能把多个数组结合成一个包含每个元素的单个数组。
1 | var arrays = [[1, 2, 3], [4, 5], [6]]; |
母亲和孩子的年龄差
使用本章的数据集,计算母亲和孩子的平均年龄差(母亲生小孩时的年龄)。可以使用本章中的average
函数。
注意并非所有提到的母亲都在数据集当中。使用byName
找到名字所对应的对象,可能会有帮助。
1 | function average(array) { |
历史预期寿命
我们查找出数据集中寿命超过90岁的人,会发现这些人质存在在最后一代中。那我们再仔细研究一下这个现象。
计算并显示数据集中每个世纪的平均寿命。确定一个人属于哪个世纪的方法:卒年除以100,然后进位,比如Math.ceil(person.died / 100)
。
1 | function average(array) { |
附加分:写一个分组函数groupBy
。groupBy
函数应该接受两个参数,一个是数组,还有一个是函数,这个函数为每一个元素计算其分组,并返回带有名称的分好组的数据。
every和some
每个数组都有标准方法every
和some
。两个都是断言函数,当我们给这两个函数传入数组作为参数,他们的返回是true或者false。就像 && 只有在两边的表达式都为真的时候才返回true,every
只有在数组中所有元素都为真的时候才返回true。类似地,some
在数组中任意元素为真时返回true。他们在不必要时不会做更多的计算,比如,如果some
找到的第一个元素为真,就不会继续向后查找。
写两个函数,every
和some
,功能类似上述,但是这里数组作为一个参数。而不是数组中的一个方法。
1 | // Your code here. |