Generator函数(下)

前端开发
2018年10月25日
0

yield*语句

如果在Generator函数内部调用另一个Generator函数,默认情况下是没有效果的。

js
function* foo () { yield 'a'; yield 'b'; } function* bar () { yield 'x'; foo(); yield 'y'; } for (let v of bar()) { console.log(v); } // 'x' // 'y'

上面的代码中,foobar都是Generator函数,在bar里面调用foo是不会有效果的。

这时就需要用到yield*语句,用来在一个Generator函数里面执行另一个Generator函数

js
function* bar () { yield 'x'; yield* foo(); yield 'y'; } // 等同于 function* bar () { yield 'x'; yield 'a'; yield 'b'; yield 'y'; } // 等同于 function* bar () { yield 'x'; for (let v of foo()) { yield v; } yield 'y' }

从语法角度看,如果yield命令后面跟的是一个遍历器对象,则需要在yield命令后面加上星号,表明返回的是一个遍历器对象。这被称为yield*语句

yield*语句等同于在Generator函数内部部署一个for...of循环

js
function* concat(iter1, iter2) { yield* iter1; yield* iter2; } // 等同于 function* concat(iter1, iter2) { for (var value of iter1) { yield value; } for (var value of iter2) { yield value; } }

上面的代码表明,yield*不过是for...of的一种简写形式,完全可以由后者替代。

如果yield*后面跟着一个数组,由于数组原生支持遍历器,因此会遍历数组成员。

js
function* gen() { yield* ['a', 'b', 'c']; } gen().next() // {value: 'a', done: false}

上面的代码中,yield命令后面如果不加星号,返回的是整个数组,加了星号就表示返回数组的遍历器对象。

实际上,任何数据结构只要有Iterator接口,就可以用yield*遍历。

js
let read = (function* () { yield 'hello'; yield* 'hello'; })(); read().next().value // 'hello' read().next().value // 'h'

如果被代理的Generator函数return语句,那么可以向代理它的Generator函数返回数据。

js
function* foo () { yield 2; yield 3; return 'foo'; } function* bar () { yield 1; var v = yield* foo(); console.log(`v: ${v}`); yield 4; } var it = bar(); it.next() // {value: 1, done: false} it.next() // {value: 2, done: false} it.next() // {value: 3, done: false} it.next() // 'v: foo' // {value: 4, done: false} it.next() // {value: undefined, done:true}

在上面的代码中,第4次调用next方法时,屏幕上会有输出,这是因为函数foo的return语句向函数bar提供了返回值。

再看一个例子:

js
function* genFuncWithReturn () { yield 'a'; yield 'b'; return 'The result'; } function* logReturned (genObj) { let result = yield* genObj; console.log(result); } [...logReturned(genFuncWithReturn())] // The result // 值为 ['a', 'b']

上面的代码中,存在两次遍历。第一次是扩展运算符遍历函数logReturned返回的遍历器对象,第二次是yield*语句遍历函数genFuncWithReturn返回的遍历器对象。这两次遍历的效果是叠加的,最终表现为扩展运算符遍历函数genFuncWithReturn返回的遍历器对象。所以最后的数据表达式得到的值是['a', 'b']。但是,函数genFuncWithReturnreturn语句的返回值The result会返回给函数logReturned内部的result变量,因此会有终端输出。

yield*命令可以很方便地取出嵌套数组的所有成员

js
function* iterTree (tree) { if (Array.isArray(tree)) { for (let i = 0; i < tree.length; i++) { yield* iterTree(tree[i]); } } else { yield tree; } } const tree = ['a', ['b', 'c'], ['d', 'e']]; for (let x of iterTree(tree)) { console.log(x) } // a // b // c // d // e

下面是一个稍微复杂的例子,使用yield*语句遍历完全二叉数

js
// 二叉树的构造函数 // 3个参数分别是左子数、当前节点和右子树。 function Tree (left, label, right) { this.left = left; this.label = label; this.right = right; } // 中序(inorder)遍历函数 // 由于返回的是一个遍历器,所以要用Generator函数 // 函数体内采用递归算法,所以左子树和右子树要用yield*遍历。 function* inorder (t) { if (t) { yield* inorder(t.left); yield t.label; yield* inorder(t.right); } } // 生成二叉树 function make (array) { // 判断是否为叶节点 if (array.length === 1) return new Tree(null, array[0], null); return new Tree(make(array[0]), array[1], make(array[2])); } let tree = make([[['a'], 'b', ['c']], 'd', [['e'], 'f', ['d']]]); // 遍历二叉树 var result = []; for (let node of inorder(tree)) { result.push(node); } console.log(result); // ['a', 'b', 'c', 'd', 'e', 'f', 'g']

