函数的扩展(下)
箭头函数
基本用法
ES6允许使用箭头(=>)
定义函数。
js
var f = v => v;
// 等同于
var f = function (v) {
return v;
}
如果箭头函数不需要参数或需要多个参数,就使用圆括号代表参数部分。
js
var f = () => 5;
// 等同于
var f = function () {
return 5;
}
var f = (num1, num2) => num1 + num2;
// 等同于
var f = function (num1, num2) {
return num1 + num2;
}
如果箭头函数的代码块部分多于一条语句,就要使用大括号将其括起来,并使用return语句
返回。
js
var sum = (num1, num2) => { return num1 + num2; }
由于大括号被解释为代码块,所以如果箭头函数直接返回一个对象,必须在对象外面加上括号。
js
var 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);
下面是另一个例子。
js
// 正常函数写法
var result = values.sort(function (a, b) {
return a - b;
});
// 箭头函数的写法
var result = values.sort((a, b) => a - b);
下面是rest参数
与箭头函数结果的例子。
js
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对象
就是定义时所在的对象,而不是使用时所在的对象。 -
不可以当作构造函数。也就是说,不可以使用
new命令
,否则会抛出一个错误。 -
不可以使用
arguments对象
,该对象在函数体内不存在。如果要用,可以用rest参数
代替。 -
不可以使用
yield命令
,因此箭头函数不能用作Generator函数
。
其中,第一点尤其值得注意。this对象
的指向是可变的,但是在箭头函数中它是固定的。
js
function foo () {
setTimeout( () => {
console.log("id: ", this.id);
}, 100 )
}
foo.call({id: 42}); // id: 42
上面的代码中,setTimeout
的参数是一个箭头函数,100毫秒后执行。如果是普通函数,执行时this
应该指向全局对象,但是箭头函数导致this
总是指向函数所在的对象。
下面是另一个例子。
js
var handler = {
id: '123456',
init: function () {
document.addEventListener('click', event => this.doSomething(event.type), false)
},
doSomething: function (type) {
console.log('Handling ' + type + ' for' + this.id);
}
}
上面的init方法
中使用了箭头函数,这导致this
总是指向handler对象
。否则,回调函数运行时,this.doSomething
这一行就会报错,因此此时this
指向全局对象。
js
function Timer () {
this.seconds = 0
setInterval( () => this.seconds++, 1000)
}
var timer = new Timer()
setTimeout(() => console.log(timer.seconds), 3100) // 3
上面的代码中,Timer函数
内部的setInterval
调用了this.seconds属性
,通过箭头函数让this
总是指向Timer
的实例对象。否则,输出结果是0,而不是3。
this指向的固定化,并不是因为箭头函数内部有绑定this机制,实际原因是箭头函数根本就没有自己的this,导致内部的this就是外层代码的this。正因为它没有this,所以也就不能用作构造函数。
问题:下面的代码中有几个this?
js
function () {
return () => {
return () => {
return () => {
console.log({'id': this.id});
}
}
}
}
foo.call({id: 42})()()(); // id: 42
上面的代码中只有一个this
,就是函数foo
的this
。因为所有的内层函数都是箭头函数,都没有自己的this
,所以它的this
其实就是最外层foo函数
的this
。
除了this
,arguments
、super
和new.target
这三个变量在箭头函数中也是不存在的,分别指向外层函数的对应变量。
js
function foo () {
setTimeout( () => {
console.log('args': arguments);
}, 100);
}
foo(2, 4, 6, 8); // args: [2, 4, 6, 8]
上面的代码中,箭头函数内部的变量arguments
,其实就是函数foo
的arguments变量
。
另外,由于箭头函数没有自己的this
,当然也就不能用call()
、apply()
、bind()
这些方法去改变this
的指向。
js
(function () {
return [
(() => this.x).bind({x: 'inner'})()
]
}).call({x: 'outer'});
// ['outer']
上面的代码中,箭头函数没有自己的this
,所以bind方法
无效。
长期以来,JavaScript语言的this对象
一直是一个令人头痛的问题,在对象方法中使用this
必须非常小心。箭头函数“绑定”this
,很大程度上解决了这个困扰。
嵌套的箭头函数
箭头函数内部还可以使用箭头函数。
js
// ES5语法的多重嵌套函数
function insert (value) {
return {
into: function (array) {
return {
after: function (afterValue) {
array.splice(array.indexOf(afterValue) + 1, 0, value);
return array;
}
};
}
};
}
insert(2).into([1, 3]).after(1); // [1, 2, 3]
// 用箭头函数改写
let insert = (value) => ({into: (array) => ({after: (afterValue) => {
array.splice(array.indexOf(afterValue) + 1, 0, value);
return array;
}})});
insert(2).into([1, 3]).after(1); // [1, 2, 3]
下面是部署管道机制(pipeline)的例子,即前一个函数的输出是最后一个函数的输入。
js
const pipeline = (...funcs) =>
val => funcs.reduce((a, b) => b(a), val);
const plus1 = a => a + 1;
const mult2 = a => a * 2;
const addThenMult = pipeline(plus1, mutl2);
addThenMult(5) // 12
如果觉得上面的写法可读性比较差,也可以采用下面的写法。
js
const plus1 = a => a + 1;
const mult2 = a => a * 2;
mult2(plus1(5)) // 12
箭头函数还有一个功能,就是可以很方便地改写λ
演算。
js
// λ 演算的写法
fix = λf.(λx.f(λv.x(x)(v)))(λx.f(λv.x(x)(v)))
// ES6的写法
var fix = f => (x => f(v => v(x)(v)))
(x => f(v => x(x)(v)));
上面两种写法几乎是一一对应的。由于λ
演算对于计算机科学非常重要,这使得我们可以用ES6作为替代工具,探索计算机科学。
函数绑定
箭头函数可以绑定this对象
,大大减少了绑定this对象
的写法(call、apply、bind)。但是,箭头函数并非适用于所有场合,所以ES7提出了“函数绑定”(function bind)运算符,用来取代call、apply、bind调用。虽然该方法还是ES7的一个提案(https://github.com/zenparsing/es-function-bind),但是Babel转码器已经支持。
函数绑定运算符
是并排的双冒号(::
),双冒号左边是一个对象,右边是一个函数。该运算符会自动将左边的对象作为上下文环境(即this对象
)绑定到右边的函数上。
js
foo::bar;
// 等同于
bar.bind(foo);
foo::bar(...arguments);
// 等同于
bar.apply(foo, arguments);
const hasOwnProperty = Object.prototype.hasOwnProperty;
function hasOwn (obj, key) {
return obj::hasOwnProperty(key);
}
如果双冒号左边为空,右边是一个对象的方法,则等于将该方法绑定在该对象上。
js
var method = obj::obj.foo
// 等同于
var method = ::obj.foo
let log = ::console.log
// 等同于
var log = console.log.bind(console);
由于双冒号运算符返回的还是原对象,因此可以采用链式写法。
js
// 例一
import { map, takeWhile, forEach } from 'iterlib';
getPlayers()::map(x => x.character())
::takeWhile(x => x.strenth > 100)
::forEach(x => console.log(x));
// 例二
let { find, html } = jake;
document.querySelectAll('div.myClass')::find('p')::html('hahaha');
尾调用优化
什么是尾调用
尾调用
(Tail Call)是函数式编程的一个重要概念,本身非常简单,就是指某个函数的最后一步是调用另一个函数。
js
function f (x) {
return g(x);
}
以下情况都不属于尾调用:
js
// 情况一:调用函数后还有赋值操作
function f (x) {
let y = g(x);
return y;
}
// 情况二:调用后还有操作
function f (x) {
return g(x) + 1;
}
// 情况三
function f (x) {
g(x);
}
// 等同于,所以也不属于尾调用
function f (x) {
g(x);
return undefined;
}
尾调用不一定出现在函数尾部,只要是最后一步操作即可。
js
// m(x)和n(x)都属于尾调用,因为它们都是函数f的最后一步操作。
function f (x) {
if (x > 0) {
return m(x);
}
return n(x);
}
尾调用优化
尾调用之所以与其他调用不同,就在于其特殊的调用位置。
我们知道,函数调用会在内存形成一个“调用记录”,又称“调用帧”(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);
}
f();
//等同于
g(3);
上面的代码中,如果函数g
不是尾调用,函数f
就需要保存内部变量m
和n
的值、g
的调用位置等信息。但是由于调用g
之后,函数f
就结束了,所以执行到最后一步,完全可以删除f(x)
的调用帧,只保留g(3)
的调用帧。
这就叫作**“尾调用优化”(Tail call Optimization)**,即只保留内层函数的调用帧。如果函数都是尾调用,那么完全可以做到每次执行时调用帧只一有项,这将大大节省内存。这就是“尾调用优化”的意义。
注意,只有不再用到外层函数的变量,内层函数的调用帧才会取代外层函数的调用帧,否则就不法进行“尾调用优化”。
js
// 不会进行尾调用优化,因为内层函数inner用到了外层函数addOne的内部变量one。
function addOne (a) {
var one = 1;
function inner (b) {
return b + one;
}
return inner(a);
}
尾递归
函数调用自身称为递归。如果尾调用自身就称为尾递归。
递归非常耗费内存,因为需要同时保存成百上千个调用帧,很容易发生“栈溢出”错误(stack overflow
)。但对于尾递归来说,由于只存在一个调用帧,所以永远不会发生“栈溢出”错误。
js
function factorial (n) {
if (n === 1); return 1;
return n * factorial(n - 1);
}
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);
}
factorial(5, 1) // 120
由此可见,“尾调用优化”对递归操作意义重大,所以一些函数式编程语言将其写入了语言规格。ES6也是如此,第一次明确规定,所有ECMAScript的实现,都必须部署“尾调用优化”。这也就是说,在ES6中,只要使用尾递归,就不会发生栈溢出,相对节省内存。
注意,只有开启严格模式,尾调用优化才会生效。一旦启用尾调用优化,func.arguments
和func.caller
这两个函数内部对象就失去意义了,因为外层的帧会被整个替换掉,这两个对象包含的信息会被移除。严格模式下,这两个对象也是不可用的。
js
function restricted () {
"use strict";
restricted.caller; // 报错
restricted.arguments; // 报错
}
restricted();
递归函数的改写
尾递归的实现往往需要改写递归函数,确保最后一步只调用自身。做到这一点的方法,就是把所有用到内部变量改写成函数的参数。比如上面的例子,阶乘函数factorial
需要用到一个中间变量total
,那就把这个中间变量改写成函数的参数。这样做的缺点是不太直观,第一眼很难看出来,为什么计算5的阶乘需要传入两个参数5和1?
有两个方法可以解决这个问题。
方法一:在尾递归函数之外再提供一个正常形式的函数。
js
function tailFactorial (n, total) {
if (n === 1); return total;
return tailFactorial(n - 1, n * total);
}
function factorial(n) {
return tailFactorial(n, 1);
}
factorial(5); // 120
上面的代码通过一个正常形式的阶乘函数factorial调用尾递归函数tailFactorial,看起来就正常多了。
函数编程有一个概念,叫作柯里化(currying),意思是将多参数的函数转换成单参数的形式。这里也可以使用柯里化。
js
function 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 = curring(tailFactorial, 1);
factorial(5) // 120
上面的代码通过柯里化将尾递归函数tailFactorial变为只接受1个参数的factorizl。
第二种方法:采用ES6的函数参数默认值。
js
function factorial(n, total = 1) {
if (n === 1) return total;
return factorial(n - 1, n * total);
}
factorial(5) // 120
上面的代码中,参数total有默认值1,所以调用时不用提供这个值。
总结:递归本质上是一种循环操作。纯粹的函数式编程语言没有循环操作命令,所有的循环都用递归实现,这就是为什么尾递归对这些语言极其重要。对于其他支持“尾调用优化”的语言(比如Lua、ES6),只需要知道循环可以用递归代替,而一旦使用递归,就最好使用尾递归。
函数参数的尾逗号
ES7有一个提案(https//github.com/jeffmo/es-trailing-function-commas),允许函数的最后一个参数有尾逗号(trailing comma)。
目前,函数的定义和调用都不允许参数有尾逗号。
js
function clownsEverywhere(
param1,
param2
) { /* ... */ }
clownsEverywhere(
'foo',
'bar'
);
如果以后要在函数的定义中添加参数,就势必要添加一个逗号。对版本管理系统来说,就会显示添加逗号那一行也发生了改变。这看上去有点冗余,因此新提案允许定义和调用时尾部有一个逗号。
js
function clownsEverywhere(
param1,
param2,
) { /* ... */ }
clownsEverywhere('foo', 'bar',);
以上,摘抄自阮一峰老师的《ES标准入门》