JavaScriptのGeneratorであそぼう 〜これって関数型!?編〜
Pythonだと呼吸と同じくらい自然に使う(諸説あり)generatorですが、どうもJavaScript界隈ではあまり使われている様子はありません。
というか一部の言語をのぞいて遅延リスト自体があまり使われていない気もしますが……それはまた別のおはなし。
お題: APIコールを${interval}秒ごとに遅延して分割させよう
今回書いたコードの動作はここでチェックできます。
また、日常的にフル活用する機能ではないので文章中に誤り、勘違いなどが含まれている可能性があります。
あったらこっそり、かつ優しく教えてください。
普通に正しい実装
どうでしょう。
例えば100人分の情報を更新するとして、APIの呼び出し制限にひっかからないようinterval
秒ごとにfetchを分割遅延したいことが稀にあるかもしれないし、ないかもしれないです。
ナイーブに実装するとこんな感じでしょう
async function updateUsers(humans: ReadonlyArray<Human>, interval: number) {
for (const human of humans) {
await refreshProfile(human);
await new Promise((resolve) => setTimeout(resolve, interval));
}
}
現実世界ではこれにエラー制御やなんやらがはいってややこしくなるとおもいますが、100人いたら99人はこんな感じで書くとおもいます。
(ちなみに当たり前ですが直列にしない場合はちゃんとPromise.allを使いましょうね)
さて、じゃあ今度はそんな感じで書かないへそ曲がりの気持ちになってみましょう。
宣言的実装
さて、というわけで宣言的パラダイムでやっていきましょう。
先ほどのは命令的パラダイムです。
ちなみに対比させていますが、別にどっちが優れているとかではないのでお好きなほうで書きましょう。
僕は宣言的なのが好きですが。
- ヒューマンのリスト
- ヒューマンのリストを使い、処理した結果を受ける(わかりにくいですがawaitはPromiseを処理しています)
- 更新の間隔は空ける
これは独立した抽象タスクです。
抽象タスクは関数を分けなければなりません。わけましょう。
2
にある、リストのひとつひとつの要素を使い処理した結果を受ける関数って見覚えがありますよね?
Array.prototype.mapです。
それのもっと使いやすい版を作りましょう。
export function map<T, U>(fn: (x: T) => U) {
return function* (xs: Iterable<T>) {
for (const x of xs) {
yield fn(x);
}
};
}
わあ使いやすい。
ためしにこれを使って並列処理を書いてみましょう。
import {pipe} from 'fp-ts/es6/function';
// てきとうなダミー
function refreshProfile(h: Human) {
return new Promise((resolve) => setTimeout(() => resolve(`${h} done`), 300));
}
const humans = ['taro', 'jiro', 'saburo'];
const from = performance.now();
const result = await pipe(
humans,
map((human) => refreshProfile(human)),
(x) => Promise.all(x),
);
console.log(result); // ["taro done","jiro done","saburo done"]
console.log(performance.now() - from); // 30x
はいうまくいきましたね。
同時並行的に実行されるのでかかった時間は300msです。
じゃあ次に直列化しましょう。
その前にいくつかヘルパーを用意します。
まず、JavaScriptには Promise.all
はあっても Promise.seriese
がないので代わりをつくります。
/** Promise.allの直列版、あるいはfp-tsのTask.sequenceTみたいなもんだとおもいねえ */
export async function* sequencePromise<T>(iter: Iterable<Promise<T>>): AsyncGenerator<Awaited<T>, void, undefined | (() => Promise<unknown>)> {
for await (const i of iter) {
yield i;
}
}
さぁ、これを使って直列実行しましょう。
const humans = ["taro", "jiro", "saburo"];
const processes = pipe(
humans,
map((human) => refreshProfile(human)),
sequencePromise,
);
const from = performance.now();
for await (const p of processes) {
console.log(p); // taro done, jiro done, saburo done
}
console.log(performance.now() - from); // 90x
はい。
並列版は300msで終了していたのが、ちゃんと900msかかるようになっているのがわかりますね。
「なんでfor awaitの直前に計測開始してるんだ???」
と思われる方もいるかもしれませんが、これは遅延評価というGeneratorのもつ特色のひとつです。
つまり宣言時には実際は開始されず、評価されてはじめて処理が発生するわけですね。
さて、じゃあこのまま、APIの呼び出し制限にかからないようリクエストの合間に100msの間隔を空ける関数を実装してみましょう。
export function asEvenly(interval: number) {
return async function* <T>(xs: Iterable<T>) {
for (const x of xs) {
yield x;
await new Promise((resolve) => setTimeout(resolve, interval));
}
};
}
はいできました。
じゃあこれを使ってみましょう。
// 省略
const processes = pipe(
humans,
map((human) => refreshProfile(human)),
sequencePromise,
asEvenly(100),
);
// 省略
はい。
300ms * 3 + 100 * 3 = 1200ms
が計測されました。
え?console.log(p)
の後ろにsetTimeout
仕込めばいいだろって?
もっともなツッコミですが、処理の抽象化、そして手続きに自己説明的な名前をつけるのはプログラミングにおいて非常に大切なことです。
例えばエラー処理などでこのfor文の中身が非常に長くなったとき、あるいは分岐が発生したとき、この末尾のsetTimeoutの存在がなんのためにあったのかは薄れてしまいます。
しかしasEvenly(interval: 100)
という表記は(まぁ命名センスはさておき)それ自体がコメントとして自己説明しています。
宣言的プログラミングの良さは、命令型が好むイディオムによる表記よりも抽象度の高い操作がしやすいこと、それによって読み味を向上させやすいところにあります。
また、抽象度を高め切ると別の概念と手を繋げる新しい仕組みが誕生したりします。みんな大好きモナドとかもそのひとつですね。
反面、デバッカーを利用した逐次的なコードリーディングがしにくい、オーバーヘッドが高まる可能性がある、脳の使い方が変わるので難易度が高い、JavaScriptのような仕組みは用意してあっても支援する体制にかける言語だとオレオレフレームワークが生まれる……などの弊害はあります。
実際、ジェネレーターを利用した記述はmap
, sequensePromise
, asEvenly
などヘルパーを自作しており、総合的な行数は増えています。
単純にイディオムをコピペし、humans
をforで回し、都度fetchし、sleepする。それ困ることは……ウーン、まあ人によるでしょう。しかし現実世界のコードは、ここにリトライやエラー処理、ロギングが挟まりさらに複雑化します。ゆえに結論は謎。
そしてポーリングへ
さて、まあどっちのスタイルをとるかは人それぞれですがせっかくなので
抽象度を高め切ると別の概念と手を繋げる新しい仕組みが誕生したりします
の実例をあげてみましょう。
今回asEvenly
をfor文に仕込んだsetTimeout(スリープ)
というイディオムに適用しましたが、この関数はほんらい「一定時間ごとにiteratorを消化する」という抽象度の高い動作をします。
さて「一定時間ごとにxxxしたい」……といえばそう、ポーリングですね?
というわけでポーリング処理を書いてみましょう。
宣言的プログラミングをする場合、ポーリングに必要なのは無限です。
なので無限のリストを作りましょう。
function* infinityVoid() {
while(true) {
yield void 0;
}
}
const mugen = infinityVoid();
const processes = pipe(
mugen,
map(() => refreshProfile('gojo')),
asEvenly(100),
);
for await (const p of processes) {
console.log(p);
}
素晴らしいですね!とっても簡単に無限ポーリングが書けました。
ちなみに無限リストに対してArray.from
のような全体を評価する処理を書くと全てが終わるので気をつけましょう。
take(これもJSにはないので自作しましょう)を使う、↑のようにgeneratorで読み解く……などなどの対処が必要です。
余談:無限を持ち運びたい
さて、みなさんが使っているReactのようなアプリケーションだと、このような無限は少々扱いにくいです。
持ち運び、購読できる形にしなければなりません。
購読できる……無限……そう、Observableとかが良さそうですね!
もちろんまだ標準化されていないですし、proposal用のpolyfillもあるんだかないんだかよくわかりません。これもまた自作が必要です。
今回は僕が昔作ったやつを使います。
ではGeneratorからObservableへの変換を書きましょう。
export function fromIterator<T>(iter: Iterator<T> | AsyncIterator<T, void, void>) {
return new Observable<T>(async (sbc) => {
while (true) {
const v = await iter.next();
if (v.done) {
return sbc.complete();
}
sbc.next(v.value);
}
});
}
いいですね。
あとはこれで
const mugenNoObserver = pipe(
mugen,
map(() => refreshProfile('gojo')),
asEvenly(100),
fromIterator,
);
とし、React側からmugenNoObserver
を適当な方法で監視しましょう。
おめでとうございます。
遅延実行からポーリングという似て非なるタスクへ同じ仕組みが転用されました。再利用性って素敵だね。
global変数にしても良いですし、UseSyncExternalStoreと接続( https://zenn.dev/hatchinee/articles/c075fdef8f54d0 )するのもよさそうです。
拙作のStoreライブラリのnozuchiはObservableをstore購読させる仕組みを取り入れたりしています。
(もしも本気でフルに使うならメモリリーク対策はいれたほうが良さそう)
まとめ
いかがでしたか?
いかんせんJavaScriptはIteratorまわりの設計が不親切で使いにくいです。
(Safariとかいうフロントエンドエンジニアの敵を除いて)Iterator.prototype.map
がされはじめたり(むしろなんで最初からそっちで仕様決まってないの?????)はしているものの、本気でプロダクションに使うならそれなりの覚悟が必要です。
とはいえGeneratorは正式に実装されて久しい機能ですし、async/awaitの先行実装であるco
や一瞬だけ一世を風靡したRedux-Saga
のように独創的な使い方が可能な素晴らしいツールです。
今回は使っていませんが、TNext
の定義を使えばnextの引数を使ってyield越しにコルーチンの中身とコミュニケーションできたりもします。
Observableと微妙に親和性があるのも良いですね。
あなたの脳の道具箱にしまっておいて損はないでしょう。いつか役に立つ日が来るはずです。
ちなみに筆者はrx-jsはいらんやろ派ですが、Observableだけはさっさと仕様に入ってくれとおもってます。あとPipelineOperatorはよ!
遅延リストはメモリ効率の点で有利に書きやすいものの、実行速度はやや犠牲になる傾向があります。
用法要領を守って楽しく使いましょう。
Discussion