diff --git a/5.md b/5.md index ac65345..a7e2586 100644 --- a/5.md +++ b/5.md @@ -1,11 +1,15 @@ ## 五、高阶函数 + + + + 开发大型程序通常需要耗费大量财力和物力,这绝不仅仅是因为构建程序所花费时间的问题。大型程序的复杂程度总是很高,而这些复杂性也会给开发人员带来不少困扰,而程序错误或缺陷(bug)往往就是这些时候引入的。大型程序为这些缺陷提供了良好的藏身之所,因此我们更加难以在大型程序中找到它们。 让我们简单回顾一下前言当中的两个示例。其中第一个程序包含了6行代码并可以直接运行。 ``` -var total = 0, count = 1; +let total = 0, count = 1; while (count <= 10) { total += count; count += 1; @@ -45,109 +49,69 @@ sum和range这两个函数定义的操作当然会包含循环、计数和其他 在编程的时候,我们不能期望所有功能都是现成的。因此,你可能就会像第一份食谱那样编写你的程序,逐个编写计算机需要执行的代码和步骤,而忽略了这些步骤之上的抽象概念。 -作为一名程序员,我们需要具备在恰当时候将代码抽象出来,形成一个新的函数或概念的能力。 +在编程时,注意您的抽象级别什么时候过低,是一项非常有用的技能。 -### 5.2 数组遍历抽象 +### 5.2 重复的抽象 我们已经了解的普通函数就是一种很好的构建抽象的工具。但有些时候,光有函数也不一定能够解决我们的问题。 -在前面的章节中,出现了好几次这样的for循环语句: +程序以给定次数执行某些操作很常见。 你可以为此写一个`for`循环,就像这样: ``` -var array = [1, 2, 3]; -for (var i = 0; i < array.length; i++) { - var current = array[i]; - console.log(current); +for (let i = 0; i < 10; i++) { + console.log(i); } ``` -这段代码的含义是,将数组中每个元素都打印到控制台中。但这段代码其实绕了个弯,它使用了一个用于计数的变量i,该变量用于检查数组的长度和提取当前遍历的元素。这样的编写方法不仅不够优雅,而且也为程序缺陷留下了可乘之机。我们有可能不小心复用了变量i、错误地将length拼写成length,或者混淆了变量i和current等。 - -那么,让我们来试试将这段代码抽象成一个函数。你会怎么做? - -嗯,遍历整个数组并调用console.log来打印每个元素,倒是非常简单。 +我们是否能够将“做某件事`N`次”抽象为函数? 编写一个调用`console.log` `N`次的函数是很容易的。 ``` -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 -``` - -通常来说,我们不会给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)); - } +function repeatLog(n) { + for (let i = 0; i < n; i++) { + console.log(i); } - return phis; } ``` -使用forEach来改写这段代码,可以让实现变得更加简洁清晰。 +但如果我们想执行打印数字以外的操作该怎么办呢?我们可以使用函数来定义我们想做的事,而函数也是值,因此我们可以将期望执行的操作封装成函数,然后传递进来。 ``` -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 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 function(m) { return m > n; }; + return m => m > n; } -var greaterThan10 = greaterThan(10); +let greaterThan10 = greaterThan(10); console.log(greaterThan10(11)); // → true ``` @@ -156,16 +120,16 @@ console.log(greaterThan10(11)); ``` function noisy(f) { - return function(arg) { - console.log("calling with", arg); - var val = f(arg); - console.log("called with", arg, "- got", val); - return val; + return (...args) => { + console.log("calling with", args); + let result = f(...args); + console.log("called with", args, ", returned", result); + return result; }; } -noisy(Boolean)(0); -// → calling with 0 -// → called with 0 - got false +noisy(Math.min)(3, 2, 1); +// → calling with [3, 2, 1] +// → called with [3, 2, 1] , returned 1 ``` 你甚至可以使用高阶函数来实现新的控制流。 @@ -174,12 +138,8 @@ noisy(Boolean)(0); 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() { +repeat(3, n => { + unless(n % 2 == 1, () => { console.log(n, "is even"); }); }); @@ -187,143 +147,92 @@ repeat(3, function(n) { // → 2 is even ``` -高阶函数的用法正是利用了我们在第3章中所讨论的词法作用域规则。在前面的示例当中,变量n是属于外部函数的参数,但由于内部函数可以访问外部的作用域,因此内部的函数也可以使用变量n。内部函数的函数体可以访问其外部环境的变量,这与循环及条件语句中的{}语句块有异曲同工之妙。其中内部函数与语句块之间的主要区别在于,在内部函数中声明的变量不会因为外部函数执行结束而丢失,这个特性十分有用。 - -### 5.4 参数传递 - -我们在前面定义的函数noisy会把参数传递给另一个函数使用,这会导致一个相当严重的问题。 +有一个内置的数组方法,`forEach`,它提供了类似`for/of`循环的东西,作为一个高阶函数。 ``` -function noisy(f) { - return function(arg) { - console.log("calling with", arg); - var val = f(arg); - console.log("called with", arg, "- got", val); - return val; - }; +["A", "B"].forEach(l => console.log(l)); +// → A +// → B +``` + +## 脚本数据集 + +数据处理是高阶函数表现突出的一个领域。 为了处理数据,我们需要一些真实数据。 本章将使用脚本书写系统的数据集,例如拉丁文,西里尔文或阿拉伯文。 + +请记住第 1 章中的 Unicode,该系统为书面语言中的每个字符分配一个数字。 大多数这些字符都与特定的脚本相关联。 该标准包含 140 个不同的脚本 - 81 个今天仍在使用,59 个是历史性的。 + +虽然我只能流利地阅读拉丁字符,但我很欣赏这样一个事实,即人们使用其他至少 80 种书写系统来编写文本,其中许多我甚至不认识。 例如,以下是泰米尔语手写体的示例。 + +![]() + +示例数据集包含 Unicode 中定义的 140 个脚本的一些信息。 本章的[编码沙箱](https://eloquentjavascript.net/code#5)中提供了`SCRIPTS`绑定。 该绑定包含一组对象,其中每个对象都描述了一个脚本。 + +``` +{ + name: "Coptic", + ranges: [[994, 1008], [11392, 11508], [11513, 11520]], + direction: "ltr", + year: -200, + living: false, + link: "https://en.wikipedia.org/wiki/Coptic_alphabet" } ``` -如果函数f接受多个参数,那么该函数只能接受第一个参数。我们可以为内部函数添加多个参数(arg1、arg2等),然后将这些参数传递给f,但问题在于noisy函数并不知道f函数需要多少参数。因为noisy函数只能传递固定数量的参数给f,因此也不能获取函数的arguments.length,函数f没有办法知道调用者传递给noisy的参数个数。 +这样的对象会告诉您脚本的名称,分配给它的 Unicode 范围,书写方向,(近似)起始时间,是否仍在使用以及更多信息的链接。 方向可以是从左到右的`"ltr"`,从右到左的`"rtl"`(阿拉伯语和希伯来语文字的写法),或者从上到下的`"ttb"`(蒙古文的写法)。 -JavaScript函数的apply方法可以解决这个问题。该方法接受一个参数数组(或与数组相似的对象),并使用这些参数调用其所属的函数。 - -``` -function transparentWrapping(f) { - return function() { - return f.apply(null, arguments); - }; -} -``` - -这里的trasparentWrapping函数没有什么实际用处,但该函数返回的内部函数可以将用户指定的参数全部传递给f。其原理是内部函数调用f函数的apply方法,并将自身的arguments对象传递给该方法。这里我们将apply方法的第一个参数设置为null,该参数用于模拟方法调用。我们会在下一章中使用这个函数的时候再进行详细介绍。 - -### 5.5 JSON - -在JavaScript中,许多高阶函数都会对数组中的元素应用某个函数进行处理。在这些函数当中,最简单的就是forEach方法。数组中提供了许多类似于forEach的方法。为了熟悉这些方法的用法,我们来看一下另一个数据集。 - -几年前,有人翻阅了大量资料并把这些资料整理成了一本我家的族谱(姓氏是Haverbeke,即Oatbrook)。我翻阅了这本书期望能够找到骑士、海盗或炼金师,结果我找到的基本都是弗兰德的农民。作为我自己的消遣习惯,我从中提取了我的直系祖先的信息,并将这些数据录入了计算机当中。 - -我创建的文件内容是这样的: - -``` -[ - {"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 Object Notation的缩写。该格式广泛用于数据存储和Web通信。 - -编写JSON格式代码的方式与编写JavaScript数组和对象的方式十分相似,不过有些限制条件。所有属性名都必须用双引号括起来,而且只能使用简单的数据表达式,不能填写函数调用、变量以及任何含有实际计算过程的代码。JSON当中也不能包含注释。 - -JavaScript提供了JSON.stringify函数,用于将数据转换成该格式。还有JSON.parse函数,用于将该格式数据转换成原有的数据类型。JSON.stringify接受一个JavaScript的值,并返回一个JSON编码的字符串。JSON.parse接受一个字符串,并返回一个解码后的值。 - -``` -var string = JSON.stringify({name: "X", born: 1980}); -console.log(string); -// → {"name":"X","born":1980} -console.log(JSON.parse(string).born); -// → 1980 -``` - -你可以从在线沙盒([http://eloquentjavascript.net/code/](http://eloquentjavascript.net/code/))的本章中下载一个文件,该文件中包含了一个ANCESTRY_FILE变量。该变量是一个包含JSON文件内容的字符串。让我们将字符串解码,并统计一下数据中包含的人数。 - -``` -var ancestry = JSON.parse(ANCESTRY_FILE); -console.log(ancestry.length); -// → 39 -``` +`ranges`属性包含 Unicode 字符范围数组,每个数组都有两元素,包含下限和上限。 这些范围内的任何字符代码都会分配给脚本。 下限是包括的(代码 994 是一个科普特字符),并且上限排除在外(代码 1008 不是)。 ### 5.6 数组过滤 -我们可以使用以下函数来查询族谱数据中1924年的年轻人。该函数会过滤掉数组中不符合规定的元素。 +为了找到数据集中仍在使用的脚本,以下函数可能会有所帮助。 它过滤掉数组中未通过测试的元素: ``` function filter(array, test) { - var passed = []; - for (var i = 0; i < array.length; i++) { - if (test(array[i])) - passed.push(array[i]); + let passed = []; + for (let element of array) { + if (test(element)) { + passed.push(element); + } } return passed; } -console.log(filter(ancestry, function(person) { - return person.born > 1900 && person.born < 1925; -})); -// → [{name: "Philibert Haverbeke", …}, …] +console.log(filter(SCRIPTS, script => script.living)); +// → [{name: "Adlam", …}, …] ``` -该函数使用test函数作为参数来实现过滤操作。我们对数组中的每个元素调用test函数,并通过其返回值来确定当前元素是否满足条件。 - -在该文件中,有三个人满足过滤条件:我的祖父、祖母和叔(或伯)祖母。 +该函数使用名为`test`的参数(一个函数值)填充计算中的“间隙” - 决定要收集哪些元素的过程。 需要注意的是,filter函数并没有从当前数组中删除元素,而是新建了一个数组,并将满足条件的元素存入新建的数组中。这个函数是一个“纯函数”,因为该函数并未修改给定的数组。 -与forEach一样,filter函数也是数组中提供的一个标准方法。本例中定义的函数只是用于展示内部实现原理。今后我们会使用以下方法来过滤数据: +与forEach一样,filter函数也是标准的数组方法。本例中定义的函数只是用于展示内部实现原理。今后我们会使用以下方法来过滤数据: ``` -console.log(ancestry.filter(function(person) { - return person.father == "Carel Haverbeke"; -})); -// → [{name: "Carolus Haverbeke", …}] +console.log(SCRIPTS.filter(s => s.direction == "ttb")); +// → [{name: "Mongolian", …}, …] ``` ### 5.7 使用map函数转换数组 -假设我们已经通过某种方式对ancestry数组进行了过滤,生成一个用于表示人的信息的数组。但我们想创建一个包含人名的数组,因为这样更加易于查阅。 +假设我们已经通过某种方式过滤了`SCRIPTS`数组,生成一个用于表示脚本的信息数组。但我们想创建一个包含名称的数组,因为这样更加易于检查。 map方法可以对数组中的每个元素调用函数,然后利用返回值来构建一个新的数组,实现转换数组的操作。新建数组的长度与输入的数组一致,但其中的内容却通过对每个元素调用的函数“映射”成新的形式。 ``` function map(array, transform) { - var mapped = []; - for (var i = 0; i < array.length; i++) - mapped.push(transform(array[i])); + let mapped = []; + for (let element of array) { + mapped.push(transform(element)); + } 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"] +let rtlScripts = SCRIPTS.filter(s => s.direction == "rtl"); +console.log(map(rtlScripts, s => s.name)); +// → ["Adlam", "Arabic", "Imperial Aramaic", …] ``` -有趣的是,活到至少90岁的人就是我们之前过滤出来的那三个人,即20世纪20年代的那几个年轻人,这几个人在我的数据集中恰好属于最年轻的一代。我想这应该得益于医疗水平的长足进步。 - -与forEach和filter一样,map也是数组中的一个标准方法。 +与forEach和filter一样,map也是标准的数组方法。 ### 5.8 使用reduce进行数据汇总