作为对象属性的Generator函数

如果一个对象的属性是Generator函数,那么可以简写成下面的形式:

js
let obj = { * myGeneratorMethod () { ... } };

上面的代码中,myGeneratorMethod属性前面有一个星号,表示这个属性是Generator函数

其完整形式如下,与上面的写法是等价的。

js
let obj = { myGeneratorMethod: function* () { ... } }

Generator函数的this

Generator函数总是返回一个遍历器,ES6规定这个遍历器是Generator函数的实例,它也继承了Generator函数prototype对象上的写法。

js
function* g () {} g.prototype.hello = function () { return 'hi'; } let obj = g(); obj instanceof g // true obj.hello() // 'hi'

上面的代码表明,Generator函数g返回的遍历器objg的实例,而且继承了g.prototype。但是,如果把g当作普通的构造函数,则并不会生效,因为g返回的总是遍历器对象,而不是this对象

js
function* g () { this.a = 11; } let obj = g(); obj.a // undefined

上面的代码中,Generator函数gthis对象上添加了一个属性a,但是obj对象拿不到这个属性。

js
function* F () { yield this.x = 2; yield this.y = 3; }

上面的代码中,函数F是一个构造函数,以是一个Generator函数。这时,使用new命令就无法生成F的实例了,因为F返回的是一个内部指针。

js
'next' in (new F()) // true

由于new F()返回的是一个Iterator对象,具有next方法,所以上面的表达式值为true

如果要把Generator函数当作正常的构造函数使用,可以采用下面的变通方法。首先生成一个空对象,使用bind方法绑定Generator函数内部的this。这样,构造函数调用以后,这个空对象就是Generator函数的实例对象了。

js
function* F () { yield this.x = 2; yield this.y = 3; } var obj = {}; var f = F.bind(obj)(); f.next(); // Object {value: 2, done: false} f.next(); // Object {value: 3, done: false} f.next(); // Object {value: undefined, done: true} obj // {x: 2, y: 3}

上面的代码中,首先F内部的this对象绑定obj对象,然后调用它,返回一个Iterator对象。这个对象执行了3次next方法(因为F内部有2条yield语句),完成F内部所有代码的运行。这时,所有内部属性都绑定在obj对象上,因而obj对象也就成了F的实例

Generator函数推导

ES7在数组推导的基础上提出了Generator函数推导(Generator comprehension)。

js
let generator = function* () { for (let i = 0; i < 6; i++) { yield i; } } let squared = ( for (n of generator()) n * n); // 等同于 let squared = Array.from(generator()).map(n => n * n); console.log(...squared); // 0 1 4 9 16 25

“推导”这种语法结构不仅可用于数组,ES7还将其推广到了Generator函数for...of循环会自动调用遍历器的next方法,将返回值的value属性作为数组的一个成员。

Generator函数推导是对数组结构的一种模拟,其最大优点是惰性求值,这样可以保证效率。请看下面的例子:

js
let bigArray = new Array(100000); for (let i = 0; i < 100000; i++) { bigArray[i] = i; } let first = bigArray.map(n => n * n)[0]; console.log(first);

上面的例子遍历了一个大数组,但是在真正遍历之前,这个数组就已经生成,占用了系统资源。如果改用Generator函数推导,就能避免这一点。下面的代码只有在用到的时候才会生成一个大数组。

js
let bigGenerator = function* () { for (let i = 0; i < 100000; i++) { yield i; } } let squared = (for (n of bigGenerator()) n * n); console.log(squared.next());

含义

Generator与状态机

Generator是实现状态机的最佳结构。比如,下面的clock函数就是一个状态机。

js
var ticking = true; var clock = function () { if (ticking) { console.log('Tick!'); } else { console.log('Tock!'); } ticking = !ticking; }

上面的clock函数一共有两种状态(TickTock),每运行一次,就改变一次状态。这个函数如果用Generator实现,则如下:

