イテレータは今となっては多くのプログラミング言語に存在する概念で、繰り返し処理やループ、ストリームといった対象を抽象化してくれるものです。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
の返り値はdone
とvalue
プロパティを持つオブジェクトであり、done
によってイテレータのデータを全部取り出し終わったかどうかを判断できます。
なお、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
メソッドのイテレータ版です。
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
を仕込んでみるとこのことが分かります。
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のみを発生させるイテレータを返します。
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 が表示される
}
なお、当然ながらmap
やfilter
の返り値は新たなイテレータなので、さらにイテレータのメソッドを呼ぶことも可能です。いわゆるメソッドチェーンというやつですね。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 が表示される
}
このような使い方をする場合にイテレータの利点が少し出ています。イテレータを使わない場合は以下のようにすると同じことができますが、下の場合はmap
やfilter
を呼ぶたびに配列ができており、最終結果以外は使わないので無駄です。一方イテレータの場合に作られるのは新しいイテレータオブジェクトであり、イテレータは計算が遅延されるという特徴のため無駄な中間結果は作られません。
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個値を発生させた時点で終了します。
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
メソッドで途中で打ち切ることで有限イテレータに変換できます。
const iter = fib().take(10);
for (const val of iter) {
console.log(val); // 0 1 1 2 3 5 8 13 21 34 が表示される
}
drop
drop
は、元のイテレータの最初のいくつかの要素を捨てる(無視する)ようなイテレータを作ります。
const iter = fib().drop(5).take(5);
for (const val of iter) {
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]
のような配列になっています。
const iter = fib().drop(5).take(5).asIndexedPairs();
for (const val of iter) {
console.log(val); // [0, 5] [1, 8] [2, 13] [3, 21] [4, 34] が表示される
}
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
のイテレータ版です。すなわち、イテレータの出力を順番に取り出してその値を引数にコールバック関数を呼び出すという動作をします。
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
となります。
console.log(fib().drop(5).take(5).every(x => x % 2 === 0)); // false
some
配列のsome
のイテレータ版です。イテレータの値どれか一つに対して条件が成り立てばtrue
が返ります。
find
配列のfind
のイテレータ版です。すなわち、イテレータが出力する値を順番に見ていき、関数で与えられた条件を満たす値が出てきたらその値を返します。イテレータを最後まで読んでも条件を満たす値が存在しなかった場合はundefined
が返ります。
const firstEvenValue = fib().drop(5).find(x => x % 2 === 0);
console.log(firstEvenValue); // 8
以上で今回のプロポーザル「Iterator Helpers」に(今のところ)含まれているメソッドは終わりです。だいたい配列にもあるやつでしたね。
イテレータを変換するメソッドに関する注意
この記事の前半で紹介したいくつかのメソッドはイテレータを変換するものでした。例えばconst iter2 = iter1.map(func)
として作ったイテレータが出力する値は、iter1
が出力する各値に関数func
を噛ませた値になります。
より正確に言えば、こうして作ったiter2
のnext
メソッドを呼び出した場合、内部的にはiter1
のnext
メソッドが呼び出され、その結果がfunc
で加工されることになります。ということは、iter1
とiter2
を同時に扱うと次のようにややこしいことが起こります。
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
となり、value
に0
が入ります。その後、for-of
文の中に書かれたiter1.next()
によってiter1
から値1
が取り出されます。
そしてfor-of
文の次のループに入り、再びiter2.next
が呼ばれます。内部的にiter1.next
が呼ばれるとその返り値は2
であり、よってiter2.next
の返り値が20
となりそれがvalues
に入ります。よって、0
の次にconsole.log
で表示されるのは20
となります。
このような使い方はあまりすべきではありませんが。できてしまうので注意しましょう。(余談ですが、Rustとかはこういうのが出来ないようになっていて偉いですね。)
非同期イテレータへのメソッド追加
ところで、JavaScriptには非同期イテレータという概念もあります。非同期イテレータは、普通のイテレータと似ていますがnext
メソッドを呼び出すと{ done: false, value: 123 }
のような結果オブジェクトの代わりにそのようなオブジェクトに解決されるPromise
を返すオブジェクトです。
非同期イテレータは、async function*
で宣言されるasyncジェネレータ関数を用いるなどの方法で作ることができ、for-await-of
文でループすることができます。
この記事で紹介している「Iterator Helpers」プロポーザルでは、非同期イテレータに対してもメソッドを追加するとされています。ただ、まだ仕様等の記述が不完全でありその全容がどうなるのかはよく分かりませんでした。推測ですが、普通のイテレータにあるメソッドは非同期イテレータにも用意されるのではないかと思います。
ただし、非同期ならではの機能も追加されそうな雰囲気がしています。例えば、非同期イテレータのmap
メソッドに渡す関数は、返り値としてPromiseを返すことができます。
これ以上の詳細については不明なので続報を待ちましょう。
まとめ
この記事ではJavaScriptのイテレータにメソッドを追加するプロポーザル「Iterator Helpers」の内容を今分かる範囲で紹介しました。実際に利用可能になるまでにはまだ変化があるかもしれませんので、温かい目で見守りましょう。
このあたりが入ると今よりはイテレータが使われるようになるかもしれませんから、イテレータのイの字も分からないという方は今のうちに勉強しておくのが吉です。何かイテレータが強くて使いやすい言語をやるのもいいですね。Rustとか。
記事を読んだ方には「メソッド少なすぎ😓(ここに好きなメソッド名を入れよう)すらないとかJavaScriptクソだな😅」などといった感想を覚えた方も多いかと思います。ECMAScriptの標準化の仕組み上機能追加は最大公約数的になりがちであるという言い訳はしつつ、筆者もまあ「takeWhile
くらいは入れてくれないと何もできなくない? バカなの?」とか思っていないでもありませんのでまあ否定はしません。そのような意見はぜひ自由に触れ回りましょう。
ご意見ご感想をお待ちしています。