来源: ApacheCN『JavaScript 编程精解 中文第三版』翻译项目原文:Functionsjavascript
译者:飞龙html
协议:CC BY-NC-SA 4.0java
自豪地采用谷歌翻译git
部分参考了《JavaScript 编程精解(第 2 版)》程序员
人们认为计算机科学是天才的艺术,可是实际状况相反,只是许多人在其它人基础上作一些东西,就像一面由石子垒成的墙。github
高德纳apache
函数是 JavaScript 编程的面包和黄油。 将一段程序包装成值的概念有不少用途。 它为咱们提供了方法,用于构建更大程序,减小重复,将名称和子程序关联,以及将这些子程序相互隔离。编程
函数最明显的应用是定义新词汇。 用散文创造新词汇一般是很差的风格。 但在编程中,它是不可或缺的。闭包
以英语为母语的典型成年人,大约有 2 万字的词汇量。 不多有编程语言内置了 2 万个命令。并且,可用的词汇的定义每每比人类语言更精确,所以灵活性更低。 所以,咱们一般会引入新的概念,来避免过多重复。框架
函数定义是一个常规绑定,其中绑定的值是一个函数。 例如,这段代码定义了square
,来引用一个函数,它产生给定数字的平方:
const square = function(x) { return x * x; }; console.log(square(12)); // → 144
函数使用以关键字function
起始的表达式建立。 函数有一组参数(在本例中只有x
)和一个主体,它包含调用该函数时要执行的语句。 以这种方式建立的函数的函数体,必须始终包在花括号中,即便它仅包含一个语句。
一个函数能够包含多个参数,也能够不含参数。在下面的例子中,makeNoise
函数中没有包含任何参数,而power
则使用了两个参数:
var 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
声明的绑定,其实是它们的声明所在的块的局部对象,因此若是你在循环中建立了一个,那么循环以前和以后的代码就不能“看见”它。JavaScript 2015 以前,只有函数建立新的做用域,所以,使用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(value) { 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 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"); };
在语言中没有很好的理由,同时拥有箭头函数和函数表达式。 除了咱们将在第 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
若是你在一个参数后面写了一个=
运算符,而后是一个表达式,那么当没有提供它时,该表达式的值将会替换该参数。
例如,这个版本的power
使其第二个参数是可选的。 若是你没有提供或传递undefined
,它将默认为 2,函数的行为就像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
,该函数建立了一个局部绑定localVariable
,并返回一个函数,用于访问并返回局部绑定localVariable
。
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)
开始的解决方案。 这一调用将进一步递归,来探索每一个后续的解,它产生小于或等于目标数字。 因为它没有找到一个命中目标的解,因此它向第一个调用返回null
。 那里的||
操做符会使探索(1 * 3)
的调用发生。 这个搜索的运气更好 - 它的第一次递归调用,经过另外一个递归调用,命中了目标数字。 最内层的调用返回一个字符串,而且中间调用中的每一个“||”运算符都会传递该字符串,最终返回解决方案。
这里有两种经常使用的方法,将函数引入到程序中。
首先是你发现本身写了不少次很是类似的代码。 咱们最好不要这样作。 拥有更多的代码,意味着更多的错误空间,而且想要了解程序的人阅读更多资料。 因此咱们选取重复的功能,为它找到一个好名字,并把它放到一个函数中。
第二种方法是,你发现你须要一些你尚未写的功能,这听起来像是它应该有本身的函数。 您将首先命名该函数,而后您将编写它的主体。 在实际定义函数自己以前,您甚至可能会开始编写使用该函数的代码。
给函数起名的难易程度取决于咱们封装的函数的用途是否明确。对此,咱们一块儿来看一个例子。
咱们想编写一个打印两个数字的程序,第一个数字是农场中牛的数量,第二个数字是农场中鸡的数量,并在数字后面跟上Cows
和Chickens
用以说明,而且在两个数字前填充 0,以使得每一个数字老是由三位数字组成。
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
的函数具备很好的名称,使读取代码的人更容易弄清它的功能。 并且这样的函数在更多的状况下是有用的,不只仅是这个特定程序。 例如,您可使用它来帮助打印精确对齐的数字表格。
咱们的函数应该包括多少功能呢?咱们能够编写一个很是简单的函数,只支持将数字扩展成 3 字符宽。也能够编写一个复杂通用的数字格式化系统,能够处理分数、负数、小数点对齐和使用不一样字符填充等。
一个实用原则是不要故做聪明,除非你肯定你会须要它。 为你遇到的每个功能编写通用“框架”是很诱人的。 控制住那种冲动。 你不会完成任何真正的工做 - 你只会编写你永远不会使用的代码。
咱们能够将函数分红两类:一类调用后产生反作用,而另外一类则产生返回值(固然咱们也能够定义同时产生反作用和返回值的函数)。
在农场案例当中,咱们调用第一个辅助函数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)); // → ??
你能够经过编写"string"[N]
,来从字符串中获得第N
个字符或字母。 返回的值将是只包含一个字符的字符串(例如"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