Flow 0.14.0 版本开始支持生成器函数。生成器函数为 JavaScript 程序提供了一种独特的能力:暂停和恢复执行的能力。这种控制方式为 async/await 铺平了道路,async/await 是一个即将推出的功能,Flow 已经支持它。
关于生成器的描述,已经有很多优秀的资料。我将重点关注静态类型与生成器的交互。有关生成器的信息,请参考以下资料:
- Jafar Husain 做了一个非常清晰且图文并茂的演讲,涵盖了生成器。我链接到了他开始讲解生成器的地方,但我强烈建议您观看整个演讲。
- Axel Rauschmayer 撰写了《探索 ES6》,这是一本全面介绍 ES6 的书籍,他慷慨地将内容免费发布在网上,其中有一章专门介绍生成器。
- 久负盛名的 MDN 有一个有用的页面,描述了
Iterator
接口和生成器。
在 Flow 中,Generator
接口有三个类型参数:Yield
、Return
和 Next
。Yield
是从生成器函数中产生的值的类型。Return
是从生成器函数中返回的值的类型。Next
是通过 Generator
本身的 next
方法传递到生成器中的值的类型。例如,类型为 Generator<string,number,boolean>
的生成器值将产生 string
,返回一个 number
,并将从其调用者接收 boolean
。
对于任何类型 T
,Generator<T,void,void>
既是 Iterable<T>
又是 Iterator<T>
。
生成器的独特特性使我们能够自然地表示无限序列。考虑自然数的无限序列
function *nats() {
let i = 0;
while (true) {
yield i++;
}
}
由于生成器也是迭代器,我们可以手动迭代生成器
const gen = nats();
console.log(gen.next()); // { done: false, value: 0 }
console.log(gen.next()); // { done: false, value: 1 }
console.log(gen.next()); // { done: false, value: 2 }
当 done
为 false 时,value
将具有生成器的 Yield
类型。当 done
为 true 时,value
将具有生成器的 Return
类型,或者如果消费者迭代超过完成值,则为 void
。
function *test() {
yield 1;
return "complete";
}
const gen = test();
console.log(gen.next()); // { done: false, value: 1 }
console.log(gen.next()); // { done: true, value: "complete" }
console.log(gen.next()); // { done: true, value: undefined }
由于这种行为,手动迭代会带来类型问题。让我们尝试通过手动迭代从 nats
生成器中获取前 10 个值
const gen = nats();
const take10: number[] = [];
for (let i = 0; i < 10; i++) {
const { done, value } = gen.next();
if (done) {
break;
} else {
take10.push(value); // error!
}
}
test.js:13
13: const { done, value } = gen.next();
^^^^^^^^^^ call of method `next`
17: take10.push(value); // error!
^^^^^ undefined. This type is incompatible with
11: const take10: number[] = [];
^^^^^^ number
Flow 正在抱怨 value
可能为 undefined
。这是因为 value
的类型为 Yield | Return | void
,在 nats
的实例中简化为 number | void
。我们可以引入一个动态类型测试来让 Flow 相信 value
在 done
为 false 时始终为 number
的不变式。
const gen = nats();
const take10: number[] = [];
for (let i = 0; i < 10; i++) {
const { done, value } = gen.next();
if (done) {
break;
} else {
if (typeof value === "undefined") {
throw new Error("`value` must be a number.");
}
take10.push(value); // no error
}
}
有一个未解决的问题,它将通过使用 done
值作为哨兵来细化标记联合,从而使上面的动态类型测试变得不必要。也就是说,当 done
为 true
时,Flow 将知道 value
始终为 Yield
类型,否则为 Return | void
类型。
即使没有动态类型测试,这段代码也很冗长,而且很难看出意图。由于生成器也是可迭代的,我们也可以使用 for...of
循环
const take10: number[] = [];
let i = 0;
for (let nat of nats()) {
if (i === 10) break;
take10.push(nat);
i++;
}
这样好多了。for...of
循环结构会忽略完成值,因此 Flow 理解 nat
始终为 number
。让我们使用生成器函数进一步推广这种模式
function *take<T>(n: number, xs: Iterable<T>): Iterable<T> {
if (n <= 0) return;
let i = 0;
for (let x of xs) {
yield x;
if (++i === n) return;
}
}
for (let n of take(10, nats())) {
console.log(n); // 0, 1, 2, 3, 4, 5, 6, 7, 8, 9
}
请注意,我们显式地注释了 take
生成器的参数和返回类型。这是为了确保 Flow 理解完全通用的类型。这是因为 Flow 目前无法推断出完全通用的类型,而是累积下界,从而导致联合类型。
function identity(x) { return x }
var a: string = identity(""); // error
var b: number = identity(0); // error
上面的代码会产生错误,因为 Flow 将 string
和 number
添加为描述与 x
绑定的值的类型的类型变量的下界。也就是说,Flow 认为 identity
的类型为 (x: string | number) => string | number
,因为这些是实际通过函数的类型。
生成器的另一个重要功能是能够从消费者向生成器传递值。让我们考虑一个生成器 scan
,它使用提供的函数来减少传递到生成器中的值。我们的 scan
与 Array.prototype.reduce
类似,但它返回每个中间值,并且值是通过 next
迭代地提供的。
作为第一步,我们可以这样写
function *scan<T,U>(init: U, f: (acc: U, x: T) => U): Generator<U,void,T> {
let acc = init;
while (true) {
const next = yield acc;
acc = f(acc, next);
}
}
我们可以使用此定义来实现一个迭代求和过程
let sum = scan(0, (a,b) => a + b);
console.log(sum.next()); // { done: false, value: 0 }
console.log(sum.next(1)); // { done: false, value: 1 }
console.log(sum.next(2)); // { done: false, value: 3 }
console.log(sum.next(3)); // { done: false, value: 6 }
但是,当我们尝试检查 scan
的上述定义时,Flow 会抱怨
test.js:7
7: acc = f(acc, next);
^^^^^^^^^^^^ function call
7: acc = f(acc, next);
^^^^ undefined. This type is incompatible with
3: function *scan<T,U>(init: U, f: (acc: U, x: T) => U): Generator<U,void,T> {
^ some incompatible instantiation of T
Flow 正在抱怨我们的值 next
可能是 void
而不是预期的 T
,在 sum
示例中为 number
。这种行为对于确保类型安全是必要的。为了启动生成器,我们的消费者必须首先在没有参数的情况下调用 next
。为了适应这一点,Flow 理解 next
的参数是可选的。这意味着 Flow 将允许以下代码
let sum = scan(0, (a,b) => a + b);
console.log(sum.next()); // first call primes the generator
console.log(sum.next()); // we should pass a value, but don't need to
一般来说,Flow 不知道哪个调用是“第一个”。虽然将值传递给第一个 next
应该是一个错误,而不将值传递给后续的 next
应该是一个错误,但 Flow 做出了妥协,迫使您的生成器处理可能为 void
的值。简而言之,给定类型为 Generator<Y,R,N>
的生成器和类型为 Y
的值 x
,表达式 yield x
的类型为 N | void
。
我们可以更新我们的定义,使用一个动态类型测试在运行时强制执行非 void
不变式
function *scan<T,U>(init: U, f: (acc: U, x: T) => U): Generator<U,void,T> {
let acc = init;
while (true) {
const next = yield acc;
if (typeof next === "undefined") {
throw new Error("Caller must provide an argument to `next`.");
}
acc = f(acc, next);
}
}
在处理类型化生成器时,还有一个重要的注意事项。从生成器中产生的每个值都必须由单个类型描述。类似地,通过 next
传递到生成器的每个值都必须由单个类型描述。
考虑以下生成器
function *foo() {
yield 0;
yield "";
}
const gen = foo();
const a: number = gen.next().value; // error
const b: string = gen.next().value; // error
这完全是合法的 JavaScript,并且值 a
和 b
在运行时确实具有正确的类型。但是,Flow 会拒绝此程序。我们生成器的 Yield
类型参数具有 number | string
的具体类型。迭代器结果对象的 value
属性具有 number | string | void
类型。
我们可以观察到传递到生成器中的值的类似行为
function *bar() {
var a = yield;
var b = yield;
return {a,b};
}
const gen = bar();
gen.next(); // prime the generator
gen.next(0);
const ret: { a: number, b: string } = gen.next("").value; // error
值 ret
在运行时具有注释的类型,但 Flow 也会拒绝此程序。我们生成器的 Next
类型参数具有 number | string
的具体类型。因此,迭代器结果对象的 value
属性具有 void | { a: void | number | string, b: void | number | string }
类型。
虽然可以使用动态类型测试来解决这些问题,但另一个实用的选择是使用 any
来承担类型安全责任。
function *bar(): Generator {
var a = yield;
var b = yield;
return {a,b};
}
const gen = bar();
gen.next(); // prime the generator
gen.next(0);
const ret: void | { a: number, b: string } = gen.next("").value; // OK
(请注意,注释 Generator
等效于 Generator<any,any,any>
。)
呼!我希望这能帮助您在自己的代码中使用生成器。我还希望这能让你对将静态分析应用于 JavaScript 等高度动态语言的困难有所了解。
总结一下,以下是我们在静态类型化的 JS 中使用生成器时学到的一些经验教训:
- 使用生成器来实现自定义可迭代对象。
- 使用动态类型测试来解包 yield 表达式的可选返回类型。
- 避免产生或接收多种类型值的生成器,或者使用
any
。