你可能不知道的迭代器与生成器

原文发布于 github.com/ta7sudan/no…, 如需转载请保留原作者 @ta7sudan.

注: 本文只会写一些个人觉得比较重要的细节, 而非全面介绍迭代器和生成器.

迭代器, 迭代器协议和可迭代协议

我们知道, js 中并没有其他语言那样的接口语法来强制约束一个对象必须实现某些方法, 比如 Java 的 interface. 不过语法只是形式, 接口的思想在任何语言里都是适用的. 在 js 里要想实现接口往往是靠着口头的约定, 当然这种约定是不具有语法层面的约束力的. 而众所周知的约定, 我们也可以称它为协议. ES6 定义了迭代器协议就是这样一种约定, 本质上来说也就是定义了一个接口.

迭代器协议

迭代器协议规定了一个对象需要实现一个 next() 方法, 该方法接受一个可选参数, 方法返回值必须是一个对象, 对象必须包含 donevalue 两个属性, 其中 done 是一个布尔值, value 为类型任意, 当 donetrue 时, value 可以省略. 其实到这里, 如果实现了上面所有内容, 则一个对象就算是实现了迭代器协议了. 当然, 规范有一些语义层面的约束, 那就是 donetrue 时, 意味着迭代已经完成, 迭代器不会再产生新的值, 而为 false 则表示可迭代序列还可以继续产生新的值. 总的来说, 语义层面的约束你也可以不遵守它, 最多只会产生逻辑错误, 而前面的约束不遵守, 则相当于没有实现迭代器协议, 在使用的时候会产生运行时错误.

迭代器

我们把一个实现了迭代器协议的对象称为迭代器或迭代器对象. 我们一般称迭代器关闭了/结束了即是指 next() 返回值的 donetrue 了. 一个迭代器就像下面这样.

var iter = {
	i: 0,
	next() {
		if (this.i < 5) {
			return {
				value: this.i++,
				done: false
			};
		} else {
			return {
				value: this.i,
				done: true
			};
		}
	}
};
复制代码

非常简单, 就一个普通对象, 并没有任何特殊的地方, 这就是一个实现了迭代器协议的对象.

基于迭代器协议的特点, 我们可以知道, 一个迭代器对象的状态, 一旦 next() 返回的对象的 donetrue, 之后再调用 next() 就不可能再回到 donefalse 的状态了. 当然, 从实现的角度来说你也可以违反这一点, 但是这并没有什么好处. 基于这一点, 最好不要在一个迭代器关闭了之后重用这个迭代器对象.

可迭代协议

可迭代协议规定了一个对象需要实现一个属性名为 Symbol.iterator 的方法, 方法不接受参数, 返回值必须是一个对象, 对象必须实现了迭代器协议, 即该方法返回一个迭代器. 这个方法一般也被称为 @@iterator 方法.

可迭代对象

可迭代对象即实现了可迭代协议的对象, 一个简单的可迭代对象就像下面这样.

var iter = {
	i: 0,
	next() {
		if (this.i < 5) {
			return {
				value: this.i++,
				done: false
			};
		} else {
			return {
				value: this.i,
				done: true
			};
		}
	}
};

var iterable = {
	[Symbol.iterator]() {
		return iter;
	}
}
复制代码

iterable 即一个可迭代对象, 可迭代对象可以被用于 for...of, 展开运算符 ..., 数组解构和 yield*.

同样, 基于迭代器协议的特点, @@iterator 方法最好每次调用都返回一个新的迭代器对象. 虽然可迭代协议并没有约束这一点.

生成器

生成器是一个函数, 返回一个对象, 对象实现了迭代器协议和可迭代协议.

function* test() {
	yield 1;
	yield 2;
	yield 3;
}

console.log(typeof test); // function
console.log(typeof test().next); // function
console.log(typeof test()[Symbol.iterator]); // function
复制代码

通常我们把生成器函数的返回值称为生成器对象. 虽然声明语法看上去不太一样, 不过它的类型也就是一个普通函数.

生成器不能作为构造函数, 因为它没有 [[Construct]] 内部属性.

生成器也不存在箭头函数形式的匿名生成器. eg.

var test = *() => {
	yield 1;
}; // error

var test = function* () {
	yield 1;
}; // ok
复制代码

生成器作为属性方法的简写可以这样.

var obj = {
	*test() {
		yield 1;
	}
};
// 而不用
var obj = {
	test: function* {
		yield 1;
	}
};
// 虽然这样也OK
复制代码

