📚

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

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

【2024/10/08】

  • Stage 4 となり、ES2025 に入ることが確定したため、タイトルと本文を更新
  • TypeScript 5.6 がリリースされたため、Beta の記述を消去

【2024/08/11】

  • TypeScript の BuiltinIteratorIteratorObject にリネームされたため修正

【2024/07/28】

  • TypeScript 5.6 Beta より Iterator Helpers の型サポート BuiltinIterator が入るため、その章を追加

【2023/10/01】

【2022/12/02】


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

ES2015 から Symbol.iterator メソッドを持つ 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.prototype.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

ES2025 Iterator Helpers

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

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

// Array.prototype.values で Iterator クラスを継承した ArrayIterator を作る
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.prototype) がないと使うことが出来ません。

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

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

もしくはメソッドを使う直前に Iterator.from を使って Iterable をビルトインの Iterator インスタンスに変換するのもいいでしょう[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.prototype.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

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

TypeScript の型定義

TypeScript 5.6 より新たに IteratorObject インターフェースが追加されました。ざっくりとした表にまとめると以下のようになるかなと思います。

インターフェース (ざっくり)要求される型 備考
Iterator next メソッドを持つ
Iterable Symbol.iterator メソッドを持つ for-of やスプレッド構文で扱える
IterableIterator IteratorIterable の交差型[4]
IteratorObject IterableIterator かつ Iterator Helpers のメソッドを持つ globalThis.Iterator クラスを継承するとこの型になる

関連する提案

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

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

また Iterable インターフェースを実装したオブジェクトから配列を作るには Array.from がありますが、AsyncIterable インターフェースのオブジェクトから配列を作る函数は今のところありません。このミッシングピースを埋める提案 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 クラスのことを意味するようになるかもしれません。ややこしいですね。

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

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

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

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

  4. 厳密には交差型ではありません。 ↩︎

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

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