js
var clock = function* (_) { while (true) { yield _; console.log('Tick!'); yield _; console.log('Tock!'); } }

对比上面的Generator实现与ES5实现,可以看到少了用来保存状态的外部变量ticking,这样就更简洁,更完全(状态不会被非法篡改),更符合函数式编程的思想,在写法上也更优雅。Generator之所以不用外部变量保存状态,是因为它本身就包含了一个状态信息,即目前是否处于暂停态。

Generator与协程

协程(coroutine)是一种程序运行的方式,可以理解成“协作的线程”或“协作的函数”。协程既可以用单线程实现,也可以用多线程实现;前者是一种特殊的子例程,后者是一种特殊的线程。

协程与子例程的差异

传统的“子例程”(subroutine)采用堆栈式“后进先出”的执行方式,只有当调用的子函数完全执行完毕,才会结束执行父函数。协程与其不同,多个线程(单线程情况下即多个函数)可以并行执行,但只有一个线程(或函数)处于正在运行状态,其他线程(或函数)都处于暂停态(suspended),线程(或函数)之间可以交换执行权。也就是说,一个线程(或函数)执行到一半,可以暂停执行,将执行权交给另一个线程(或函数),等到稍后收加执行权时再恢复执行。这种可以并行执行、交换执行权的线程(或函数),就称为协程。

从实现上看,在内存中子例程只使用一个栈(stack),而协程是同时存在多个栈,但是只有一个栈是在运行态。也就是说,协程是以多占用内存为代价实现多任务并行运行。

协程与普通线程的差异

不难看出,协程适用于多任务运行的环境。在这个意义上,它与普通的线程很相似,都有自己的执行上下文,可以分享全局变量。它们的不同之处在于,同一时间可以有多个线程处于运行状态,但是运行的协程只能有一个,其他协程都处于暂停态。此处,普通的线程是抢占式的,到底哪个线程优先得到资源,必须由运行环境决定,但是协程是合作式的,执行权由协程自己分配。

ECMAScript是单线程语言,只能保持一个调用栈。引入协程以后,每个任务可以保持自己的调用栈。这样做的最大好处,就是抛出错误时可以找到原始的调用栈,不至于像异步操作的回调函数那样,一旦出错原始的调用栈早已结束。

Generator函数是ES6对协程的实现,但是属于不完全实现。Generator函数被称为“半协程”(semi-coroutine),意思是只有Generator函数的调用者才能将程序的执行权还给Generator函数。如果是完全实现的协程,任何函数都可以让暂停的协程继续执行。

如果将Generator函数当作协程,完全可以将多个需要互相协作的任务写成Generator函数,它们之间使用yield语句交换控制权。

应用

Generator可以暂停函数执行,返回任意表达式的值。这种特点使得Genertor有多种应用场景。

异步操作的同步化表达

Generator函数的暂停执行效果,意味着可以把异步操作写在yield语句里面,等到调用next方法时再往后执行。这实际上就等同于不需要写回调函数了,因为异步操作的后续操作可以放在yield语句下面,反正要等到调用next方法时再执行。所以,Generator函数的一个重要实际意义就是用于处理异步操作,改写回调函数。

js
function* loadUI () { showLoadingScreen(); yield loadUIDataAsynchronously(); hideLoadingScreen(); } var loader = loadUI(); // 加载UI loader.next(); // 卸载UI loader.next();

上面的代码表示,第一次调用loadUI函数时,该函数不会执行,仅返回一个遍历器。下一次对该遍历器使用next方法,则会显示加载界面,并且异步加载数据。等到数据加载完成,再一次使用next方法,则会隐藏加载界面。可以看到,这种写法的好处是所有加载界面的逻辑都被封装在一个函数中,按部就班非常清晰。

AJAX是典型的异步操作,通过Generator函数部署AJAX操作,可以用同步的方式表达。

js
function* main () { var result = yield request('http://xxx.url'); var resp = JSON.parse(result); console.log(resp.value); } function request (url) { makeAjaxCall(url, function (response) { it.next(response) }); } var it = main(); it.next();

上面的main函数就是通过AJAX操作获取数据。可以看到,除了多一个yield,它几乎与同步操作的写法完全一样。注意,makeAjaxCall函数中的next方法必须加上response参数,因为yield语句构成的表达式本身是没有值的,总是等于undefined

