第七章 函数的扩展
本章节为函数的相关扩展,包括参数默认值、rest
参数、严格模式、name
属性、箭头函数、this
绑定、尾调用优化等七个方面。
函数参数默认值
在ES6之前,为函数参数指定默认值需要采用变通的方法,例如通过判断参数是否赋值来设定默认值。但是这也会有一个问题,如果用户传了 false
或 0等 ,则也会短路运算赋值后半部分。而在ES6标准中,允许直接在参数定义的后面设置默认值,使得代码更加简洁和自然。
function fn(x) {
x = x || 1
return x
}
fn() // 1
fn(2) // 2
fn(0) // 1
function fn(x = 1) {
return x
}
fn() // 1
fn(2) // 2
fn(0) // 0
ES6 写法比 ES5 写法简洁许多,而且非常自然。除了简洁外,ES6写法还有两个好处:
- 阅读代码的人可以立刻意识到哪些参数是可以省略的,不用查看函数体或文档
- 有利于将来的代码优化,即使未来的版本彻底拿掉这个参数,也不会导致以前的代码无法运行。
另外,需要注意的是,参数是默认声明的,在函数中无法使用 let
或 const
再次声明;参数默认值不是传值的,而是每次都重新计算默认值表达式的值。也就是说,参数默认值是惰性求值的。这意味着每次调用函数时都会重新计算默认值表达式的值,而不是默认值被固定为定义时的值。
function (x, y) {
let x = 1 // error
const y = 2 // error
}
function lazy(p = x + 1) {
return p
}
lazy(99) // 100
lazy(1) // 2
参数默认值可以与解构赋值的默认值的结合使用。首先,通过对象的解构赋值默认值,可以为函数参数中的对象属性设置默认值。如果函数参数不是一个对象,解构赋值将无法进行,会报错。只有在参数对象没有对应属性时,才会使用默认值。
function fn({x, y = 5}) {
return [x, y]
}
fn({x: 1, y: 2}) // [1, 2]
fn({x: 1}) // [1, 5]
fn({}) // [undefined, 5]
fn() // TypeError: Cannot read property ’ x ’ of undefined
对比一下下面两种写法:
function fn({x = 0, y = 0} = {}) {
console.log(x, y)
}
fn() // 0, 0
fn({x: 1}) // 1, 0
fn({x: 1, y: 3}) // 1, 3
fn({}) // 0, 0
function fn({x, y} = {x: 0, y: 0}) {
console.log(x, y)
}
fn() // 0, 0
fn({x: 1}) // 1, undefined
fn({x: 1, y: 3}) // 1, 3
fn({}) // undefined, undefined
写法一将函数参数的默认值设为空对象,并设置了解构赋值的默认值;写法二将函数参数的默认值设为一个具有具体属性的对象,并未设置解构赋值的默认值。
在函数中设置默认值时,通常应该将具有默认值的参数放在函数的尾部。这样做有助于更清楚地识别哪些参数是被省略的。如果非尾部的参数设置了默认值,那么这些参数实际上是无法被省略的。
当传入 undefined
时,会触发参数默认值,而传入 null
则不会触发默认值。
function fn (x = 1, y) {
console.log(x, y)
}
fn(2) // 2, undefined
fn(undefined, 2) // 1, 2
fn(null, 2) // null, 2
fn(, 2) // erro
总结而言,通过将具有默认值的参数放在函数的尾部可以更清晰地处理参数的省略情况,而非尾部的参数无法被省略,除非显式输入 undefined
。
在函数指定了默认值后,函数的 length
属性将失真。length
属性返回的是函数预期传入的参数个数,而指定了默认值的参数不再计入该个数。
(function fn (a, b, c = 1) {}).length // 2
(function fn (a, b, c) {}).length // 3
(function fn (a = 1) {}).length // 0
另外,如果函数使用了剩余参数(rest parameters
),即使用了 ...args
语法来接收可变数量的参数,那么 length
属性将返回0,因为剩余参数不计入预期传入的参数个数。
(function fn (...args) {}).length // 0
最后,如果设置了默认值的参数不是尾参数,那么 length
属性也不再计入后面的参数。
(function fn (a = 1, b, c) {}).length // 0
(function fn (a, b = 2, c) {}).length // 1
总而言之,指定了默认值后,length
属性返回的是函数预期传入的参数个数,而指定了默认值的参数不再计入该个数。剩余参数也不会计入 length
属性。
在函数参数设置默认值时,会形成一个单独的作用域(scope
),函数内部默认值变量指向该作用域的第一个参数,而不是全局变量。如果在该作用域中变量未定义,那么默认值变量将指向外层的全局变量。此外,参数的默认值也可以是一个函数,遵循相同的作用域规则。
总结概括如下:
- 在函数声明初始化时,参数设置默认值会形成一个单独的作用域。
- 默认值变量指向该作用域的第一个参数,而不是全局变量。
- 如果作用域内部未定义变量,则默认值变量指向外层的全局变量。
- 参数的默认值也可以是一个函数,遵循相同的作用域规则。
var x = 1;
function f(x, y = x) {
console.log(y);
}
f(2); // 输出为 2
let x = 1;
function f(y = x) {
let x = 2;
console.log(y);
}
f(); // 输出为 1
function f(y = x) {
let x = 2;
console.log(y);
}
f() // ReferenceError: x is not defined
let foo = 'abc'
function f(y = () => foo) {
let foo = 'def'
console.log(y)
}
f() // abc,函数内返回的值根据作用域取到的
利用参数默认值来指定某个参数不得省略,如果省略则抛出一个错误。通过在函数定义时使用默认值为参数赋予一个函数,可以在调用函数时实现对参数的必填检查。
总结概括如下:
- 可以利用参数默认值来指定某个参数不得省略,如果省略就抛出一个错误。
- 在函数定义时,将参数的默认值设置为一个函数,当调用函数且未提供该参数时,函数会执行默认值函数从而抛出错误。
- 参数的默认值是在运行时执行的,如果参数已经被传递,则默认值中的函数不会执行。
- 参数的默认值也可以设为 undefined,表示这个参数是可省略的。
以下是附带代码说明:
// 如果不传参数则报错
function throwIfMissing() {
throw new Error('Missing parameter');
}
function foo(mustBeProvided = throwIfMissing()) {
return mustBeProvided;
}
try {
foo(); // 调用时没有提供参数,会触发错误
} catch (error) {
console.error(error); // 输出 Error: Missing parameter
}
// 可以将参数的默认值设为 undefined,表示参数是可省略的
function bar(optional = undefined) {
console.log(optional);
}
bar(); // 不提供参数,optional 的值为 undefined
rest参数
ES6 中引入了 rest
参数,它用于获取函数的多余参数,取代了之前使用 arguments
对象的方式。rest
参数的形式是 "…变量名"
,它会将多余的参数放入一个数组中。
可以使用 rest
参数来传入任意数目的参数,进行处理。rest
参数的写法更自然、简洁,并且可以直接使用数组的方法。可以利用 rest
参数改写一些原本需要使用 arguments
对象的函数。
⚠ 注意
rest
参数必须是最后一个参数,不能在其后再有其他参数,否则会报错。
函数的 length
属性不包括 rest
参数。
// 使用rest参数实现求和函数
function add(...values) {
let sum = 0;
for (let val of values) {
sum += val;
}
return sum;
}
console.log(add(2, 5, 3)); // 输出 10
// 使用rest参数代替arguments变量
function sortNumbers() {
return Array.prototype.slice.call(arguments).sort();
}
// 使用rest参数的写法
const sortNumbers = (...numbers) => numbers.sort();
// 利用rest参数改写数组push方法
function push(array, ...items) {
items.forEach(function(item) {
array.push(item);
console.log(item);
});
}
var a = [];
push(a, 1, 2, 3);
// 注意rest参数必须是最后一个参数
// 下面的写法会报错
function f(a, ...b, c) {
// ...
}
// 函数的length属性不包括rest参数
(function(a) {}).length; // 输出 1
(function(...a) {}).length; // 输出 0
(function(a, ...b) {}).length; // 输出 1
💡 总结
使用 rest
参数能够获取函数的多余参数,可配合循环实现求和、排序以及改写数组 push
方法等。但也需注意 rest
参数的限制和函数的 length
属性不包括 rest
参数。
严格模式
在ES5中,函数内部可以设定为严格模式。但是,从ES2016开始,规定只要函数参数使用了默认值、解构赋值或者扩展运算符,那么函数内部就不能显式设定为严格模式,否则会报错。
函数内部的严格模式同时适用于函数体和函数参数,但是函数执行时先执行函数参数,再执行函数体,导致参数是否应该以严格模式执行不确定。
JavaScript引擎会先成功执行带有默认值的参数,然后在函数体内部发现需要严格模式执行时才会报错。为了避免这种限制,可以全局性设定严格模式或将函数包裹在一个无参数的立即执行函数中。
// 报错示例:函数参数使用默认值但内部显式设定严格模式
function doSomething(a, b = a) {
'use strict';
// code
}
// 报错示例:函数参数使用解构赋值但内部显式设定严格模式
const doSomething = function ({a, b}) {
'use strict';
// code
}
// 报错示例:箭头函数参数使用扩展运算符但内部显式设定严格模式
const doSomething = (...a) => {
'use strict';
// code
}
// 报错示例:对象方法参数使用默认值但内部显式设定严格模式
const obj = {
doSomething({a, b}) {
'use strict';
// code
}
}
// 参数使用默认值但会在严格模式下报错的示例
function doSomething(value = 070) {
'use strict';
return value;
}
// 解决方法1:全局性设定严格模式
'use strict';
function doSomething(a, b = a) {
// code
}
// 解决方法2:将函数包裹在无参数的立即执行函数里面
const doSomething = (function () {
'use strict';
return function (value = 42) {
return value;
};
})();
name属性
在 ES5 中,将匿名函数赋值给变量时,name
属性会返回空字符串;而在 ES6 中,name
属性会返回实际的函数名。
如果将具名函数赋值给一个变量,无论是在 ES5 还是 ES6 中,name
属性都会返回这个具名函数的原本名字。
使用 Function 构造函数创建的函数实例,name 属性的值为 "anonymous"
。
使用 bind
方法创建的函数,name
属性的值会加上 "bound"
前缀。
// ES5 vs ES6 示例
var f = function () {};
// ES5
console.log(f.name); // 输出:空字符串
// ES6
console.log(f.name); // 输出:f
// 具名函数示例
const bar = function baz() {};
// ES5 & ES6
console.log(bar.name); // 输出:baz
// 函数构造函数示例
console.log((new Function).name); // 输出:anonymous
// 使用 bind 方法示例
function foo() {};
console.log(foo.bind({}).name); // 输出:bound foo
// 更复杂的 bind 示例
console.log((function() {}).bind({}).name); // 输出:bound
箭头函数
箭头函数使用简洁的语法定义函数,可以省略 function
关键字和大括号。当箭头函数不需要参数或需要多个参数时,使用圆括号表示参数部分。
如果箭头函数包含多于一条语句的代码块,需要使用大括号并显式地使用 return
返回结果。在箭头函数直接返回对象时,需要在对象外面加上括号以避免解析错误。
箭头函数可以与变量解构结合使用,使代码更加简洁。且特别适合简化回调函数的书写,可以用更紧凑的语法表达同样的功能。
// 示例1:箭头函数简化函数定义
var f = v => v;
// 相当于
var f = function(v) {
return v;
};
// 示例2:箭头函数与变量解构结合使用
const full = ({ first, last }) => first + ' ' + last;
// 相当于
function full(person) {
return person.first + ' ' + person.last;
}
// 示例3:箭头函数简化回调函数
// 正常函数写法
[1, 2, 3].map(function(x) {
return x * x;
});
// 箭头函数写法
[1, 2, 3].map(x => x * x);
// 示例4:箭头函数简化排序函数
// 正常函数写法
var result = values.sort(function(a, b) {
return a - b;
});
// 箭头函数写法
var result = values.sort((a, b) => a - b);
// 示例5:箭头函数与 rest 参数结合
const numbers = (...nums) => nums;
numbers(1, 2, 3, 4, 5); // 输出:[1, 2, 3, 4, 5]
const headAndTail = (head, ...tail) => [head, tail];
headAndTail(1, 2, 3, 4, 5); // 输出:[1, [2, 3, 4, 5]]
箭头函数有以下几个使用注意事项:
- 箭头函数的
this
对象是固定的,它继承自外层函数的this
,而不是根据使用时所在的对象决定。 - 箭头函数不能被当作构造函数使用,即不能使用
new
命令创建实例,否则会抛出错误。 - 箭头函数内部不存在
arguments
对象,如果需要使用参数,可以使用rest
参数代替。 - 箭头函数不能被用作
generator
函数,即不能使用yield
命令。
箭头函数的固定化的 this
指向特性非常有利于封装回调函数。例如,在 DOM
事件的回调函数中,可以使用箭头函数来确保 this
指向定义时的对象。
代码说明:
// 示例1:箭头函数的 this 绑定定义时所在的作用域
function foo() {
setTimeout(() => {
console.log('id:', this.id);
}, 100);
var id = 21;
foo.call({ id: 42 });
}
foo() // 输出: id: 42
// 示例2:箭头函数和普通函数在定时器中的对比
function Timer() {
this.sl = 0;
this.s2 = 0;
// 箭头函数
setInterval(() => this.sl++, 1000);
// 普通函数
setInterval(function() {
this.s2++;
}, 1000);
}
var timer = new Timer();
setTimeout(() => console.log('sl:', timer.sl), 3100); // 输出: sl: 3
setTimeout(() => console.log('s2:', timer.s2), 3100); // 输出: s2: 0
// 示例3:箭头函数内部没有自己的 this,只有外层函数的 this
function foo() {
return () => {
return () => {
return () => {
console.log('id:', this.id);
};
};
};
}
var f = foo.call({ id: 1 });
var t1 = f.call({ id: 2 })(); // 输出: id: 1
var t2 = f().call({ id: 3 })(); // 输出: id: 1
var t3 = f()().call({ id: 4 })(); // 输出: id: 1
箭头函数可以嵌套使用,提供了简洁的函数定义方式。下面是一些使用箭头函数的示例代码:
多重嵌套函数的箭头函数写法:
jsfunction insert(value) { return {into: function(array) { return {after: function(afterValue) { array.splice(array.indexOf(afterValue) + 1, 0, value); return array; }}; }}; } // 箭头函数写法 let insert = (value) => ({into: (array) => ({after: (afterValue) => { array.splice(array.indexOf(afterValue) + 1, 0, value); return array; }})});
部署管道机制的箭头函数写法:
jsconst pipeline = (...funcs) => (val) => funcs.reduce((a, b) => b(a), val); const plus1 = a => a + 1; const mult2 = a => a * 2; const addThenMult = pipeline(plus1, mult2); addThenMult(5); // 输出: 12
箭头函数改写演算:
js// 演算的写法 fix λf.(λx.f(λv.x(x)(v))) (λx.f(λv.x(x)(v))) // ES6 的写法 var fix = f => (x => f(v => x(x)(v)))(x => f(v => x(x)(v))); // 这两种写法几乎是一一对应的
总而言之,箭头函数提供了更简洁的函数声明方式,并且可以嵌套使用。
this绑定
ES7提出了“函数绑定”( function bind
)运算符,用双冒号 (::)
来取代显式绑定(call
、apply
、bind
)的调用方式。这个语法虽然是ES7的提案,但 Babel
转码器已经支持。
函数绑定运算符的语法为:foo :: bar;
,它会自动将左边的对象作为上下文环境(即 this
对象)绑定到右边的函数上,相当于 bar.bind(foo);
。
const hasOwnProperty = Object.prototype.hasOwnProperty;
// 使用函数绑定运算符来简化方法调用
function hasOwn(obj, key) {
return obj::hasOwnProperty(key);
}
foo::bar -> foo.bind(foo)
foo::bar(...arguments) -> foo.apply(foo, arguments)
// 如果双冒号左边为空,右边是一个对象的方法,则等同于将该方法绑定在该对象上
var method = obj::obj.foo;
// 等同于
var method = ::obj.foo;
// 链式写法示例
import { map, takeWhile, forEach } from 'iterlib';
getPlayers()
::map(x => x.character())
::takeWhile(x => x.strength > 100)
::forEach(x => console.log(x));
// 链式写法示例二
let { find, html } = jake;
document.querySelectorAll("div.myClass")
::find("p")
::html("hahaha");
尾调用优化
尾调用是函数式编程中的重要概念,它指的是某个函数的最后一步是调用另一个函数。
以下情况不属于尾调用:
- 最后一步调用函数之前还有其他操作;
- 最后一步调用函数的返回值还经过了其他操作;
- 最后一步调用函数后并无返回值或返回了
undefined
。
尾调用可以出现在函数的尾部,只要是最后一步操作即可。
// 尾调用的示例(最后一句代码)
function f(x) {
return g(x);
}
// 非尾调用的情况一
function f(x) {
let y = g(x);
return y;
}
// 非尾调用的情况二
function f(x) {
return g(x) + 1;
}
// 非尾调用的情况三
function f(x) {
g(x);
// 执行其他操作
}
// 尾调用的示例(非最后一句代码)
function f(x) {
if (x > 0) {
return m(x);
}
return n(x);
}
尾调用在函数调用过程中具有特殊的调用位置,它不需要保留外层函数的调用帧,因为在最后一步操作完成后,外层函数的调用帧可以被内层函数的调用帧直接取代,从而节省内存空间。
尾调用优化(Tail Call Optimization)指的是只保留内层函数的调用帧,如果所有函数都是尾调用,就可以做到每次执行时调用帧只有一项,大大节省内存。但要注意,只有当不再需要外层函数的内部变量时,内层函数的调用帧才能取代外层函数的调用帧,否则无法进行尾调用优化。
// 尾调用优化的示例
function f() {
let m = 1;
let n = 2;
return g(m + n);
}
f(); // 等同于直接调用 g(3);
// 尾调用优化的示例
function addOne(a) {
var one = 1;
function inner(b) {
return b + one;
}
return inner(a);
}
// 上面的函数不会进行尾调用优化,因为内层函数 inner 使用了外层函数 addOne 的内部变量 one。
递归是指函数调用自身的过程,而尾递归是指尾调用自身的递归形式。尾递归优化是一种编译器或解释器的优化技术,通过只保留一个调用帧,避免保存大量的调用记录,从而提高递归函数的性能并防止栈溢出错误。
尾递归的特点是,递归调用发生在函数的最后一条语句,并且递归调用的返回值直接作为当前函数的返回值,不再进行其他操作。这样可以避免每次递归调用都需要保存调用帧的开销。
// 阶乘函数的尾递归实现
function factorial(n, total = 1) {
if (n === 1) return total;
return factorial(n - 1, n * total);
}
factorial(5); // 120
// Fibonacci 数列的尾递归实现
function Fibonacci2(n, ac1 = 1, ac2 = 1) {
if (n <= 1) return ac2;
return Fibonacci2(n - 1, ac2, ac1 + ac2);
}
Fibonacci2(10); // 89
将递归调用放在函数的最后一条语句,并直接返回递归调用的结果,可以减少调用帧的开销,提高性能并避免栈溢出错误。在ES6规范中,尾调用优化被明确要求部署,使得使用尾递归的函数不会发生栈溢出错误,并节省内存空间。
在尾递归中,将所有用到的内部变量改写成函数的参数,以确保最后一步只调用自身。为了让代码更直观和易读,提出了两种解决方案:
提供一个正常形式的函数来调用尾递归函数,使代码结构更清晰:
jsfunction tailFactorial(n, total) { if (n === 1) return total; return tailFactorial(n - 1, n * total); } function factorial(n) { return tailFactorial(n, 1); } factorial(5); // 120
使用柯里化(currying)将多参数的函数转换成单参数形式,使代码更简洁:
jsfunction currying(fn, n) { return function(m) { return fn.call(this, m, n); }; } function tailFactorial(n, total) { if (n === 1) return total; return tailFactorial(n - 1, n * total); } const factorial = currying(tailFactorial, 1); factorial(5); // 120
另外,ES6 的函数默认值也是一种简洁的解决方案,可以避免在调用时提供额外的参数:
function factorial(n, total = 1) {
if (n === 1) return total;
return factorial(n - 1, n * total);
}
factorial(5); // 120
ES6 的尾调用优化只在严格模式下开启的原因。在正常模式下,函数内部的两个变量 func.arguments
和 func.caller
可以追踪函数的调用栈,但是当尾调用优化发生时,函数的调用栈会被改写,导致这两个变量失效。因此,为了保证尾调用优化的有效性,ES6规定尾调用优化仅在严格模式下生效。
function restricted() {
'use strict';
restricted.caller; // 报错
restricted.arguments; // 报错
restricted();
}
在上面的代码中,restricted.caller
和restricted.arguments
在严格模式下会导致报错,因为严格模式禁用了这两个变量,这样可以确保在尾调用优化发生时不会出现调用栈跟踪失真的情况,从而保证尾调用优化的有效性。
在不能依赖JavaScript引擎进行尾调用优化的情况下,通过自己实现尾递归优化来避免调用栈溢出的方法。具体来说,通过使用"蹦床函数"(trampoline function)或者"尾调用优化"(Tail Call Optimization,TCO)来将递归执行转换为循环执行,从而避免调用栈溢出的问题。
蹦床函数方法:
- 创建一个蹦床函数,接受一个函数作为参数。
- 在蹦床函数内部,循环执行传入的函数,直到最终返回一个非函数值为止,从而避免递归调用造成的调用栈溢出。
- 通过每一步返回一个新的函数,来避免递归执行,实现了递归转换为循环执行的效果。
jsfunction trampoline(f) { while (f && f instanceof Function) { f = f(); } return f; } function sum(x, y) { if (y > 0) { return function() { return sum(x + 1, y - 1); }; } else { return x; } } console.log(trampoline(() => sum(1, 100000))); // 输出:100001
尾调用优化方法:
- 创建一个尾调用优化函数(tco),内部使用状态变量来模拟递归执行过程。
- 在函数内部,通过判断是否处于激活状态来控制递归执行,同时使用一个数组来存储每次函数执行的参数。
- 每次函数执行都返回undefined,避免真正的递归执行,同时通过循环将参数替换实现了类似于递归的效果,但是调用栈只有一层。
jsfunction tco(f) { let value; let active = false; let accumulated = []; function accumulator() { accumulated.push(arguments); if (!active) { active = true; while (accumulated.length) { value = f.apply(this, accumulated.shift()); } active = false; return value; } } return accumulator; } const sum = tco(function(x, y) { if (y > 0) { return sum(x + 1, y - 1); } else { return x; } }); console.log(sum(1, 100000)); // 输出:100001
💡 总结
- 尾调用只要满足最后一步是调用另一个函数的条件即可,不一定要出现在函数的尾部。
- 只有当不再需要外层函数的内部变量时,才能实现尾调用优化,从而节省内存空间。
- 递归本质上是一种循环操作,在纯函数式编程语言中循环都通过递归实现。尾递归对于这些语言非常重要,因为它能够避免栈溢出并提高性能。对于支持“尾调用优化”的语言,推荐使用尾递归。
总结
在本章中,阮一峰老师带领我们深入探讨了函数的扩展特性。首先介绍了参数默认值的概念,它允许在定义函数时为参数指定默认值,简化了函数调用时的语法,同时也提高了代码的可读性。
接着讨论了 rest
参数的作用,它可以将一个不定数量的参数表示为一个数组,使得函数能够处理不固定长度的参数列表,这对于编写灵活的函数非常有用。
还提及了严格模式,它是在 ES6 中引入的一种模式,可以确保代码更加健壮和安全,避免一些常见的错误。
另外介绍了函数的 name
属性,它可以获取函数的名称,这对于调试和日志记录非常有帮助。
再然后讨论了箭头函数的特点,它是一种更简洁的函数定义方式,尤其适合于简单的函数和回调函数的书写,同时固定了函数内部的 this
指向,避免了传统函数中 this
指向不确定的问题。
最后介绍了尾调用优化,这是一种优化技术,可以使得尾调用时不会新增额外的调用帧,从而提高递归函数的性能并减少内存占用。
这些函数的扩展特性丰富了 JavaScript 的函数语法和功能,使得编程变得更加便利、高效和安全。