Edited at

JavaScriptのイテレータが持つメソッドをそろそろ知っておきたい人が読む記事

イテレータは今となっては多くのプログラミング言語に存在する概念で、繰り返し処理やループ、ストリームといった対象を抽象化してくれるものです。JavaScriptにはES2015でイテレータが追加されており、JavaScriptを触っている方にとっては既に馴染み深いものとなっています。

とはいえ、JavaScriptのイテレータにはひとつ問題点がありました。それは「イテレータを直接変換・操作できるメソッドが存在しない」という点です。従来イテレータが持つメソッドはイテレータから一つ値を取り出すnextメソッドのみであり1、それ以上の機能は何も提供されていませんでした。これにより、Rustなどのイテレータが強い言語に比べてJavaScriptのイテレータは有用性が大幅に低いものとなっていました。

この記事では、この問題を多少解消するプロポーザル「Iterator Helpers」を紹介します。これは2019年7月のTC39ミーティングでStage 2となったプロポーザルであり、ということは近い将来この記事で紹介するメソッドたちが利用可能になることが期待できるということです。

これはあくまでプロポーザルであり、この記事で紹介する内容はお手元のブラウザやnode.jsなどで今すぐ使えるものではないという点はご注意ください。プロポーザルのリポジトリにはPolyfillが用意されていますので、どうしても使いたい場合はそのPolyfillを入れてみましょう……と言いたいところですが、実は今存在するPolyfillはまともに動かないようです。残念ですね。


イテレータの復習

もしかしたらイテレータのことをあまり知らない方もいるかもしれませんので、まず少しイテレータの解説を挟みます。イテレータは知ってるよという方は次まで飛ばしましょう。

イテレータは値をひとつずつ取り出すことができるデータです。JavaScriptではイテレータはnextメソッドを持ち、このメソッドを呼ぶたびにイテレータから値がひとつずつ得られます。

イテレータの作り方は主に2種類あり、イテレータを作る既存メソッドを使うジェネレータ関数を使うかの2種類です。ジェネレータ関数というのはfunction*で宣言される関数であり、yield式が使えるのが特徴です。以下の例は、既存のメソッドであるArray.prototype.entriesを用いてイテレータを得る例です。


イテレータの例

const arr = [0, 1, 1, 2, 3, 5];

// 配列から[index, value] というペアのイテレータを得る
const entries = arr.entries();

// entriesから値をひとつ得る
console.log(entries.next()); // { done: false, value: [0, 0] }
// 次の値を得る
console.log(entries.next()); // { done: false, value: [1, 1] }
// どんどん値を得る
console.log(entries.next()); // { done: false, value: [2, 1] }
console.log(entries.next()); // { done: false, value: [3, 2] }
console.log(entries.next()); // { done: false, value: [4, 3] }
console.log(entries.next()); // { done: false, value: [5, 5] }
console.log(entries.next()); // { done: true, value: undefined }


このように、イテレータのnextメソッドを呼ぶたびにイテレータから次の値を取り出すことができます。配列のentriesメソッドによって作られるイテレータは、配列の前から順に[インデックス, 値]というペアを順番に発生されます。このように「順番にデータが発生する」という状況を表すのにイテレータは向いています。

nextの返り値はdonevalueプロパティを持つオブジェクトであり、doneによってイテレータのデータを全部取り出し終わったかどうかを判断できます。

なお、for-of文もイテレータに対応しており、これを使ってイテレータを扱うのが便利です。for-of文は、イテレータの中身を全部取り出すまで順番にループしてくれます。


for-of文の例

const arr = [0, 1, 1, 2, 3, 5];

for (const [index, value] of arr.entries()) {
console.log(index, value);
}
// 0 0
// 1 1
// 2 1
// 3 2
// 4 3
// 5 5
// と表示される

また、イテレータはArray.fromなどを用いて配列に変換するという使い道もあります。これもfor-of文と同様に、イテレータの中身を全部取り出して配列に詰める挙動になります。

このようにイテレータは、値が順番に取り出せるという点では配列と似ています。しかし、イテレータには配列には真似できない点がひとつあります。それは無限イテレータを作れるという点です。無限イテレータというのは終わりがないイテレータのことで、求められれば何個でも値を発生させ続けることができます。このようなイテレータは配列のように値を最初から全部持っているのではなく、求められたらその都度値を計算するという挙動をします。これはある意味で計算を遅延しているということであり、この遅延こそがイテレータの本質であるとも言えます。

