第三章函数
函数是 JavaScript 编程的重中之重。将一段程序封装在一个值中的概念有很多用途。它为我们提供了一种结构化大型程序的方法,减少重复,将名称与子程序相关联,以及将这些子程序彼此隔离。
函数最明显的应用是定义新的词汇。在散文写作中创造新词通常是一种不好的风格。但在编程中,它是必不可少的。
典型的成年英语使用者词汇量约为 20,000 个词。很少有编程语言内置了 20,000 条命令。而且,可用的词汇倾向于定义得更加精确,因此比人类语言的词汇更不灵活。因此,我们通常必须引入新的概念,以避免过度重复自己。
定义函数
函数定义是一种常规绑定,其中绑定的值为函数。例如,这段代码定义 square
来引用一个函数,该函数产生给定数字的平方
const square = function(x) { return x * x; }; console.log(square(12)); // → 144
函数是使用以关键字 function
开头的表达式创建的。函数有一组参数(在本例中,只有 x
)和一个主体,其中包含调用函数时要执行的语句。以这种方式创建的函数的函数体必须始终用大括号括起来,即使它只包含一个语句。
函数可以有多个参数,也可以没有参数。在以下示例中,makeNoise
没有列出任何参数名,而 power
列出了两个参数名
const 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
声明的绑定实际上是局部于块的,它们是在其中声明的,因此,如果您在循环内部创建其中一个绑定,则循环之前和之后的代码将无法“看到”它。在 2015 年之前的 JavaScript 中,只有函数会创建新的作用域,因此使用 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() { missileSystem.launch("now"); }; if (safeMode) { launchMissiles = function() {/* do nothing */}; }
在第五章中,我们将讨论通过将函数值传递给其他函数可以实现的有趣事情。
声明表示法
创建函数绑定的方法稍微简短一些。当 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"); };
语言中同时拥有箭头函数和 function
表达式没有深层原因。除了我们将在第六章中讨论的一个小细节外,它们做的事情相同。箭头函数是在 2015 年添加的,主要是为了能够以更简洁的方式编写小的函数表达式。我们将在第五章中大量使用它们。
调用栈
控制流过函数的方式有些复杂。让我们仔细看看。这是一个简单的程序,它执行了一些函数调用
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
,它将默认为二,函数将表现得像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
,该函数创建一个局部绑定。然后,它返回一个访问并返回此局部绑定的函数。
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)
开始的解决方案。该调用将进一步递归地探索所有以(1 + 5)
开始的解决方案,只要所得数字小于或等于目标数字。由于它没有找到一个命中目标的解决方案,它会将null
返回给第一个调用。在那里,||
运算符导致探索(1 * 3)
的调用发生。这次搜索更幸运 - 它的第一次递归调用,通过另一个递归调用,命中了目标数字。最里面的调用返回一个字符串,并且每个中间调用的||
运算符都会将该字符串传递下去,最终返回解决方案。
不断增长的函数
第一种是,你发现自己多次写着相似的代码。你可能不希望这样做。更多的代码意味着隐藏错误的空间更大,阅读代码的人需要阅读更多内容才能理解程序。所以你将重复的功能提取出来,为它取一个好名字,然后把它放到一个函数中。
第二种方式是,你发现你需要一些还没有编写过的功能,并且它听起来应该有自己的函数。你首先会给函数命名,然后编写它的主体。你甚至可以开始编写使用该函数的代码,然后再实际定义函数本身。
为函数找到一个好名字的难易程度,很好地反映了你试图包装的概念的清晰程度。让我们来看一个例子。
我们想要编写一个程序,打印两个数字:农场里牛和鸡的数量,并在数字后面加上“Cows”和“Chickens”这两个词,并在两个数字前面用零填充,使其始终为三位数。
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
,可以帮助阅读代码的人更容易理解它的作用。而且,这样的函数在比这个特定程序更多的场景中都有用。例如,你可以用它来帮助打印整齐的数字表格。
我们的函数应该有多聪明,有多灵活?我们可以编写任何东西,从一个非常简单的函数,它只能将一个数字填充为三位字符宽,到一个复杂的通用数字格式化系统,它可以处理分数、负数、小数点的对齐、用不同字符填充等等。
一个有用的原则是在你绝对确定需要它之前不要添加任何技巧。编写针对你遇到的每个功能的通用“框架”可能很诱人。抵制这种冲动。你不会完成任何实际的工作——你只是在编写从未使用过的代码。
函数和副作用
函数可以粗略地分为两种类型:一种是为了副作用而调用,另一种是为了返回值而调用。(当然,也可以同时具有副作用和返回值。)
农场示例中的第一个辅助函数 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
来查看它是否可以被二整除。这里还有另一种方法可以定义一个正整数是偶数还是奇数
定义一个递归函数 isEven
,对应于此描述。该函数应接受一个参数(一个正整数)并返回一个布尔值。
在 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"[N]
来获取字符串中的第 N 个字符或字母。返回值将是一个只包含一个字符的字符串(例如,"b"
)。第一个字符的位置为 0,这导致最后一个字符的位置为 string.
。换句话说,一个两个字符的字符串长度为 2,它的字符位置分别为 0 和 1。
编写一个函数 countBs
,它接受一个字符串作为其唯一参数,并返回一个数字,该数字表示字符串中包含多少个大写“B”字符。
接下来,编写一个名为 countChar
的函数,其行为类似于 countBs
,只是它接受第二个参数,该参数指示要计数的字符(而不是只计数大写“B”字符)。重写 countBs
以使用这个新函数。
// Your code here. console.log(countBs("BBC")); // → 2 console.log(countChar("kakkerlak", "k")); // → 4