追記(2017/05/2)
redux-sagaでの非同期バージョンの紹介とリンクを追記。
追記(2017/2/23修正)
元記事の追記3にて言及を頂いたように、以下の「見易い版」コードは元コードが実現していた機能が抜けおちているという誤りがあります。遅くなりましたが、お詫びの上修正させていただきます。
修正内容は以下の「refreshボタン押下ですべての候補を消去」の項目に追記しました。
上記追記の趣旨として、リアクティブプログラミングはそれほど判り難いのだ、というご指摘になっていますが、返す言葉もございません。
はじめに
先日「リアクティブプログラミングとは何だったか」という記事を目にしまして、内容はたいへん興味深かったのですが、以下の記述がありました。
『宣言的』といえそうなのはわかりますし、パラダイムとして従来のコードとは一線を画すものであることは確かですが、どう贔屓目にみてもひたすら読みづらいとしか感じられません。ここでもう一度、先に示したモナド版のほうを見て比較してみます。
比較しているのは、RxJS版と、PurescriptのAffモナド版なのですが、わたしはいずれについても詳しい知識はありませんが、ReactiveXのObservableは、私の理解では**「ストリームモナド」**であり、Affモナドも(名前から判断する限り)どっちもモナドなわけで、基本的な記述でなぜそのような差が出るのかが良くわかりませんでした。
ということで、調べてみました。
TL;DR
リアクティブプログラミングが読み難いかどうかは、書き方によるが、簡単なケースでは言うほど読みにくい、というわけでもない(個人の感想です)。また読者の背景知識にもよる。
「ひたすら読み難い」と言われているコード
先の記事中で比較対象は「あなたが求めていたリアクティブプログラミング入門」(original)に示されていたRxJSのコード(全体はこちら)です。
一部引用するとこんな感じ。
var refreshClickStream = Rx.Observable.fromEvent(refreshButton, 'click');
var close1ClickStream = Rx.Observable.fromEvent(closeButton1, 'click');
var close2ClickStream = Rx.Observable.fromEvent(closeButton2, 'click');
var close3ClickStream = Rx.Observable.fromEvent(closeButton3, 'click');
var requestStream = refreshClickStream.startWith('startup click')
.map(function() {
var randomOffset = Math.floor(Math.random()*500);
return 'https://api.github.com/users?since=' + randomOffset;
});
var responseStream = requestStream
.flatMap(function (requestUrl) {
return Rx.Observable.fromPromise($.getJSON(requestUrl));
});
function createSuggestionStream(closeClickStream) {
return closeClickStream.startWith('startup click')
.combineLatest(responseStream,
function(click, listUsers) {
return listUsers[Math.floor(Math.random()*listUsers.length)];
}
)
.merge(
refreshClickStream.map(function(){
return null;
})
)
.startWith(null);
}
var suggestion1Stream = createSuggestionStream(close1ClickStream);
var suggestion2Stream = createSuggestionStream(close2ClickStream);
var suggestion3Stream = createSuggestionStream(close3ClickStream);
:
suggestion1Stream.subscribe(function (suggestedUser) {
renderSuggestion(suggestedUser, '.suggestion1');
});
suggestion2Stream.subscribe(function (suggestedUser) {
renderSuggestion(suggestedUser, '.suggestion2');
});
suggestion3Stream.subscribe(function (suggestedUser) {
renderSuggestion(suggestedUser, '.suggestion3');
});
なるほどなるほど。確かに確かに。
なぜ読み難いのか
変数の多用
一見してわかるのは、ストリーム(=Observable)を保持するための変数を多用しているということです。この理由はおそらく、本文中での説明時の参照のしやすさ、特に図表で説明するためではないかと思いました。たとえば、
requestStream: --r--------------->
responseStream: ------R----------->
close1ClickStream: ------------c----->
suggestion1Stream: ------s-----s----->
上記の左の桁にストリームを特定するための変数名を書きたかった、ということです。
結果として読む流れが上に行ったり下に行ったりしてしまいます。ただこれは使用箇所で値を展開するようにすれば改善されるでしょう。
不要な処理(2017/2/23訂正)
いくつか不要な処理が混入していることに気付きました。具体的にはmergeの呼び出しや、startwithを2回呼び出していることなどです。ステップバイステップで改良しつつ説明していくときに、古いステップで必要だったが、処理を追加することで不要になったものを削除しわすれてる、って気がします。
この結果、 refreshClickStreamが2箇所で使用され、最後に合流していくように見え、なんだこりゃ、と思えるようになっていました。でもそれで動くっていうのもある意味すごい。
当初、refreshStreamのmergeによる合流は不要な処理と思いましたが、後述のように重要な仕様を実現するためのもので不要ではありませんでした。startwithの2回の呼び出しのうち1回は不要な処理だと思っています。
ES2015の不使用
アロー関数をつかってないので煩雑に見えます。枝葉ですけどね。
書き直してみた
上記を中心に修正してみると、先ほど引用した部分に対応する部分は以下のようになりました。(全体および詳しい説明はこちら。)
// お奨めユーザ一覧を取得する非同期通信の発行結果のプロミスをストリームで包んで返す。
function getNewUsers() {
const randomOffset = Math.floor(Math.random()*500);
const requestUrl = 'https://api.github.com/users?since=' + randomOffset
return Rx.Observable.fromPromise($.getJSON(requestUrl))
}
// closeボタンにイベントストリームを設定するぜ!
[[closeButton1, ".suggestion1"],
[closeButton2, ".suggestion2"],
[closeButton3, ".suggestion3"]]
.forEach(([closeButton, selector]) =>
Rx.Observable.fromEvent(closeButton, 'click')
.startWith('startup click')
.combineLatest(
Rx.Observable.fromEvent(refreshButton, 'click')
.startWith('startup click')
.flatMap(() => getNewUsers()),
(_, listUsers) => listUsers[Math.floor(Math.random()*listUsers.length)])
.subscribe((suggestedUser) => renderSuggestion(suggestedUser, selector)))
refreshボタン押下ですべての候補を消去(2017/2/23追記)
当初、本記事は、上記まででしたが、重要な仕様である「refreshボタン押下の瞬間に一旦すべての候補が消去される」が実装されていませんでした。私の理解不足によるもので申し訳なく思います。該当機能を実装した版を以下にしめします。
// お奨めユーザ一覧を取得する非同期通信の発行結果のプロミスをストリームで包んで返す。
function getNewUsers() {
const randomOffset = Math.floor(Math.random()*500);
const requestUrl = 'https://api.github.com/users?since=' + randomOffset
return Rx.Observable.fromPromise($.getJSON(requestUrl))
}
const refreshClickStream = Rx.Observable.fromEvent(refreshButton, 'click'); //追加
// closeボタンにイベントストリームを設定するぜ!
[[closeButton1, ".suggestion1"],
[closeButton2, ".suggestion2"],
[closeButton3, ".suggestion3"]]
.forEach(([closeButton, selector]) =>
Rx.Observable.fromEvent(closeButton, 'click')
.startWith('startup click')
.combineLatest(
refreshClickStream // 変更
.startWith('startup click')
.flatMap(() => getNewUsers()),
(_, listUsers) => listUsers[Math.floor(Math.random()*listUsers.length)])
.merge(refreshClickStream.map(()=>null))// 追加。refreshボタンが押されたらsuggestedUser==nullという出力を折り込む
.subscribe((suggestedUser) => renderSuggestion(suggestedUser, selector)))
修正版の全体はこちら。
combineLatestの使用
上記では特にcombineLatestのところが難解に感じられるかもしれませんので説明します。このサンプルコードでは、個々の推奨ユーザに付随するcloseボタンの押下ではAJAXのXHRリクエストが発行されずに、最後に実行したrefreshで取得した推奨ユーザリストの値を再利用するようにしています。
combineLatestは2つのストリームに関数を適用したストリームを返す、Haskellのリスト処理で言えば、「zipWith」に対応するものです。ただしストリームは非同期で離散的な値であるので、イベント生成タイミングとzipするべきペアは自明ではありません。combineLatestは「いずれかのストリームのイベント発生のタイミングで、それぞれのストリームの直近で最後の値」に対して、指定した関数を適用し、その結果から成るストリームを返します。
このコードでは、refreshボタンのストリームから「推奨ユーザリストのストリーム」を生成させ、combineLatestで組合せることによって、close時に利用できる「refreshで最後に取得した推奨ユーザリスト」のイベントストリームを生成します。
もちろん上記のコードはcombineLatestの意味がわからないと理解できませんが、ReactiveXの中核価値の一つは、ストリームに対する高機能なオペレータが数多く取り揃えられていることであり、それらを適切に使い分けて活用するのがRxのキモの一つだと思います。興味を引くための例示コードとしては適切だと言えましょう。
実際、「あなたが求めていたリアクティブプログラミング入門」でも(太字は引用者)、こんな風に書かれています。
これは1つをクリックしただけなのに、クローズして全ての候補を再読み込みする。この問題を解決する方法は色々あるが、面白さを保つためにも、先ほどのレスポンスを再利用して解決してみる。APIレスポンスのページサイズは100人のユーザー分あるが、我々は3人分しか使っていない。そこにはまだ豊富な新しいデータがある。追加のリクエストをする必要は無い
キャッシュしないバージョン
Affモナド版では、推奨ユーザ一覧のキャッシュ処理を(おそらく)していません。「同機能なものに対するコード比較」をしてみるのも意味があるかと思うので、(訂正、されておりました。大変もうしわけありません)。
RxJSでキャッシュをしないバージョンも書いてみました(全体はこちら)。
// closeボタンにイベントストリームを設定するぜ!
[[closeButton1, ".suggestion1"],
[closeButton2, ".suggestion2"],
[closeButton3, ".suggestion3"]]
.forEach(([closeButton, selector]) =>
Rx.Observable.fromEvent(closeButton, 'click')
.startWith(null)
.merge(Rx.Observable.fromEvent(refreshButton, 'click').startWith(null))
.flatMap(() => getNewUsers())
.subscribe((listUsers) => renderSuggestion(listUsers[Math.floor(Math.random()*listUsers.length)], selector)))
上記ではcombineLatestは使用せずに、単にmergeをしています。「refreshを押したかあるいはその推奨ユーザに対するcloseを押したか」のいずれかで都度推奨ユーザ一覧の取得処理が行なわれます。
ずいぶんと分かりやすくなった気がしますがいかがしょうか。
読み難いのか?
ここまで見てきたように、ReactiveX/RxJSのコードを読むにはストリームオペレータに関する知識を必要とします。でもそれは、例えばHaskellのList処理で、foldlとかscanl,zipWithなどの標準ライブラリ関数を使ったコードに対して、それらの関数が何をするかを知らないと理解が難しいと感じるのと同様です。
逆にそれらに習熟したならば、短く書け、むしろ読みやすいと感じる場合も多々あるでしょう。
ちなみに、このコードはRxの真価を発揮するのにはたぶん単純すぎます。複数の関連し合うイベントソースに対する処理記述において、モジュラリティとコンポーザビリティが得られることが、真にユニークな、ほかの方法では得られないリアクティブプログラミングの利点です。しかしだからといって、単純なケースが書きにくいわけではないと思います。例えば非同期モナド(Promise想定)などと少なくとも同程度ではないでしょうか。
もちろん、ストリームが何個もあって、相互に絡みあう結合・分岐が複雑になってくると、わかりにくくなって、図表とかが必要になってくるでしょう。かと言ってそれをストリームを使わないで書いた場合、その何倍もツラいコードになる気がします。
んでやっぱり読み難いのか?(2017/2/23追記)
やってしまってもう恥かしいので、読者の判断におまかせしたいと思います。今後、redux-sagaなどで非同期バージョンを書いて比較してみようと思います。
redux-sagaでの非同期バージョン(2017/5/2追記)
redux-sagaでの非同期バージョンを書いた。元の仕様に加えて、リフレッシュ・リムーブについてモーダルダイアログでの確認も追加している(sagaらしくて面白いので)。デモ
// show modal dialog and get user response(Ok/Cancel) synchronously
function* askYesNo(content) {
yield put(Actions.setModal({ show: true, title: 'Are you sure?', content }));
const answer = yield race({
ok: take(Types.UI_MODAL_OK),
cancel: take(Types.UI_MODAL_CANCEL),
});
yield put(Actions.setModal({ show: false }));
return answer;
}
// remove and get new follower
function* remove(users, action) {
// make sure to remove
if (
action.payload.verify &&
!(yield askYesNo(<div>Delete and refresh this follower?</div>)).ok
) {
return;
}
// get one random user from the users list
const user = users[Math.floor(Math.random() * users.length)];
yield put(Actions.setFollower({ idx: action.payload.idx, user }));
}
// refresh all folllowers
export function* refresh(action) {
// make sure to refresh
if (
action.payload.verify &&
!(yield askYesNo(<div>Refresh all followers?<br /></div>)).ok
) {
return;
}
// remove all followers on screen immediately
yield [0, 1, 2].map(i =>
put(Actions.setFollower({ idx: i, user: { avatar_url: null } }))
);
try {
// get user list pool (reuse following remove calls)
yield put(Actions.setLoading(true));
const users = yield call(Api.getNewUsers);
yield put(Actions.setLoading(false));
// remove and refresh all followers
yield [0, 1, 2].map(i =>
fork(remove, users, Actions.remove({ idx: i, verify: false }))
);
// wait until remove link[x] clicks
yield takeLatest(Types.UI_REMOVE, remove, users);
} catch (e) {
console.error(e);
}
}
// single entry point to start all Sagas at once
export default function* rootSaga() {
yield takeLatest(Types.UI_REFRESH, refresh);
}
その他
- 元記事の「あなたが求めていたリアクティブプログラミング入門」のサンプルコードだけを読むと、結構ひどいという印象になるのはしょうがない気がする。かといって、稠密なプロフェッショナルコードが入門記事のサンプルとしてふさわしいか、というと違う気もする。難しいところです。
- ReactiveXの利点は他にもある。バックプレッシャーつきバッファリング、他言語での同アーキテクチャ共有などなど。
- 私はReactiveX/RxJSの知識はあんまりありません(この記事を書くために調べただけで本格的に使ったことはない)ので、不足や間違いなどありましたらご指摘ください。
- この記事では流れでReactiveX/RxJSをもって「リアクティブプログラミングの代表」みたいな話にしてしまってますが、一例です。本当はそれどころかReactiveX/RxJSが厳密な意味でリアクティブプログラミングにあてはまるかは不明です(参考→FRP。わけわかんねー。)。この記事では、少なくとも「広義のリアクティブプログラミング」にはあてはまるものだとみなしています。
- flatMapをネストさせていくときに、JSではdo記法がないので煩雑になるかと思いましたが(当初それが原因かと当て推量していた)、今回のコードではそんな複雑なものではありませんでした。
おわりに
元記事「リアクティブプログラミングとは何だったか」をかかれた、hiruberutoさまにおかれましては、上記調査のきっかけを作ってくださったことを感謝いたします。ありがとうございました。