调用生成器函数并不执行生成器函数, 而是返回生成器对象, 只有调用生成器对象的 next() throw() return() 方法才会执行生成器函数.

OK, 现在有了生成器函数之后, 我们要实现一个可迭代对象就更加简单了, 可以这样写.

var iterable = {
	*[Symbol.iterator]() {
		yield 1;
		yield 2;
		yield 3;
	}
};
复制代码

比起前面自己手写一个迭代器, 再手写可迭代对象的 Symbol.iterator 方法又简洁了许多.

yield

yield 后面可以跟一个表达式, 而它和后面的表达式本身也是一个表达式, 所以可以出现在任何表达式可以出现的位置.

function* test() {
	var a = yield 1;
	return a;
}
var iter = test();
console.log(iter.next()); // {value: 1, done: false}
console.log(iter.next()); // {value: undefined, done: true}
复制代码

yield 表达式的值是 iter.next() 传入的值, 也就是说, yield 表达式的值默认是 undefined. 这里我们没有给 next() 传入值, 所以 a 也是 undefined.

我们可以简单地认为, 生成器函数的执行在 yield 表达式位置暂停, 然后下一次执行从 yield 表达式所在的语句(包括)开始.

function* test() {
	var a = console.log('aaa') + (yield 1) + console.log('bbb');
	return a;
}
var iter = test();
console.log(iter.next());
console.log(iter.next());
// {value: 1, done: false}
// test
// {value: NaN, done: true}
复制代码

可以看到, 这里一开始执行了 console.log('aaa'), 因为 + 从左往右依次求值, 这个会在 yield 表达式之前被执行, 而后面的 console.log('bbb') 则不会在第一次 next() 时执行, 因为 yield 表达式已经暂停了函数执行, 所以具体函数在哪个位置暂停, 要看 yield 表达式出现的位置, 以及一些运算符的执行顺序. 比如如果 yield 出现在逗号表达式的后面的某一项, 则逗号表达式前面的表达式都会在 yield 暂停之前被执行, 而如果 yield 表达式出现在逗号表达式前面的某一项, 则相反.

function* test() {
	(yield 1, console.log('test'));
}
var iter = test();
console.log(iter.next());
console.log(iter.next());
// {value: 1, done: false}
// test
// {value: undefined, done: true}

function* test() {
	(console.log('test'), yield 1);
}
var iter = test();
console.log(iter.next());
console.log(iter.next());
// test
// {value: 1, done: false}
// {value: undefined, done: true}
复制代码

yield 其实更像是一个运算符, 它的优先级比较低.

function* test() {
	var a = yield 1 + 2;
}
var iter = test();
console.log(iter.next());
console.log(iter.next());
// {value: 3, done: false}
// {value: undefined, done: true}
复制代码

可以看到, 这里第一次 next() 的返回值是 3 而不是 1, 因为先计算了 1 + 2, 这相当于 yield (1 + 2). 如果需要 yield 的值是 1, 则应该加上括号.

function* test() {
	var a = (yield 1) + 2;
}
var iter = test();
console.log(iter.next());
console.log(iter.next());
复制代码

yield 不能跨越函数的边界, 就像 return 一样. 所以这样是不行的.

function* test() {
	var arr = [1, 2, 3];
	arr.forEach(item => {
		yield item;
	});
}
复制代码

生成器对象

前面我们已经说过, 生成器对象实现了迭代器协议和可迭代协议, 所以毫无疑问它具有 next() 方法和 Symbol.iterator 方法, 所以生成器对象既是可迭代对象又是迭代器对象. 事实上, 它主要有以下几个方法.

  • next()
  • throw()
  • return()
  • [Symbol.iterator]()

前面三个函数的返回值都一样, 都是一个具有 donevalue 的对象.

next()

next() 方法很简单, 就如同迭代器协议中所说的, 它返回值一定是一个对象, 且对象一定包含了 donevalue 两个属性, 只不过它还多了一个可选参数, 参数作为上一个 yield 表达式的值. 另一方面, 一旦 next() 被调用, 则生成器函数从上一个 yield 表达式位置或函数起始位置恢复执行, 执行到下一个 yield 表达式的位置暂停, 它的参数作为上一个 yield 表达式的值, 返回值始终非空, 并且返回值的 value 是下一个 yield 表达式的给出的值(即是 yield 后面表达式的值, 而不是 yield 表达式的值). 注意关键词执行到, 上一个, 下一个, 所以在第一次执行 next() 时, 给它传参是没有意义的, 因为第一次执行 next() 是执行到第一个 yield, 而它没有上一个 yield 表达式. 事实上, 第一次传参是通过生成器函数本身的调用来完成的.

