函数的扩展详解
1、函数参数的默认值
1.1、基本用法
ES6允许为函数的参数设置默认值,即直接写在参数定义的后面。
js
function log(x, y = 'World') {
console.log(x, y);
}
log("Hello");//Hello World
可以看到,ES6的写法比ES5简洁许多,而且非常自然。下面是另一个例子。
js
function Point(x = 0, y = 0) {
this.x = x;
this.y = y;
}
let p = new Point();
console.log(p);//Point { x: 0, y: 0 }
除了简洁,ES6的写法还有两个好处:首先,阅读代码的人可以立刻意识到哪些参数是可以省略的,不用查看函数体或文档;其次,有利于将来的代码优化,即使未来的版本彻底拿掉这个参数,也不会导致以前的代码无法运行。
参数变量是默认声明的,所以不能用let或const再次声明。
js
function foo(x = 5) {
let x = 1;//error
const x = 2;//error
}
上面的代码中,参数变量x是默认声明的,在函数体中不能用let或const再次声明,否则会报错。
1.2、与解构赋值默认值结合使用
参数默认值可以与解构赋值的默认值结合起来使用。
js
function foo({x, y = 5}) {
console.log(x, y);
}
foo({});//undefined 5
foo({x: 1});//1 5
foo({x: 1, y: 2});//1 2
foo();//typeError
上面的代码使用了对象的解构赋值默认值,而没有使用函数参数的默认值。只有当函数foo的参数是一个对象时,变量x和y才会通过解构赋值而生成。如果函数foo调用时参数不是对象,变量x和y就不会生成,从而报错。只有参数对象没有y属性时,y的默认值5才会生效。
js
function fetch(url, {body = '', method = 'GET', headers = {}}) {
console.log(method);
}
fetch('http://example.com', {});//GET
fetch('http://example.com');//TypeError
上面的代码中,如果函数fetch的第二个参数是一个对象,就可以为它的3个属性设置默认值。上面的写法不能省略第二个参数,如果结合函数参数的默认值,就可以省略第二个参数。这时,就出现了双重默认值。
js
function fetch(url, {method = 'GET'} = {}) {
console.log(method);
}
fetch('http://example.com');//GET
上面的代码中,函数fetch没有第二个参数时,函数参数的默认值就会生效,然后才是解构赋值的默认值生效,变量method取到默认值GET。
区别示例:
js
//写法一
function m1({x = 0, y = 0} = {}) {
return [x, y];
}
//写法二
function m2({x, y} = {x: 0, y: 0}) {
return [x, y];
}
上面两种写法都对函数的参数设定了默认值,区别是:写法一中函数参数的默认值是空对象,但是设置了对象解构赋值的默认值;写法二中函数参数的默认值是一个有具体属性的函数,但是没有设置对象解构赋值的默认值。
js
//写法一
function m1({x = 0, y = 0} = {}) {
return [x, y];
}
//写法二
function m2({x, y} = {x: 0, y: 0}) {
return [x, y];
}
//函数没有参数的情况
m1();//[0, 0]
m2();//[0, 0]
//x和y都有值的情况
m1({x: 3, y: 8});//[3, 8]
m2({x: 3, y: 8});//[3, 8]
//x有值,y无值的情况
m1({x: 3});//[3, 0]
m2({x: 3});//[3, undefined]
//x和y都没有值的情况
m1({});// [0, 0]
m2({});// [undefined, undefined]
m1({z: 3});//[0, 0]
m2({z: 3});//[undefined, undefined]
1.3、参数默认值的位置
通常情况下,定义了默认值的参数应该是函数的尾参数。因为这样比较容易看出,到底省略了哪些参数。如果非尾部的参数设置默认值,实际上这个参数是无法省略的。
js
//示例一
function f(x = 1, y) {
return [x, y];
}
f()// [1, undefined]
f(2)//[2, undefined]
f(, 1) //报错
f(undefined, 1)// [1, 1]
//示例二
function f(x, y = 5, z) {
return [x, y, x];
}
f()// [undefined, 5, undefined]
f(2)//[1, 5, undefined]
f(1, ,2) //报错
f(1, undefined, 2)// [1, 5, 2]
上面的代码中,有默认值的参数都不是尾参数。这时,无法只省略该参数而不省略其后的参数,除非显式输入undefined。
1.4、函数的Iength属性
指定了默认值以后,函数的length属性将返回没有指定默认值的参数个数。也就是说,指定了默认值后,length属性将失真。
js
(function(a){}).length //1
(function(a = 5){}).length //0
(function(a, b, c = 5){}).length // 2
上面的代码中,length属性的返回值等于函数的参数个数减去指定了默认值的参数个数。比如,上面的最后一个函数,定义了3个参数,其中有一个参数c指定了默认值,因此length属性等于3减去1,即2。
这是因为length属性的含义是,该函数预期传入的参数个数。某个参数指定默认值以后,预期传入的参数个数就不包括这个参数了。同理,rest参数也不会计入length属性。
js
(function(..args){}).length //0
1.5、作用域
一个需要注意的地方是,如果参数默认值是一个变量,则该变量所处的作用域与其他变量的作用域规则是一样的,即先是当前函数的作用域,然后才是全局作用域。
js
var x = 1;
function f(x, y = x) {
console.log(y);
}
f(2);//2
上面的代码中,参数y的默认值等于x。调用时,由于函数作用域内部的变量x已经生成,所以y等于参数x而不是全局变量x。
如果调用时函数作用域内部的变量x没有生成,结果就会不一样。
js
let x = 1;
function f(y = x) {
let x = 2;
console.log(y);
}
f();//1
上面的代码中,函数调用时y的默认值变量x尚未在函数内部生成,所以x指向全局变量。
2、rest参数
ES6引入了rest参数(形式为"...变量名"),用于获取函数的多余参数,这样就不需要使用arguments对象了。rest参数搭配的变量是一个数组,该变量将多余的参数放入其中。
js
function add(...values) {
let sum = 0;
for(let val of values) {
sum += val;
}
return sum;
}
add(2, 5, 3);//10
以上代码中的add函数是一个求和函数,利用rest参数可以向该函数传入任意数目的参数。
下面是一个rest参数代替arguments变量的例子。
js
//arguments变量的写法
const sortNumbers = () =>
Array.prototype.slice.call(arguments).sort();
//rest参数的写法
const sortNumbers = (...numbers) => numbers.sort();
比较上面的两种写法可以发现,rest参数的写法更自然也更简洁。
rest参数中的变量代表一个数组,所以数组特有的方法都可以用于这个变量。下面是一个利用rest参数改写数组push方法的例子。
js
function push(array, ...items) {
items.forEach((item)=>{
array.push(item);
console.log(item);
});
}
var a = [];
push(a, 1, 2, 3, 4);//[ 1, 2, 3, 4 ]
console.log(a);
函数的length属性不包括rest参数。
js
console.log(push.length);//1
3、 扩展运算符
3.1、含义
扩展运算符(spread)是三个点(...)。它好比rest参数的逆运算,将一个数组转为用逗号分隔的参数序列。
js
console.log(...[1, 2, 3]);//1 2 3
console.log(1, ...[2, 3, 4], 5);//1 2 3 4 5
该运算符主要用于函数调用。
js
function push(array, ...items) {
array.push(...items);
}
function add(x, y) {
return x + y;
}
let numbers = [4, 38];
console.log(add(...numbers));//42
上面的代码中,array.push(...items)和add(...numbers)这两行都是函数的调用,它们都使用了扩展运算符。该运算符将一个数组变为参数序列。
扩展运算符与正常的函数参数可以结合使用,非常灵活。
js
function f(v, w, x, y, z) {}
let args = [0, 1];
f(-1, ...args, 2, ...[3]);
3.2、替代数组的appIy方法
由于扩展运算符可以展开数组,所以不再需要apply方法将数组转为函数的参数了。
js
//ES5的写法
function f(x, y, z) {}
let args = [0, 1, 2];
f.apply(null, args);
//ES6的写法
function f(x, y, z) {}
let args = [0, 1, 2];
f(...args);
下面是扩展运算符取代apply方法的一个实际的例子,应用Math.max方法简化求数组最大元素的写法。
js
//ES5的写法
Math.max.apply(null, [14, 3, 77])
//ES6的写法
Math.max(...[14, 3, 77])
//等同于
Math.max(14, 3, 77)
上面的代码表示,由于JavaScript不提供求数组最大元素的函数,所以只能套用Math.max函数将数组转为一个参数序列,然后求最大值。有了扩展运算符以后,就可以直接用Math.max了。另一个例子是通过push函数将一个数组添加到另一个数组的尾部。
js
//ES5的写法
var arr1 = [0, 1, 2];
var arr2 = [3, 4, 5];
Array.prototype.push.apply(arr1, arr2);
//ES6的写法
let arr1 = [0, 1, 2];
let arr2 = [3, 4, 5];
arr1.push(...arr2);
上面的ES5写法中,push方法的参数不能是数组,所以只好通过apply方法变通使用push方法。有了扩展运算符,就可以直接将数组传入push方法。
3.3、扩展运算符的应用
合并数组:
js
//ES5
[1, 2].concat(more)
//ES6
[1, 2, ...more]
与解构赋值结合:
扩展运算符可以与解构赋值结合起来用于生成数组。
js
//ES5
a = list[0], rest = list.slice(1);
//ES6
[a, ...rest] = list;
函数返回值:
JavaScript的函数只能返回一个值,如果需要返回多个值,只能返回数组或对象。扩展运算符提供了解决这个问题的一种变通方法。
js
let dateFields = readDateFields(database);
let d = new Date(...dateFields);
上面的代码从数据库取出一行数据,通过扩展运算符直接将其传入构造函数Date。
字符串:
扩展运算符还可以将字符串转为真正的数组。
js
[..."hello"]
//['h', 'e', 'l', 'l', 'o']
上面的写法有一个重要的好处,那就是能够正确识别32位的Unicode字符。
类似数组的对象:
任何类似数组的对象都可以用扩展运算符转为真正的数组。
js
let nodeList = document.querySelectorAll('div');
let array = [...nodeList];
Map和Set结构,Generator函数:
扩展运算符内部调用的是数据结构的Iterator接口,因此只要具有Iterator接口的对象,都可以使用扩展运算符,比如Map结构。
js
let map = new Map([
[1, 'one'],
[2, 'two'],
[3, 'three']
]);
let arr = [...map.keys()];
console.log(arr);//[ 1, 2, 3 ]
Generator函数运行后返回一个遍历器对象,因此也可以使用扩展运算符。
js
let go = function*() {
yield 1;
yield 2;
yield 3;
};
console.log([...go()]);//[ 1, 2, 3 ]
上面的代码中,变量go是一个Generator函数,执行后返回的是一个遍历器对象,对这个遍历器对象执行扩展运算符,就会将内部遍历得到的值转为一个数组。
4、name属性
函数的name属性返回该函数的函数名。
js
function foo(){}
foo.name//foo
需要注意的是,ES6对这个属性的行为做出了一些修改。如果将一个匿名函数赋值给一个变量,ES5的name属性会返回空字符串,而ES6的name属性会返回实际的函数名。
js
let func1 = function (){};
//ES5
func1.name //""
//ES6
func1.name //"func1"
上面的代码中,变量func1等于一个匿名函数,ES5和ES6的name属性返回的值不一样。
如果将一个具名函数赋值给一个变量,则ES5和ES6的name属性都返回这个具名函数原本的名字。
js
const bar = function baz(){};
//ES5
bar.name //"baz"
//ES6
bar.name //"baz"
Function构造函数返回的函数实例,name属性的值为"anonymous"。
js
(new Function).name //"anonymous"
bind返回的函数,name属性值会加上"bound "前缀。
js
function foo() {}
foo.bind({}).name //"bound foo"
(function(){}.bind({}).name //"bound"
5、箭头函数
5.1、基本用法
ES6允许使用"箭头"(=>)定义函数。
js
let f = v -> v;
上面的箭头函数等同于:
js
let f = function(v) {
return v;
}
如果箭头函数不需要参数或需要多个参数,就使用圆括号代表参数部分。
js
let f = () => 5;
//等价于
let f = function() {
return 5;
}
let sum = (num1, num2) => num1 + num2;
//等价于
let sum = function(num1, num2) {
return num1 + num2;
}
如果箭头函数的代码块部分多于一条语句,就要使用大括号将其括起来,并使用return语句返回。
js
let sum = (num1, num2) => {return num1 + num2;}
由于大括号被解释为代码块,所以如果箭头函数直接返回一个对象,必须在对象外面加上括号。
js
let getTempItem = id => ({id: id, name: "Temp"});
箭头函数可以与变量解构结合使用。
js
const full = ({first, last}) => first + ' ' + last;
//等价于
function full(person) {
return person.first + ' ' + person.last;
}
箭头函数使得表达更加简洁。
js
const isEven = n => n % 2 == 0;
const square = n => n * n;
上面的代码只用了两行就定义了两个简单的工具函数。如果不用箭头函数,可能就要占用多行,而且还不如现在这样写醒目。
箭头函数的一个用处是简化回调函数。
js
//正常函数
[1, 2, 3].map(function (x) {
return x * x;
});
//箭头函数
[1, 2, 3].map(x => x * x);
下面是rest参数与箭头函数结合的例子。
js
const numbers = (...nums) => nums;
numbers(1, 2, 3, 4, 5);//[ 1, 2, 3, 4, 5 ]
const headAndTail = (head, ...tail) => [head, tail];
console.log(headAndTail(1, 2, 3, 4, 5));//[ 1, [ 2, 3, 4, 5 ] ]
5.2、使用注意点
- 函数体内的this对象就是定义时所在的对象,而不是使用时所在的对象。
- 不可以当作构造函数。也就是说,不可以使用new命令,否则会抛出一个错误。
- 不可以使用arguments对象,该对象在函数体内不存在。如果要用,可以用rest参数代替。
- 不可以使用yield命令,因此箭头函数不能用作Generator函数。
5.3、嵌套的箭头函数
js
let insert = (value) => ({into: (array) => ({after: (afterValue => {
array.splice(array.indexOf(afterValue) + 1, 0, value);
return array;
})})});
let x = insert(2).into([1, 3]).after(1);
console.log(x);//[ 1, 2, 3 ]
下面是一个部署管道机制(pipeline)的例子,即前一个函数的输出是后一个函数的输入。
js
const pipeline = (...funcs) =>
val => funcs.reduce((a, b) => b(a), val);
const plus1 = a => a + 1;
const mul2 = a => a * 2;
const addThenMult = pipeline(plus1, mul2);
console.log(addThenMult(5));//12
箭头函数还有一个功能,就是可以很方便地改写λ演算。
js
//λ演算
fix = λf.(λx.f(λv.x(x)(v)))(λx.f(λv.x(x)(v)))
//ES6写法
let fix = f => (x => f(v => x(x)(v)))
(x => f(v => x(x)(v)));
上面的两种写法几乎是一一对应的。由于λ演算对于计算机科学非常重要,这使得我们可以用ES6作为替代工具,探索计算机科学。
7、尾调用优化
7.1、什么是尾调用
尾调用(Tail Call)是函数式编程的一个重要概念,本身非常简单,一句话就能说清楚,就是指某个函数的最后一步是调用另一个函数。
js
function f(x) {
return g(x);
}
上面的代码中,函数f的最后一步是调用函数g,这就叫尾调用。
js
//情况一
function f(x) {
let y = g(x);
return y;
}
//情况二
function f(x) {
return g(x) + 1;
}
//情况三
function f(x){
g(x);
}
上面的代码中,情况一是调用函数g之后还有赋值操作,所以不属于尾调用,即使语义完全一样;情况二也属于调用后还有操作,即使写在一行内;情况三等同于下面的代码。
js
function f(x) {
g(x);
return undefined;
}
尾调用不一定出现在函数尾部,只要是最后一步操作即可。
js
function f(x) {
if(x > 0) {
return m(x);
}
return n(x);
}
上面的代码中,函数m和n都属于尾调用,因为它们都是函数f的最后一步操作。
7.2、尾调用优化
尾调用之所以与其他调用不同,就在于其特殊的调用位置。
我们知道,函数调用会在内存形成一个"调用记录",又称"调用帧"(call frame),保存调用位置和内部变量等信息。如果在函数A的内部调用函数B,那么在A的调用帧上方,还会形成一个B的调用帧。等到B运行结束,将结果返回到A,B的调用帧才会消失。如果函数B内部还调用了函数C,那就还有一个C的调用帧,以此类推。所有的调用帧就形成一个"调用栈"(call stack)。
尾调用由于是函数的最后一步操作,所以不需要保留外层函数的调用帧,因为调用位置、内部变量等信息都不会再用到了,直接用内层函数的调用帧取代外层函数的即可。
js
function f() {
let m = 1;
let n = 2;
return g(m + n);
}
f();
//等同于
function f() {
return g(3);j
}
f();
//等同于
g(3);
上面的代码中,如果函数g不是尾调用,函数f就需要保存内部变量m和n的值、g的调用位置等信息。但由于调用g之后,函数f就结束了,所以执行到最后一步,完全可以删除f(x)的调用帧,只保留g(3)的调用帧。
这就叫作"尾调用优化"(Tail Call Optimization),即只保留内层函数的调用帧。如果所有函数都是尾调用,那么完全可以做到每次执行时调用帧只有一项,这将大大节省内存。这就是"尾调用优化"的意义。
注意,只有不再用到外层函数的内部变量,内层函数的调用帧才会取代外层函数的调用帧,否则就无法进行"尾调用优化"。
7.3、尾递归
函数调用自身称为递归。如果尾调用自身就称为尾递归。
递归非常耗费内存,因为需要同时保存成百上千个调用帧,很容易发生"栈溢出"错误(stack overflow)。但对于尾递归来说,由于只存在一个调用帧,所以永远不会发生"栈溢出"错误。
js
function factorial(n) {
if (n === 1)
return 1;
return n * factorial(n - 1);
}
console.log(factorial(5));//120
上面的代码是一个阶乘函数,计算n的阶乘,最多需要保存n个调用记录,复杂度为O(n)。
如果改写成尾递归,只保留一个调用记录,则复杂度为O(1)。
js
function factorial(n, total) {
if (n === 1)
return total;
return factorial(n - 1, n * total);
}
console.log(factorial(5, 1));//120
由此可见,"尾调用优化"对递归操作意义重大,所以一些函数式编程语言将其写入了语言规格。ES6也是如此,第一次明确规定,所有ECMAScript的实现,都必须部署"尾调用优化"。这就是说,在ES6中,只要使用尾递归,就不会发生栈溢出,相对节省内存。
注意,只有开启严格模式,尾调用优化才会生效。一旦启用尾调用优化,func.arguments和func.caller这两个函数内部对象就失去意义了,因为外层的帧会被整个替换掉,这两个对象包含的信息会被移除。严格模式下,这两个对象也是不可用的。
js
function restricted() {
'use strict';
restricted.caller;//报错
restricted.arguments;//报错
}
restricted();