下面是另一个例子,通过Generator函数逐行读取文本文件。

js
function* numbers() { let file = new FileReader('numbers.txt'); try { while (!file.eof) { yield parseInt(file.readLine(), 10); } } finally { file.close(); } }

上面的代码打开文本文件,使用yield语句可以手动逐行读取文件。

控制流管理

如果有一个多步操作非常耗时,采用回调函数可能会写成下面这样:

js
step1(function (value) { step2(function (value2) { step3(function (value3) { step4(function (value4) { // Do something with value4 }) }) }) })

采用Promise改写上面的代码如下:

js
Q.fcall(step1) .then(stpe2) .then(stpe3) .then(stpe4) .then(function (value4) { // Do something with value4 }, function (error) { // Handle any error from stpe1 through step4 }) .done();

上面的代码已经把回调函数改成了直线执行的形式,但是加入大量的Promise语法。

Generator函数可以进一步改善代码流程:

js
function* longRunningTask () { try { var value1 = yield step1(); var value2 = yield step2(); var value3 = yield step3(); var value4 = yield step4(); // Do something with value4 } catch (e) { // Handle any error from stpe1 through step4 } }

然后,使用一个函数按次序自动执行所有步骤:

js
scheduler(longRunningTask()); function scheduler (task) { setTimeout(function () { var taskObj = task.next(tast.value); // 如果 Generator函数 未结束,就继续调用 if (!taskObj.done) { task.value = taskObj.value scheduler(task); } }, 0); }

注意,yield语句是同步运行,不是异步运行。实际操作中,一般让yield语句返回Promise对象

js
var Q = require('q'); function delay(milliseconds) { var deferred = Q.defer(); setTimeout(deferred.resolve, milliseconds); return deferred.promise; } function* f () { yield delay(100); }

上面的代码使用了Promise的函数库Qyield语句返回的就是一个Promise对象

多个任务按顺序一个接一个执行时,yield语句可以按顺序排列。多个任何需要并列执行时(比如只有任务A和任务B都执行完,才能执行任务C),可以采用数组的写法。

js
function* parallelDownloads () { let [text1, text2] = yield [ taskA(), taskB() ]; console.log(text1, text2); }

上面的代码中,yield语句的参数是一个数组,成员就是两个任务——taskAtaskB,只有等这两个任务都完成,才会接着执行下面的语句。

部署Iterator接口

利用Generator函数可以在任意对象上部署Iterator接口

js
function* iterEntries (obj) { let keys = Object.keys(obj); for (let i = 0; i < keys.length; i++) { ley key = keys[i]; yield [key, obj[key]]; } } let myObj = {foo: 3, bar: 7}; for (let [key, value] of myObj) { console.log(key, value); } // foo 3 // bar 7

下面是一个对数组部署Iterator接口的例子,尽管数组原生具有这个接口。

js
function* makeSimpleGenerator (array) { var nextIndex = 0; while (nextIndex < array.length) { yield array[nextIndex++]; } } var gen = makeSimpleGenerator(['yo', 'ya']); gen.next().value // 'yo' gen.next().value // 'ya' gen.next().done // true

作为数据结构

Generator可以看作数据结构,更确切地说,可以看作一个数组结构,因为Generator函数可以返回一系列的值,这意味着它可以对任意表达式提供类似数组的接口。

js
function* doStuff () { yield fs.readFile.bind(null, 'hello.txt') yield fs.readFile.bind(null, 'world.txt') yield fs.readFile.bind(null, 'and-such.txt') }

上面的代码依次返回3个函数,但是由于使用了Generator函数,导致可以像处理数组那样处理3个返回的函数。

js
for (task of doStuff()) { // task 是一个函数,可以像回调函数那样使用它 }

实际上,如果用ES5表达,完成可以用数组模拟Generator的这种用法。

js
function doStuff () { return [ fs.readFile.bind(null, 'hello.txt'), fs.readFile.bind(null, 'world.txt'), fs.readFile.bind(null, 'and-such.txt') ]; }

上面的函数可以用一模一样的for...of循环处理。两相比较不难看出,Generator使得数据或操作具备了类似数组的接口。

以上,摘抄自阮一峰老师的《ES6标准入门》