ただ、無限イテレータは扱うのが大変です。上と同じようにfor-of文を使って回すと、イテレータの終わりがないため無限ループとなってしまいます。何らかの方法で自分で処理を打ち切る必要があります。

無限ループの例をややベタですがひとつ紹介します。先ほどから例に使っているarrはいわゆるフィボナッチ数列の最初のいくつかを切り取ったものですね。フィボナッチ数列は無限に続くことが知られているので、フィボナッチ数列を表す無限イテレータをジェネレータ関数を使って作ってみましょう。


フィボナッチ数列を出力し続けるジェネレータ関数

function* fib() {

yield 0;
yield 1;
let a = 0, b = 1;
while (true) {
[a, b] = [b, a + b];
yield b;
}
}

const iter = fib();


このように作ったiterは、0 1 1 2 3 5 8 13 21 ...のように無限に値を発生させ続けます。使う側はこんな感じで途中でループを打ち切ってあげましょう。

for (const value of iter) {

if (value >= 100) break;
console.log(value);
}

イテレータのメソッドが無い状態では、イテレータで出来ることはこれくらいでした。


イテレータのメソッドたち

では、今回紹介するプロポーザル「Iterator Helpers」でどのようなメソッドが追加されるのかを紹介していきます。名前を見ただけで分かる方もいるでしょうから、まず名前を列挙しておきます。


  • map

  • filter

  • take

  • drop

  • asIndexedPairs

  • reduce

  • toArray

  • forEach

  • some

  • every

  • find


map

mapメソッドは、イテレータの各値が与えられた関数で変換された新しいイテレータを返します。つまるところ、配列のmapメソッドのイテレータ版です。


mapメソッドの例

const arr = [0, 1, 1, 2, 3, 5];

const iter1 = arr.values(); // arrの要素を順番に発生させるイテレータ
const iter2 = iter1.map(x => x * 2); // iter1の各要素が2倍されたイテレータ
for (const val of iter2) {
console.log(val);
}
// 0 2 2 4 6 10 が表示される

また、これはイテレータ一般に言えることですが、イテレータの各要素の変換は実際にその要素が必要になるまで遅延されます。つまり、要素がnextで取り出されるときにはじめてその要素に対する変換が発生するということです。mapに渡すメソッドにconsole.logを仕込んでみるとこのことが分かります。


mapメソッドの例

const arr = [0, 1, 1, 2, 3, 5];

const iter1 = arr.values();
const iter2 = iter1.map(x => {
console.log(x, "を変換します");
return x * 2;
});
for (const val of iter2) {
console.log(val);
}

これを実行すると次のように表示されるでしょう。

0 を変換します

0
1 を変換します
2
1 を変換します
2
2 を変換します
4
3 を変換します
6
5 を変換します
10

すなわち、for-of文によってiter2から値が取り出される瞬間に、内部では「iter1から値を取り出す→mapに渡された関数を呼び出して値を変換する」という処理が起こっていることになります。


filter

filterも配列のfilterメソッドのイテレータ版です。

すなわち、元のイテレータが発生させる値のうち、filterに渡した関数がtrueを返すもの2のみを発生させるイテレータを返します。


filterメソッドの例

const arr = [0, 1, 1, 2, 3, 5, 8, 13, 21];

const iter1 = arr.values();
const iter2 = iter1.filter(x => x % 2 === 0);
for (const val of iter2) {
console.log(val); // 0 2 8 が表示される
}

なお、当然ながらmapfilterの返り値は新たなイテレータなので、さらにイテレータのメソッドを呼ぶことも可能です。いわゆるメソッドチェーンというやつですね。mapfilterを組み合わせる例はこんな感じです。


mapとfilterを組み合わせる例

const arr = [0, 1, 1, 2, 3, 5, 8, 13, 21];

const iter1 = arr.values();
const iter2 =
iter1
.filter(x => x % 2 === 0)
.map(x => x * 10);
for (const val of iter2) {
console.log(val); // 0 20 80 が表示される
}

このような使い方をする場合にイテレータの利点が少し出ています。イテレータを使わない場合は以下のようにすると同じことができますが、下の場合はmapfilterを呼ぶたびに配列ができており、最終結果以外は使わないので無駄です。一方イテレータの場合に作られるのは新しいイテレータオブジェクトであり、イテレータは計算が遅延されるという特徴のため無駄な中間結果は作られません。


イテレータを使わない例

const arr = [0, 1, 1, 2, 3, 5, 8, 13, 21];