function* test() {
	var a = yield 1;
	return a;
}
var iter = test();
console.log(iter.next(2)); // 没有任何作用
console.log(iter.next());

// ----------

function* test() {
	var a = yield 1;
	return a;
}
var iter = test();
console.log(iter.next());
console.log(iter.next(2)); // 有用, 使得 a == 2
复制代码

另一方面, 当我们调用生成器函数并给它传参的时候, 并不会执行生成器函数本身.

function* test(b) {
	console.log(b)
	var a = yield 1;
	return a;
}
var iter = test(0); // 这里不会执行 console.log(b)
console.log(iter.next());
console.log(iter.next());
复制代码

而是在第一次执行 next() 的时候才开始执行生成器函数. 换句话说, next() 可以启动生成器函数. 从这一点来说, 生成器函数也具有收集参数延迟执行的作用.

最后, 当生成器函数运行结束以后, 多次调用生成器对象的 next() 不会再让生成器函数重新开始执行或继续执行了, 并且始终返回 {done: true, value: undefined}.

throw()

throw()next() 很相似, 同样也接受一个参数, 返回值也是一个包含 donevalue 的对象. throw() 被执行, 生成器函数从上一个 yield 表达式位置或函数起始位置恢复执行, 执行到下一个 yield 表达式位置暂停, 但是一旦 throw() 使得生成器函数开始执行, 就会在生成器函数内部抛出一个异常, 它的参数被作为异常的值, 它的返回值的 value 是下一个 yield 表达式给出的值. 什么意思呢?

function* test(b) {
	try {
		yield 1;
	} catch (error) {
		console.log('catch');
	}
	yield 2;
}
var iter = test();
console.log(iter.next()); // {value: 1, done: false}
console.log(iter.throw(new Error('test')));
// catch
// {value: 2, done: false}
复制代码

这相当于

function* test(b) {
	try {
		yield 1;
		throw new Error('test');
	} catch (error) {
		console.log('catch');
	}
	yield 2;
}
复制代码

这给了我们从外部向生成器函数中抛出异常的能力, 并且这个异常可以在生成器函数内部被捕获到. 另外, 它的参数类型并不一定要是 Error 类型, 可以是任意类型, 它们都会被当作异常从而被捕获.

最后, 当生成器函数运行结束以后, 再调用生成器对象的throw() 的行为和 next() 几乎一样, 只不过它还是会触发一个异常.

return()

同样, return()next() 也类似, 它也接受一个参数, 并且返回一个包含 donevalue 的对象, return() 也会使得生成器函数从上一个 yield 表达式位置或函数起始位置恢复执行, 执行到下一个 yield 表达式位置暂停, 但是一旦 return() 使得生成器函数开始执行, 它就会触发生成器函数直接 return. 如果没有下一个可达的 yield 表达式, 则它的参数就是生成器函数 return 的值, 它的返回值的 value 就是 return 的值也即它的参数, 而 done 则始终为 true. 注意这里可能和很多人认知不太一样, 因为大部分时候它就直接触发生成器函数返回了, 生成器函数后面的内容都不会被执行了, 你怎么说它能使生成器函数恢复执行呢? 并且它后面的 yield 表达式怎么可能还有机会执行呢? 注意我们强调了可达的, 别忘了, 我们还有超越 returnfinally.

function* test() {
	try {
		yield 1;
		yield 2;
		yield 3;
	} catch(e) {

	} finally {
		console.log('ok');
	}
}
var iter = test();
console.log(iter.next()); // {value: 1, done: false}
// ok
console.log(iter.return(5)); // {value: 5, done: true}
复制代码

可以看到, 它其实是触发了生成器函数的执行的, 如果真的不触发生成器函数执行, 那就不会输出 finally 中的 ok 了, 它就像函数的 return 一样, 最终还是要先等待 finally 的执行, 所以是先输出了 ok 再输出了 return() 的值, 并且 return()done 置为了 true.

当生成器函数执行完以后, 多次调用生成器对象的 return() 行为也和 next() 差不多, 只不过它的返回值 value 即是它的参数.

再看一个例子.

function* test() {
	try {
		yield 1;
		yield 2;
		yield 3;
	} catch(e) {

	} finally {
		yield 4;
		console.log('ok');
	}
}
var iter = test();
console.log(iter.next()); // {value: 1, done: false}
console.log(iter.return(5)); // {value: 4, done: false}
// ok
console.log(iter.next()); //  // {value: 5, done: true}
复制代码

