3. 函数
函数是 JavaScript 编程中最重要的工具之一。用一个值封装一段程序的概念有很多用途。它为我们提供了一种结构化大型程序的方法,可以减少重复,将名称与子程序关联起来,并将这些子程序相互隔离。
函数最明显的应用是定义新的词汇。在散文中创造新词通常不是好的风格,但在编程中却是不可或缺的。典型的成人英语使用者的词汇量约为 20,000 个单词。很少有编程语言内置 20,000 条命令。而且,现有的词汇往往比人类语言的定义更精确,因此灵活性也更低。因此,我们必须引入新词,以避免过度冗长。
定义函数
函数定义是一种常规绑定,绑定的值是一个函数。例如,这段代码中的 square 定义指的是一个产生给定数字平方的函数:
函数是通过以关键字 function 开头的表达式创建的。函数有一组参数(本例中只有 x)和一个主体,主体包含调用函数时要执行的语句。以这种方式创建的函数体必须始终用大括号封装,即使它只包含一条语句。
函数可以有多个参数,也可以没有参数。在下面的示例中,makeNoise 没有列出任何参数名,而 roundTo(将 n 取整到最接近 step 的倍数)则列出了两个参数:
有些函数(如 roundTo 和 square)会产生一个值,而有些函数(如 makeNoise)则不会,其唯一的结果是一个副作用。返回语句决定了函数返回的值。当控制遇到这样的语句时,它会立即跳出当前函数,并将返回值交给调用函数的代码。如果 return 关键字后面没有表达式,函数将返回未定义的值。完全没有 return 语句的函数(如 makeNoise)也会返回未定义值。
函数参数的行为与常规绑定类似,但其初始值由函数的调用者给出,而不是函数本身的代码。
绑定和范围
每个绑定都有一个作用域,也就是绑定可见的程序部分。对于在任何函数、代码块或模块(参见第 10 章)之外定义的绑定,其作用域是整个程序--你可以在任何地方引用这些绑定。这些绑定称为全局绑定。
为函数参数创建的绑定或在函数内部声明的绑定只能在该函数中引用,因此称为局部绑定。每次调用函数时,都会创建这些绑定的新实例。这就在一定程度上实现了函数之间的隔离--每个函数调用都在自己的小世界(本地环境)中运行,通常无需了解全局环境中发生了什么。
使用 let 和 const 声明的绑定实际上是声明该绑定的代码块的本地绑定,因此如果在循环中创建绑定,循环前后的代码都无法 “看到 ”它。在 2015 年以前的 JavaScript 中,只有函数才会创建新的作用域,因此使用 var 关键字创建的旧式绑定在其所在的整个函数中都是可见的,如果不在函数中,则在全局作用域中也是可见的。
let、const、var对比
//let,const
for (let i = 0; i < 3; i++) {
let x = i;
console.log(x); // x 在这里可见
}
console.log(x); // 报错:x 在循环外不可见
//var
for (var i = 0; i < 3; i++) {
var x = i;
console.log(x); // x 在这里可见
}
console.log(x); // 依然可见,输出 2
函数作用域
每个作用域都可以 “向外看 ”其周围的作用域,因此 x 在示例中的代码块内部是可见的。例外情况是多个绑定具有相同的名称,在这种情况下,代码只能看到最内层的绑定。例如,当函数 halve 内的代码引用 n 时,它看到的是自己的 n,而不是全局的 n。
嵌套范围
JavaScript 不仅区分全局绑定和局部绑定。块和函数可以在其他块和函数内部创建,从而产生多种程度的局部性。
例如,这个函数输出制作一批鹰嘴豆泥所需的配料,它的内部还有另一个函数:
成分函数内部的代码可以看到外部函数的因子绑定,但其本地绑定,如单位或成分数量,在外部函数中是不可见的。程序块内部可见的绑定集合由程序块在程序文本中的位置决定。每个局部作用域都能看到包含它的所有局部作用域,而所有作用域都能看到全局作用域。这种绑定可见性方法称为词法作用域。
定义函数的方式
作为值的函数
函数绑定(变量)通常只是作为程序特定部分的名称。这种绑定只定义一次,永远不会更改。这就很容易混淆函数及其名称。但两者是不同的。函数值可以做其他值可以做的所有事情--你可以在任意表达式中使用它,而不仅仅是调用它。我们可以将函数值存储在一个新的绑定中,并将其作为参数传递给一个函数,等等。同样,保存函数的绑定仍然只是一个普通的绑定,如果不是常量,也可以分配一个新值,就像这样:
在第 5 章中,我们将讨论通过将函数值传递给其他函数可以实现的有趣功能。
声明符号
还有一种更简便的方法来创建函数绑定。在语句开头使用函数关键字时,其工作方式有所不同:
这是一个函数声明。该语句定义了绑定方格,并将其指向给定函数。这种写法稍微简单一些,而且不需要在函数后面加上分号。
这种函数定义形式有一个微妙之处。
前面的代码可以正常运行,尽管函数的定义在使用它的代码之下。函数声明不属于常规的从上到下的控制流。从概念上讲,它们被移到了其作用域的顶端,可以被该作用域中的所有代码使用。这种做法有时很有用,因为它提供了一种自由,可以按照看起来最清晰的方式对代码进行排序,而不必担心在使用之前必须定义所有函数。
箭头函数
还有第三种函数符号,与其他符号截然不同。它不使用函数关键字,而是使用由等号和大于号组成的箭头(=>)(不要与大于或等于运算符混淆,后者的写法是 >=):
箭头位于参数列表之后,后面是函数的主体。它表达的意思类似于 “此输入(参数)产生此结果(主体)”。当只有一个参数名时,可以省略参数列表周围的括号。如果主体是一个单独的表达式,而不是一个大括号中的代码块,函数将返回该表达式。因此,这两个 square 定义的作用是相同的:
当箭头函数没有任何参数时,其参数列表只是一组空的括号。
在语言中同时使用箭头函数和函数表达式并没有什么深层次的原因。除了我们将在第 6 章讨论的一个小细节外,它们的作用是一样的。箭头函数是在 2015 年添加的,主要是为了能以更简洁的方式编写小函数表达式。我们将在第 5 章中经常使用它们。
调用堆栈
控制在函数中流动的方式有些复杂。让我们来详细了解一下。下面是一个简单的程序,它调用了几个函数:
该程序的运行过程大致如下:调用 greet 会导致控制权跳转到该函数的起始行(第 2 行)。该函数调用 console.log,console.log 接管控制,完成其工作,然后将控制返回到第 2 行。在那里,它到达了 greet 函数的终点,因此返回到调用它的地方--第 4 行。之后的一行再次调用 console.log。返回后,程序结束。
我们可以这样示意控制流:
由于函数返回时必须跳回到调用它的地方,因此计算机必须记住调用发生的上下文。在一种情况下,console.log 完成后必须返回到 greet 函数。在另一种情况下,它返回到程序的结尾。
计算机存储上下文的地方就是调用栈。每次调用函数时,当前上下文都会存储在这个栈的顶部。当函数返回时,它会从堆栈中移除顶部的上下文,并使用该上下文继续执行。
存储这个堆栈需要计算机内存的空间。如果堆栈过大,计算机就会出现 “堆栈空间不足 ”或 “递归过多 ”等故障信息。下面的代码通过向计算机提出一个非常难的问题来说明这一点,这个问题会导致两个函数之间的无限来回。或者说,如果计算机有一个无限大的堆栈,那么这个过程就是无限的。现在的情况是,我们将耗尽空间,或者说 “炸掉堆栈”。
可选参数
下面的代码是允许的,并且可以顺利执行:
我们只用一个参数定义了 square。然而,当我们使用三个参数调用它时,语言并没有抱怨。它会忽略多余的参数,计算第一个参数的平方。
JavaScript 对可以传递给函数的参数数量要求非常宽松。如果传递的参数过多,多余的参数将被忽略。如果传递的参数太少,缺少的参数会被赋值为undefined。
这样做的坏处是,你有可能--甚至很有可能--不小心给函数传递了错误的参数个数。而且没有人会告诉你。好的一面是,你可以利用这种行为允许函数以不同的参数数被调用。例如,这个 minus 函数试图模仿-运算符,使用一个或两个参数:
如果在参数后写入 = 操作符,并在其后写入表达式,那么在没有给出参数时,表达式的值将取代参数。例如,这个版本的 roundTo 将第二个参数设为可选参数。如果不提供或传递未定义的值,它将默认为 1:
下一章将介绍一种方法,让函数体可以获取传递给它的整个参数列表。这种方法非常有用,因为它允许函数接受任意数量的参数。例如,console.log 就能做到这一点,输出所有给定值:
闭包
将函数视为值的功能,加上每次调用函数时都会重新创建本地绑定这一事实,带来了一个有趣的问题: 当创建本地绑定的函数调用不再有效时,本地绑定会发生什么变化?
下面的代码展示了一个例子。它定义了一个函数 wrapValue,用于创建本地绑定。然后返回一个访问并返回该本地绑定的函数。
这种情况是允许的,而且正如你所希望的那样--绑定的两个实例仍然可以被访问。这种情况很好地说明了这样一个事实:本地绑定在每次调用中都会重新创建,不同的调用不会影响彼此的本地绑定。
这一特性--能够在外层作用域中引用本地绑定的特定实例--被称为闭包。引用周围本地作用域绑定的函数称为闭包。这种行为不仅让你不必担心绑定的生命周期,还能以一些创造性的方式使用函数值。
稍作改动,我们就可以将前面的示例转化为创建任意乘法的函数。
由于参数本身就是一个本地绑定,所以 wrapValue 示例中的显式本地绑定并不是真正需要的。这样思考程序需要一些练习。一个好的心智模型是将函数值视为既包含函数体中的代码,又包含创建函数的环境。在调用时,函数体看到的是创建时的环境,而不是调用时的环境。
在前面的示例中,调用 multiplier 时创建了一个环境,其中的因子参数绑定为 2,而返回的函数值(存储在 twice 中)会记住这个环境,因此调用时会将参数乘以 2。
递归
函数调用自身是完全没问题的,只要不频繁到溢出堆栈。调用自身的函数称为递归函数。递归允许某些函数以不同的风格编写。例如,这个幂函数的功能与 **(指数化)运算符相同:
这与数学家定义幂级数的方式相当接近,可以说比我们在第 2 章中使用的循环更清楚地描述了这一概念。该函数以越来越小的指数多次调用自身,以实现重复乘法。不过,这种实现方式有一个问题:在典型的 JavaScript 实现中,它比使用 for 循环的版本慢三倍左右。一般来说,运行一个简单的循环要比多次调用一个函数便宜。
速度与优雅的两难选择非常有趣。你可以把它看作是人类友好性和机器友好性之间的连续统一体。几乎任何程序都可以通过使其变得更大、更复杂而变得更快。程序员必须找到一个适当的平衡点。就幂函数而言,低级(循环)版本仍然相当简单易读。用递归函数代替它并没有太大意义。但通常情况下,程序要处理的概念非常复杂,为了让程序更简单明了而放弃一些效率是有帮助的(视情况而决定是否使用递归)。
担心效率会让人分心。这是使程序设计复杂化的另一个因素,而当你在做一件已经很困难的事情时,额外的担心可能会让你瘫痪。因此,一般来说,你应该从编写正确易懂的程序开始。如果你担心写得太慢(通常不会太慢),因为大多数代码的执行频率根本不足以耗费大量时间(你可以事后测量),并在必要时加以改进。
递归并不总是循环的低效替代品。有些问题使用递归确实比循环更容易解决。大多数情况下,这些问题需要探索或处理多个 “分支”,而每个 “分支 ”又可能分支出更多的 “分支”。
请看这个谜题:从数字 1 开始,重复地加上 5 或乘以 3,可以产生一个无限的数字集。如何编写一个函数,在给定一个数字的情况下,试图找到一个能产生该数字的加法和乘法序列?例如,数字 13 可以通过先乘 3 再加 5 两次得到,而数字 15 则根本无法得到。
下面是一个递归解决方案:
请注意,这个程序并不一定能找到最短的操作序列。只要能找到任何序列,它就满足了。
如果你不能马上理解这段代码的工作原理,也没关系。让我们一起来完成它,因为它是一个很好的递归思维练习。内部函数 find 执行实际的递归操作。它需要两个参数:当前数字和一个记录我们如何得到这个数字的字符串。如果它找到了解决方案,就会返回一个字符串,说明如何到达目标值。如果从这个数字开始找不到解决方案,则返回空值。
为此,函数会执行三个操作之一。如果当前数字是目标数字,则当前历史记录是达到目标数字的方法,因此返回当前历史记录。如果当前数字大于目标数字,那么继续探索这个分支就没有意义了,因为加法和乘法都只会让数字变大,所以返回空值。最后,如果我们的数字仍然小于目标值,函数会尝试从当前数字开始的两种可能路径,即调用自身两次,一次是加法,一次是乘法。如果第一次调用返回的结果不是空值,则返回该结果。否则,无论第二次调用返回的是字符串还是空值,都会返回。
为了更好地理解这个函数如何产生我们想要的效果,让我们看看在搜索数字 13 的解时对 find 的所有调用:
缩进表示调用堆栈的深度。第一次调用 find 时,函数会调用自身来探索以 (1 + 5) 开头的解。该调用将进一步递归,以探索每一个产生的数字小于或等于目标数字的连续解。由于没有找到符合目标值的解,它返回空值到第一次调用。在这里,操作符“? ”会导致探索 (1 * 3) 的调用发生。这次搜索更幸运--它的第一次递归调用,通过另一次递归调用,找到了目标数字。最内层的调用返回一个字符串,中间调用中的每个?。
增长函数
在程序中引入函数或多或少有两种自然的方式。
第一种是当你发现自己多次编写类似的代码时。你最好不要这样做,因为代码越多,就意味着隐藏错误的空间越大,也意味着人们在理解程序时需要阅读的材料越多。因此,你要把重复的功能取一个好名字,并把它放到一个函数中。
第二种方法是,你发现自己需要一些尚未编写的功能,而这些功能听起来应该有自己的函数。你首先为函数命名,然后编写它的主体。你甚至可能在真正定义函数之前就开始编写使用该函数的代码。
为一个函数找到一个好名字有多难,就说明你要包装的概念有多清晰。让我们来看一个例子。
我们想编写一个程序,打印两个数字:一个农场里的牛和鸡的数量,在它们后面加上 “牛 ”和 “鸡 ”的字样,并在这两个数字前面填充零,使它们总是三位数:
这就需要一个包含两个参数的函数--牛的数量和鸡的数量。让我们开始编码吧。
在字符串表达式后写入 .length 将得到该字符串的长度。因此,while 循环会不断在数字字符串前面添加零,直到它们至少有三个字符长为止。
任务完成!但是,就在我们准备把代码(连同一张高额发票)寄给农场主的时候,她打电话告诉我们,她也开始养猪了,我们能不能扩展一下软件,让它也能打印猪呢?
我们当然可以。但就在我们再次复制和粘贴这四行代码时,我们停下来重新考虑。一定有更好的办法。下面是第一次尝试:
可以运行!不过,printZeroPaddedWithLabel 这个名字有点别扭。它把打印、零填充和添加标签这三件事混淆到了一个函数中。与其把程序中重复的部分全盘搬出,不如试着挑出一个单一的概念:
如果函数的名字像 zeroPad 一样好听、明显,阅读代码的人就更容易知道它的作用。这样的函数还能在更多情况下使用,而不仅仅是在这个特定的程序中。例如,你可以用它来帮助打印排列整齐的数字表格。我们的函数应该有多智能、多用途?我们可以写任何东西,从只能将一个数字填充为三个字符宽的极其简单的函数,到可以处理小数、负数、小数点对齐、填充不同字符等复杂的通用数字格式化系统。
一个有用的原则是,除非你绝对肯定会用到,否则就不要添加巧妙的功能。为遇到的每一个功能编写通用的 “框架 ”是很有诱惑力的。抵制这种冲动。你不会完成任何真正的工作--你会忙于编写永远用不上的代码。
功能和副作用
函数可大致分为为副作用调用的函数和为返回值调用的函数(当然也可能既有副作用又有返回值)。
农场示例中的第一个辅助函数 printZeroPaddedWithLabel 被调用的副作用是:打印一行。第二个版本的 zeroPad 则是为了返回值而调用的。与第一个版本相比,第二个版本在更多情况下有用,这绝非巧合。与直接执行副作用的函数相比,创建值的函数更容易以新的方式进行组合。
纯函数是一种特殊的产生值的函数,它不仅没有副作用,也不依赖于其他代码的副作用,例如,它不会读取其值可能会改变的全局绑定。纯函数有一个令人愉快的特性,即当调用相同的参数时,它总是产生相同的值(而不做其他任何事情)。对这种函数的调用可以用其返回值代替,而不会改变代码的含义。当您不确定纯函数是否正常工作时,您可以通过简单地调用该函数来测试它,并知道如果它在该上下文中正常工作,那么它在任何上下文中都会正常工作。非纯函数往往需要更多的脚手架来测试。
不过,在编写不纯粹的函数时也不必感到难过。副作用往往很有用。例如,我们无法编写纯粹版本的 console.log,而 console.log 却很好用。当我们使用副作用时,有些操作也更容易以高效的方式表达出来。
总结
本章教你如何编写自己的函数。函数关键字作为表达式使用时,可以创建一个函数值。作为语句使用时,它可以用来声明绑定,并赋予绑定一个函数作为其值。箭头函数是创建函数的另一种方法。
理解函数的一个关键部分是理解作用域。每个代码块都会创建一个新的作用域。在给定作用域中声明的参数和绑定是局部的,从外部看不到。用 var 声明的绑定行为则不同--它们最终会进入最近的函数作用域或全局作用域。
将程序执行的任务分离到不同的函数中很有帮助。这样你就不必经常重复自己的工作,而且函数可以将代码分成若干部分,帮助你组织程序。
练习
最小值
上一章介绍了返回最小参数的标准函数 Math.min。现在我们可以自己编写一个类似的函数。定义接收两个参数并返回其最小值的函数 min。
代码:
function minimum(a, b) {
return a - b < 0 ? a : b
}
console.log(minimum(1, 2));
递归
我们已经看到,我们可以用 %(余数运算符)来检验一个数是偶数还是奇数,用 % 2 来检验它是否能被 2 整除。下面是定义一个正整数是偶数还是奇数的另一种方法:
- 零为偶数。
- 1 是奇数。
- 对于任何其他数字 N,其偶数性与 N - 2 相同。
定义一个与此描述相对应的递归函数 isEven。该函数应接受一个参数(一个正整数)并返回一个布尔值。对 50 和 75 进行测试。看看它在-1 时的表现如何。为什么?你有办法解决这个问题吗?
function isEven(num) {
if (num == 0) {
return true;
} else if (num == 1) {
return false;
} else {
return isEven(num - 2)
}
}
console.log(isEven(50));//true
console.log(isEven(75));//false
console.log(isEven(-1));//RangeError: Maximum call stack size exceeded,堆栈溢出
数豆子
在字符串后写入 [N](例如 string[2]),可以从字符串中获取第 N 个字符或字母。得到的值将是一个只包含一个字符的字符串(例如 “b”)。第一个字符的位置是 0,因此最后一个字符的位置是 string.length - 1。换句话说,一个两个字符的字符串的长度是 2,其字符的位置是 0 和 1。
编写一个名为 countBs 的函数,将字符串作为唯一参数,并返回一个表示字符串中有多少个大写 B 字符的数字。接着,编写一个名为 countChar 的函数,它的行为与 countBs 类似,但它需要第二个参数,该参数表示要计算的字符(而不是只计算大写 B 字符)。重写 countBs 以使用这个新函数。
代码:
function countChar(source, target) {
let res = 0;
for (let i = 0; i < source.length; i++) {
if (source[i] == target) {
res = res + 1;
}
}
return res;
}
console.log(countChar('BOB', 'B'));//2
console.log(countChar('kakker', 'B'));