遅延リストを扱うライブラリと ES2025 Iterator Helpers
変更情報
【2024/10/08】
- Stage 4 となり、ES2025 に入ることが確定したため、タイトルと本文を更新
- TypeScript 5.6 がリリースされたため、Beta の記述を消去
【2024/08/11】
- TypeScript の
BuiltinIterator
がIteratorObject
にリネームされたため修正
【2024/07/28】
- TypeScript 5.6 Beta より Iterator Helpers の型サポート
BuiltinIterator
が入るため、その章を追加
【2023/10/01】
【2022/12/02】
- 2022年11月に Stage 3 になったため記事のタイトル、一部内容を変更
イテレーターのインターフェースについて
ES2015 から Symbol.iterator
メソッドを持つ Iterable
インターフェースを実装したオブジェクトに対して for-of
やスプレッド構文を使うことが出来ます。インターフェースについて詳しくは qnighy さんの記事を御覧ください。
遅延リストを扱うライブラリ
整数が入った配列があったとします。その配列に対して要素を二乗し、一つ足し、その後で偶数のみを取り出すことを考えます。愚直に実装すると以下のようになります。
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();
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),
),
);
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();
インターフェースとクラス
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]。
さて適切な処理を定義するのが不可能だったと判断され、Iterator Helpers ではジェネレーターのプロトコルに対応しないこととなりました。つまり throw
メソッドは実装されず、next
や return
メソッドの引数をソースとなったイテレーターには渡しません。
TypeScript の型定義
TypeScript 5.6 より新たに IteratorObject
インターフェースが追加されました。ざっくりとした表にまとめると以下のようになるかなと思います。
インターフェース | (ざっくり)要求される型 | 備考 |
---|---|---|
Iterator |
next メソッドを持つ |
|
Iterable |
Symbol.iterator メソッドを持つ |
for-of やスプレッド構文で扱える |
IterableIterator |
Iterator と Iterable の交差型[4]
|
|
IteratorObject |
IterableIterator かつ Iterator Helpers のメソッドを持つ |
globalThis.Iterator クラスを継承するとこの型になる |
関連する提案
グローバルに AsyncIterator
クラスを追加し、%AsyncIteratorPrototype%
(AsyncIterator.prototype
) に新たにヘルパーメソッドを追加する提案が 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);
結び
今回は Iterator Helpers を紹介してみました。便利ではあるんですが、ただでさえ複雑な JavaScript のイテレーター周りの仕様が更に複雑になりそうな印象を受けますね。
今までは Iterator
と言うとそのインターフェースを意味していましたが、今後は Iterator
クラスのことを意味するようになるかもしれません。ややこしいですね。
ECMAScript の提案を追うのは割と楽しいので皆さんもよかったらどうぞ。
Discussion
これは遅延評価ではなく、「遅延リスト」や「ストリーム」のような表現が正しいです。遅延評価は評価戦略の話であり、ESの評価戦略は遅延評価ではありません。
ご指摘ありがとうございます。記事も読まさせていただきました。
確かに問題がありそうだと思ったため修正しました。
JavaScript の世界で単に「ストリーム」という言葉を使うと WHATWG Streams など別のことを意味してしまいそうだったため、「遅延リスト」の方を選ばせていただきました。
一方で Lazy.js が
と紹介されていたり、英語版の Wikipedia の Lazy evaluation には Simulating laziness in eager languages の例として JavaScript のジェネレーター函数の例
が載っていたりと広い意味での「遅延評価」という言葉が流通してしまっている感は否めない気がしますね……。