ECMAScript 2015を実践! 〜Generatorで遊ぼう〜
hiroqn
Play with Generator
今回はIterator
やGenerator
に対して抽象的な操作をしていくことで、generator
への理解を深めたいと思います。つまり、Iterable
を対象にIterator
を返すような関数を作っていきます。
こう書くと難しく感じるかもしれませんがArray
に対してのmap
やfilter
のようにIterable
に対してのmap
やfilter
などを作っていきます。
補足
TypeScriptの型の上ではIterator
とIterableIterator
が分かれていますが、このブログ上でのIterator
は全てIterableIterator
を考えてください。
Operator for Iterable
bind operator
今回、いろいろな関数を実装していくうえで既存の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);
Operator
take
前回のブログにあった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
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
がどのようなインスタンスなのか」ということや「メソッドが実装されていること」を気にする必要はありません。
なんだか便利な気持ちになってきませんか?
Operator for Generator
このように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
はミュータブル(状態が変更可能)なので扱うときに意識する必要があります。なのでイミュータブルに扱ってみましょう。
Operator
イミュータブルで扱うためにはどうするのが良いでしょうか?前回のブログにあったrangeRepeat
を思い出してください。関数の中でrange
を呼んでいたと思いますが、range
という関数は関数を読んだ時にIterator
を返すので関数のまま操作すればイミュータブルと考えることができます。
なのでGenerator関数自体に操作を加えてみましょう。
mapG & filterG & takeG
今回は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
指定した回数だけrepeat
してくれるようなオペレーターをIterable
に対して実装することは難しいですが、Generatorに対しては簡単になります。
function repeatG(n) {
const generator = this;
return function * () {
let i = 0;
while (i < n) {
yield * generator();
i += 1;
}
}
}
example
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
でない)。ただイミュータブルに扱うと状態を考えることが減るのでコーディングの速度は上がるかもしれませんよ!
問題
1
上に書いたreduce(accumulator)
を実装して見てください。
テスト
console.log(range(10)::reduce((acc, x) => acc + x))
console.log('Hello World'::reduce((acc, x) => acc + x))