函数的扩展(下)

前端开发
2018年10月19日
326

箭头函数

基本用法

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]]

使用注意点

箭头函数有几个使用注意点。

  1. 函数体内的this对象就是定义时所在的对象,而不是使用时所在的对象。

  2. 不可以当作构造函数。也就是说,不可以使用new命令,否则会抛出一个错误。

  3. 不可以使用arguments对象,该对象在函数体内不存在。如果要用,可以用rest参数代替。

  4. 不可以使用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,就是函数foothis。因为所有的内层函数都是箭头函数,都没有自己的this,所以它的this其实就是最外层foo函数this

除了thisargumentssupernew.target这三个变量在箭头函数中也是不存在的,分别指向外层函数的对应变量。

js
function foo () { setTimeout( () => { console.log('args': arguments); }, 100); } foo(2, 4, 6, 8); // args: [2, 4, 6, 8]

上面的代码中,箭头函数内部的变量arguments,其实就是函数fooarguments变量

另外,由于箭头函数没有自己的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就需要保存内部变量mn的值、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.argumentsfunc.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标准入门》