这个例子就更加诡异了, return() 返回值的 done 不再是自己的参数了, 它的 done 也不再是 true 了, 所以 return() 返回值的 done 并不一定总是为 true, value 也不一定总是它自己的参数. 但是这事情要怎么理解呢? 语言表述能力有限, 比较难说清楚, 但是我们可以做一个等价替换.

function* test() {
	try {
		yield 1;
		return 5;
		yield 2;
		yield 3;
	} catch(e) {

	} finally {
		yield 4;
		console.log('ok');
	}
}
var iter = test();
console.log(iter.next());
console.log(iter.next());
console.log(iter.next());
复制代码

结果和上面的例子一样, 而其实这也是出现这样结果的原因.

其实个人觉得, 对于 next() throw()return(), 我们都把它当作是 next() 就好了, 而对于 throw()return(), 就当它是等价的 throw 语句和 return 语句, 然后我们再按照 next() 的逻辑走. 这样就不会有什么理解上的偏差了. 当然, 上面的例子只是极端情况, 实际上我们几乎不会也不应当这么写就是了.

另外, 上面的 return() 都是基于生成器对象来说明的, 但是并不仅仅只有生成器对象具有 return() 方法, return() 方法也不仅仅只对生成器对象有意义, 之后会更具体讨论.

yield*

yield* 后面可以接一个表达式, 表达式的值必须是一个可迭代对象, yield* 会调用可迭代对象的 Symbol.iterator 方法. 而 yield* 本身也是一个表达式, 即

<expr> := yield* <expr>
复制代码

很多地方都说 yield* 是委托生成器的, 其实 yield* 并不仅仅可以委托生成器, 而是可以委托任意可迭代对象.

yield* generator(); // ok
yield* [1, 2, 3]; // ok

// or

var iter = {
	i: 0,
	next() {
		if (this.i < 5) {
			return {
				value: this.i++,
				done: false
			};
		} else {
			return {
				value: this.i,
				done: true
			};
		}
	},
	return() {
		console.log('return');
		return {done: true};
	}
};

var iterable = {
	[Symbol.iterator]() {
		return iter;
	}
}

function* test() {
	var a = yield* iterable;
	return a;
}
var it = test();
console.log(it.next());
console.log(it.next());
console.log(it.next());
console.log(it.next());
console.log(it.next());
console.log(it.next());
复制代码

以上这些都是 OK 的, 所以 yield* 后面不仅仅可以是生成器对象. 注意我们上面的例子中变量 a 的值, 也即 yield* 表达式的值. yield* 表达式的值是可迭代对象的最后一个值, 也即 donetruevalue 的值.

我们还注意到, 我们的迭代器中有个 return() 方法, 尽管在这里是没有什么意义的, 不过之后的例子会和它进行对照. 这里只说一下, yield* 不会调用迭代器的 return() 方法, 因为 yield* 被视为消费完了可迭代对象(消费完是指 donetrue), 注意这个方法是在迭代器上, 不是在可迭代对象上. 注意这里我们强调了消费完这一概念, 在后面的数组解构例子中会更清楚看到这一点.

基于上面的知识, 我们需要注意一点, 就是在委托生成器的时候, 默认情况下是不会 yield 出生成器的返回值的.

function* f() {
	yield 1;
	yield 2;
	yield 3;
	console.log('test');
	return 4;
}

function* test() {
	yield* f();
}
var it = test();
console.log(it.next()); // {value: 1, done: false}
console.log(it.next()); // {value: 2, done: false}
console.log(it.next()); // {value: 3, done: false}
// test
console.log(it.next()); // {value: undefined, done: false}
复制代码

可以看到, 并没有 f() 的返回值 4, 但是它还是会执行完我们委托的生成器函数, 如果希望 yield 这个返回值, 我们应当像前面那样去取得 yield* 表达式的值, 再加一个 yield. 即

function* f() {
	yield 1;
	yield 2;
	yield 3;
	return 4;
}

function* test() {
	yield yield* f();
}
var it = test();
console.log(it.next()); // {value: 1, done: false}
console.log(it.next()); // {value: 2, done: false}
console.log(it.next()); // {value: 3, done: false}
console.log(it.next()); // {value: 4, done: false}
复制代码

如果一个可迭代对象可以产生 n 个值, 则 yield* 只能 yield 出前 n - 1 个值, 而最后一个值作为 yield* 表达式本身的返回值.

