三、函数
三、函数
人们认为计算机科学是天才的艺术,但是实际情况相反,只是许多人在其它人基础上做一些东西,就像一面由石子垒成的墙。
高德纳
函数是 JavaScript 编程的面包和黄油。 将一段程序包装成值的概念有很多用途。 它为我们提供了方法,用于构建更大程序,减少重复,将名称和子程序关联,以及将这些子程序相互隔离。
函数最明显的应用是定义新词汇。 用散文创造新词汇通常是不好的风格。 但在编程中,它是不可或缺的。
以英语为母语的典型成年人,大约有 2 万字的词汇量。 很少有编程语言内置了 2 万个命令。而且,可用的词汇的定义往往比人类语言更精确,因此灵活性更低。 因此,我们通常会引入新的概念,来避免过多重复。
定义函数
函数定义是一个常规绑定,其中绑定的值是一个函数。 例如,这段代码定义了square
,来引用一个函数,它产生给定数字的平方:
const square = function(x) {
return x * x;
};
console.log(square(12));
// → 144
函数使用以关键字function
起始的表达式创建。 函数有一组参数(在本例中只有x
)和一个主体,它包含调用该函数时要执行的语句。 以这种方式创建的函数的函数体,必须始终包在花括号中,即使它仅包含一个语句。
一个函数可以包含多个参数,也可以不含参数。在下面的例子中,makeNoise
函数中没有包含任何参数,而power
则使用了两个参数:
var makeNoise = function() {
console.log("Pling!");
};
makeNoise();
// → Pling!
const power = function(base, exponent) {
let result = 1;
for (let count = 0; count < exponent; count++) {
result *= base;
}
return result;
};
console.log(power(2, 10));
// → 1024
有些函数会产生一个值,比如power
和square
,有些函数不会,比如makeNoise
,它的唯一结果是副作用。 return
语句决定函数返回的值。 当控制流遇到这样的语句时,它立即跳出当前函数并将返回的值赋给调用该函数的代码。 不带表达式的return
关键字,会导致函数返回undefined
。 没有return
语句的函数,比如makeNoise
,同样返回undefined
。
函数的参数行为与常规绑定相似,但它们的初始值由函数的调用者提供,而不是函数本身的代码。
绑定和作用域
每个绑定都有一个作用域,它是程序的一部分,其中绑定是可见的。 对于在任何函数或块之外定义的绑定,作用域是整个程序 - 您可以在任何地方引用这种绑定。它们被称为全局的。
但是为函数参数创建的,或在函数内部声明的绑定,只能在该函数中引用,所以它们被称为局部绑定。 每次调用该函数时,都会创建这些绑定的新实例。 这提供了函数之间的一些隔离 - 每个函数调用,都在它自己的小世界(它的局部环境)中运行,并且通常可以在不知道全局环境中发生的事情的情况下理解。
用let
和const
声明的绑定,实际上是它们的声明所在的块的局部对象,所以如果你在循环中创建了一个,那么循环之前和之后的代码就不能“看见”它。JavaScript 2015 之前,只有函数创建新的作用域,因此,使用var
关键字创建的旧式绑定,在它们出现的整个函数中内都可见,或者如果它们不在函数中,在全局作用域可见。
let x = 10;
if (true) {
let y = 20;
var z = 30;
console.log(x + y + z);
// → 60
}
// y is not visible here
console.log(x + z);
// → 40
每个作用域都可以“向外查看”它周围的作用域,所以示例中的块内可以看到x
。 当多个绑定具有相同名称时例外 - 在这种情况下,代码只能看到最内层的那个。 例如,当halve
函数中的代码引用n
时,它看到它自己的n
,而不是全局的n
。
const halve = function(n) {
return n / 2;
}
let n = 10;
console.log(halve(100));
// → 50
console.log(n);
// → 10
嵌套作用域
JavaScript 不仅区分全局和局部绑定。 块和函数可以在其他块和函数内部创建,产生多层局部环境。
例如,这个函数(输出制作一批鹰嘴豆泥所需的配料)的内部有另一个函数:
const hummus = function(factor) {
const ingredient = function(amount, unit, name) {
let ingredientAmount = amount * factor;
if (ingredientAmount > 1) {
unit += "s";
}
console.log(`${ingredientAmount} ${unit} ${name}`);
};
ingredient(1, "can", "chickpeas");
ingredient(0.25, "cup", "tahini");
ingredient(0.25, "cup", "lemon juice");
ingredient(1, "clove", "garlic");
ingredient(2, "tablespoon", "olive oil");
ingredient(0.5, "teaspoon", "cumin");
};
ingredient
函数中的代码,可以从外部函数中看到factor
绑定。 但是它的局部绑定,比如unit
或ingredientAmount
,在外层函数中是不可见的。
简而言之,每个局部作用域也可以看到所有包含它的局部作用域。 块内可见的绑定集,由这个块在程序文本中的位置决定。 每个局部作用域也可以看到包含它的所有局部作用域,并且所有作用域都可以看到全局作用域。 这种绑定可见性方法称为词法作用域。
作为值的函数
函数绑定通常只充当程序特定部分的名称。 这样的绑定被定义一次,永远不会改变。 这使得容易混淆函数和名称。
let launchMissiles = function(value) {
missileSystem.launch("now");
};
if (safeMode) {
launchMissiles = function() {/* do nothing */};
}
在第 5 章中,我们将会讨论一些高级功能:将函数类型的值传递给其他函数。
符号声明
创建函数绑定的方法稍短。 当在语句开头使用function
关键字时,它的工作方式不同。
function square(x) {
return x * x;
}
这是函数声明。 该语句定义了绑定square
并将其指向给定的函数。 写起来稍微容易一些,并且在函数之后不需要分号。
这种形式的函数定义有一个微妙之处。
console.log("The future says:", future());
function future() {
return "You'll never have flying cars";
}
前面的代码可以执行,即使在函数定义在使用它的代码下面。 函数声明不是常规的从上到下的控制流的一部分。 在概念上,它们移到了其作用域的顶部,并可被该作用域内的所有代码使用。 这有时是有用的,因为它以一种看似有意义的方式,提供了对代码进行排序的自由,而无需担心在使用之前必须定义所有函数。
箭头函数
函数的第三个符号与其他函数看起来有很大不同。 它不使用function
关键字,而是使用由等号和大于号组成的箭头(=>
)(不要与大于等于运算符混淆,该运算符写做>=
)。
const power = (base, exponent) => {
let result = 1;
for (let count = 0; count < exponent; count++) {
result *= base;
}
return result;
};
箭头出现在参数列表后面,然后是函数的主体。 它表达了一些东西,类似“这个输入(参数)产生这个结果(主体)”。
如果只有一个参数名称,则可以省略参数列表周围的括号。 如果主体是单个表达式,而不是大括号中的块,则表达式将从函数返回。 所以这两个square
的定义是一样的:
const square1 = (x) => { return x * x; };
const square2 = x => x * x;
当一个箭头函数没有参数时,它的参数列表只是一组空括号。
const horn = () => {
console.log("Toot");
};
在语言中没有很好的理由,同时拥有箭头函数和函数表达式。 除了我们将在第 6 章中讨论的一个小细节外,他们实现相同的东西。 在 2015 年增加了箭头函数,主要是为了能够以简短的方式编写小函数表达式。 我们将在第 5 章中使用它们。
调用栈
控制流经过函数的方式有点复杂。 让我们仔细看看它。 这是一个简单的程序,它执行了一些函数调用:
function greet(who) {
console.log("Hello " + who);
}
greet("Harry");
console.log("Bye");
这个程序的执行大致是这样的:对greet
的调用使控制流跳转到该函数的开始(第 2 行)。 该函数调用控制台的console.log
来完成它的工作,然后将控制流返回到第 2 行。 它到达greet
函数的末尾,所以它返回到调用它的地方,这是第 4 行。 之后的一行再次调用console.log
。 之后,程序结束。
我们可以使用下图表示出控制流:
not in function
in greet
in console.log
in greet
not in function
in console.log
not in function
由于函数在返回时必须跳回调用它的地方,因此计算机必须记住调用发生处上下文。 在一种情况下,console.log
完成后必须返回greet
函数。 在另一种情况下,它返回到程序的结尾。
计算机存储此上下文的地方是调用栈。 每次调用函数时,当前上下文都存储在此栈的顶部。 当函数返回时,它会从栈中删除顶部上下文,并使用该上下文继续执行。
存储这个栈需要计算机内存中的空间。 当栈变得太大时,计算机将失败,并显示“栈空间不足”或“递归太多”等消息。 下面的代码通过向计算机提出一个非常困难的问题来说明这一点,这个问题会导致两个函数之间的无限的来回调用。 相反,如果计算机有无限的栈,它将会是无限的。 事实上,我们将耗尽空间,或者“把栈顶破”。
function chicken() {
return egg();
}
function egg() {
return chicken();
}
console.log(chicken() + " came first.");
// → ??
可选参数
下面的代码可以正常执行:
function square(x) { return x * x; }
console.log(square(4, true, "hedgehog"));
// → 16
我们定义了square
,只带有一个参数。 然而,当我们使用三个参数调用它时,语言并不会报错。 它会忽略额外的参数并计算第一个参数的平方。
JavaScript 对传入函数的参数数量几乎不做任何限制。如果你传递了过多参数,多余的参数就会被忽略掉,而如果你传递的参数过少,遗漏的参数将会被赋值成undefined
。
该特性的缺点是你可能恰好向函数传递了错误数量的参数,但没有人会告诉你这个错误。
优点是这种行为可以用于使用不同数量的参数调用一个函数。 例如,这个minus
函数试图通过作用于一个或两个参数,来模仿-
运算符:
function minus(a, b) {
if (b === undefined) return -a;
else return a - b;
}
console.log(minus(10));
// → -10
console.log(minus(10, 5));
// → 5
如果你在一个参数后面写了一个=
运算符,然后是一个表达式,那么当没有提供它时,该表达式的值将会替换该参数。
例如,这个版本的power
使其第二个参数是可选的。 如果你没有提供或传递undefined
,它将默认为 2,函数的行为就像square
。
function power(base, exponent = 2) {
let result = 1;
for (let count = 0; count < exponent; count++) {
result *= base;
}
return result;
}
console.log(power(4));
// → 16
console.log(power(2, 6));
// → 64
在下一章当中,我们将会了解如何获取传递给函数的整个参数列表。我们可以借助于这种特性来实现函数接收任意数量的参数。比如console.log
就利用了这种特性,它可以用来输出所有传递给它的值。
console.log("C", "O", 2);
// → C O 2
闭包
函数可以作为值使用,而且其局部绑定会在每次函数调用时重新创建,由此引出一个值得我们探讨的问题:如果函数已经执行结束,那么这些由函数创建的局部绑定会如何处理呢?
下面的示例代码展示了这种情况。代码中定义了函数wrapValue
,该函数创建了一个局部绑定localVariable
,并返回一个函数,用于访问并返回局部绑定localVariable
。
function wrapValue(n) {
let local = n;
return () => local;
}
let wrap1 = wrapValue(1);
let wrap2 = wrapValue(2);
console.log(wrap1());
// → 1
console.log(wrap2());
// → 2
这是允许的并且按照您的希望运行 - 绑定的两个实例仍然可以访问。 这种情况很好地证明了一个事实,每次调用都会重新创建局部绑定,而且不同的调用不能覆盖彼此的局部绑定。
这种特性(可以引用封闭作用域中的局部绑定的特定实例)称为闭包。 引用来自周围的局部作用域的绑定的函数称为(一个)闭包。 这种行为不仅可以让您免于担心绑定的生命周期,而且还可以以创造性的方式使用函数值。
我们对上面那个例子稍加修改,就可以创建一个可以乘以任意数字的函数。
function multiplier(factor) {
return number => number * factor;
}
let twice = multiplier(2);
console.log(twice(5));
// → 10
由于参数本身就是一个局部绑定,所以wrapValue
示例中显式的local
绑定并不是真的需要。
考虑这样的程序需要一些实践。 一个好的心智模型是,将函数值看作值,包含他们主体中的代码和它们的创建环境。 被调用时,函数体会看到它的创建环境,而不是它的调用环境。
这个例子调用multiplier
并创建一个环境,其中factor
参数绑定了 2。 它返回的函数值,存储在twice
中,会记住这个环境。 所以当它被调用时,它将它的参数乘以 2。
递归
一个函数调用自己是完全可以的,只要它没有经常这样做以致溢出栈。 调用自己的函数被称为递归函数。 递归允许一些函数以不同的风格编写。 举个例子,这是power
的替代实现:
function power(base, exponent) {
if (exponent == 0) {
return 1;
} else {
return base * power(base, exponent - 1);
}
}
console.log(power(2, 3));
// → 8
这与数学家定义幂运算的方式非常接近,并且可以比循环变体将该概念描述得更清楚。 该函数以更小的指数多次调用自己以实现重复的乘法。
但是这个实现有一个问题:在典型的 JavaScript 实现中,它大约比循环版本慢三倍。 通过简单循环来运行,通常比多次调用函数开销低。
速度与优雅的困境是一个有趣的问题。 您可以将其视为人性化和机器友好性之间的权衡。 几乎所有的程序都可以通过更大更复杂的方式加速。 程序员必须达到适当的平衡。
在power
函数的情况下,不雅的(循环)版本仍然非常简单易读。 用递归版本替换它没有什么意义。 然而,通常情况下,一个程序处理相当复杂的概念,为了让程序更直接,放弃一些效率是有帮助的。
担心效率可能会令人分心。 这又是另一个让程序设计变复杂的因素,当你做了一件已经很困难的事情时,担心的额外事情可能会瘫痪。
因此,总是先写一些正确且容易理解的东西。 如果您担心速度太慢 - 通常不是这样,因为大多数代码的执行不足以花费大量时间 - 您可以事后进行测量并在必要时进行改进。
递归并不总是循环的低效率替代方法。 递归比循环更容易解决解决一些问题。 这些问题通常是需要探索或处理几个“分支”的问题,每个“分支”可能再次派生为更多的分支。
考虑这个难题:从数字 1 开始,反复加 5 或乘 3,就可以产生无限数量的新数字。 你会如何编写一个函数,给定一个数字,它试图找出产生这个数字的,这种加法和乘法的序列?
例如,数字 13 可以通过先乘 3 然后再加 5 两次来到达,而数字 15 根本无法到达。
使用递归编码的解决方案如下所示:
function findSolution(target) {
function find(current, history) {
if (current == target) {
return history;
} else if (current > target) {
return null;
} else {
return find(current + 5, `(${history} + 5)`) ||
find(current * 3, `(${history} * 3)`);
}
}
return find(1, "1");
}
console.log(findSolution(24));
// → (((1 * 3) + 5) * 3)
需要注意的是该程序并不需要找出最短运算序列,只需要找出任何一个满足要求的序列即可。
如果你没有看到它的工作原理,那也没关系。 让我们浏览它,因为它是递归思维的很好的练习。
内层函数find
进行实际的递归。 它有两个参数:当前数字和记录我们如何到达这个数字的字符串。 如果找到解决方案,它会返回一个字符串,显示如何到达目标。 如果从这个数字开始找不到解决方案,则返回null
。
为此,该函数执行三个操作之一。 如果当前数字是目标数字,则当前历史记录是到达目标的一种方式,因此将其返回。 如果当前的数字大于目标,则进一步探索该分支是没有意义的,因为加法和乘法只会使数字变大,所以它返回null
。 最后,如果我们仍然低于目标数字,函数会尝试从当前数字开始的两个可能路径,通过调用它自己两次,一次是加法,一次是乘法。 如果第一次调用返回非null
的东西,则返回它。 否则,返回第二个调用,无论它产生字符串还是null
。
为了更好地理解函数执行过程,让我们来看一下搜索数字 13 时,find
函数的调用情况:
find(1, "1")
find(6, "(1 + 5)")
find(11, "((1 + 5) + 5)")
find(16, "(((1 + 5) + 5) + 5)")
too big
find(33, "(((1 + 5) + 5) * 3)")
too big
find(18, "((1 + 5) * 3)")
too big
find(3, "(1 * 3)")
find(8, "((1 * 3) + 5)")
find(13, "(((1 * 3) + 5) + 5)")
found!
缩进表示调用栈的深度。 第一次调用find
时,它首先调用自己来探索以(1 + 5)
开始的解决方案。 这一调用将进一步递归,来探索每个后续的解,它产生小于或等于目标数字。 由于它没有找到一个命中目标的解,所以它向第一个调用返回null
。 那里的||
操作符会使探索(1 * 3)
的调用发生。 这个搜索的运气更好 - 它的第一次递归调用,通过另一个递归调用,命中了目标数字。 最内层的调用返回一个字符串,并且中间调用中的每个“||”运算符都会传递该字符串,最终返回解决方案。
添加新函数
这里有两种常用的方法,将函数引入到程序中。
首先是你发现自己写了很多次非常相似的代码。 我们最好不要这样做。 拥有更多的代码,意味着更多的错误空间,并且想要了解程序的人阅读更多资料。 所以我们选取重复的功能,为它找到一个好名字,并把它放到一个函数中。
第二种方法是,你发现你需要一些你还没有写的功能,这听起来像是它应该有自己的函数。 您将首先命名该函数,然后您将编写它的主体。 在实际定义函数本身之前,您甚至可能会开始编写使用该函数的代码。
给函数起名的难易程度取决于我们封装的函数的用途是否明确。对此,我们一起来看一个例子。
我们想编写一个打印两个数字的程序,第一个数字是农场中牛的数量,第二个数字是农场中鸡的数量,并在数字后面跟上Cows
和Chickens
用以说明,并且在两个数字前填充 0,以使得每个数字总是由三位数字组成。
007 Cows
011 Chickens
这需要两个参数的函数 - 牛的数量和鸡的数量。 让我们来编程。
function printFarmInventory(cows, chickens) {
let cowString = String(cows);
while (cowString.length < 3) {
cowString = "0" + cowString;
}
console.log(`${cowString} Cows`);
let chickenString = String(chickens);
while (chickenString.length < 3) {
chickenString = "0" + chickenString;
}
console.log(`${chickenString} Chickens`);
}
printFarmInventory(7, 11);
在字符串表达式后面写.length
会给我们这个字符串的长度。 因此,while
循环在数字字符串前面加上零,直到它们至少有三个字符的长度。
任务完成! 但就在我们即将向农民发送代码(连同大量发票)时,她打电话告诉我们,她也开始饲养猪,我们是否可以扩展软件来打印猪的数量?
当然没有问题。但是当再次复制粘贴这四行代码的时候,我们停了下来并重新思考。一定还有更好的方案来解决我们的问题。以下是第一种尝试:
function printZeroPaddedWithLabel(number, label) {
let numberString = String(number);
while (numberString.length < 3) {
numberString = "0" + numberString;
}
console.log(`${numberString} ${label}`);
}
function printFarmInventory(cows, chickens, pigs) {
printZeroPaddedWithLabel(cows, "Cows");
printZeroPaddedWithLabel(chickens, "Chickens");
printZeroPaddedWithLabel(pigs, "Pigs");
}
printFarmInventory(7, 11, 3);
这种方法解决了我们的问题!但是printZeroPaddedWithLabel
这个函数并不十分恰当。它把三个操作,即打印信息、数字补零和添加标签放到了一个函数中处理。
这一次,我们不再将程序当中重复的代码提取成一个函数,而只是提取其中一项操作。
function zeroPad(number, width) {
let string = String(number);
while (string.length < width) {
string = "0" + string;
}
return string;
}
function printFarmInventory(cows, chickens, pigs) {
console.log(`${zeroPad(cows, 3)} Cows`);
console.log(`${zeroPad(chickens, 3)} Chickens`);
console.log(`${zeroPad(pigs, 3)} Pigs`);
}
printFarmInventory(7, 16, 3);
名为zeroPad
的函数具有很好的名称,使读取代码的人更容易弄清它的功能。 而且这样的函数在更多的情况下是有用的,不仅仅是这个特定程序。 例如,您可以使用它来帮助打印精确对齐的数字表格。
我们的函数应该包括多少功能呢?我们可以编写一个非常简单的函数,只支持将数字扩展成 3 字符宽。也可以编写一个复杂通用的数字格式化系统,可以处理分数、负数、小数点对齐和使用不同字符填充等。
一个实用原则是不要故作聪明,除非你确定你会需要它。 为你遇到的每一个功能编写通用“框架”是很诱人的。 控制住那种冲动。 你不会完成任何真正的工作 - 你只会编写你永远不会使用的代码。
函数及其副作用
我们可以将函数分成两类:一类调用后产生副作用,而另一类则产生返回值(当然我们也可以定义同时产生副作用和返回值的函数)。
在农场案例当中,我们调用第一个辅助函数printZeroPaddedWithLabel
来产生副作用,打印一行文本信息。而在第二个版本中有一个zeroPad
函数,我们调用它来产生返回值。第二个函数比第一个函数的应用场景更加广泛,这并非偶然。相比于直接产生副作用的函数,产生返回值的函数则更容易集成到新的环境当中使用。
纯函数是一种特定类型的,生成值的函数,它不仅没有副作用,而且也不依赖其他代码的副作用,例如,它不读取值可能会改变的全局绑定。 纯函数具有令人愉快的属性,当用相同的参数调用它时,它总是产生相同的值(并且不会做任何其他操作)。 这种函数的调用,可以由它的返回值代替而不改变代码的含义。 当你不确定纯函数是否正常工作时,你可以通过简单地调用它来测试它,并且知道如果它在当前上下文中工作,它将在任何上下文中工作。 非纯函数往往需要更多的脚手架来测试。
尽管如此,我们也没有必要觉得非纯函数就不好,然后将这类函数从代码中删除。副作用常常是非常有用的。比如说,我们不可能去编写一个纯函数版本的console.log
,但console.log
依然十分实用。而在副作用的帮助下,有些操作则更易、更快实现,因此考虑到运算速度,有时候纯函数并不可取。
本章小结
本章教你如何编写自己的函数。 当用作表达式时,function
关键字可以创建一个函数值。 当作为一个语句使用时,它可以用来声明一个绑定,并给它一个函数作为它的值。 箭头函数是另一种创建函数的方式。
// Define f to hold a function value
const f = function(a) {
console.log(a + 2);
};
// Declare g to be a function
function g(a, b) {
return a * b * 3.5;
}
// A less verbose function value
let h = a => a % 3;
理解函数的一个关键方面是理解作用域。 每个块创建一个新的作用域。 在给定作用域内声明的参数和绑定是局部的,并且从外部看不到。 用var
声明的绑定行为不同 - 它们最终在最近的函数作用域或全局作用域内。
将程序执行的任务分成不同的功能是有帮助的。 你不必重复自己,函数可以通过将代码分组成一些具体事物,来组织程序。
习题
最小值
前一章介绍了标准函数Math.min
,它可以返回参数中的最小值。我们现在可以构建相似的东西。编写一个函数min
,接受两个参数,并返回其最小值。
// Your code here.
console.log(min(0, 10));
// → 0
console.log(min(0, -10));
// → -10
递归
我们已经看到,%
(取余运算符)可以用于判断一个数是否是偶数,通过使用% 2
来检查它是否被 2 整除。这里有另一种方法来判断一个数字是偶数还是奇数:
0是偶数
1是奇数
对于其他任何数字N,其奇偶性与N–2相同。
定义对应此描述的递归函数isEven
。 该函数应该接受一个参数(一个正整数)并返回一个布尔值。
使用 50 与 75 测试该函数。想想如果参数为 –1 会发生什么以及产生相应结果的原因。请你想一个方法来修正该问题。
// Your code here.
console.log(isEven(50));
// → true
console.log(isEven(75));
// → false
console.log(isEven(-1));
// → ??
字符计数
你可以通过编写"string"[N]
,来从字符串中得到第N
个字符或字母。 返回的值将是只包含一个字符的字符串(例如"b"
)。 第一个字符的位置为零,这会使最后一个字符在string.length - 1
。 换句话说,含有两个字符的字符串的长度为2,其字符的位置为 0 和 1。
编写一个函数countBs
,接受一个字符串参数,并返回一个数字,表示该字符串中有多少个大写字母"B"
。
接着编写一个函数countChar
,和countBs
作用一样,唯一区别是接受第二个参数,指定需要统计的字符(而不仅仅能统计大写字母"B"
)。并使用这个新函数重写函数countBs
。
// Your code here.
console.log(countBs("BBC"));
// → 2
console.log(countChar("kakkerlak", "k"));
// → 4