TypeScript 与惰性计算 | 青训营

284 阅读3分钟

什么是惰性计算

惰性计算即在使用计算值时才计算出值。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 中的数组也有类似流的概念,但是它不是惰性的。也就是说一大串 mapfilter 等操作会产生多个中间数组,如果原数组元素量很多,消耗是非常大的。那 TypeScript 中该如何实现惰性计算的流呢?

正如第一节,我们得借助 Generator。首先传入一个数组将其转换成 Generator,之后得操作都是基于该 GeneratorGenerator 在调用 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。