for...of

for...of 用来迭代一个可迭代对象, 它会调用可迭代对象的 Symbol.iterator 方法得到一个迭代器, 循环每执行一次就会调用一次迭代器对象的 next() 方法, 并将 next() 返回的对象的 value 存储在一个变量中, 循环持续这一过程知道返回对象的 donetrue, 当然, donetrue 时的 value 也会被遍历到.

如果将 for...of 用于不可迭代的对象则报错.

for...of 遍历字符串时得到的是完整的字符, 而非单个编码单元, 即我们可以放心使用 for...of 来获取字符串中的每个字符.

for...ofdonetrue 时, 就停止读取其他值, 即如果一个可迭代对象可以产生 n 个值, 则 for...of 只能遍历前 n - 1 个值.

function* gen() {
	yield 1;
	yield 2;
	yield 3;
	console.log('test');
	return 4;
}

for (const v of gen()) {
	console.log(v);
}
// 1
// 2
// 3
// test
复制代码

可以看到, 并不会输出 4. 但是同样, 它也会执行完生成器函数.

break

还记得我们之前例子中的迭代器对象有个 return() 方法吗? 还记得之前我们说过, return() 方法不仅仅是生成器对象特有的, 它也不仅仅只对生成器对象有意义. 事实上, 尽管迭代器协议没有要求实现一个 return() 方法, 但这个方法对于迭代器对象而言也很重要.

for...of 中, 一旦循环被 break, 则会调用可迭代对象的迭代器的 return() 方法. 还是之前的例子.

var iter = {
	i: 0,
	next() {
		if (this.i < 5) {
			return {
				value: this.i++,
				done: false
			};
		} else {
			return {
				value: this.i,
				done: true
			};
		}
	},
	return() {
		console.log('return');
		return {
			value: 7,
			done: false
		};
	}
};

var iterable = {
	[Symbol.iterator]() {
		return iter;
	}
}

for (const v of iterable) {
	console.log(v);
	break;
}
// 0
// return
复制代码

可以看到, 我们的 return() 方法被调用了, 这有什么用呢? 这让我们实现的迭代器能够知道自己什么时候被提前关闭了.

for...of 在遍历的时候总是会调用这个迭代器的 return() 方法吗? 并不是.

var iter = {
	i: 0,
	next() {
		if (this.i < 5) {
			return {
				value: this.i++,
				done: false
			};
		} else {
			return {
				value: this.i,
				done: true
			};
		}
	},
	return() {
		console.log('return');
		return {
			value: 7,
			done: false
		};
	}
};

var iterable = {
	[Symbol.iterator]() {
		return iter;
	}
}

for (const v of iterable) {
	console.log(v);
}
// 0
// 1
// 2
// 3
// 4
复制代码

这里我们没有使用 break, 所以 return() 也没有被调用. 事实上, 只有当一个可迭代对象产生的数据没有被消费完时才会调用可迭代对象的迭代器的 return(). 怎么定义消费完? 准确来说应该是, donetrue 时, 并且第 n - 1 次迭代全部完成就算消费完. 可以看下面的例子.

var iter = {
	i: 0,
	next() {
		if (this.i < 5) {
			return {
				value: this.i++,
				done: false
			};
		} else {
			return {
				value: this.i,
				done: true
			};
		}
	},
	return() {
		console.log('return');
		return {
			value: 7,
			done: false
		};
	}
};

var iterable = {
	[Symbol.iterator]() {
		return iter;
	}
}

for (const v of iterable) { // 没有消费完
	console.log(v);
	if (v === 2) {
		break;
	}
}

// or
for (const v of iterable) { // 也没有消费完!!!
	console.log(v);
	if (v === 4) {
		break;
	}
}
// or
for (const v of iterable) { // 也没有消费完!!!
	console.log(v);
	throw new Error('err');
}
// or
for (const v of iterable) { // 消费完了
	console.log(v);
}
复制代码

可以看到, 前面三种情况都是没有消费完的, 即使已经读到 n - 1 个数据了, 但是因为该次迭代还未执行完就 break 了, 所以也没有消费完, 或者你也可以理解为, 只要执行了 break 就没有消费完, 另外在某一次迭代中因为异常中断了也属于没有消费完.

即我们最后可以总结为, for...of 会在可迭代对象的数据没有被消费完时调用可迭代对象的迭代器的 return() 方法.

