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

異次元的廢D發表於2018-08-12

原文釋出於 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()

參考資料

相關文章