📚

遅延リストを扱うライブラリと Stage 3 Iterator Helpers

2021/09/25に公開
2
変更情報

【2022/12/02 変更】
2022年11月に Stage 3 になったため記事のタイトル、一部内容を変更しています。
【2023/10/01 変更】
2023年1月に Async Iterator Helpers が別の提案としてスプリットされ Stage 2 に降格されたため、一部内容を変更しています。


イテレーターのインターフェースについて

ES2015 から Iterable インターフェースを実装したオブジェクトに対して for-of やスプレッド構文を使うことが出来ます。インターフェースについて詳しくは qnighy さんの記事を御覧ください。

https://zenn.dev/qnighy/articles/112af47edfda96

遅延リストを扱うライブラリ

整数が入った配列があったとします。その配列に対して要素を二乗し、一つ足し、その後で偶数のみを取り出すことを考えます。愚直に実装すると以下のようになります。

const arr = [];
for (let i = 0; i < 100000; ++i) {
  arr[i] = i;
}

const result = arr
  .map((val) => val * val)
  .map((val) => val + 1)
  .filter((val) => val % 2 === 0);

この実装には問題があります。Array#map は配列から新しく配列を作るメソッドなので、その度にヒープメモリを確保し消費してしまいます。パフォーマンスが良くありません。

この問題に対処する一つの方法が lazy に実行することです。最後に必要になるまで配列を作らなければ、無駄なメモリ使用を防ぐことが出来ます。

Lazy.js

JavaScript にまだイテレーターがなかった時代に作られた Lazy.js というライブラリがあります。独自に Sequence クラスを実装して作られています。

import Lazy from "lazy.js";

const arr = [];
for (let i = 0; i < 100000; ++i) {
  arr[i] = i;
}

const result = Lazy(arr)
  .map((val) => val * val)
  .map((val) => val + 1)
  .filter((val) => val % 2 === 0)
  .toArray();

https://danieltao.com/lazy.js/

IxJS

Push-based Streams のための RxJS は有名ですが、それとは対照的に Pull-based Streams のための IxJS があります。独自に IterableX クラスを実装して作られています。

import { from, toArray } from "ix/iterable";
import { map, filter } from "ix/iterable/operators";

const arr = [];
for (let i = 0; i < 100000; ++i) {
  arr[i] = i;
}

const result = toArray(
  from(arr).pipe(
    map((val) => val * val),
    map((val) => val + 1),
    filter((val) => val % 2 === 0),
  ),
);

https://github.com/ReactiveX/IxJS

Stage 3 Iterator Helpers

%IteratorPrototype% に新たにヘルパーメソッドを追加し、グローバルに Iterator クラスを追加する提案が Stage 3 Iterator Helpers です。

const arr = [];
for (let i = 0; i < 100000; ++i) {
  arr[i] = i;
}

// Array#values で Iterator クラスを継承したインスタンスを作る
const result1 = arr.values()
  .map((val) => val * val)
  .map((val) => val + 1)
  .filter((val) => val % 2 === 0)
  .toArray();

// もしくは Iterator.from で Iterator クラスを継承したインスタンスを作る
const result2 = Iterator.from(arr)
  .map((val) => val * val)
  .map((val) => val + 1)
  .filter((val) => val % 2 === 0)
  .toArray();

https://github.com/tc39/proposal-iterator-helpers

インターフェースとクラス

for-of やスプレッド構文については Iterable インターフェースを実装していれば使うことが出来ました。しかしこの提案のメソッドは Iterator インターフェースだけでは不十分で、プロトタイプチェーンの中に %IteratorPrototype% がないと使うことが出来ません。

もしジェネレーター函数を使わずに独自で Iterator インターフェースを実装している場合は、追加された Iterator クラスを継承する必要があります。

class MyIterator extends Iterator {
  next() {}
  return() {}
}

もしくはメソッドを使う直前に Iterator.from を使って変換するのもいいでしょう[1]

イテレーターは再利用不可能

注意点として Iterator Helpers のメソッドではイテレーターの再利用ができません。

const arr = [];
for (let i = 0; i < 100000; ++i) {
  arr[i] = i;
}

const iterator = arr.values()
  .map((val) => val * val)
  .map((val) => val + 1);

// この Iterator#toArray で iterator を完了状態にする
const mapped = iterator.toArray();

