今時のJavaScripterなら必ず使っているであろうArray.prototype.map
やArray.prototype.filter
ですが、この度これらと同等の機能を持つ関数がIterator
のメソッドとして追加されました。
2024年11月現在、Safariが非対応ですがそれ以外の主要ブラウザでは実装済みです。またNode.jsでも22.0.0から使えるようです。
新しいメソッド
Iterator.prototype.drop()
Iterator.prototype.every()
Iterator.prototype.filter()
Iterator.prototype.find()
Iterator.prototype.flatMap()
Iterator.prototype.forEach()
Iterator.prototype.map()
Iterator.prototype.reduce()
Iterator.prototype.some()
Iterator.prototype.take()
Iterator.prototype.toArray()
使い方
使い方はArray
の同名のメソッドと基本的に同じです。
function *foo() {
yield 1;
yield 2;
yield 3;
yield 4;
}
const newIter = foo().filter(x => x % 2).map(x => x * 3);
for(const x of newIter) {
console.log(x); // 3, 9
}
drop()
とtake()
はArray
にはないですが、drop(limit: number)
はイテレーターの最初limit
個を捨ててそれ以降を返すイテレータを返す、take(limit: number)
はイテレータの最初limit
個のみを返し以降を捨てたイテレータを返す関数です。
const newIter = foo().drop(1).take(2);
for(const x of newIter) {
console.log(x); // 2, 3
}
Array
のメソッドと何が違うのか?
これまでのArray.prototype.map()
やArray.prototype.filter()
はArray
のメソッドなので当然Array
にしか使えませんでした。
// ✅ 以下はこれまでもできた
[1, 2, 3, 4].map(x => x + 1) // [1, 2, 3, 4]はArray
// ❌ 以下はこれまでできなかった
foo().filter(x => x % 2).map(x => x * 3) // foo()はイテレーター
new Map([['a', 42], ['b', 43]]).keys().map(x => 'foo ' + x) // Map.keys()の戻り値はイテレーター
そこでこれまでイテレーターでfilterやmapしたい時にはスプレッド演算子を使う([...xxx]
)などしてArrayに変換していました。
// こうやっていた
[...foo()].filter(x => x % 2).map(x => x * 3)
[...new Map([['a', 42], ['b', 43]]).keys()].map(x => 'foo ' + x)
これをイテレーターにもメソッドを生やすことでイテレーターにも直接使えるようにしたのが今回のメソッド達です。そしてArray型だった戻り値もイテレータになります。
// これからは以下のままで動く
const x = foo().filter(x => x % 2).map(x => x * 3); // xは3,9を順に返すイテレーター
const y = new Map([['a', 42], ['b', 43]]).keys().map(x => 'foo ' + x); // yはfoo a,foo bを順に返すイテレーター
戻り値がイテレーターなので上のx
のようにイテレーターに対して例えばfilter
とmap
を連続して適用したイテレーターを作るということもできます。
Array
のメソッドと比べて何がいいのか?
これまでもイテレーターをArray
に変換すれば同じことができた訳ですが、今回追加されたイテレーターのメソッドを使うことでどんな良いことがあるんでしょうか?主要なメリットはメモリ使用量が減ることです。
Arrayのメソッドは配列を入力として配列を返します。よって例えば以下のコードでは入力配列[1, 2, 3, 4]
から始まり、次にfilter
を適用した後の配列[1, 3]
が作られ、最後にmap
を適用した配列[3, 9]
が作られます。
[1, 2, 3, 4].filter(x => x % 2).map(x => x * 3)
この長さ4程度の配列なら問題ないですが、例えばこの配列の長さが10万あったらどうでしょうか?万単位の要素をもつ配列がfilter
やmap
をかけるごとに毎回生成されるのはメモリ効率が良くありません。
一方でイテレーターを使った場合はどうなるのか。イテレーター版のfilter
やmap
はそれらの関数を呼び出された時点では実際のフィルターやマップ処理は実行されません。いつ実行されるかというとそれら関数の戻り値のイテレーターが次の値を取得しようとした時です。
例えば以下のコードを使ってfor
文でイテレーターから値を取り出すときの挙動を考えます。
const b = foo(); // bはイテレーターとする
const c = b.map(x => x * 3);
// const c = foo().map(x => x * 3); と同じ
for(const x of c) {
console.log(x);
}
for
文によりイテレーターc
から次の値を取り出そうとすると、c
の元であるmap
が入力であるイテレーターb
から次の値を取り出しその値に3をかけて返します。その値がfor
文の変数x
として代入されconsole.log
されることになります。ループ内の処理が終わるとfor
文はまた次の値をc
から取り出そうとし最初と同じことが起こる、これが次の値がなくなるまで繰り返されます。
更に以下のようにfilter
とmap
を連続して適用した場合はどうなるのか、考え方は同じです。
const a = foo(); // aはイテレーターとする
const b = a.filter(x => x % 2);
const c = b.map(x => x * 3);
// const c = foo().filter(x => x % 2).map(x => x * 3); と同じ
for(const x of c) {
console.log(x);
}
for
文によりイテレーターc
から次の値を取り出そうとすると、c
の元であるmap
が入力であるイテレーターb
から次の値を取り出そうとします。するとb
の元であるfilter
は入力であるイテレーターa
から次の値を取り出します。filter
はもし取り出した値がフィルタ条件に合致しなかった場合はさらに次の値をa
から取り出して、合致したものを返します。返された値はmap
が取り出して3をかけて返します。これがx
となり、ループ内の処理が終わるとまた次の値をc
から取り出そうとして最初に戻るという事が繰り返されます。
ここで注目すべきなのは、このイテレーターを使った処理ではその過程で配列を生成していないという点です。要素をストリーミングのようにひとつずつ処理していくので入力が10個でも10万個でも、filter
やmap
での加工に使うメモリ使用量は変わりません。
このメモリ使用量が要素数に関わらず一定であり、中間で無駄なメモリを消費しないのがイテレーターの優れた点です。
あとがき
JavaScriptのmapやfilterが配列にしか使えないことに私は以前から違和感を持っていました。C#とかPythonだとイテレーターに対してmapやfilterして遅延実行するのがむしろ普通だからです。それがJavaScriptでもようやくできるようになりました。