const arr2 =
arr
.filter(x => x % 2 === 0)
.map(x => x * 10);
for (const val of arr2) {
console.log(val); // 0 20 80 が表示される
}


take

takeメソッドは引数として数値limitを受け取り、「元のイテレータの最初のlimit個の値だけを出力するイテレータ」を返すメソッドです。下の例ではiter2は5個値を発生させた時点で終了します。


takeメソッドの例

const arr = [0, 1, 1, 2, 3, 5, 8, 13, 21];

const iter1 = arr.values();
const iter2 = iter1.take(5);
for (const val of iter2) {
console.log(val); // 0 1 1 2 3 が表示される
}

このメソッドの偉い点は、無限イテレータを打ち切ることができるという点です。上のほうで例に出てきたフィボナッチ数列のイテレータは無限イテレータですが、takeメソッドで途中で打ち切ることで有限イテレータに変換できます。


takeメソッドの例

const iter = fib().take(10);

for (const val of iter2) {
console.log(val); // 0 1 1 2 3 5 8 13 21 34 が表示される
}


drop

dropは、元のイテレータの最初のいくつかの要素を捨てる(無視する)ようなイテレータを作ります。


dropメソッドの例

const iter = fib().drop(5).take(5);

for (const val of iter2) {
console.log(val); // 5 8 13 21 34 が表示される
}

この例では、fib().drop(5)fib()の結果である無限イテレータの最初の5個を捨てるイテレータです。すなわち、5 8 13 21 34 55...という無限イテレータになります。それをtake(5)で最初の5個だけにしているため上のような結果になります。

イテレータを用途に合わせていい感じに調整したいときに使えることがあります。


asIndexedPairs

これは唯一見慣れない名前かもしれません。Rustではenumerateと呼ばれているあれです。正直名前が微妙な気がするのでenumerateとかにして欲しいと個人的には思っているのですが、逆にenumerateからasIndexedPairsに改名された形跡があるので難しいかもしれません。

これは、イテレータの出力を[インデックス, 値]というペアに変換する新しいイテレータを返します。つまり、配列のentriesメソッドのイテレータ版です。このイテレータから最初に出力される値のインデックスは0となり、その次は1、さらにその次は2……となります。

次の例では、さっきのfib().drop(5).take(5)をさらにasIndexedPairs()で変換してから使っています。これによりiterの各出力は[0, 5]のような配列になっています。


asIndexedPairsメソッドの例

const iter = fib().drop(5).take(5).asIndexedPairs();

for (const val of iter2) {
console.log(val); // [0, 5] [1, 8] [2, 13] [3, 21] [4, 34] が表示される
}


reduce

ここまではイテレータを別のイテレータに変換するメソッドでしたが、ここからはイテレータを“消費”するメソッドです。

イテレータのreduceメソッドは配列のreduceのイテレータ版であり、配列を前から順番に処理する代わりにイテレータの値を順番に取り出して使用します。

このメソッドを呼び出した時点でイテレータの値は最後まで取り出されます。以降で紹介するメソッドも同じですが、無限イテレータに対してこのメソッドを呼び出した場合は無限ループに陥ってしまうので注意しましょう。


reduceの例

const iter = fib().drop(5).take(5);

const sum = iter.reduce((a, b) => a + b, 0);

console.log(sum); // 81 (=5+8+13+21+34)



toArray

toArrayはイテレータの内容を全部読んで配列に詰めて返すメソッドです。iter.toArray()Array.from(iter)と同じですね。

当初はcollectという名前だったという説がありますが、どうやら今はtoArrayという名前で検討されているようです。


forEach

forEachは配列のforEachのイテレータ版です。すなわち、イテレータの出力を順番に取り出してその値を引数にコールバック関数を呼び出すという動作をします。


foreachの例

fib().drop(5).take(5).forEach(value => {

console.log(value); // 5 8 13 21 34 が表示される
});

for-of文でも同じことができますのでまあ好みに合わせて使いましょう。for-of文に比べるとメソッドチェーンだけで完結するというメリットがあります。


every

これも配列のeveryのイテレータ版です。イテレータの出力が順番に関数に渡され、全ての値に対してコールバック関数がtrueを返した場合はeveryの返り値がtrueとなり、そうでなければfalseとなります。


everyの例

console.log(fib().drop(5).take(5).every(x => x % 2 === 0)); // false



some

配列のsomeのイテレータ版です。イテレータの値どれか一つに対して条件が成り立てばtrueが返ります。