// iterator は既に完了しているため、空配列になってしまう
const filtered = iterator
  .filter((val) => val % 2 === 0)
  .toArray();

console.assert( filtered.length === 0 );

これは Lazy.js や IxJS とは異なる特徴です。Lazy.js や IxJS では独自クラスの中にそのソースとなる配列への参照を保持しているため、何度でも toArray によって配列を作ることが出来ます[2]一方でネイティブの Iterator クラスではそれが出来ません。

これが問題になることはあまりないと思いますが、また同様の手順でイテレーターを作るか、一旦配列にしてしまうかなどして対処する必要があります。

ジェネレータープロトコルには非対応

Iterator Helpers のメソッドを使った場合、単純に [[Prototype]]%IteratorPrototype% (Iterator.prototype) を持つインスタンスではなく、更にもう一段階ラップした %IteratorHelperPrototype% を持つインスタンスを返すようになっています[3]

https://tc39.es/proposal-iterator-helpers/#sec-iterator-helper-objects

ソースイテレーターに returnthrow メソッドが定義されていた場合に、それがちゃんと受け継がれるようになっています。

【2022/12/02 追記】

適切な処理を定義するのが不可能だったと判断され、Iterator Helpers ではジェネレーターのプロトコルに対応しないこととなりました。つまり throw メソッドは実装されず、nextreturn メソッドの引数をソースとなったイテレーターに渡しません。

関連する提案

%AsyncIteratorPrototype% に新たにヘルパーメソッドを追加し、グローバルに AsyncIterator クラスを追加する提案が Stage 2 Async Iterator Helpers です。元々は同じ提案でしたがスプリットされました。

https://github.com/tc39/proposal-async-iterator-helpers

また Iterable インターフェースを実装したオブジェクトから配列を作るには Array.from がありますが、AsyncIterable インターフェースのオブジェクトから配列を作る函数は今のところありません。このミッシングピースを埋める提案 Stage 2 Array.fromAsync というのもあります。

const asyncIterable = (async function*() {
  for (let i = 0; i < 10; ++i) {
    yield i;
  }
})();

const arr = await Array.fromAsync(asyncIterable);

https://github.com/tc39/proposal-array-from-async

結び

今回は Iterator Helpers を紹介してみました。便利ではあるんですが、ただでさえ複雑な JavaScript のイテレーター周りの仕様が更に複雑になりそうな印象を受けますね。

今までは Iterator と言うとそのインターフェースを意味していましたが、今後は Iterator クラスのことを意味するようになるかもしれません。TypeScript の型定義はいったいどうなるんでしょうか……。

ECMAScript の提案を追うのは割と楽しいので皆さんもよかったらどうぞ。

脚注
  1. Promise.resolve を使って ThenablePromise 化するのに似ています。 ↩︎

  2. IxJS の from 函数には Iterable だけではなく、Iterator インターフェースを実装したオブジェクトを突っ込むことができます。そのような場合についても再利用できません。 ↩︎

  3. Iterator.from 函数を使った場合にその引数が Iterator クラスを継承したインスタンスでない場合は [[Prototype]]%WrapForValidIteratorPrototype% を持つインスタンスを返すみたいです。複雑ですね。 ↩︎

Discussion

bleis-tiftbleis-tift

これは遅延評価ではなく、「遅延リスト」や「ストリーム」のような表現が正しいです。遅延評価は評価戦略の話であり、ESの評価戦略は遅延評価ではありません。

petamorikenpetamoriken

ご指摘ありがとうございます。記事も読まさせていただきました。

https://bleis-tift.hatenablog.com/entry/20130102/1357062031

確かに問題がありそうだと思ったため修正しました。
JavaScript の世界で単に「ストリーム」という言葉を使うと WHATWG Streams など別のことを意味してしまいそうだったため、「遅延リスト」の方を選ばせていただきました。

一方で Lazy.js が

So, Lazy.js is basically Underscore with lazy evaluation. Is that it?

https://github.com/dtao/lazy.js#features

と紹介されていたり、英語版の Wikipedia の Lazy evaluation には Simulating laziness in eager languages の例として JavaScript のジェネレーター函数の例

https://en.wikipedia.org/wiki/Lazy_evaluation#JavaScript

が載っていたりと広い意味での「遅延評価」という言葉が流通してしまっている感は否めない気がしますね……。