什么是惰性计算
惰性计算即在使用计算值时才计算出值。TypeScript 或者说 JavaScript 可以通过 Generator 实现惰性求值。
一个 Generator 对象可以通过 yield 关键字返回值,当调用 next 方法时,它会试图恢复到上次执行的 yiled 语句后继续执行,直到下一条 yiled 语句。因此,直到我们调用 next 方法,Generator 是不会执行的。
type Lazy<T> = Generator<T, void, unknown>;
function lazy<T>(factory: () => T): Lazy<T> {
return (function* () {
yield factory();
})();
}
const lazyValue = lazy(() => {
console.log('evaluating...');
return 1 + 1;
});
console.log('start');
console.log(lazyValue.next());
console.log('end');
`
// start
// evaluating...
// { value: 2, done: false }
// end
流
如果你学过 Java,会知道 Java 中有流这个概念。“它将要处理的元素集合看作一种流,流在管道中传输,并且可以在管道的节点上进行处理,比如筛选,排序,聚合等”。
流对象上的方法分为两种,一种用于产生新的流,另一种则是计算最终的结果。你应该看出来了,流是惰性的。在没有调用消费类方法时,所有的中间操作都是没有消耗的。
而 TypeScript 或者说是 JavaScript 中的数组也有类似流的概念,但是它不是惰性的。也就是说一大串 map,filter 等操作会产生多个中间数组,如果原数组元素量很多,消耗是非常大的。那 TypeScript 中该如何实现惰性计算的流呢?
正如第一节,我们得借助 Generator。首先传入一个数组将其转换成 Generator,之后得操作都是基于该 Generator。Generator 在调用 next (本例中使用 for..of 替代)之前是不会消耗计算的,计算过程也不会产生新数组。
class Lazy<T> {
private _iter: Generator<T>;
constructor(source: T[] | Generator<T>) {
if (Array.isArray(source)) {
this._iter = (function* () {
for (let i = 0; i < source.length; i++) {
yield source[i];
}
})();
} else {
this._iter = source;
}
}
filter(fn: (item: T) => boolean): Lazy<T> {
const iter = this._iter;
const filetrIter = (function* () {
for (const item of iter) {
if (fn(item)) {
yield item;
}
}
})();
return new Lazy<T>(filetrIter);
}
map<U>(fn: (item: T) => U): Lazy<U> {
const iter = this._iter;
const mapIter = (function* () {
for (const item of iter) {
yield fn(item);
}
})();
return new Lazy<U>(mapIter);
}
take(num: number): Lazy<T> {
const iter = this._iter;
const takeIter = (function* () {
for (const item of iter) {
if (num-- > 0) {
yield item;
} else {
break;
}
}
})();
return new Lazy<T>(takeIter);
}
collect(): T[] {
const result: T[] = [];
const iter = this._iter;
for (const item of iter) {
result.push(item);
}
return result;
}
}
下面的例子是筛选数组中的偶数并映射成其乘方,最后取其中前两位。
const arr = [1, 2, 3, 4, 5, 6, 7, 8, 9];
const result = new Lazy(arr)
.filter((item) => item % 2 === 0)
.map((item) => item * 2)
.take(2)
.collect();
console.log(result); // [4, 8]
总结
惰性计算在某些方面相较于常规计算方式在性能上几乎是碾压。比如上一节中使用了 take 方法,惰性计算只需要计算两项,而常规计算需要对所有数组元素施加计算才能得到结果。但也可以看出在其他方面,至少上一节中的实现方式,惰性计算优势并不大。
上节中的流式可以看作计算式函数式编程范式的拓展。其实其中提到的 TypeScript 中函数链式计算会产生影响性能的中介结果,但是我们完全可以使用命令式的编程避免。使用流式编程更多是为了提供一种复用方法。TypeScript 中有很多类似的库实现了这一点,如 Lazyjs。