那么这个 return() 方法有什么要求没呢? 它不接受参数(当然, 生成器对象的可以接受一个可选参数), 它必须返回一个对象, 否则被调用时会报错. 只要返回的对象需要包含什么其实并不重要. 只不过通常来说, 我们也按照 next() 一样返回一个包含 done: true 的对象, 至于 value 是否需要都没啥关系, 不会被用到. 另一方面是, 建议在 return() 里面也修改掉 next() 返回对象的 donetrue, 确保逻辑上这个迭代器已经结束.

var iter = {
	i: 0,
	done: false,
	next() {
		if (this.i < 5) {
			return {
				value: this.i++,
				done: this.done
			};
		} else {
			this.done = true;
			return {
				value: this.i,
				done: this.done
			};
		}
	},
	return() {
		console.log('return');
		this.done = true;
		return {
			value: 7,
			done: this.done
		};
	}
};

var iterable = {
	[Symbol.iterator]() {
		return iter;
	}
}

for (const v of iterable) {
	console.log(v);
	break;
}

for (const vv of iterable) {
	console.log(vv);
}
复制代码

这里迭代器的所有方法返回的 done 都共享了一个内部状态, 这样第二个 for...of 就不会开始迭代了, 否则的话第二个 for...of 又会接着遍历迭代器.

另外, 前面的例子种一直有个问题, 就是我们的迭代器被重用了, 但这里只是为了方便演示, 实际情况中我们绝不应该这么写, 这里引用 MDN 的例子.

var gen = (function *(){
  yield 1;
  yield 2;
  yield 3;
})();
for (let o of gen) {
  console.log(o);
  break;  // Closes iterator
}

// The generator should not be re-used, the following does not make sense!
for (let o of gen) {
  console.log(o); // Never called.
}
复制代码

所以记住不要重用迭代器!

展开运算符

展开运算符接受的也是一个可迭代对象. 所以把一个可迭代对象转为数组的最简单方式是这样.

var arr = [...iterable];
复制代码

当然, 下面这些也都是合法的.

var iter = {
	i: 0,
	next() {
		if (this.i < 5) {
			return {
				value: this.i++,
				done: false
			};
		} else {
			return {
				value: this.i,
				done: true
			};
		}
	},
	return() {
		console.log('return');
		return {done: true};
	}
};

var iterable = {
	[Symbol.iterator]() {
		return iter;
	}
}

var a = [...iterable];

// or

function* gen() {
	yield 1;
	yield 2;
	yield 3;
}

var b = [...gen()];
复制代码

另外, 我们发现, 展开运算符不会调用可迭代对象的 Symbol.iterator 返回的迭代器的 return() 方法, 因为展开运算符也会消费完可迭代对象产生的值.

但是展开运算符和 for...of yield* 一样, 也会忽略掉最后一个值, 只要 donetrue 就停止读取其他值.

function* gen() {
	yield 1;
	yield 2;
	yield 3;
	console.log('test');
	return 4;
}

var arr = [...gen()];
// test
console.log(arr); // [1, 2, 3]
复制代码

同样, 它也会执行完生成器函数.

数组解构

数组解构其实也并不要求是对一个数组进行解构赋值, 而是对任何可迭代对象都可以进行数组解构, 所以下面这些也都是合法的.

var iter = {
	i: 0,
	next() {
		if (this.i < 5) {
			return {
				value: this.i++,
				done: false
			};
		} else {
			return {
				value: this.i,
				done: true
			};
		}
	},
	return() {
		console.log('return');
		return {done: true};
	}
};

var iterable = {
	[Symbol.iterator]() {
		return iter;
	}
}


var [a, b, c] = iterable;

// or
function* gen() {
	yield 1;
	yield 2;
	yield 3;
}

var [a, b, c] = gen();
console.log(a, b, c);
复制代码

数组解构在没有将可迭代对象的值消费完时, 会调用可迭代对象的 Symbol.iterator 返回的迭代器的 return() 方法, 而如果数组解构消费完了可迭代对象时(donetrue 时), 则不会调用 return() 方法. 可以看下面的例子.

var iter = {
	i: 0,
	next() {
		if (this.i < 5) {
			return {
				value: this.i++,
				done: false
			};
		} else {
			return {
				value: this.i,
				done: true
			};
		}
	},
	return() {
		console.log('return');
		return {
			done: true
		}
	}
};

var iterable = {
	[Symbol.iterator]() {
		return iter;
	}
}

var [a, b, c] = iterable;
// return
复制代码

而如果最后是这样

var [a, b, c, d, e, f] = iterable;
复制代码