find

配列のfindのイテレータ版です。すなわち、イテレータが出力する値を順番に見ていき、関数で与えられた条件を満たす値が出てきたらその値を返します。イテレータを最後まで読んでも条件を満たす値が存在しなかった場合はundefinedが返ります。


findの例

const firstEvenValue = fib().drop(5).find(x => x % 2 === 0);

console.log(firstEvenValue); // 8


以上で今回のプロポーザル「Iterator Helpers」に(今のところ)含まれているメソッドは終わりです。だいたい配列にもあるやつでしたね。


イテレータを変換するメソッドに関する注意

この記事の前半で紹介したいくつかのメソッドはイテレータを変換するものでした。例えばconst iter2 = iter1.map(func)として作ったイテレータが出力する値は、iter1が出力する各値に関数funcを噛ませた値になります。

より正確に言えば、こうして作ったiter2nextメソッドを呼び出した場合、内部的にはiter1nextメソッドが呼び出され、その結果がfuncで加工されることになります。ということは、iter1iter2を同時に扱うと次のようにややこしいことが起こります。

const iter1 = [0, 1, 2, 3, 4, 5].values();

const iter2 = iter1.map(x => x * 10);

for (const value of iter2) {
console.log(value);
iter1.next();
}

これを実行すると、表示されるのは0 20 40の3つのはずです。for-of文でまずiter2.nextが呼ばれて、内部的にiter1.nextが呼ばれます。iter1.nextの返り値は0なのでiter2.nextの返り値も0となり、value0が入ります。その後、for-of文の中に書かれたiter1.next()によってiter1から値1が取り出されます。

そしてfor-of文の次のループに入り、再びiter2.nextが呼ばれます。内部的にiter1.nextが呼ばれるとその返り値は2であり、よってiter2.nextの返り値が20となりそれがvaluesに入ります。よって、0の次にconsole.logで表示されるのは20となります。

このような使い方はあまりすべきではありませんが。できてしまうので注意しましょう。(余談ですが、Rustとかはこういうのが出来ないようになっていて偉いですね。)


非同期イテレータへのメソッド追加

ところで、JavaScirptには非同期イテレータという概念もあります。非同期イテレータは、普通のイテレータと似ていますがnextメソッドを呼び出すと{ done: false, value: 123 }のような結果オブジェクトの代わりにそのようなオブジェクトに解決されるPromiseを返すオブジェクトです。

非同期イテレータは、async function*で宣言されるasyncジェネレータ関数を用いるなどの方法で作ることができ、for-await-of文でループすることができます。

この記事で紹介している「Iterator Helpers」プロポーザルでは、非同期イテレータに対してもメソッドを追加するとされています。ただ、まだ仕様等の記述が不完全でありその全容がどうなるのかはよく分かりませんでした。推測ですが、普通のイテレータにあるメソッドは非同期イテレータにも用意されるのではないかと思います。

ただし、非同期ならではの機能も追加されそうな雰囲気がしています。例えば、非同期イテレータのmapメソッドに渡す関数は、返り値としてPromiseを返すことができます。

これ以上の詳細については不明なので続報を待ちましょう。


まとめ

この記事ではJavaScriptのイテレータにメソッドを追加するプロポーザル「Iterator Helpers」の内容を今分かる範囲で紹介しました。実際に利用可能になるまでにはまだ変化があるかもしれませんので、温かい目で見守りましょう。

このあたりが入ると今よりはイテレータが使われるようになるかもしれませんから、イテレータのイの字も分からないという方は今のうちに勉強しておくのが吉です。何かイテレータが強くて使いやすい言語をやるのもいいですね。Rustとか。

記事を読んだ方には「メソッド少なすぎ😓(ここに好きなメソッド名を入れよう)すらないとかJavaScriptクソだな😅」などといった感想を覚えた方も多いかと思います。ECMAScriptの標準化の仕組み上機能追加は最大公約数的になりがちであるという言い訳はしつつ、筆者もまあ「takeWhileくらいは入れてくれないと何もできなくない? バカなの?」とか思っていないでもありませんのでまあ否定はしません。そのような意見はぜひ自由に触れ回りましょう。

ご意見ご感想をお待ちしています。





  1. ジェネレータ関数により作られたイテレータはさらにreturnメソッドとthrowメソッドも持ちます。 



  2. 正確には「真偽値に変換するとtrueになる値」ですが、簡単のため省略しています。以降も同様です。