第 3 章函数
你已经见过函数值,比如 alert
,以及如何调用它们。函数是 JavaScript 编程的重中之重。将一段程序封装到一个值的概念有很多用途。它是一个工具,用于构建更大的程序、减少重复、将名称与子程序相关联,以及将这些子程序彼此隔离。
函数最明显的应用是定义新的词汇。在常规的人类语言散文写作中创建新词通常是糟糕的风格。但在编程中,它是必不可少的。
典型的成年英语使用者拥有大约 20,000 个词汇。很少有编程语言内置了 20,000 条命令。而且,可用的词汇往往比人类语言中定义得更精确,因此更不灵活。因此,我们通常必须添加一些我们自己的词汇,以避免过度重复。
定义函数
函数定义仅仅是一个常规的变量定义,其中给变量赋的值恰好是一个函数。例如,以下代码定义了变量 square
,它引用一个函数,该函数产生给定数字的平方
var square = function(x) { return x * x; }; console.log(square(12)); // → 144
函数是由一个以 function
关键字开头的表达式创建的。函数具有一组参数(在本例中,只有 x
)和一个主体,其中包含在调用函数时要执行的语句。函数体必须始终用花括号括起来,即使它只包含一条语句(如上例)。
函数可以有多个参数,也可以没有参数。在以下示例中,makeNoise
没有列出任何参数名称,而 power
列出了两个参数名称
var makeNoise = function() { console.log("Pling!"); }; makeNoise(); // → Pling! var power = function(base, exponent) { var result = 1; for (var count = 0; count < exponent; count++) result *= base; return result; }; console.log(power(2, 10)); // → 1024
有些函数产生一个值,例如 power
和 square
,有些函数不产生值,例如 makeNoise
,它只产生副作用。return
语句决定函数返回的值。当控制流遇到这样的语句时,它会立即跳出当前函数,并将返回值传递给调用该函数的代码。return
关键字后面没有表达式,将导致函数返回 undefined
。
参数和作用域
函数的参数的行为类似于普通变量,但它们的初始值是由调用函数的代码提供的,而不是函数本身的代码。
函数的一个重要特性是,函数内部创建的变量,包括其参数,是局部于函数的。这意味着,例如,power
示例中的 result
变量将在每次调用函数时被重新创建,并且这些独立的实例不会相互干扰。
这种变量的“局部性”仅适用于参数和在函数体内部用 var
关键字声明的变量。在任何函数外部声明的变量称为全局变量,因为它们在整个程序中可见。可以从函数内部访问这些变量,只要你没有用相同的名称声明局部变量。
以下代码演示了这一点。它定义并调用了两个函数,这两个函数都将一个值赋给变量 x
。第一个函数将变量声明为局部变量,因此只更改局部变量。第二个函数没有在局部范围内声明 x
,因此在它内部对 x
的引用是指向在示例开头定义的全局变量 x
。
var x = "outside"; var f1 = function() { var x = "inside f1"; }; f1(); console.log(x); // → outside var f2 = function() { x = "inside f2"; }; f2(); console.log(x); // → inside f2
这种行为有助于防止函数之间意外干扰。如果所有变量都是由整个程序共享的,那么要确保永远不会为两个不同的目的使用同一个名称将需要大量努力。而且,如果你确实重用了变量名称,你可能会看到来自无关代码的奇怪影响,这些代码会干扰你的变量的值。通过将函数局部变量视为只存在于函数内部,该语言使我们能够将函数读作和理解为小宇宙,而不必担心所有代码。
嵌套作用域
JavaScript 不仅区分全局变量和局部变量。函数可以在其他函数内部创建,产生几种不同程度的局部性。
var landscape = function() { var result = ""; var flat = function(size) { for (var count = 0; count < size; count++) result += "_"; }; var mountain = function(size) { result += "/"; for (var count = 0; count < size; count++) result += "'"; result += "\\"; }; flat(3); mountain(4); flat(6); mountain(1); flat(1); return result; }; console.log(landscape()); // → ___/''''\______/'\_
flat
和 mountain
函数可以“看到”名为 result
的变量,因为它们在定义该变量的函数内部。但是,它们无法看到彼此的 count
变量,因为它们彼此的作用域之外。landscape
函数外部的环境看不到 landscape
内部定义的任何变量。
简而言之,每个局部作用域也可以看到包含它的所有局部作用域。函数内部可见的变量集由该函数在程序文本中的位置决定。所有来自函数定义周围的块的变量都是可见的——这意味着包含它的函数体中的变量和程序顶层的变量。这种变量可见性方法称为词法作用域。
有其他编程语言经验的人可能会预期,花括号之间的任何代码块都会产生一个新的局部环境。但在 JavaScript 中,只有函数会创建新的作用域。你被允许使用独立的代码块。
var something = 1; { var something = 2; // Do stuff with variable something... } // Outside of the block again...
但是,代码块中的 something
指的是与代码块外部相同的变量。事实上,尽管允许使用这种代码块,但它们只用于对 if
语句或循环的主体进行分组。
如果你觉得这很奇怪,那你并不孤单。下一版 JavaScript 将引入一个 let
关键字,它的作用类似于 var
,但会创建一个局部于封闭代码块的变量,而不是局部于封闭函数的变量。
函数作为值
函数变量通常只是程序特定部分的名称。这样的变量只定义一次,并且从不更改。这使得人们很容易开始混淆函数和它的名称。
但是,两者是不同的。函数值可以做其他值可以做的事情——你可以在任意表达式中使用它,而不仅仅是调用它。可以将函数值存储在新的位置,将它作为参数传递给函数,等等。同样,一个保存函数的变量仍然只是一个常规变量,并且可以像这样被赋予新值
var launchMissiles = function(value) { missileSystem.launch("now"); }; if (safeMode) launchMissiles = function(value) {/* do nothing */};
在第 5 章中,我们将讨论通过将函数值传递给其他函数可以实现的奇妙事情。
声明语法
有一种稍微简短的方法来说“var square = function…
”。function
关键字也可以用在语句的开头,如下所示
function square(x) { return x * x; }
这是一个函数声明。该语句定义了变量 square
并将其指向给定的函数。到目前为止一切都好。但是,这种形式的函数定义有一个微妙之处。
console.log("The future says:", future()); function future() { return "We STILL have no flying cars."; }
这段代码可以运行,即使函数定义在使用它的代码下面。这是因为函数声明不是常规的从上到下的控制流的一部分。从概念上讲,它们会被移动到其作用域的顶部,并且可以被该作用域中的所有代码使用。这有时很有用,因为它让我们能够以一种有意义的方式对代码进行排序,而无需担心必须在第一次使用之前定义所有函数。
当你将这样的函数定义放在条件 (if
) 代码块或循环内部时会发生什么?好吧,不要这样做。在不同的浏览器中,不同的 JavaScript 平台在这种情况下的行为传统上是不同的,而最新的标准实际上禁止这样做。如果你希望程序的行为一致,只在函数或程序的最外层代码块中使用这种形式的函数定义语句。
function example() { function a() {} // Okay if (something) { function b() {} // Danger! } }
调用栈
仔细研究一下控制流如何通过函数的方式将很有帮助。这是一个简单的程序,它进行了一些函数调用
function greet(who) { console.log("Hello " + who); } greet("Harry"); console.log("Bye");
对该程序的一次运行大致如下:对 greet
的调用会导致控制流跳转到该函数的开头(第 2 行)。它调用 console.log
(一个内置的浏览器函数),该函数接管控制权,完成其工作,然后将控制权返回到第 2 行。然后,它到达 greet
函数的末尾,因此它返回到调用它的位置,即第 4 行。下一行再次调用 console.log
。
top greet console.log greet top console.log top
因为函数在返回时必须跳回调用它的位置,所以计算机必须记住函数被调用时的上下文。在一个例子中,console.log
必须跳回 greet
函数。在另一个例子中,它跳回程序的末尾。
存储此堆栈需要计算机内存中的空间。当堆栈增长过大时,计算机将失败并显示类似“堆栈空间不足”或“递归过多”的消息。以下代码通过向计算机提出一个非常难的问题来说明这一点,这会导致两个函数之间无限的来回调用。确切地说,如果计算机具有无限的堆栈,它 *将* 是无限的。就目前而言,我们将用尽空间,或者“炸毁堆栈”。
function chicken() { return egg(); } function egg() { return chicken(); } console.log(chicken() + " came first."); // → ??
可选参数
alert("Hello", "Good Evening", "How do you do?");
函数 alert
官方只接受一个参数。但是,当你像这样调用它时,它不会抱怨。它只是忽略其他参数并显示“Hello”。
JavaScript 对传递给函数的参数数量极其宽容。如果你传递太多,多余的参数将被忽略。如果你传递太少,缺少的参数将被简单地分配值 undefined
。
这样做有一个缺点,那就是你可能会意外地向函数传递错误数量的参数,而且没有人会告诉你。
好处是,这种行为可以用于让函数接收“可选”参数。例如,以下版本的 power
可以使用两个参数或一个参数来调用,在这种情况下,指数被假定为 2,并且该函数的行为类似于 square
。
function power(base, exponent) { if (exponent == undefined) exponent = 2; var result = 1; for (var count = 0; count < exponent; count++) result *= base; return result; } console.log(power(4)); // → 16 console.log(power(4, 3)); // → 64
在 下一章中,我们将看到一种方法,函数体可以通过该方法获取传递的精确参数列表。这很有用,因为它使函数能够接受任意数量的参数。例如,console.log
利用了这一点——它输出给定给它的所有值。
console.log("R", 2, "D", 2); // → R 2 D 2
闭包
将函数视为值的能力,再加上每次调用函数时都会“重新创建”局部变量的事实,引发了一个有趣的问题。当创建局部变量的函数调用不再处于活动状态时,会发生什么?
以下代码展示了一个示例。它定义了一个函数 wrapValue
,该函数创建一个局部变量。然后它返回一个访问并返回此局部变量的函数。
function wrapValue(n) { var localVariable = n; return function() { return localVariable; }; } var wrap1 = wrapValue(1); var wrap2 = wrapValue(2); console.log(wrap1()); // → 1 console.log(wrap2()); // → 2
这是允许的,并且按你期望的那样工作——变量仍然可以访问。事实上,变量的多个实例可以同时处于活动状态,这是对每次调用都会真正重新创建局部变量这一概念的另一个很好的说明——不同的调用不会破坏彼此的局部变量。
此功能——能够引用封闭函数中局部变量的特定实例——称为 *闭包*。一个“封闭”一些局部变量的函数称为 *闭包*。这种行为不仅让你不必担心变量的生命周期,而且还允许对函数值进行一些创造性的使用。
通过稍微改变一下,我们可以将前面的示例变成一种创建以任意数量相乘的函数的方法。
function multiplier(factor) { return function(number) { return number * factor; }; } var twice = multiplier(2); console.log(twice(5)); // → 10
wrapValue
示例中的显式 localVariable
不需要,因为参数本身就是一个局部变量。
思考这样的程序需要一些练习。一个好的心理模型是将 function
关键字视为“冻结”其主体中的代码并将其包装到一个包中(函数值)。因此,当你阅读 return function(...) {...}
时,将其视为返回一个对计算片段的句柄,该片段被冻结以备将来使用。
在示例中,multiplier
返回一段被冻结的代码,该代码被存储在 twice
变量中。最后一行然后调用此变量中的值,导致冻结的代码 (return number * factor;
) 被激活。它仍然可以访问创建它的 multiplier
调用中的 factor
变量,此外,它还可以通过其 number
参数访问解冻时传递的参数 5。
递归
函数可以调用自身,只要它注意不要溢出堆栈即可。一个调用自身的函数称为 *递归*。递归允许以不同的风格编写一些函数。例如,这是 power
的另一种实现
function power(base, exponent) { if (exponent == 0) return 1; else return base * power(base, exponent - 1); } console.log(power(2, 3)); // → 8
这与数学家定义指数的方式非常接近,并且可以说比循环变体更优雅地描述了这个概念。该函数使用不同的参数多次调用自身来实现重复的乘法。
但这种实现有一个重要的问题:在典型的 JavaScript 实现中,它比循环版本慢大约 10 倍。运行一个简单的循环比多次调用函数便宜得多。
速度与优雅的困境是一个有趣的问题。你可以将其视为人类友好性与机器友好性之间的一种连续体。几乎任何程序都可以通过使其变得更大、更复杂来加快速度。程序员必须决定一个适当的平衡。
在 之前 的 power
函数的情况下,不优雅的(循环)版本仍然相当简单易懂。用递归版本替换它没有多大意义。但是,通常程序处理的概念非常复杂,以至于放弃一些效率以便使程序更直观成为一个有吸引力的选择。
许多程序员重复的,我也完全同意的基本规则是,在确切知道程序太慢之前不要担心效率。如果是这样,找出哪些部分占用了最多的时间,然后开始在这些部分交换优雅和效率。
当然,这条规则并不意味着应该完全忽略性能。在许多情况下,就像 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
。
为了更好地理解此函数如何产生我们想要的效果,让我们看看搜索数字 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)
和 (1 * 3)
开头的解决方案。第一次调用尝试找到以 (1 + 5)
开头的解决方案,并使用递归探索 *所有* 产生小于或等于目标数字的数字的解决方案。由于它没有找到击中目标的解决方案,因此它将 null
返回给第一次调用。在第一次调用中,||
运算符导致探索 (1 * 3)
的调用发生。此搜索运气更好,因为它的第一次递归调用,通过 *另一个* 递归调用,碰巧碰到了目标数字 13。这个最内部的递归调用返回一个字符串,并且中间调用中的每个 ||
运算符都会将该字符串传递下去,最终返回我们的解决方案。
增长的函数
第一种是你发现自己多次编写非常相似的代码。我们希望避免这样做,因为拥有更多代码意味着隐藏错误的空间更大,而且阅读代码的人需要阅读的材料更多。因此,我们将重复的功能提取出来,为它找到一个好名字,并将其放入一个函数中。
第二种方法是你发现你需要一些你还没有编写过而且听起来应该有自己的函数的功能。你将首先命名该函数,然后编写它的主体。你甚至可以在实际定义函数本身之前就开始编写使用该函数的代码。
给函数起一个好名字的难度,很好地反映了你想要封装的概念的清晰程度。让我们举个例子。
我们想要编写一个程序,它打印两个数字,即农场里奶牛和鸡的数量,并在它们后面加上 Cows
和 Chickens
字样,并在两个数字之前用零填充,使它们始终为三位数。
007 Cows 011 Chickens
function printFarmInventory(cows, chickens) { var cowString = String(cows); while (cowString.length < 3) cowString = "0" + cowString; console.log(cowString + " Cows"); var chickenString = String(chickens); while (chickenString.length < 3) chickenString = "0" + chickenString; console.log(chickenString + " Chickens"); } printFarmInventory(7, 11);
在字符串值后面添加 .length
将给出该字符串的长度。因此,while
循环不断在数字字符串前面添加零,直到它们至少包含三个字符。
任务完成!但是,就在我们要把代码(当然还有巨额发票)发给农民的时候,他打电话来说他开始养猪了,能不能请我们把软件扩展到也打印猪的数量?
当然可以。但是,就在我们准备复制粘贴那四行代码一次的时候,我们停下来重新考虑了一下。一定有更好的方法。这里是一个初步尝试
function printZeroPaddedWithLabel(number, label) { var 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) { var 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
,可以让阅读代码的人更容易地理解它的作用。它在比这个特定程序更多的场景中都有用。例如,你可以用它来帮助打印排版整齐的数字表格。
我们的函数应该有多智能、多通用?我们可以编写任何函数,从一个简单的函数(仅仅填充数字,使其宽度为三个字符),到一个复杂的通用数字格式化系统(处理小数、负数、小数点的对齐、用不同字符填充等等)。
一个有用的原则是不添加任何聪明的东西,除非你绝对确定你需要它。写一些通用的“框架”来处理你遇到的每一个小功能,这可能很诱人。要抵制这种冲动。你不会完成任何实际工作,而且你会最终写出很多没人会用到的代码。
函数和副作用
函数可以粗略地分为两种:为了副作用而调用和为了返回值而调用。(虽然同时具有副作用并返回一个值也是绝对可能的。)
农场示例中的第一个辅助函数 printZeroPaddedWithLabel
,是为了它的副作用而调用的:它打印一行。第二个版本 zeroPad
则是为了它的返回值而调用的。第二个函数比第一个函数在更多情况下有用,这并非巧合。创建值的函数比直接执行副作用的函数更容易以新的方式组合。
纯函数是一种特殊的产生值的函数,它不仅没有副作用,而且不依赖于其他代码的副作用——例如,它不读取偶尔会被其他代码改变的全局变量。纯函数具有一个令人愉快的特性,即当使用相同的参数调用时,它总是产生相同的值(并且不执行任何其他操作)。这使得它易于推理。对这种函数的调用可以在思想上用它的结果代替,而不会改变代码的含义。当你无法确定纯函数是否工作正常时,你可以通过简单地调用它来测试它,并且知道如果它在该上下文中起作用,它将在任何上下文中起作用。非纯函数可能会根据各种因素返回不同的值,并具有难以测试和思考的副作用。
不过,在编写非纯函数时,没有必要感到沮丧,也不需要发动一场圣战来从代码中清除它们。副作用通常很有用。例如,不可能编写 console.log
的纯版本,而 console.log
当然很有用。当我们使用副作用时,一些操作也更容易以有效的方式表达,因此计算速度可能是避免纯粹性的原因。
总结
本章教你如何编写自己的函数。function
关键字,当用作表达式时,可以创建一个函数值。当用作语句时,它可以用来声明一个变量,并赋予它一个函数作为它的值。
// Create a function value f var f = function(a) { console.log(a + 2); }; // Declare g to be a function function g(a, b) { return a * b * 3.5; }
理解函数的一个关键方面是理解局部作用域。在函数内部声明的参数和变量对函数是局部的,在每次调用函数时重新创建,并且从外部不可见。在另一个函数内部声明的函数可以访问外部函数的局部作用域。
将程序执行的任务分离到不同的函数中是有帮助的。你将不必重复自己那么多,而且函数可以通过将代码分组为概念块来使程序更易读,就像章节和部分帮助组织普通文本一样。
练习
最小值
之前的章节介绍了标准函数 Math.min
,它返回其最小的参数。我们现在可以自己做。编写一个函数 min
,它接受两个参数并返回它们的最小值。
// Your code here. console.log(min(0, 10)); // → 0 console.log(min(0, -10)); // → -10
递归
我们已经看到,%
(余数运算符)可以用来测试一个数字是偶数还是奇数,通过使用 % 2
来检查它是否可以被二整除。这里还有另一种方法来定义一个正整数是偶数还是奇数
定义一个递归函数 isEven
,对应于此描述。该函数应该接受一个 number
参数并返回一个布尔值。
在 50 和 75 上测试它。看看它在 -1 上的表现如何。为什么?你能想到一种解决方法吗?
// Your code here. console.log(isEven(50)); // → true console.log(isEven(75)); // → false console.log(isEven(-1)); // → ??
你的函数可能与本章递归 findSolution
示例 中的内部 find
函数有些相似,它包含一个 if
/else if
/else
链,用于测试三种情况中哪一种适用。最终的 else
,对应于第三种情况,进行递归调用。每个分支都应该包含一个 return
语句,或者以某种方式安排返回一个特定值。
当给定一个负数时,函数会一次又一次地递归,传递给自身一个越来越小的负数,从而越来越远离返回结果。它最终会耗尽堆栈空间并中止。
数豆子
你可以通过编写 "string".charAt(N)
来获得字符串中的第 N 个字符或字母,类似于你如何使用 "s".length
来获得它的长度。返回值将是一个仅包含一个字符的字符串(例如,"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