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

289
5.md
View File

@ -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进行数据汇总