跳至主要内容

使用 Flow 编写类型化的生成器

Flow 0.14.0 版本开始支持生成器函数。生成器函数为 JavaScript 程序提供了一种独特的能力:暂停和恢复执行的能力。这种控制方式为 async/await 铺平了道路,async/await 是一个即将推出的功能,Flow 已经支持它。

关于生成器的描述,已经有很多优秀的资料。我将重点关注静态类型与生成器的交互。有关生成器的信息,请参考以下资料:

  • Jafar Husain 做了一个非常清晰且图文并茂的演讲,涵盖了生成器。我链接到了他开始讲解生成器的地方,但我强烈建议您观看整个演讲。
  • Axel Rauschmayer 撰写了《探索 ES6》,这是一本全面介绍 ES6 的书籍,他慷慨地将内容免费发布在网上,其中有一章专门介绍生成器
  • 久负盛名的 MDN 有一个有用的页面,描述了 Iterator 接口和生成器。

在 Flow 中,Generator 接口有三个类型参数:YieldReturnNextYield 是从生成器函数中产生的值的类型。Return 是从生成器函数中返回的值的类型。Next 是通过 Generator 本身的 next 方法传递到生成器中的值的类型。例如,类型为 Generator<string,number,boolean> 的生成器值将产生 string,返回一个 number,并将从其调用者接收 boolean

对于任何类型 TGenerator<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 相信 valuedone 为 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 值作为哨兵来细化标记联合,从而使上面的动态类型测试变得不必要。也就是说,当 donetrue 时,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 将 stringnumber 添加为描述与 x 绑定的值的类型的类型变量的下界。也就是说,Flow 认为 identity 的类型为 (x: string | number) => string | number,因为这些是实际通过函数的类型。

生成器的另一个重要功能是能够从消费者向生成器传递值。让我们考虑一个生成器 scan,它使用提供的函数来减少传递到生成器中的值。我们的 scanArray.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,并且值 ab 在运行时确实具有正确的类型。但是,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