函数
函数是 JavaScript 编程中最核心的工具之一。将一段程序封装成一个值的概念有很多用途。它让我们能够构建大型程序,减少重复,将名称与子程序关联,以及将这些子程序彼此隔离。
函数最明显的应用是定义新的词汇。在散文写作中创造新词通常是糟糕的风格,但在编程中,它则是不可或缺的。
典型的成年英语使用者在词汇量大约为 20,000 个词。很少有编程语言内置 20,000 条命令。而且现有的词汇往往比人类语言定义得更加精确,因此也更不灵活。因此,我们必须引入新词来避免过度冗长。
定义函数
函数定义是一个普通的绑定,其中绑定的值为一个函数。例如,这段代码定义了 square
来引用一个函数,该函数生成给定数字的平方。
const square = function(x) { return x * x; }; console.log(square(12)); // → 144
函数是用一个以 function
关键字开头的表达式创建的。函数具有一组参数(在本例中,只有 x
)和一个函数体,其中包含在调用函数时要执行的语句。这样创建的函数体必须始终用大括号括起来,即使它只包含一条语句。
函数可以有多个参数,也可以没有参数。在下面的示例中,makeNoise
没有列出任何参数名称,而 roundTo
(将 n
四舍五入到 step
的最近倍数)列出了两个。
const makeNoise = function() { console.log("Pling!"); }; makeNoise(); // → Pling! const roundTo = function(n, step) { let remainder = n % step; return n - remainder + (remainder < step / 2 ? 0 : step); }; console.log(roundTo(23, 10)); // → 20
有些函数,例如 roundTo
和 square
,会生成一个值,而有些则不会,例如 makeNoise
,它唯一的输出是副作用。return
语句决定函数返回的值。当控制流遇到这样的语句时,它会立即跳出当前函数并将返回值传递给调用该函数的代码。没有表达式跟在后面的 return
关键字会导致函数返回 undefined
。没有 return
语句的函数,如 makeNoise
,同样会返回 undefined
。
函数的参数的行为与普通的绑定类似,但它们的初始值由函数的调用者提供,而不是函数本身的代码。
绑定和作用域
每个绑定都有一个作用域,它是程序中绑定可见的部分。对于在任何函数、块或模块(参见第 10 章)之外定义的绑定,作用域是整个程序 - 你可以在任何地方引用这些绑定。这些被称为全局绑定。
为函数参数创建或在函数内部声明的绑定只能在该函数中引用,因此被称为局部绑定。每次调用函数时,都会创建这些绑定的新实例。这提供了一些函数之间的隔离 - 每次函数调用都在它自己的小世界(它的局部环境)中运行,通常可以理解函数的行为,而无需了解全局环境中的大量信息。
用 let
和 const
声明的绑定实际上是它们所在的块的局部绑定,因此,如果你在循环内部创建一个这样的绑定,则循环之前和之后的代码将无法“看到”它。在 2015 年之前的 JavaScript 中,只有函数才会创建新的作用域,因此使用 var
关键字创建的旧式绑定在它们出现的整个函数中可见 - 或者如果它们不在函数中,则在整个全局作用域中可见。
let x = 10; // global if (true) { let y = 20; // local to block var z = 30; // also global }
每个作用域都可以“看到”它周围的作用域,因此 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 */}; }
在第 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 roundTo = (n, step) => { let remainder = n % step; return n - remainder + (remainder < step / 2 ? 0 : step); };
箭头出现在参数列表之后,后面跟着函数体。它表达的意思类似于“这个输入(参数)产生这个结果(函数体)”。
当只有一个参数名称时,可以省略参数列表周围的圆括号。如果函数体是一个单一的表达式,而不是用大括号括起来的块,那么这个表达式将从函数返回。因此,这两个 square
的定义做的是相同的事情。
const square1 = (x) => { return x * x; }; const square2 = x => x * x;
const horn = () => { console.log("Toot"); };
在语言中同时使用箭头函数和 function
表达式没有深层原因。除了一个我们将在第 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
如果你在参数后写一个=
操作符,后面跟着一个表达式,那么当该参数未给出时,该表达式的值将替换该参数。例如,这个版本的roundTo
使其第二个参数可选。如果你不提供它或传递值undefined
,它将默认为 1
function roundTo(n, step = 1) { let remainder = n % step; return n - remainder + (remainder < step / 2 ? 0 : step); }; console.log(roundTo(4.5)); // → 5 console.log(roundTo(4.5, 2)); // → 4
在下一章中,我们将介绍一种函数体可以获取其传递的所有参数列表的方法。这很有用,因为它允许函数接受任意数量的参数。例如,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
这与数学家定义求幂的方式非常接近,并且可以说比我们在第 2 章中使用的循环更清晰地描述了这个概念。该函数多次调用自身,指数越来越小,以实现重复的乘法。
然而,这个实现有一个问题:在典型的 JavaScript 实现中,它比使用for
循环的版本慢大约三倍。运行一个简单的循环通常比多次调用函数更便宜。
速度与优雅的困境是一个有趣的问题。你可以将其视为人类友好性和机器友好性之间的一种连续体。几乎任何程序都可以通过使其更大更复杂来使其更快。程序员必须找到适当的平衡。
在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”字样,并在两个数字前面用零填充,使其始终为三位数。
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
来查看它是否可以被 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
语句,或者以某种方式安排返回一个特定值。
豆子计数
你可以通过在字符串后面写 [N]
来获得字符串中的第 N 个字符或字母(例如,string[2]
)。返回值将是一个只包含一个字符的字符串(例如,"b"
)。第一个字符的位置是 0,这会导致最后一个字符位于位置 string.
。换句话说,一个包含两个字符的字符串长度为 2,它的字符位置分别是 0 和 1。
编写一个名为 countBs
的函数,它接收一个字符串作为其唯一参数,并返回一个表示字符串中包含多少个大写字母 B 的数字。
接下来,编写一个名为 countChar
的函数,它的行为与 countBs
相似,但它接收第二个参数,表示要计数的字符(而不是只计数大写字母 B)。重写 countBs
以利用这个新函数。
// Your code here. console.log(countBs("BOB")); // → 2 console.log(countChar("kakkerlak", "k")); // → 4