则不会调用 return(), 因为可迭代对象已经被消费完了.

同样, 数组解构也不会读取最后一个值, 也是在 donetrue 时停止读取.

function* gen() {
	yield 1;
	yield 2;
	yield 3;
	console.log('test');
	return 4;
}

var [a, b, c, d] = gen();
// test
console.log(a, b, c, d); // 1 2 3 undefined
复制代码

同样, 在这种时候, 它也会执行完生成器函数.

GeneratorFunction

我们知道所有的普通函数都是 Function 的实例, 我们也可以用 new Function() 来创建一个函数, 但是如果是生成器函数, 是否也有这样的形式创建? 看着这个标题, 可能你会认为存在一个 GeneratorFunction 的全局对象, 然而其实全局作用域中并不存在 GeneratorFunction 这么一个内建对象. 不过仅仅是说, 它不在全局作用域中而已, 这个内建对象本身还是存在的. 我们可以通过下面这样的方式获取它.

var GeneratorFunction = Object.getPrototypeOf(function*(){}).constructor
复制代码

之后我们便可以使它来创建生成器函数了.

var test = new GeneratorFunction('arg0', 'yield 1');
复制代码

总的来说, 它和 Function 几乎一样, 比如它创建的生成器函数也是在全局作用域的. 具体用法参考 MDN.

资源的回收

现在我们来看一个具体场景. 考虑我们的迭代器是用来按行读取文件的, 每次调用迭代器的 next() 便会返回一行的内容, 所以我们的迭代器这样实现.

var iter = {
	file: {
		line: 0,
		readLine() {
			return `line ${this.line++}`;
		},
		close() {
			console.log('close');
		}
	},
	done: false,
	next() {
		if (this.file.line < 5) {
			return {
				value: this.file.readLine(),
				done: this.done
			}
		} else {
			this.file.close();
			this.done = true;
			return {
				value: 'EOF',
				done: this.done
			}
		}
	}
};
复制代码

在这里我们模拟了一个文件, 它相当于一个文件描述符, 并且它有一个 close() 方法. 我们应当在读取完所有行(假设一共 5 行)之后关闭这个文件, 所以上面的代码看起来没什么问题. 接着我们构造一个可迭代对象.

var iterable = {
	[Symbol.iterator]() {
		return iter;
	}
}
复制代码

最终我们通过 for...of 来实现按行读取文件.

var iter = {
	file: {
		line: 0,
		readLine() {
			return `line ${this.line++}`;
		},
		close() {
			console.log('close');
		}
	},
	done: false,
	next() {
		if (this.file.line < 5) {
			return {
				value: this.file.readLine(),
				done: this.done
			}
		} else {
			this.file.close();
			this.done = true;
			return {
				value: 'EOF',
				done: this.done
			}
		}
	},
	return() {
		this.file.close();
		this.done = true;
		return {
			done: this.done
		};
	}
};

var iterable = {
	[Symbol.iterator]() {
		return iter;
	}
}

for (const line of iterable) {
	console.log(line);
}
// line 0
// line 1
// line 2
// line 3
// line 4
// close
复制代码

很好, 一切正常, 我们优雅地实现了按行读取, 并且关闭了这个文件. 那假如我们只读了一行就想退出 for...of 循环呢? 很简单, 我们加上一个 break 就好.

for (const line of iterable) {
	console.log(line);
	break;
}
// line 0
复制代码

但是我们发现这次文件没有被正确关闭掉. So, 怎么办呢? 假如我们作为迭代器的实现者, 其实我们并不知道其他人/用户会怎么使用我们的迭代器, 我们希望最好能够有一种方式, 能够让我们的迭代器知道自己是否被消费完, So, 我们很容易想到前面提到的迭代器的 return() 方法. 于是我们这么实现.

var iter = {
	file: {
		line: 0,
		readLine() {
			return `line ${this.line++}`;
		},
		close() {
			console.log('close');
		}
	},
	done: false,
	next() {
		if (this.file.line < 5) {
			return {
				value: this.file.readLine(),
				done: this.done
			}
		} else {
			this.file.close();
			this.done = true;
			return {
				value: 'EOF',
				done: this.done
			}
		}
	},
	return() {
		this.file.close();
		this.done = true;
		return {
			done: this.done
		};
	}
};

var iterable = {
	[Symbol.iterator]() {
		return iter;
	}
}

for (const line of iterable) {
	console.log(line);
	break;
}
// line 0
// close
复制代码

OK, 现在我们如愿关闭了文件, 不论是否读完了所有内容.

