第三章: 函数
¶ 程序经常需要在不同的地方做相同的事情。 在每次都需要的时候都重复所有必要的语句是乏味且容易出错的。 将它们放在一个地方,并在程序需要的时候绕道通过那里会更好。 这就是 函数被发明出来的原因:它们是程序可以随时调用的代码块。 在屏幕上显示字符串需要相当多的语句,但是当我们有了 print
函数后,我们只需要写 print("Aleph")
就可以了。
¶ 然而,仅仅将函数视为代码块并不足以体现它们的价值。 在需要的时候,它们可以扮演纯函数、算法、间接访问、抽象、决策、模块、延续、数据结构等等的角色。 能够有效地使用函数是任何严肃编程都必备的技能。 本章介绍了函数的基本知识,第六章 更深入地讨论了函数的细节。
¶ 首先,纯函数是在我希望你已经经历过的一些数学课上被称为函数的东西。 计算一个数字的余弦或绝对值是只有一个参数的纯函数。 加法是两个参数的纯函数。
¶ 纯函数的定义特性是它们在给定相同参数时总是返回相同的值,并且永远不会产生副作用。 它们接受一些参数,根据这些参数返回一个值,并且不与其他任何东西发生交互。
¶ 在 JavaScript 中,加法是一个运算符,但它可以像这样封装在一个函数中(尽管看起来毫无意义,我们将会遇到它实际上很有用的情况)
function add(a, b) { return a + b; } show(add(2, 2));
¶ add
是函数的名称。 a
和 b
是两个参数的名称。 return a + b;
是函数体。
¶ 关键字 function
始终用于创建新函数。 当它后面跟着一个变量名时,生成的函数将存储在这个变量名下。 变量名之后是一个 参数名列表,最后是 函数体。 与 while
循环或 if
语句的函数体不同,函数体的花括号是强制性的 1。
¶ 关键字 return
后面跟着一个表达式,用于确定函数返回的值。 当控制流程遇到 return
语句时,它会立即跳出当前函数,并将返回的值传递给调用该函数的代码。 没有表达式后缀的 return
语句将导致函数返回 undefined
。
¶ 当然,函数体可以包含多个语句。 这是一个计算幂的函数(带正整数指数)
function power(base, exponent) { var result = 1; for (var count = 0; count < exponent; count++) result *= base; return result; } show(power(2, 10));
¶ 如果你解决了 练习 2.2,这个计算幂的技巧应该看起来很熟悉。
¶ 创建变量(result
)并更新它是副作用。 我不是刚刚说过纯函数没有副作用吗?
¶ 在函数内部创建的变量只存在于函数内部。 这很幸运,否则程序员将不得不为每个变量想出一个不同的名称。 因为 result
只存在于 power
内部,所以对其的更改只持续到函数返回,从调用它的代码的角度来看,没有副作用。
¶ 编写一个名为 absolute
的函数,它返回其参数的绝对值。 负数的绝对值是该负数的正值,正数(或零)的绝对值是该数本身。
function absolute(number) { if (number < 0) return -number; else return number; } show(absolute(-144));
¶ 纯函数具有两个非常好的特性。 它们很容易思考,也很容易重复使用。
¶ 如果一个函数是纯函数,那么对它的调用可以被视为一个独立的实体。 当你不确定它是否工作正常时,可以通过从控制台直接调用它来测试它,这很简单,因为它不依赖于任何上下文 2。 使这些测试自动化很容易——编写一个程序来测试特定的函数。 非纯函数可能会根据各种因素返回不同的值,并可能产生难以测试和理解的副作用。
¶ 因为纯函数是自给自足的,它们比非纯函数在更广泛的情况下更有用和相关。 以 show
为例。 这个函数的用处取决于屏幕上是否有专门用于打印输出的地方。 如果那个地方不存在,这个函数就没有用处。 我们可以想象一个相关的函数,让我们称之为 format
,它接受一个值作为参数并返回一个表示该值的字符串。 这个函数比 show
在更多情况下有用。
¶ 当然,format
并不能解决与 show
相同的问题,任何纯函数都无法解决这个问题,因为这需要副作用。 在许多情况下,非纯函数正是你需要的。 在其他情况下,可以使用纯函数解决问题,但非纯函数更方便或更高效。
¶ 因此,当某件事可以很容易地表示为一个纯函数时,就用这种方式编写它。 但永远不要因为编写非纯函数而感到内疚。
¶ 具有副作用的函数不需要包含 return
语句。 如果没有遇到 return
语句,函数将返回 undefined
。
function yell(message) { alert(message + "!!"); } yell("Yow");
¶ 函数的参数名在函数内部可用作变量。 它们将引用函数被调用时参数的值,并且与在函数内部创建的普通变量一样,它们在函数外部不存在。 除了 顶层环境之外,函数调用还会创建更小的、 局部环境。 在查找函数内部的变量时,首先检查局部环境,只有当该变量不存在时,才会在顶层环境中查找它。 这使得函数内部的变量能够 '覆盖' 具有相同名称的顶层变量。
function alertIsPrint(value) { var alert = print; alert(value); } alertIsPrint("Troglodites");
¶ 这个局部环境中的变量只对函数内部的代码可见。 如果这个函数调用另一个函数,新调用的函数将无法看到第一个函数内部的变量。
var variable = "top-level"; function printVariable() { print("inside printVariable, the variable holds '" + variable + "'."); } function test() { var variable = "local"; print("inside test, the variable holds '" + variable + "'."); printVariable(); } test();
¶ 然而,这是一个微妙但非常有用的现象,当一个函数在另一个函数 内部 定义时,它的局部环境将基于包围它的局部环境,而不是顶层环境。
var variable = "top-level"; function parentFunction() { var variable = "local"; function childFunction() { print(variable); } childFunction(); } parentFunction();
¶ 这意味着函数内部可见哪些变量是由函数在程序文本中的位置决定的。 所有在函数定义 '上方' 定义的变量都是可见的,这意味着函数体中包围它的变量和程序顶层的变量都是可见的。 这种变量可见性方法被称为 词法作用域。
¶ 有其他编程语言经验的人可能期望一个 代码块(花括号之间的)也会产生一个新的局部环境。 在 JavaScript 中并非如此。 函数是唯一创建新作用域的事物。 你可以像这样使用独立的代码块...
var something = 1; { var something = 2; print("Inside: " + something); } print("Outside: " + something);
¶ ... 但是块内部的 something
指的是与块外部相同的变量。 实际上,尽管允许使用这样的代码块,但它们完全没有用处。 大多数人同意这在 JavaScript 设计人员的设计中是一个小错误,ECMAScript Harmony 将添加一些方法来定义停留在代码块内部的变量(let
关键字)。
¶ 这里有一个可能让你感到惊讶的例子
var variable = "top-level"; function parentFunction() { var variable = "local"; function childFunction() { print(variable); } return childFunction; } var child = parentFunction(); child();
¶ parentFunction
返回其内部函数,底部的代码调用了这个函数。 即使 parentFunction
此时已完成执行,但 variable
为 "local"
的局部环境仍然存在,childFunction
仍然使用它。 这种现象被称为 闭包。
¶ 除了让程序员很容易通过查看程序文本的形状来快速了解变量在程序的哪个部分可用之外,词法作用域还允许我们 '合成' 函数。 通过使用封闭函数中的某些变量,内部函数可以被设置为执行不同的操作。 想象一下,我们需要几个不同但相似的函数,一个将它的参数加 2,另一个加 5,等等。
function makeAddFunction(amount) { function add(number) { return number + amount; } return add; } var addTwo = makeAddFunction(2); var addFive = makeAddFunction(5); show(addTwo(1) + addFive(1));
¶ 为了理解这一点,你应该将函数视为不仅打包了计算,还打包了环境。 顶层函数简单地在顶层环境中执行,这一点很明显。 但是,在另一个函数内部定义的函数会保留对该函数在定义时存在的环境的访问权限。
¶ 因此,上面示例中的 add
函数是在调用 makeAddFunction
时创建的,它捕获了一个 amount
具有特定值的 环境。 它将这个环境与计算 return number + amount
打包成一个值,然后从外部函数返回。
¶ 当这个返回的函数(addTwo
或 addFive
)被调用时,一个新的环境——其中变量 number
具有一个值——被创建,作为捕获的环境的子环境(其中 amount
具有一个值)。 然后将这两个值相加,并返回结果。
¶ 除了不同的函数可以包含相同名称的变量而不会互相干扰之外,这些作用域规则还允许函数 自身 调用而不会出现问题。 一个调用自身的函数被称为递归。 递归允许一些有趣的定义。 看一下这个 power
的实现
function power(base, exponent) { if (exponent == 0) return 1; else return base * power(base, exponent - 1); }
¶ 这非常接近数学家定义指数的方式,在我看来,它比之前的版本要好得多。它有点循环,但没有 `while`、`for` 甚至局部副作用。通过调用自身,该函数产生了相同的效果。
¶ 不过,有一个重要的问题:在大多数浏览器中,这个第二个版本比第一个版本慢大约十倍。在 JavaScript 中,遍历一个简单的循环比多次调用一个函数要便宜得多。
¶ 速度与 优雅之间的困境很有趣。它不仅出现在决定是否使用递归时。在许多情况下,一个优雅、直观且通常简短的解决方案可以被一个更复杂但更快的解决方案所取代。
¶ 以上面的 `power` 函数为例,非优雅的版本仍然足够简单易读。用递归版本替换它没有多大意义。然而,程序处理的概念通常会变得非常复杂,以至于为了使程序更简洁而放弃一些效率成为了一个有吸引力的选择。
¶ 许多程序员都重复过这条基本原则,我也完全同意,那就是在程序被证明太慢之前,不要担心效率问题。当它确实太慢时,找出哪些部分太慢,然后开始用效率来换取优雅。
¶ 当然,上面的规则并不意味着应该完全忽略性能。在许多情况下,就像 `power` 函数一样,'优雅' 方法并没有带来多少简洁性。在其他情况下,经验丰富的程序员可以立即发现,简单的方案永远不可能足够快。
¶ 我之所以大肆宣扬这一点,是因为令人惊讶的是,许多程序员对效率非常狂热,甚至在最小的细节上也是如此。结果是,程序更大、更复杂,而且往往更不正确,编写起来比更简洁的等效程序花费更长时间,而且运行速度也只快了一点点。
¶ 但是我在谈论递归。与递归密切相关的概念叫做 栈。当一个函数被调用时,控制权被交给该函数的主体。当这个主体返回时,调用该函数的代码将继续执行。当主体正在运行时,计算机必须记住调用该函数的上下文,以便它知道之后从哪里继续执行。存储此上下文的区域称为栈。
¶ 之所以称为 '栈',是因为,正如我们所见,一个函数主体可以再次调用一个函数。每次调用函数时,都需要存储另一个上下文。可以将此想象为一个上下文堆栈。每次调用函数时,当前上下文都会被扔到堆栈的顶部。当一个函数返回时,堆栈顶部的上下文将被取出并恢复执行。
¶ 这个栈需要计算机内存中的空间来存储。当栈变得太大时,计算机将抛出类似 "堆栈空间不足" 或 "递归过多" 的消息。编写递归函数时必须牢记这一点。
function chicken() { return egg(); } function egg() { return chicken(); } print(chicken() + " came first.");
¶ 除了演示编写一个错误程序的非常有趣的方式之外,这个例子还表明,一个函数不必直接调用自身来实现递归。如果它调用另一个函数,而另一个函数(直接或间接)再次调用第一个函数,那么它仍然是递归的。
¶ 递归并不总是循环的效率较低的替代方案。有些问题用递归解决比用循环解决要容易得多。这些问题通常需要探索或处理多个 '分支',每个分支都可能再次分支成更多分支。
¶ 考虑以下谜题:从数字 1 开始,反复地加 5 或乘以 3,可以产生无限多个新数字。你将如何编写一个函数,它接受一个数字,并尝试找到一个加法和乘法序列来生成该数字?
¶ 例如,数字 13 可以通过先将 1 乘以 3,然后两次加 5 来得到。数字 15 根本无法得到。
¶ 以下是解决方案
function findSequence(goal) { function find(start, history) { if (start == goal) return history; else if (start > goal) return null; else return find(start + 5, "(" + history + " + 5)") || find(start * 3, "(" + history + " * 3)"); } return find(1, "1"); } print(findSequence(24));
¶ 请注意,它并不一定能找到最短的操作序列,只要找到任何一个序列它就满意了。
¶ 内部的 `find` 函数通过两种不同的方式调用自身,探索了对当前数字加 5 和乘以 3 的可能性。当它找到该数字时,它将返回 `history` 字符串,该字符串用于记录为得到该数字所执行的所有运算符。它还会检查当前数字是否大于 `goal`,因为如果它大于 `goal`,我们应该停止探索这个分支,因为它不会给我们目标数字。
¶ 示例中 `||` 运算符的使用可以理解为 '返回通过对 `start` 加 5 找到的解决方案,如果失败,则返回通过将 `start` 乘以 3 找到的解决方案'。也可以用更冗长的方式写成这样
else { var found = find(start + 5, "(" + history + " + 5)"); if (found == null) found = find(start * 3, "(" + history + " * 3)"); return found; }
¶ 尽管函数定义作为语句出现在程序的其余部分之间,但它们并不属于同一个时间线。
print("The future says: ", future()); function future() { return "We STILL have no flying cars."; }
¶ 实际发生的是,计算机会查找所有函数定义,并在它开始执行程序的其余部分之前存储相关的函数。对于在其他函数内部定义的函数也是如此。当外部函数被调用时,首先发生的事情是,所有内部函数都被添加到新环境中。
¶ 还有一种定义函数值的方式,它更接近于其他值创建的方式。当 `function` 关键字用于期望表达式的地方时,它会被视为一个生成函数值的表达式。用这种方式创建的函数不必被命名(虽然可以给它们命名)。
var add = function(a, b) { return a + b; }; show(add(5, 5));
¶ 注意 `add` 定义后面的分号。正常的函数定义不需要这些,但这个语句与 `var add = 22;` 的总体结构相同,因此需要分号。
¶ 这种函数值被称为 匿名函数,因为它没有名字。有时给函数命名是无用的,比如我们之前看到的 `makeAddFunction` 示例中。
function makeAddFunction(amount) { return function (number) { return number + amount; }; }
¶ 由于 `makeAddFunction` 的第一个版本中名为 `add` 的函数只被引用过一次,因此该名称没有任何作用,我们可以直接返回函数值。
¶ 编写一个函数 `greaterThan`,它接受一个参数,一个数字,并返回一个表示测试的函数。当这个返回的函数被调用时,它接受一个数字作为参数,并返回一个布尔值:如果给定的数字大于用于创建测试函数的数字,则返回 `true`,否则返回 `false`。
function greaterThan(x) { return function(y) { return y > x; }; } var greaterThanTen = greaterThan(10); show(greaterThanTen(9));
¶ 尝试以下操作
alert("Hello", "Good Evening", "How do you do?", "Goodbye");
¶ 函数 `alert` 正式只接受一个参数。但是当你像这样调用它时,计算机一点也不抱怨,只是忽略了其他参数。
show();
¶ 显然,你甚至可以传入过少的参数。当没有传入参数时,它在函数内部的值为 `undefined`。
¶ 在下一章中,我们将看到一种方法,函数主体可以通过这种方法获取传入的准确参数列表。这很有用,因为它可以使一个函数接受任意数量的参数。`print` 使用了这种方法。
print("R", 2, "D", 2);
¶ 当然,这样做的缺点是,也可能不小心向预期固定数量参数的函数(如 `alert`)传入错误数量的参数,而且永远不会被告知。