今回はIteratorやGeneratorに対して抽象的な操作をしていくことで、generatorへの理解を深めたいと思います。つまり、Iterableを対象にIteratorを返すような関数を作っていきます。
こう書くと難しく感じるかもしれませんがArrayに対してのmapやfilterのようにIterableに対してのmapやfilterなどを作っていきます。
TypeScriptの型の上ではIteratorとIterableIteratorが分かれていますが、このブログ上でのIteratorは全てIterableIteratorを考えてください。
今回、いろいろな関数を実装していくうえで既存のJavaScript実行エンジンでは実装されていないシンタックスを使います。それは::という演算子で普段bind演算子と呼ばれていることが多いと思います。JavaScriptは関数を呼び出すときにthisとなるものをcallやapplyやbindで変えることができますが、それらのシンタックスシュガーとなるので::は機能的には今までのものと変わりません。
const obj = {x: 4, y: 5};
function f(z) {console.log(this.x + z)}
この場合obj::f(53)はf.call(obj, 53) と等しくなります。つまりfが呼び出された時のthisはobjになり57が出力されます。また、obj::fとf.bind(obj)も同様に等しいです。
以下のリンクは仕様になります。
なお、今回実行するコードはこちらで試すと良いかもしれません。
bind演算子はまだまだアイデアレベルでECMAScriptに入るかどうか解らないですが個人的には非常に重要で必要となるものと考えています。ある関数fはあるcontextをthisに取ることができるという形で関数をつくっていくと互いに素なライブラリを作ることができます。当然以下のように第一引数にcontextを取るような関数にもできますが、bind演算子を使うことによって可読性を上げることができると考えています。
function f(context, a, b, c) {
}
f(context, a, b, c);
function f(a, b, c) {
this // context
}
context::f(a, b, c);
前回のブログにあったtakeについて考えてみましょう。以下のtakeはthisの型がIterableであるとき呼ぶことができます。iterable::take(4)のような形式になります。
function * take(n) {
if (n <= 0) {
return;
}
let i = 0;
for (const value of this) {
yield value;
i += 1;
if (i >= n) {
return;
}
}
}
ここでif(){return}の位置について考えることは非常に重要です。例えば、以下のように書いたとすると何が起きるでしょうか?
function * take(n) {
if (n < 0) {
return;
}
let i = 0;
for (const value of this) {
if (i >= n) {
return;
}
yield value;
i += 1;
}
}
下の記述はtake(x)とした時にthis.next()がx+1回呼ばれてしまいます。例を挙げると下の記述では.next()を読んだ瞬間にthrowされるような[...function * (){throw new Error();}()::take(0)]ではエラーを吐いてしまいます。どちらがいいかは主観になってしまいますがtakeの意味合いを考えた時に僕は上のほうがいいと思います。
前回のブログの回答例を書くので試してみましょう。
function * fib() {
let [a, b] = [0, 1];
while (true) {
yield b;
[a, b] = [b, a + b];
}
}
[...fib()::take(5)] // => [1,1,2,3,5]
余談ですがこのfibが返すIteratorは無限の長さを持っているのでArray.from(fib())としてしまうと無限ループになってしまいます。このように無限の長さのリストのように扱えることもIteratorの魅力です。
map、filter、reduceは以下のように書くことができます。reduceはIterableに対してRを返すのでGeneratorでは書く必要はありません。
function * map(project) {
for (const value of this) {
yield project(value);
}
}
function * filter(predicate) {
for (const value of this) {
if (predicate(value)) {
yield value;
}
}
}
function reduce(accumulator, seed) {
// 👇problem
const iterator = this;
// 👆
for (const value of iterator) {
seed = accumulator(seed, value);
}
return seed;
}
fib()::filter(x => Boolean(x % 2))::take(10)::reduce((acc, x) => acc + x, 0)
ちなみにreduceの第2引数を省略した時Array.prototype.reduceでは最初の要素が第2引数のseedになりますが、その場合どう書くのが良いでしょうか? 問題にしておくので、矢印で囲まれた範囲を変えてみてください。
これらの関数はIteraor用ではなくIterable用に作っています。Generatorをつかって作ったIteratorはIterableであることを利用すると、IterableなArray,String,Map,Setに対して使うことができるようになります。
IterableやそれをつくりだすGenerator、そしてbind演算子を利用することによって「thisがどのようなインスタンスなのか」ということや「メソッドが実装されていること」を気にする必要はありません。
なんだか便利な気持ちになってきませんか?
このようにIterableに抽象的な操作ができるのですが、すこし不便なところがあります。以下のコードの例を見てください。a0,a1,b0,b1の違いを考えてみましょう。
const fibIterator = fib(); const a0 = fibIterator::take(10)::reduce((acc, x) => acc + x, 0); const a1 = fibIterator::take(10)::reduce((acc, x) => acc + x, 0); const b0 = fib()::take(10)::reduce((acc, x) => acc + x, 0); const b1 = fib()::take(10)::reduce((acc, x) => acc + x, 0);
a0 === a1はtrueでしょうか? 実行してみるとfalseになると思います。それに比べてb0 === b1はtrueになります。これはreduceをした時にfibIteratorの状態が変わってしまうからです。
このようにIteratorとして扱うことで遅延評価ができるようになりましたが、内部の値を取り出す時にIteratorはミュータブル(状態が変更可能)なので扱うときに意識する必要があります。なのでイミュータブルに扱ってみましょう。
イミュータブルで扱うためにはどうするのが良いでしょうか?前回のブログにあったrangeRepeatを思い出してください。関数の中でrangeを呼んでいたと思いますが、rangeという関数は関数を読んだ時にIteratorを返すので関数のまま操作すればイミュータブルと考えることができます。
なのでGenerator関数自体に操作を加えてみましょう。
今回はGeneratorに対してのオペレーターなのでthisの型は() => Iteratorです。
function mapG(project) {
const generator = this;
return function * map() {
for (const value of generator()) {
yield project(value);
}
};
}
function filterG(predicate) {
const generator = this;
return function * filter() {
for (const value of generator()) {
if (predicate(value)) {
yield value;
}
}
}
}
// こう書いても大丈夫です
function takeG(n) {
return () => take.call(this(), n);
}
実はreduceGはあまり実装する必要が無いため、下記のコードには含まれていません。なぜでしょうか? 今回、何のためにGeneratorに対するオペレーターを実装しているかを考えてみましょう!
指定した回数だけrepeatしてくれるようなオペレーターをIterableに対して実装することは難しいですが、Generatorに対しては簡単になります。
function repeatG(n) {
const generator = this;
return function * () {
let i = 0;
while (i < n) {
yield * generator();
i += 1;
}
}
}
const g1 = fib::filterG(x => Boolean(x % 2))::takeG(5)
console.log([...g1::repeatG(3)()])
console.log(g1()::reduce((acc, x) => acc + x, 0))
//repeatG()やtakeG()が返すものがGenerator!
Generatorのまま扱うことで便利なこともありますが、あるGeneratorが毎回結果の違うIteratorを返してくる場合は予想外の挙動になることもあります。またGeneratorはIterableではないので毎回Iteratorを生成するのも面倒に感じるかもしれません(つまりgenerator[Symbol.iterator] === generatorでない)。ただイミュータブルに扱うと状態を考えることが減るのでコーディングの速度は上がるかもしれませんよ!
上に書いたreduce(accumulator)を実装して見てください。
console.log(range(10)::reduce((acc, x) => acc + x))
console.log('Hello World'::reduce((acc, x) => acc + x))
※ コメントはこちらのに同意の上、投稿ください。