但是上面例子中我们都是自己实现的迭代器对象, 那对于生成器函数返回的生成器对象呢? 毫无疑问, 我们知道如果 for...of break 了肯定也会调用生成器对象的 return() 方法, 但是这个方法并不是我们自己实现的, 难道我们为了做资源释放的操作需要重写生成器对象的 return() 方法吗? 即使这样可以, 但是你能够保证你实现的 return() 的行为和 Generator.prototype.return() 一致吗? 还是让我们来看例子吧.

function* genf() {
	var file = {
		close() {
			console.log('close');
		}
	};
	yield 'line 0';
	yield 'line 1';
	yield 'line 2';
	yield 'line 3';
	yield 'line 4';
	file.close();
}


for (const line of genf()) {
	console.log(line);
}
// line 0
// line 1
// line 2
// line 3
// line 4
// close
复制代码

现在文件被正确关闭, 让我们给它加上 break.

function* genf() {
	var file = {
		close() {
			console.log('close');
		}
	};
	yield 'line 0';
	yield 'line 1';
	yield 'line 2';
	yield 'line 3';
	yield 'line 4';
	file.close();
}


for (const line of genf()) {
	console.log(line);
	break;
}
// line 0
复制代码

GG, 并没有关闭文件. 毫无疑问, 此时 return() 是会被调用的, 但是这个 return() 并不受我们控制, 重写 return() 也不是一个明智的操作. 我们需要的是能够知道 return() 什么时候被调用了, 这样我们可以在 return() 被调用之后释放掉资源. 再仔细想想, 我们真的需要知道 return() 什么时候被调用了吗? 其实我们只需要在 return() 被调用之后释放掉资源, 至于 return() 什么时候被调用我们其实并不关心, 知道 return() 什么时候被调用只是让我们可以在之后释放资源, 但是我们知道 return() 什么时候被调用并不是必要条件. So, 怎么确保在 return() 之后能执行我们想要的操作? 优先级最高的 finally.

function* genf() {
	var file = {
		close() {
			console.log('close');
		}
	};
	try {
		yield 'line 0';
		yield 'line 1';
		yield 'line 2';
		yield 'line 3';
		yield 'line 4';
	} finally {
		file.close();
	}
}


for (const line of genf()) {
	console.log(line);
	break;
}
// line 0
// close
复制代码

OK, 一切完美.

总结一下, 在我们自己实现迭代器的时候, 最好加上 return() 方法, 尤其当迭代器涉及 IO 之类的操作时, 有了 return() 方便我们进行资源回收, 但是资源回收操作不仅仅应该在 return() 中实现, 也需要在 next() 中实现, 因为 return() 并不总是会被调用, 而是只有当迭代器没有被消费完时才会被调用. 另外最好确保 next()return() 调用以后的 done 的状态一致, 即如果 return() 被调用, 则下次 next()done 也为 true, 当 next() 调用后的 donetrue, 则 return() 返回对象的 done 也为 true.

而在我们实现生成器函数的时候, 如果有 IO 操作涉及一些资源的创建与回收, 也记得在最后使用 finally 进行回收.

The end

最后再比较一下 for...of yield* 数组解构和展开运算符.

  • 它们都接受一个可迭代对象, 并且都会调用可迭代对象的迭代器的 next() 方法
  • 如果一个可迭代对象可以产生 n 个值, 则它们最多都会调用 n 次 next() 方法
  • 对于生成器函数, 它们最多都会将生成器函数执行完
  • 它们最多都只使用前 n - 1 个值
  • 对于 for...of 它只会迭代最多 n - 1 次, 但是执行 n 次 next()
  • 对于 yield*, 它一定产生 n - 1 个 yield 表达式, 执行 n 次 next(), 而第 n 个值作为它自身表达式的值
  • 对于数组解构, 如果有 m 个变量被赋值, 则它执行 m 次 next(), 而它最多只能使用 n - 1 个值, 所以它最多给 n - 1 个变量赋值, 此时如果是生成器函数则并不会执行完, 想要将生成器函数执行完则必须赋值 n 个变量, 此时最后一个变量是 undefined
  • 对于展开运算符, 它一定展开 n - 1 个值, 执行 n 次 next(), 所以它一定会执行完生成器函数
  • 其中 yield* 和展开运算符都是一定会消费完可迭代对象的, 所以它们不会调用 return() 方法, 而 for...of 和数组解构则有可能不会消费完可迭代对象, 此时它们都会调用 return()

参考资料

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值