bacon.zundoko
ズンドコキヨシまとめ
繰り返し発生するデータに対して逐一判定を行い何らかの処理をかけた上で終了処理を行う…ストリーム処理である。ストリーム処理と言えばFRP。FRPと言えばBacon.js(一般的な見解とは異なる可能性がある)。
ということで、Bacon.jsでズンドコテストを行うモジュールを実装してみた。しかるべき理由の無いvanilla JSの利用は心身の健康を損なう危険があるのでES2015で実装しており、そのためズンドコテストの実装例の提示であるとともにwebpack/babelによるモジュール実装のほぼ最小構成のサンプルにもなっている。UMDで出力しているので多分ブラウザでも動く(試してない)。
以下では実装内容に寄せてBacon.jsに関するトピックを中心に解説する。
(過度の)入力汎用化
つい頼まれてもいない汎用化を行ってYAGNIから外れてしまうアンチパターンを踏むのはプログラマーの宿痾と言えるだろう。ここでは、文字列を定数として定義しておく代わりにインターフェースの引数として与えられるようにすることで、ズンドコキヨシに縛られない無用に自由な出力を実現している。ES2015で実装しているため、デフォルト引数の利用が可能である。
const zun = 'ズン',
doko = 'ドコ',
kiyoshi = 'キ・ヨ・シ!';
export function run(items=[zun, doko], terminator=[zun, zun, zun, zun, doko], endMessage=kiyoshi) {
// ...
}
ストリームの起点の実装
Bacon.jsで実装する場合、まずストリームに対する変換や消費処理を行うためのデータの源は何になるのかを考える必要がある。GUI実装の場合はユーザー操作によるDOMイベントが発生させるイベントデータを源流にするストリームに対する処理を実装することになるだろうし、一定時間でループするタイマーを源にするケース、AJAXなどの非同期処理をFRPに組み込むなら、非同期処理の終了という単発で終了する寿命の短いストリームに対する処理になるだろう。
今回の場合は、ある条件を満たすまで無限に「ズン」か「ドコ」を発生させるストリームをまず源として定義したい、ということになりそうだ。
Bacon.jsは様々なストリーム生成関数を提供しているが、今回の用途に適用できそうなものとしてBacon.repeatを利用することにした。これは、引数として与えた関数がfalsyな値を返すまで永久にその関数の実行を繰り返し、返り値(のストリーム内の値)を下流に流し続けるストリームを生成する関数である。値をランダムに選択する実装は色々やりようがあるだろうが、ズンドコテストのロジックそのものに関係ないコードはノイズになるためここはlodashにお出まし願おう。
Bacon.repeat((/* i */) => { // iには繰り返し回数が渡されるが、今回は未使用のため省略
// Bacon.repeatは返り値としてBacon.Observableを期待しているため
// 値を一つ流し即死するストリームを生成するBacon.onceでラップしている
return Bacon.once(_.sample(items));
})
ES2015のアロー関数は、単式の場合returnとcurly braceを省略することができるため、上は以下のように記述することができる。
Bacon.repeat(() => Bacon.once(_.sample(items)))
これで、「与えられた配列からランダムに一つ文字列を返し続けるストリーム」を得ることができた。所謂無限ループ状態な実装だが、ストリームがまだ不完全な状態のため実際のループはまだ始まっていない。
ストリームに影響を与えない副作用を挟む
次は、ストリームを流れてくる全ての値の出力を実現したい。この処理は元のストリームやそれを流れる値にはなんの影響も与えない。Bacon.jsは、このような処理を実現するためにdoAction()等のdo*()系インターフェースを提供しており、例えば以下のようにコンソールへの出力を挟むことができる。
stream
.doAction((x) => {
console.log(x);
}
.map((x) => {
// ...
doAction()に渡した関数の中で何をしようが、ストリームを流れていく値は何も変更なく下流に流れていく。実際は、コンソールへの出力はデバッグなどに頻出する処理であるため、上と同じことをするdoLog()というインターフェースが提供されておりこのように書くことができる。
Bacon.repeat(() => Bacon.once(_.sample(items)))
.doLog()
これで、「与えられた配列からランダムに一つ文字列をコンソールに出力し続ける」ストリームを得ることができた。だが、do*()系の処理はあくまで「あいだに処理を挟み込む」ものであってまだストリームを消費しているわけではないため、ストリームは不完全な状態であり処理は開始されない。
過去の状態を保存し参照する
Bacon.jsによる実装は「今、ここ」に着目しやすいものであり、例えば今回「ズン」が流れてきたとか「ドコ」が流れてきたというイベントに対する実装を行っていくのが基本になるが、それだけでは「前回何が流れてきたかによって処理を変更したい」とか「過去数回分のイベント値を全て使いたい」というような処理が実装できない。
Bacon.jsはこのような処理のためにいくつかインターフェースを提供しているが、多分よく使われるのはscan()だろう。scan(seed, f)は、seedを初期値としfの前回実行時の返り値と今回ストリームを流れてきた値を受け取る二引数の関数fを与えることで、ストリームを流れてくる値に対して処理を積み重ねて変換処理を行うことができる。
ここで欲しいのは、元は単なる一文字列が流れてくるストリームであるものを、終了判定をするために最大五回分の文字列を要素として持つ配列が流れるストリームに変換する処理であり、例えば以下のようになるだろう。
Bacon.repeat(() => Bacon.once(_.sample(items)))
.doLog()
.scan([], (prev, current) => {
let result = prev.concat(current);
if (result.length > terminator.length) {
result.shift();
}
return result;
})
prevには前回イベント発生時のresultが渡され、currentは今回イベントで上流から流れてきたストリーム値が渡される。ここより下流に流される値は、「ズン」のような一文字列ではなく、最大で終了条件の配列と同じ要素数の文字列の配列に変換されることになる。
ここでprevに対して破壊的な変更をせず敢えてconcatを使っているのには理由がある。Bacon.jsでストリームの変換処理などとして渡す関数は基本的には所謂「純粋な関数」であるべきで、処理の内容はその関数の中で閉じ外界に影響を及ぼさないほうが良い。例えば、ここではscan()のseedに即値を渡しているが、これがどこか別のところで定義された変数(仮にaとする)が渡されていてfの中でprevに直接破壊的変更を行っている場合、何か別のところでaを利用していたりすると追跡が非常に困難な不具合が発生する可能性がある(経験者談)。
Bacon.jsに渡す関数が返す値は、その関数の中で新しく定義した変数を返すようにしておくのが単純で分かりやすいだろう。
「過去n回分のストリーム値たち」を返すストリーム
scan()について長々解説したが、実はBacon.jsは今回欲しい処理をそのまま行うより高水準なインターフェースslidingWindow()を提供している。ストリームに対してslidingWindow(max[, min])を呼ぶことで、元のストリームを最大max回分のストリーム値の履歴を要素として持つ配列を流すストリームに変換することができる。これを使うと上記の実装は以下のように書き換えることができる。
Bacon.repeat(() => Bacon.once(_.sample(items)))
.doLog()
.slidingWindow(terminator.length)
これで、「与えられた配列からランダムに一つ文字列をコンソールに出力し続けつつ最大で終了条件と同じ要素数の履歴を値として流す」ストリームを得ることができた。ストリームはまだ不完全なままである。
ストリームを終了させる
Bacon.jsでは必ずしも生成したストリームを「終了」させなければいけないわけではなく、例えばGUI実装の場合、ユーザー操作イベントを処理するストリームはページが表示されている限り永久にイベントを待ち続けるだろう。
が、今回の場合ズンドコテストの仕様として「一定の条件により処理を終了させ指定のメッセージを出力する」というものがあるため、ストリームに明示的に終了条件を指定する必要がある。
通常の手続き的な実装の世界観で考えると、条件判定によってストリームを能動的に終了させる処理を実装するようなやり方が思い浮かぶが、ドキュメントに
So the business of unsubscribing is mostly internal business and you can ignore it unless you're working on a custom stream implementation or a stream combinator. In that case, I welcome you to contribute your stuff to bacon.js.
とある通りおそらくそれはあまりBacon.js的な考え方ではない。Bacon.jsのストリームは生成時に宣言的に記述され一旦イベントループが回ったら後は自己管理をさせるものと考えるのがよく、終了条件を指定するためのインターフェースも提供されている。今回の場合、その中のtakeWhile()を利用するのが適切だろう。takewhile(f)にbooleanを返す所謂predicate関数fを渡すことで、ストリームに「fがfalseを返したら終了する」という条件を指定することができる(正確には、takeWhile()を呼んだ元ストリームから、そのような終了条件を持つ別ストリームを新たに生成する)。
slidingWindow()の結果ストリームが流す値と終了条件の配列が同じな場合に終了させたいので、以下のような実装になるだろう。配列同士の比較にはまたlodashにお出まし頂く。
Bacon.repeat(() => Bacon.once(_.sample(items)))
.doLog()
.slidingWindow(terminator.length)
.takeWhile((xs) => {
return !_.isEqual(xs, terminator);
})
このpredicateも単式なので、実際は記述の省略が可能である(例示は省く)。
これで、「与えられた配列からランダムに一つ文字列をコンソールに出力し続けつつ終了条件と同じ並びで値が末尾に発生したら終了する」ストリームを得ることができた。ストリームはまだ不完全なままである。
ストリームを消費する
ここまで、繰り返し「ストリームが不完全である(ため、Bacon.repeat()による無限ループは発生しない)」という表現をしてきた(Bacon.jsのストリームに対して不完全という表現をするのは私の造語である)。では「ストリームとして完全である」とはどういう状態かというと、少なくとも1つの、ストリームを消費する処理が指定されている状態である。消費というのは、ここではストリーム処理でよく使われる"consume"の直訳として使っている。
これまで元のストリームに対して副次的な処理を挟んだり値を変換したりはしていたが、ストリームはそれを流れてきた値を終端箇所で何か外界に意味のある処理に使って初めて意味のあるものとなる(例えば、ユーザーがボタンを押したというイベントストリームがAJAXアクセスのストリームに接続され、AJAXストリームの結果が最終的に表示されているDOMの内容を変化させる、など)。水道管は、途中の配管をどんなにつなげていったとしても末端に蛇口を付けない限り水を流すわけにはいかない。
Bacon.jsは、ストリームに終端処理を指定するためのインターフェースとしていくつかのon***()を提供しており、ストリームに対してonValue()などが呼ばれた時に初めて源から処理が発生して値が流れる準備ができる。GUI実装ならユーザーイベントへの反応を開始するだろうし、今回のBacon.repeat()だったら指定した関数によるループが開始する。
ほとんどの場合ストリームの消費はonValue()を用いて指定したコールバックで上流のイベントが発生するたびに逐次的に終端処理が繰り返されるのだが、今回の場合欲しい終端処理は「ストリームが停止した時に特定のメッセージを表示する」というもののため、「ストリームの終了イベントに対して終端処理を設定する」onEnd()を利用する。
Bacon.repeat(() => Bacon.once(_.sample(items)))
.doLog()
.slidingWindow(terminator.length)
.takeWhile((xs) => !_.isEqual(xs, terminator))
.onEnd(() => {
console.log(endMessage);
});
これで、欲しいストリームを完全な形で構築することができた。slidingWindowやtakeWhileといった高水準なストリームユーティリティに助けられてる点が九割方ある気もするが、forやifなどの実装者の意図にとってはノイズでしかない記述の見られない、意図をそのまま表現したようなコードになっているのではないだろうか。
実行
ちゃんと実装できているか確認するためテストコードを実行したみた。
$ npm run build
$ npm test
> bacon.zundoko@0.0.1 test C:\*********************\bacon.zundoko
> babel-node test/index.js
ズン
ドコ
ズン
ドコ
ドコ
ズン
ドコ
ズン
ズン
ドコ
ドコ
ドコ
ドコ
ズン
ズン
ズン
ズン
ドコ
キ・ヨ・シ!
問題なさそうだ。引数による拡張は以下のようになるだろう。
const iya = 'イヤーッ!',
guwa = 'グワーッ!',
sayonara = 'サヨナラ!';
zundoko.run([iya, guwa], [iya, guwa, iya, guwa], sayonara);
$ npm test
> bacon.zundoko@0.0.1 test C:\*********************\bacon.zundoko
> babel-node test/index.js
グワーッ!
グワーッ!
グワーッ!
イヤーッ!
イヤーッ!
グワーッ!
イヤーッ!
イヤーッ!
イヤーッ!
グワーッ!
グワーッ!
イヤーッ!
イヤーッ!
グワーッ!
イヤーッ!
イヤーッ!
グワーッ!
イヤーッ!
グワーッ!
サヨナラ!
オタッシャデー!