リアクティブプログラミングとは何だったのか

  • 594
    いいね
  • 5
    コメント

kleisli.png

※この記事はずいぶん内容がわかりづらかったようで、さまざまな反応を頂きました。追記が複数ありますので、併せてご覧ください。

TL;DR Version:

リアクティブプログラミングに挑戦しようとした。がっかりした。

はじめに

私のこの記事は「【翻訳】あなたが求めていたリアクティブプログラミング入門」に触発されて?書かれたもので、そちらの元ネタの記事に先に目を通しておいたほうが理解がしやすいと思います。そちらの記事は本当に解説がわかりやすく、そして何よりとても説明が具体的なので、リアクティブプログラミングについて知りたいかたには大変おすすめです。リアクティブプログラミングの解説には、漠然としたことしか言っておらずさっぱり参考にならないものも多いのですが、いや本当に多いのですが、この元ネタの記事では図表が適切に使われているだけでなく具体的な問題提起と具体的なコードによる解決策が示されており、リアクティブプログラミングについての解説記事のうち間違いなくもっとも優良なもののひとつだと言えると思います。

あらかじめ注意しておきますが、私のこの記事は自分でリアクティブプログラミングを試してみたところうまく行かなかったという残念な話であって、この記事を最後まで読んで内容に納得したとしても、あまり役に立つ知見にはならないと思われます。宝物が埋まっている場所なら誰でも知りたいと思うでしょうが、宝物が埋まっていない場所を知りたがる人はあまりいない、ということです。なお、言語は例によってJavaScriptとPureScriptを使いますが、どちらの言語のコードもみっしり解説をつけてあるので、疎くてもわりと大丈夫だと思います。

私のこの記事はほんとにクソみたいに長い割に無益なので、忙しい人は「まとめ」だけ読むといいと思います。

リアクティブプログラミングとは

そもそもFunctional Reactive Programming(FRP)あるいはReactive Programming(RP)とは何を指しているのかという問題があるのですが、どうもこの両者は区別されるべきものであるようです:

元ネタの記事に対しては「記事中でFRPって言っているけど、それはRPではあってもFRPじゃない」みたいな指摘も入っているのですが、そのような混乱も含めて、筆者のこの記事では「リアクティブプログラミング」は元ネタの記事と同じものを指しているものとします。すなわち、非同期に時間的変化しうるデータを表す型の操作が中心になるプログラミングで、しかもファンクショナルリアクティブプログラミングではないリアクティブプログラミングのほうだということです。

RxならObservable、Elmやpurescript-signalならSignalが、Bacon.jsならEventStreamが、そのようなデータ型となっていて、元ネタの記事ではこれを『ストリーム』と呼んでいます。なお、ObservableとかSignalとかStreamとかEventStreamとかいろんな呼び方がありますが、同じものを指していると考えて差し支えありません。また、リアクティブ宣言で言われている『リアクティブシステム』等はここではまったく関係がありません。

仮想DOM/非同期処理モナドとストリームを比較する

さて、筆者は元ネタの記事を読んでとても納得したので、長い間スルーしていたリアクティブプログラミングに改めて取り組んでみようと思ったわけであります。この新しいパラダイムの威力を検証するには、ある程度実用性のありそうなアプリケーションを作って比較してみるのがいいでしょう。元ネタの記事では、Twitterにあるようなフォローお勧めユーザを表示するウィジェットを作成するというサンプルを扱っていたので、筆者もこれと同じものを実装して検証してみようと思います。きっとこのお題はリアクティブプログラミングでないと表現しにくいお題で、リアクティブプログラミングのメリットを示すのに向いているのでしょう。

比較のため、まずはストリームを使っていないバージョンを作成し、それからそれをストリームを使ったものに改良し、それから両者を比較することで、どう改良されたのかを検証することにします。作成したコードは以下のgistにおいてあります。全体は100行ほど、状態の更新に関わる部分はせいぜい30行ほどです。

(2017年1月 追記)言語や各種ライブラリがバージョンアップしまくったので、上のコードはもうコンパイルがまったく通りません。現在のコンパイラでコンパイルできるように修正してリファクタリングを加えたものを用意したので、実際のコードについてはこちらを御覧ください。

なお、インポート文を除けば1全体は100行程度、アプリケーションの状態の更新をすべて司るeval関数の実装はたったの20行ほどです。

これをストリームを使ってもっとわかりやすく単純なコードへと改良できれば、ストリームは役に立つと言えます。これをコンパイルして動かすと次のような感じになります。「Refresh」ボタンを押すとユーザ一覧すべてが更新されます。また×ボタンを押すと、そのユーザだけを非表示にし、代わりに別のユーザを表示することができます。

それで、プロトタイプが完成したところで、PureScriptでElmライクなストリーム(シグナル)を提供するpurescript-signalを導入してこれを改良し、ここをこう改良できたよー!だからここで役立ったよー!という話に持って行くつもりだったのですが……ここまで進めてはたと気づきました。あっダメだこれストリームではどうやっても改良にならないです。これがなぜかは後述しますが、とにかく当初思い描いていた文章構成はここで頓挫することになりました。

プロトタイプとその改良版という比較はできませんでしたが、それでも筆者が書いたPureScriptの非同期処理モナド版と、元ネタの記事のJavaScriptのストリーム版を比較することはできます。仕方ないのでこの両者を比較することでストリームの有用性を検証していくことにしたいと思います。そもそも言語がぜんぜん違うので比較しにくいと思いますがご了承ください。コードのすべてを解説するわけには行かないので、ページの更新ボタンを押したときの処理の流れを中心に検討していきます。

PureScript/モナド版

まずは筆者の書いたPureScript/モナド版を見て行きましょう。(追記: この部分のソースコードはすでに大幅に書き換わっていて、以下の説明はもう正確ではありませんが、議論の大筋に変化はありません。) アーキテクチャは使用しているUIフレームワークにそのまま従っており、仮想DOMに加えて、いわゆるFluxだとかElmアーキテクチャにあるような、アクションの送信で状態の変更と仮想DOMの更新を行っていくタイプのやつです。状態の更新の処理は、何か特別なパラダイムにそって書いたというわけではなく、処理の順序を思いつくまま書いたベッタベタのベタ書きであり、PureScriptのコードとしてはごくごく平凡なコードです。

ユーザ一覧の更新はRefreshアクションを投げたときに実行されますが、まずはページの読み込みが完了したときに候補ユーザが表示されるように、最初に実行されるmainの中でRefreshアクションを投げる処理があります。app.driver $ action Refreshと書くだけです。

main :: Eff Effects Unit
main = runAff throwException pure $ void do
    app <- runUI ui { display: [], users : [] } -- アプリケーションを作成する
    onLoad $ appendToBody app.node              -- 作成が終わったら、次に要素をページに追加する
    app.driver $ action Refresh                 -- それが終わったら、`Refresh`アクションを投げてユーザ一覧を更新する

また、ユーザ一覧更新ボタンを押した時にも候補一覧が更新されなければなりませんから、onClickイベントハンドラにinput_ Refreshと書いて、クリックされるとRefreshアクションが飛んでいくようにします。

button [class_ (className "refresh"), onClick (input_ Refresh)] [text "Refresh"]

これだけで、2種類の別のタイミングで同じ処理を呼び出すということができました。特に困難も複雑さもありません。単に実行したいタイミングでRefreshアクションを投げるだけの話です。さて、Refreshアクションを飛ばすと、以下の部分が順次実行されます。行数としては(JSONのパースを別にすれば)わずか7行です。

set { display: replicate numberOfUsers Nothing, users: [] }   -- まず一覧をクリアする
randomOffset <- liftEff' $ randomInt 0 500                    -- それが終わったら0-500の間の乱数を1個生成する
res <- liftH $ Affjax.get ("https://api.github.com/users?since=" ++ show randomOffset) -- 次にgetでAPIを叩く
let parsed = fromMaybe [] (parse res.response)                -- レスポンスが返ってきら、そのJSONをパース
let display = Just <$> take numberOfUsers parsed              -- 上から3人を取り出す
let users = drop numberOfUsers parsed                         -- 残りの人を取り出す
set { display, users }                                        -- 状態を設定する

追記:この部分のコードはリファクタリングされ、現在は以下の3行になっています。

modify _ { display = replicate numberOfUsers Nothing }
users <- lift $ fetchUsers =<< (liftEff $ randomInt 0 500)
modify _ { display = Just <$> take numberOfUsers users, users = drop numberOfUsers users }

PureScriptに馴染みがない人も少なくない……というか知ってる人なんて皆無だと思うので、ちょっと詳しく説明しておきます。まず一行目、

set { display: replicate numberOfUsers Nothing, users: [] }

これはset関数を呼んで仮想DOMの状態を設定しています。次の状態を引数として与えているだけで、これで状態が更新されて勝手に再描画が走ります。ここではユーザ一覧を非表示にするために、状態として[null, null, null]みたいな意味の[Nothing, Nothing, Nothing]を設定しています。ここであえてユーザ一覧の表示を消しているのは、AJAXのような時間のかかる処理の前にユーザの操作に即座に反応して表示を変えることでユーザビリティを向上するためです。これは元の記事のコードの振る舞いと同じです。状態が更新され再描画が終わったら、処理は次の行に進みます。その行の処理が終わったら次の行に進む。プログラムの世界では当たり前のことです。

randomOffset <- liftEff' $ randomInt 0 500

randomIntは乱数を生成する関数です。0から500までの範囲の乱数を生成し、結果をrandomOffsetに束縛しています。計算の結果は、その同じ行で=<-を使って変数に代入する。これもプログラムでは当たり前であるように思えますが、そんな簡単なことさえできない世界もあることが後でわかります。

この行で謎なのはliftEff'という関数ですが、これは単なる型合わせだと思ってください。乱数を生成するという振る舞い自体は何も変わりません。Haskell/PureScriptではこの手のlift何とか系がたくさん登場しますが、読むときには基本的に無視して構いません。乱数生成ができたら次の行に進みます

res <- liftH $ Affjax.get ("https://api.github.com/users?since=" ++ show randomOffset)

次はget関数を呼んでAjaxでGithubのAPIを叩いています。getという名前は仮想DOMの状態を取得するgetと名前がモロ被りしているので、頭にAffjaxというプリフィックスをつけて衝突を避けています。getの引数は取得したいURLなので、先ほどの乱数をURLの末尾に連結しています。liftHもただの型合わせなので気にしません。Ajaxの結果が返ってきたら、それが変数resに束縛されます。それから処理は次の行に進みます。

let parsed = fromMaybe [] (parse res.response)                
let display = Just <$> take numberOfUsers parsed          
let users = drop numberOfUsers parsed                

これらの行では、先ほどのAjaxの結果を別に定義したparse関数でパースし、ユーザ情報の配列に直しているだけです。APIを叩いて得た生の結果は不要な情報が沢山含まれていたので、扱いやすい形式に変換しています。それからtake関数とdrop関数でユーザ情報の配列から先頭3人とその残りを取り出し、それぞれ変数displayusersに束縛しています。それが終わったら次の行に進みます。

set { display, users }                                       

またset関数を呼び、再び状態を更新しています。今度はユーザ情報を取得したあとなので、空の配列ではなく先ほどAjaxで取得したユーザ情報を与えます。自動的に再描画が走り、ユーザの情報が表示されます。これで処理は終わり、ブラウザは次のユーザの入力を待ちます。

別にコールバック地獄が発生するわけでもなく、データバインディングが複雑というわけでもなく、特に何もコーディング上の問題は起きていません。データバインディングはすべて仮想DOM側が行ってくれるわけで、setで状態を更新する以外にデータバインディングについて考慮すべき点はありません。これはもっと大きなプログラムになったとしても同じで、規模が大きくなると何か特別な問題が発生するというものでもありません。もちろん規模が大きくなればその全容を把握するのは難しくなりますが、そうなったら適切に処理を関数として分割するだけです。それは普通の同期処理の時と同じ問題が発生し同じ対処をするだけであり、そこに特に非同期処理特有の問題やイベント処理特有の問題というものはないでしょう。これ以上何も特別な処置は要らないと思います。

処理の流れを追うには上から下まで一行づつ順番に見ていくだけだという単純な話です。そんなことプログラムでは当たり前じゃないかと思うかもしれませんが、その当たり前が当たり前ではないというところが、この後始まる話の恐ろしいところです。次にストリーム版について見て何か改良すべきヒントを探すことにします。ストリーム版には羨ましくなるような更にエレガントな解法が提示されているものと期待したいところです。

JavaScript/ストリーム版

さて、次はストリーム版を見ていきます。元ネタの記事の文末にコード全体が掲載されているので、適宜そちらも御覧ください。ストリーム版ではfromEventでクリックイベントのストリームが作成されているところから追跡を開始するとよさそうです。

var refreshClickStream = Rx.Observable.fromEvent(refreshButton, 'click');

このrefreshClickStreamがクリックのイベントを扱うストリームなので、このストリームを流れるデータがどのように伝わってくのかをたどっていけば処理の流れがわかるはずです。しかし少し困ったことに、refreshClickStreamは2箇所で使われているのです。

var requestStream = refreshClickStream.startWith('startup click')
  ...
  ...

 .merge(
    refreshClickStream.map(function(){ return null; })
  )

  ...

つまり、更新ボタンを押すといきなり処理の流れが分岐し、2種類の処理が同時に走ると捉えるしかありません。もちろんJavaScriptはシングルスレッドモデルなので真の並列処理ではないにしろ、考え方としては並列処理そのものです。これは明らかに可読性を低下させると思うのですが、そんな複雑なことが必要なアプリケーションでしたっけ……?とにかく、どちらを先に追うべきか選ばなければなりませんが、きっと先に書かれているほうが先に実行されるのでしょう、requestStreamの定義で使われている前者のほうから追っていくことにします。

さて、refreshClickStreamにはまずいきなり.startWithがくっつけられていますが、これは今から追う処理がページの読み込み時にも実行されることを指示しているだけで、ボタンを押した時の処理には関係のないコードなので、これは無視します。さてその次に行われる処理ですが、次の行を見てみると、mapがあり、明示的なコールバックが行われています。

.map(function() {
    var randomOffset = Math.floor(Math.random()*500);
    return 'https://api.github.com/users?since=' + randomOffset;
  });

ストリームでコールバック地獄を解消できるという触れ込みだった気がしますが、コールバックそのものは普通に山のように登場します。でもまあ多重にネストされているわけではないので、プチコールバック地獄といったところでしょうか。このコールバックの中身がいつ何回どのように実行されるのかは、mapがどのような関数か知らなければなりません。非同期処理モナド版なら次の行は次に実行されるんだろうなーみたいな素朴な思考で読めますが、ストリーム版ではストリーム独特のmap,foldMap,merge,combineLatestといった大量のコンビネータの意味をよく理解して読み進める必要があります。

さて、とにかくクリックされたら、まずは

var randomOffset = Math.floor(Math.random()*500);
return 'https://api.github.com/users?since=' + randomOffset;

という2行が実行されることがわかりました。ここで返された値はストリームの現在の値として次の処理へと渡されるわけですが、この次の処理はどこでしょうか?ストリーム版では、次の処理は単純に次の行にあるとは限りません。このストリーム全体はrequestStreamという名前ですから、次にrequestStreamを追えばいいことがわかります。requestStreamが使われていたのは次の一箇所でした。今度は分岐しなくて大丈夫そうです。

var responseStream = requestStream
  .flatMap(function (requestUrl) {
    return Rx.Observable.fromPromise($.ajax({url: requestUrl}));
  });

今度はflatMapです。flatMapの性質を考えれば、先ほどの処理に続いてすぐにこのコールバック関数が実行されることがわかります。コールバック関数の引数にrequestUrlがありますが、さきほどのmapのコールバックの中で計算された値が渡されます。ここではそれを引数に使ってajaxを呼んでいます。

さて、このajaxのあとの処理ですが、ここでこのストリームの定義は途切れているので、また上に戻ってストリーム全体の名前を確認します。2行ほど上に戻るとresponseStreamという変数に束縛されていることがわかるので、次にこの変数がどこで使われているかを追えばいいかわかります。

そうやって「次の処理」を探すと、次はsuggestion1Streamというストリームの途中で使われていることがわかります。combineLatestで他のストリームと合流しているわけですが、合流するもう一つのストリームの処理の流れを把握しておくべきでしょうか?しかしここまで結構ややこしい過程をたどっているので、また思考をぶっちぎりたくはありません。何と合流しているのかはおいておいて、先に次に何が行われるのかを見てみます。

var suggestion1Stream = close1ClickStream.startWith('startup click')
  .combineLatest(responseStream,         // このストリーム途中から追跡再開
    function(click, listUsers) {
      return listUsers[Math.floor(Math.random()*listUsers.length)];   // 次の処理
    }
  )

次の処理では、表示するユーザのインデックスを乱数で初期化しているようです。ここでclicklistUsersという2つの値が参照できるわけですが、この引数の値は何でしょうか?それはcombineLatestを知っていればわかりますが、知らない場合はここで追跡をやめてドキュメントを漁ることになります。複雑な経路をたどってきたのに、ドキュメントなんて読み始めたらすぐにこれまでの処理の流れを忘れてしまいます。combineLatestのようなコンビネータが出てきても思考を妨げないように、コンビネータの一覧は予めすべて頭に入れておいたほうがいいでしょう。

さて、それが終わって、いくつかの括弧を読み飛ばすと、次はmergeでまたストリームの合流が行われていますことがわかります。

  .merge(
    refreshClickStream.map(function(){ return null; })  
  )

あれ、このrefreshClickStreamってさっきも出てきた気が……。さきほど分岐したストリームがここで合流しているのです。分岐したストリームが再びmergeで合流するので、ボタン一回のクリックにつき、以降の処理が2度実行されることになります。さて、ここでよく考えると、分岐したストリームの枝のうち、これまで追ってきた方のストリームには途中で$.ajaxの呼び出しがありました。しかし、いま合流しようとしているもうひとつのストリームは、何も挟まずにいきなりここにたどり着いています。つまり、実はこれまで追っていたほうの支流が後で実行され、今見つけた方の支流が先に実行されるのです。それがここまできてようやくわかります。あとで実行される方の支流を先に追ってしまったら意味がわからなくなってしまいそうですが、今のストリームでは先ほど計算した「ランダムなユーザ情報」を値として運んでいますから、これのことを忘れるとまた混乱するので、ひとまずこちらの処理を追っておき、終わったら改めてもう一つの支流の方を追うことにしましょう。

さて、次はまたstartWithですが、

.startWith(null);

これは今は関係ありません。あまりこういう無関係のことに惑わされると今やっていることを忘れてしまうのでさっさと次に行きます。ここでストリームは途切れているので、次の処理を探すために、このストリーム全体の名前を確認しましょう。9行くらい上に戻ると、このストリームがはsuggestion1Streamという名前であることがわかります。これを覚えておいて次の処理を探しましょう。

suggestion1Stream.subscribe(function(suggestion) {
  if (suggestion === null) {
    // hide the first suggestion DOM element
  }
  else {
    // show the first suggestion DOM element
    // and render the data
  }
});

次の処理はsubscribeです。ここで実際のDOM操作が行われることがわかります。いま運んでいる値は何か覚えているでしょうか?9行くらい上で計算していた、ランダムに選ばれたユーザ情報です。これがsuggestionという引数に与えられ、まずnullかどうか調べます。え?nullになる可能性なんてあったっけ?と思いますが、それは後で考えましょう。nullでない場合は、そのユーザの情報をDOMに表示します。これでひとつめの支流は終わりです。

でもこれでこのコードの処理の流れすべてがわかったけではありません。このストリームは途中で分岐しているのでした。そちらの支流で値がどう流れるかを追跡します。まず、mergeで合流する部分にいきなり飛ぶのでした。またこの時運ぶ値はnullになります。

.merge(
    refreshClickStream.map(function(){ return null; })
  )

そして次の処理はsubscribeになります。

suggestion1Stream.subscribe(function(suggestion) {
  if (suggestion === null) {
    // hide the first suggestion DOM element
  }
  else {
    // show the first suggestion DOM element
    // and render the data
  }
});

ここでなぜnullで分岐していたのかがわかりました。ストリームが途中で分岐しているため、このsubscribeはボタン一回のクリックについき2回実行され、一方ではnull、もう一方ではランダムに選ばれたユーザが渡ってくるというわけです。また、ここで考えなければならないのが、どちらが先に実行されるかですが、これまでの処理の流れを思い出すと、先に追跡したほうの支流では途中にajaxが挟まっていました。あとで追跡した方は一気に合流した地点まで飛び、すぐさまnullを渡しています。このため、実は後で追跡していたほうが先にsubscribeを実行するのです。

このストリーム版のコードの特徵をまとめてみます。

  • 処理が行われる順序とコードの順序がバラバラ
  • ひとつのストリームの定義が終わるたびに先頭に戻ってストリームの名前を確認、その名前で次の処理を探す、というように上下の往復を何度も繰り返す
  • たかが2箇所で同じ処理を呼び出したいだけなのに、ストリームのマージやstartWithという独特の概念に読み替え無くてはならない
  • 途中に処理の流れの分岐が発生している。しかもajaxで処理が止まっている場合もあって、最後まで読み進めないとどちらがどういう順番で進むのかわからない
  • 分岐したのに結局合流し、しかもsubscribeでわざわざまたifで分岐して処理をわけている2
  • requestStreamresponseStreamsuggestion1Streamとストリームの名前がいろいろ登場し、これらを頭に入れておいて、複数回登場したらそれに気づかないと分岐を見落とす
  • 従来のような多重のコールバックは解消しているものの、その代わりに単一のコールバックが大量に発生している。コールバック地獄の問題の種類が変わっただけで問題がなくなっているわけではない
  • 無名関数式をmapなどのコンビネータで連結しているため、値を計算している部分と、その結果が束縛される変数が、コードの字面の上で何行も離れる
  • 無名関数式を多用するため、処理の本質に関係ない大量の括弧が挟まる
  • そして「ボタンをおした時」を追跡しているのに、ボタンを押した時には関係ない処理がstartWithで度々挟まる
  • 要するに「次の処理」をしたいだけなのに、単純に次の行に次の処理を書くという形にならず、mapflatMapが使って何度もコールバック関数の中に出たり入ったりする
  • combineLatestmergeのようなストリーム特有の操作がことあるごとに挟まり、もしそれを知らなければそこで追跡を中断してドキュメントを参照するはめになる
  • 通常の同期処理のコードとは見た目がかけ離れている

『宣言的』といえそうなのはわかりますし、パラダイムとして従来のコードとは一線を画すものであることは確かですが、どう贔屓目にみてもひたすら読みづらいとしか感じられません。ここでもう一度、先に示したモナド版のほうを見て比較してみます。

set { display: replicate numberOfUsers Nothing, users: [] }   -- まず一覧をクリアする
randomOffset <- liftEff' $ randomInt 0 500                    -- それが終わったら0-500の間の乱数を1個生成する
res <- liftH $ Affjax.get ("https://api.github.com/users?since=" ++ show randomOffset) -- 次にgetでAPIを叩く
let parsed = fromMaybe [] (parse res.response)                -- レスポンスが返ってきら、そのJSONをパース
let display = Just <$> take numberOfUsers parsed              -- 上から3人を取り出す
let users = drop numberOfUsers parsed                         -- 残りの人を取り出す
set { display, users }                                        -- 状態を設定する
  • 処理が行われる順序とコードの順序が一致している
  • ストリームの名前のようなものをたどる必要は一切ない。上から下に一方向に読み進めるだけ
  • 途中に処理の分岐や合流はない
  • 計算の結果は、<-=で、同じ行で変数に束縛される
  • 同じ処理を2箇所から呼びたいなら2箇所からアクションを投げるだけ。事実上アクションを引数にした単なる関数の呼び出しにすぎない。ストリームのマージのような独特の概念は導入されない
  • ラムダ式は使われていない。冗長な大量の括弧はない。多重のコールバックのネストを単一の多数のコールバックに変えて問題をすり替えているのではなく、コールバックが見た目上なくなっており、問題が根本的に解決されている
  • ページ読み込み時の振る舞いを指定するようなコードは混入していない(別のところに書かれている)
  • 「次の処理」は単に次の行に進むだけで、mapflatMapに相当するものは見当たらない(実はflatMapに相当するものとしてbindというものが使われているのですが、構文糖で巧妙に隠されています)
  • combineLatestmergeのような特有の操作はない(liftEff'liftHが挟まることがあるが、これは処理の流れを変更するようなものではなく、単に型の辻褄をあわせるためだけのもの)
  • 非同期処理であるにもかかわらず、同期処理のコードとほとんど見た目がかわらない

さて、両者を比較してこのような特徴があることがわかったわけですが、現在のモナド版のコードをストリームベースのコードへと置き換えるべきでしょうか?これらの特徴を考えると、今のコードをストリーム化しても、問題を持ち込むことはあっても問題を解決することはないと筆者は思います。というかストリーム版のほうが読みづらいことはこんなに説明を加えずともひと目でわかると思うのですが、それでも理由を説明せずに頭ごなしに読みづらいと言うのは良くないと思うので、このように延々と読みづらさの内容を説明することになりました。大変でした。ふぅ。

追記:なお、こんなに頑張って客観的な根拠を説明したのに、コメントした人たちの中にはぜんぜん読んでない人も多かったです……。「好き嫌いの問題だ」とか「モナドでドヤってるのが気に入らない」(?)とか、「コードの規模の問題」とか、ぜんぜん話が噛み合っていない感想を沢山もらいました。長すぎるので、長文が苦手な人の脳内メモリをオーバーフローしてしまったのかもしれません。

ストリームが採用できないその他の理由

どこをどう見てもコードが悪化するとしか考えられないということのほかにも、ストリームの導入には次のような問題があります。

1. 仮想DOMと相性が悪い

ストリームの特徴はともかく、もしストリームを使うとなったらどうなるかを考えます。イベントハンドリングをストリームに置き換えようとしますが、さていつどうやってイベントストリームを作成すればいいのでしょうか。ウィンドウのように最初から最後まで静的に存在するオブジェクトならいつでもイベントストリームを仕掛けることができますが、仮想DOMでは生のDOM要素はいつ作成されるかわからないので、事前に仕掛けるということができないのです。

じゃあどうすればいいのかと思ってReact+Rxという組み合わせやReact+Baconのサンプルコードを探してみると、componentWillMountしたタイミングでイベントストリームを生成しています。これでは、異なるコンポーネント間のストリームをマージするのはもはや絶望的です。それにMountしたりUnmountしたりするたびにストリームを組み替えるんでしょうか。せっかく仮想DOMで生DOMの状態管理から解放されたと思ったら、今度はストリームの状態管理に大わらわです。状態のないところに自分から状態を作り出していくのか……。

それでも仮想DOMでイベントストリームを作ったとして、そしてそのイベントストリームでやることといえば、上記のサンプルコードでは単にクリックに反応するストリームをそのままアイテムを破棄する関数に渡しているだけだったります。

var destroyButtonClick = EventHandler.create();
        destroyButtonClick
            .map(this.getTodo)
            .subscribe(TodoActions.destroy);

ストリームどうしをマージしたり畳み込んだりするからストリームは便利なんだと思いますが、単に関数を呼び出すためだけ、しかも同期的な呼び出しをするのに使っているというのでは何の改良にもなっていないと思います。これは何かを改善するためにストリームを使っているというより、もはやストリームを使うためだけにストリームを使っています。

仮想DOMのelm-htmlとシグナル(Elmの用語ではストリームをシグナルといいます)をうまく融合させているElmはどうでしょうか。例えばこちらのボタンを押すと値がインクリメント/デクリメントするサンプルですが、サンプルの振る舞いを考えれば、+ボタンのシグナルと-ボタンのクリックイベントのシグナルを作成し、それらを+1および-1の定数のシグナルへとマップし、それらをマージして、foldpでたたみ込めば、うまくこの振る舞いをシグナルとして記述できることが想像できます。それで実際にはどうなっているのかというと、

    [ button [ onClick address Decrement ] [ text "-" ]
update action model =
  case action of
    Increment -> model + 1
    Decrement -> model - 1

adressにアクションを投げて、updateで受け取っています。シグナルが使われているかと思いきや、全然使われていないのです。いや、サンプルコードだから難解なシグナルはあえて使っていないのだ、という解釈もあるかもしれませんし、シグナルが使われているサンプルも見てみましょう。

シグナル使い始めたとたん今度は仮想DOM使うの止めちゃうのかよ!いや、elm-htmlは比較的新しいパッケージなので、サンプルコードが追いついていないのかもしれません。それで、シグナルと仮想DOMが両方使われているコードを探しました。ありました。

TODOなので、たとえば、Deleteボタンを押せばそれが押されたというシグナルが発信され……

              , onClick address (Delete todo.id)
      Delete id ->
          { model | tasks = List.filter (\t -> t.id /= id) model.tasks }

updateにアクションが渡るだけなのかよ!結局単にaddressを通じてアクションをupdateに渡す以上のことはしておらず、シグナル特有のマッピングもマージもフィルタリングも何も使われていません。でも探してみるとちゃんとシグナルが使われている部分もあります。

-- wire the entire application together
main : Signal Html
main =
  Signal.map (view actions.address) model


-- manage the model of our application over time
model : Signal Model
model =
  Signal.foldp update initialModel actions.signal

アプリケーションとはつまりモデルをビューに結びつけたものview actions.address <~ modelであるというわけです。なるほど、非常に直感的です。また、モデルの状態は、アクションのシグナルactions.signalを初期値initialModelupdateで畳み込んだものだというわけです。仮想DOMのアクションも時間によって次々と生み出される変化する値だと捉えることができるわけです。これはとても簡潔ですし、時間によって変化する値だというシグナルの性質がよく生かされているコードだと思います。

結局のところ、仮想DOMの外側で何かするときにはシグナルが使えるが、仮想DOMの内側ではあまり使い道がありません。そして仮想DOMの外側でやることはせいぜい決まりきった手順のアプリケーションの初期化だけであり、その部分はシグナルでよく書けるとしても、アプリケーション固有の処理の大半は仮想DOMの内側で行われます。つまり、仮想DOMを使うなら、シグナルはほとんどやることがなさそうです。

2. 非同期処理の煩雑さの問題はすでに非同期処理モナドで解決されている

ストリームを導入する目的のひとつは、どうも非同期処理の煩雑さを軽減することにあるようです。しかしそれはすでに解決された過去の問題であり、それは今更ストリームを使う動機にはなりえません。PureScriptは作用がモナドで抽象化されているのでもともと非同期処理のための下地が整っており、また当然Affのような非同期処理のためのモナドが整備されているため、非同期処理は特に問題になりません。JavaScriptでさえPromiseやジェネレータ関数が実用段階にあり、非同期処理でコールバック地獄に陥るおそれはあまりなくなっています。

筆者の非同期処理モナド版のコードは、非同期処理の複雑さを解決するために何か特殊なアーキテクチャを使っているというわけではありません。使っているUIフレームワークでの通常のコーディング手順に従ったまでで、そこにもはや非同期処理は煩雑だからこうして回避しろなどという問題意識は存在していません。

3. 純粋な言語が苦手な状態管理を補強するわけではない

Elmはかなり特殊な位置づけの言語で、基本的には純粋な言語でありながら、HaskellやPureScriptのようにモナドでの抽象化が導入されておらず、モナド無しで状態を扱わなくてはなりません。そこでシグナルの内部に隠された状態がアプリケーションの状態を表現するのに役に立ちます。

先ほど見た

Signal.foldp update initialModel actions.signal

というようなElmのコードは、実はfoldpが事実上状態を保持しており、これをシグナルに応じて更新していくことで状態を表現しています。このためElmにはシグナルを導入する強い理由があります。

それに対して、Haskell/PureScriptのような汎用の言語では、状態管理をするための非常に直接的な方法が用意されています。筆者が書いた非同期処理モナド版でsetgetで思いっきり状態を読み書きできていたのはそのためです。状態を扱ううまい方法が他に用意されていないのならシグナルは役に立ちますが、普通の言語では状態を扱うためのもっと直接的な方法があるため、状態管理の方法としてのシグナルも導入の動機にはなりえません。

リアクティブプログラミングでないと解決しづらい問題はあるのか

元ネタの記事のサンプルは別にリアクティブプログラミングでないとコーディングが難しいというような問題ではありませんでした。ではリアクティブプログラミングでないと難しい問題ってあるんでしょうか?あれだけ凄い凄いと喧伝されていたリアクティブプログラミングがこんなに役に立たないはずがありません。

そう思って、いろいろググって探してみたところ、こちらの記事でRxのthrottleのような振る舞いは手続き的なコードでは難しいのではないかということで、インクリメンタルサーチのサンプルを実装する例が紹介されていました。つまり、多数の入力があった時に、入力がしばらく止んだときにだけ処理を実行するというような振る舞いです。たしかにこれは直感的にはちょっとややこしいコードになりそうだと思い、これもストリーム無しでうまく解決できるか試してみることにしました。コードは次のようになります。

これを実行すると次のようになります。

さて、このコードがどうなっているかというと、次のような使い方ができる関数throttleを用意しただけです。

modify _ { fetch = OnInput }                 -- 「入力中」の状態に変える
throttle 1000 do                             -- 1秒間入力がなくなるまで以降の処理を待機する
    modify _ { fetch = Fetching str }        -- 1秒間入力がないときだけここに進む
    let url = "http://www.google.com/complete/search?hl=en&jsonp=suggestCallBack&client=youtube&q="
    res <- liftAff' $ jsonp "suggestCallBack" (url ++ encodeURIComponent str)   -- jsonpでリクエストする 
    modify _ { fetch = Complete (parse res) }   -- jsonpが完了したら、その結果をパースして表示する

特別な条件文throttleが導入されたという感じで、別に非同期処理モナドにもthrottleの振る舞いを自然に導入できています。やはりここにも特に困難はありませんでした。

もちろんこれはthrottleが非同期処理モナドでも難しくはないことが示されただけで、実装が難しい機能は他にあるのかもしれません。非同期処理モナドに困難がないことを「証明」するには、Rxの膨大な関数をすべて実装してみせる必要がありますが、さすがにそこまではできませんでした。とはいえ、リアクティブプログラミングでないと難しい問題というのも探してもどうしても見つかりませんでした。これだけ探しても見つからないのでは、そもそもそんな問題はないのではないかという印象を抱きます。

『宣言的』?『命令的』?

ちょっと脱線気味の話になりますが、元ネタの記事にはこんな説明があります。

関数型のスタイルによって、コードは命令的ではなく、より宣言的なものになる。実行する命令列を与えるのではなく、ストリーム間の関係を定義することにより、これは何であるかを伝えるだけで良くなる。

ここでみたようなリアクティブプログラミングって、確かにいわゆる「宣言的」なコードだと思いますが、これって「関数型のスタイル」なんでしょうか?

よく考えると、筆者の書いたのはまるでC言語のように愚直な「命令的」なコードでした。そう考えると、ここでの比較は、特に関数型プログラミング言語だとはいえないJavaScriptで書かれたとても関数型らしいスタイルのコードと、関数型プログラミング言語の極北のような言語であるPureScriptで書かれた関数型らしくないコードを比較しているということになります。これではまるで、魔法使いが剣で戦って戦士が魔法の杖で戦ったらどっちが強いかみたいなあまり意味のない話に思えてしまいます。

これは、別に「宣言的」に書くのが関数型プログラミングというわけではないということだと思います。PureScriptのような純粋関数型言語でさえ、先に見たようなひたすら命令的なコードを普通に書きますし、そういうコードが書きやすいように言語仕様が工夫されています。JavaScriptのような言語でもいくらでも「宣言的」に書くことはできます。リアクティブプログラミングのようなコードは関数型だとか思わないほうがよいように思います。まして、命令的なコードより宣言的なコードのほうがいいなどということは一般には言えないと思いますし、それはさっきのコードの比較でもよくわかります。「宣言的」なコードは往々にして実行される順番の把握が難しく、そして現実のコンピュータはコードをあくまで順番に計算していくものです。闇雲に「宣言的」にすればいいというものではありません。

さてリアクティブプログラミングは重要か

非同期処理モナドで処理が実行される順番で整然と並べられて書かれているコードを、わざわざストリームでめちゃくちゃな順番に書き換えるメリットは何もありませんでした。データバインディングも仮想DOMがすべて綺麗に解決してくれます。しかも仮想DOMとはとことん相性が悪く、ストリームに使い道は見つかりませんでした。残念ながら、リアクティブプログラミングは筆者が当初期待していたようなものではありませんでした。

もっとも、ファイルストリームなどの概念は未だに現役なわけで、もちろんストリームという概念すべてが無意味になったわけではありませんが、UIプログラミングに関して言えば、イベントストリームはすでに使い道がなくなったパラダイムであるように思います。あまり詳しく調べていませんが、HaskellのFRPまわりではシミュレーション用途にストリームを使うというのもあるようで、そういう用途ならストリームの使い道も見つかるのかもしれません。ストリームには状態の計算をキャッシュして計算を効率化するという目的もあるため、大量の計算が必要になるシミュレーション用途では使い道がありそうです。

UIのイベントをストリームとして読み替えることができ、それをマッピングしたりマージしたりフィルタリングしたりすることで計算を表現できるという事実は、とても興味深く感動的です。でも明らかに可読性は低下しています。リアクティブプログラミングは確かに目の覚めるようなパラダイムシフトには違いありませんが、新たなパラダイムに飛びつく前に本当にそれが役に立つのかよく考えたほうがよさそうです。「リアクティブプログラミング」という名前がちょっと格好よすぎるせいで持て囃されている気がしないでもありません。でも、リアクティブプログラミングほどじゃないかもしれませんが、モナディックプログラミングとかいう名前もなかなか格好いいんじゃないんでしょうか。いや格好いいというより禍々しいか……。だからそういうこと言うのやめてください!モナドはこんなにいい子なのに、またモナドの印象が悪くなるじゃないですか!

ストリームの有用性を実証しよう

そうはいっても、まさかあのリアクティブプログラミングが、あのクールでナウくて世界中のセレブの間で人気沸騰中のリアクティブプログラミングがですよ、読みにくくて使い道がないなどということがあるのでしょうか。どうしても腑に落ちない人が多いはずです。というか、もとからリアクティブプログラミングに疑問を抱いていたひとを除けは、大半の人は納得していないはずです。それならストリームを使って、あのフォローボックスのコードを書いてみればいいのです。それで筆者の書いたコードよりわかりやすく、筆者が列挙した弱点のないコードが書ければ、筆者の言っていることは間違いであり、ストリームはやはり有用だと実証できたことになります。なんかツイッターやはてブでは、自分でコードを書いて試すことをしていないのに結論を出しているひとがいっぱいいるのですが、結論を出すなら自分で実際にこのサンプルの実装を試してみてからコメントください。

筆者はストリームの有用性を実証するために実際に自分でコードを書いて確かめてみました(ただしその結果、筆者の当初の期待と予想を裏切って、ストリームはうまい使い道がないという逆の結論に達してしまったわけですが)。同じように、実際にコードを書いてストリームの有用性を自分で実証してみることを強くお勧めします。そのためにはまずはリアクティブプログラミングを知らなければなりませんが、先に述べたようにリアクティブプログラミングの学習には【翻訳】あなたが求めていたリアクティブプログラミング入門がお勧めです。なお、この元ネタの記事の最後にはこうあります。

FRPは特定のアプリケーションや言語に制限されるフレームワークではない。これは実のところ、イベント駆動なソフトウェアのプログラミングならどこでも使えるパラダイムなのだ。

リアクティブプログラミングはどの言語でも、どのフレームワークでも使うことができるのです。ですから自分の好きな言語で挑戦してみると良いでしょう(筆者もそうしました)3。Rx*なんかは多数の言語に移植されているので、きっとあなたの好きな言語でもすぐに使えるはずです。

【追記1】規模の問題?

「100行の小さなコードではリアクティブプログラミングのメリットはわからない」と考えるひともいたようです。一説によると、リアクティブプログラミングのメリットが発揮されるようになるのは2000行からなんだそうです(やけに具体的な数字だ!千行でも1万行でもないあたりに拘りが見える)。リアクティブプログラミングは非同期処理やイベントハンドリング、データバインディングの煩雑さを解決するものだと言われることが多いと思うのですが(私もそう思っていますが)、その人の主張はよく言われているものとは異なっていて、リアクティブプログラミングは大規模なコードの全体像の把握の難しさを解決するものなのだそうです。あれっ?そういう話でしたっけ?どうもリアクティブプログラミングは何を解決するものなのかという根本的な点に大きなすれ違いがあるようです。

まず、「あなたが求めていたリアクティブプログラミング入門」の筆者や私がたった100行のサンプルコードでリアクティブプログラミングについて検討しているのは、リアクティブプログラミングが解決するとされている非同期処理やデータバインディングの問題の兆候はたった100行のコードからも読み取れるからです。もちろんたった100行のコードですから、そこに問題の兆候が現れたとしても、それが直ちに手に負えないような致命的な問題だということはないでしょう。でももっとコードが大規模になりその小さな問題が積み重なっていけば、やがて手に負えない問題になるのは想像がつきます。どうもその人は100行のサンプルコードからは100行のコードのことしかわからないと思っているようなのですが、100行のサンプルコードは2000行の本番コードがどうなるかを想像できるように問題点を抽出して書かれるものです。その100行のサンプルコードに現れる兆候を読み落とすことなく、それがやがてどんな問題に発展するかまでを見通してサンプルコードを読むべきだと思います。

それに、コードの規模が大きくなるに連れて全体を把握するのが難しくなるのは確かにその通りですが、それは非同期処理やデータバインディングとはまた別の問題です。コードが大きくなって把握が難しくなったら、処理を複数の関数に分割したり、その関数やデータ型をモジュールに分割することでコントロールするという対処が普通でしょう。そこにリアクティブプログラミングが関係してくる理由がよくわかりません。また、そのようなコードの分割と合成は当然モナディックな関数でも可能で、そこにリアクティブプログラミングが特別有利だという理由は見当たりません。とにかく、そのメリットの具体的な内容について説明がないことにはどうにもなりません。

「リアクティブプログラミングのメリットは規模が大きいコードでなければ発揮されない」という主張は、リアクティブプログラミングの具体的なメリットの説明を拒むための都合のいい言い訳にしか見えません。たしかにそれならリアクティブプログラミングのメリットについて議論することは不可能ですから、そのメリットが否定されることはなくなりリアクティブプログラミングは安泰です。ただし議論不可能なのでメリットが肯定されることもないでしょう(ちなみにデメリットについてはこのテキストで議論したように明らかに存在します)。

メリットが議論不可能なパラダイムなんて絵に描いた餅も同然です。小さなサンプルコードとともに具体的な根拠をもって説明してくれるのなら、私もリアクティブプログラミングに興味を持つでしょうし、大規模なコードでないとわからないからと具体的な説明を拒むのなら、私はリアクティブプログラミングに対する興味を失うでしょう。幸い私はすでにメリットをちゃんと論じることができる優れた代替手段を見つけているので、もう私は別にどちらでもよいと思っていますが。

  • リアクティブプログラミングとはスケールの問題に対処するものだ、という主張をしている人はあまり見たことがない。もちろん元ネタの記事でもそのような言及はない。私は前提を噛み合わせるために元ネタの記事を読むことを薦めたのですが、その人は元ネタの記事を読んでいないのか、そもそも前提からして噛み合っていない
  • たった100行のサンプルコードにすぎないのにすでに複雑さで大きな差がついており、この差が2000行を超えると逆転すると考えるだけの理由が見当たらない
  • リアクティブプログラミングのほうは、あまりに複雑すぎて実際にリファクタリングに失敗した人がいる。確実な根拠ではないのであまり個人の経験という主観的なものを議論に持ち込みたくはないが、もし2000行という個人的な経験まで議論の壇上に持ち出すなら、この記事で実際に起こったこのトラブルはそれ以上の経験による裏打ちになるはず
  • 2000行という数字にも、その人自身の経験しか根拠がないので、第三者である私には到底納得できるものではない(なお、私の記事は実際のコードという客観的に検証可能な根拠を元に議論しています)

【追記2】ElmからSignalが削除されました

かつてElmといえば、公式サイトのトップで "Elm is a functional reactive programming language" と掲げられるくらいでリアクティブプログラミングを中核に据えたプログラミング言語として始まったのですが、そのElmからSignalが削除されたようです。この記事で私は、

仮想DOMを使うなら、シグナルはほとんどやることがなさそうです。

というように書いたのですが、関数型リアクティブプログラミングにさよならをによれば、

Elmアーキテクチャが現れた時、SignalはElmでのプログラミングにおいて全く必要のないものだと明確になりました。

とのことで、ElmでもやはりSignalは不要という結論に達したようです。Elmさえリアクティブプログラミングを捨て、Elmの公式サイトトップページからは "Reactive" の単語は消えました。本当にありがとうございました。

【追記3】カウンターエントリを頂いたのですが、プログラムに誤りがあります

このエントリに対して、uehajさんより「リアクティブプログラミングが読み難い」というのは本当なのか?という検証記事を書いていただいたのですが、その記事で書きなおされたコードに誤りがあります。元記事の『あなたが求めていたリアクティブプログラミング入門』には次のような記述があります。

今更新ボタンについて考えてみると、問題がある。それは'更新'ボタンをクリックしてすぐには、現在の3つの候補が消えないことだ。新しい候補はレスポンスが到着してからやって来る。しかしUIを良い感じにするためには、現在の候補を更新が押された時に消す必要がある。

この振る舞いについては、私も記事の中で説明しています。

まず一行目、

set { display: replicate numberOfUsers Nothing, users: [] }

これはset関数を呼んで仮想DOMの状態を設定しています。次の状態を引数として与えているだけで、これで状態が更新されて勝手に再描画が走ります。ここではユーザ一覧を非表示にするために、状態として[null, null, null]みたいな意味の[Nothing, Nothing, Nothing]を設定しています。ここであえてユーザ一覧の表示を消しているのは、AJAXのような時間のかかる処理の前にユーザの操作に即座に反応して表示を変えることでユーザビリティを向上するためです。これは元の記事のコードの振る舞いと同じです。

この振る舞いがコードを更に複雑にしている一因なのですが、書きなおされたコードではこの振る舞いが丸ごと削除されてしまっています。書きなおされたコードが『読みやすい』のは、必要な要件を満たしていないからです。頂いたエントリには次のような記述もあります。

いくつか不要な処理が混入していることに気付きました。具体的にはmergeの呼び出しや、startwithを2回呼び出していることなどです。ステップバイステップで改良しつつ説明していくときに、古いステップで必要だったが、処理を追加することで不要になったものを削除しわすれてる、って気がします。
この結果、 refreshClickStreamが2箇所で使用され、最後に合流していくように見え、なんだこりゃ、と思えるようになっていました。でもそれで動くっていうのもある意味すごい。

それは先述の振る舞いを実現するためのもので、不要な処理ではありません。どうやらRxのコードがあまりに複雑すぎて、その部分が何のためのコードなのかuehajさんにもわからなかったようです。でも、それはこれだけ複雑で読みにくいコードなのですから、仕方のないことだとは思います。リアクティブプログラミングの読みづらさがすべて悪いのでしょう。コードを書いた本人がミスに気付かなかったのは仕方ないとはいえ、はてブやツイッターの反応を見る限り、この不具合に気付いた人は一人もいなかったようで、これだけ字数を費やして説明したのに記事の内容をよく理解していない人が多いのは残念に思いました。

【追記4】このエントリに他意はありません

このエントリで説明していることは、先述のリアクティブプログラミング入門を自分で実践してみたが改良にはならなかった、ということだけです。それ以外に意図はありません。

hirokidaichi これわかっててあえてやってるんだろうけど、逆効果にならないことを願うよ。

もしかしたら、リアクティブプログラミングをダシにして関数型プログラミングや仮想DOMの宣伝をしているなどととらえたのかもしれませんが、そのような意図はありませんし、「これわかっててあえてやってるんだろうけど」などとこちらの思惑を事実と異なる形で想像されても困惑しかありません。hirokidaichiさんはご自身でもリアクティブプログラミングについての解説をしているくらい造詣が深いようなので、リアクティブプログラミングについてもっと技術的な観点からコメントを頂けたら嬉しかったのですが、頂けたのがこのような技術と関係がないコメントだったので残念に思いました。

この方を始めとして、どうもこの記事を技術的なマウンティングだと捉えたひとがいるようなのですが、この記事にマウンティングの意図はもちろんありませんし、私にはこの記事をどう捉えればマウンティングだと受け取れるのかよくわかりませんでした。私は道具を2つ並べて比較しただけで、例えるならネジを締めるという作業のためにドライバーとハンマーのどっちが向いているのかを検討してみた、くらいの感傷しか持っていません。その内容に対して「ハンマーをディスっている」とか「ドライバーを振りかざしてドヤっている」とか「ドライバーの宣伝のために敢えて挑戦的なことを言っているのだろうけど、逆効果にならないことを願うよ」みたいなことを言われても、私にとっては意味不明です。

道具を比較して自分が使う道具を選定することは、必要なことだと思います。技術の選定を行うために道具の長所短所を洗い出すという当たり前の手順に対して「それはマウンティングだ」などという指摘を差し挟むことが、技術者としてふさわしい態度だとはとても思えません。

【追記5】この記事の階級は無差別級です

terurou JavaScriptに非同期モナドが存在しないというのを無視してぶん殴ってる。async/awaitがあってもRxが流行ってる世界の人達はどう反応するんだろ。

確かに筆者はJavaScriptに非同期モナドが存在しないというのを無視してぶん殴っています。でも、なぜJavaScriptに非同期処理モナドがないことを考慮する必要があるんでしょうか。アプリケーションを開発するという目的がなるべく簡単に達成できればどんな言語を使ってもいいと思いますし、JavaScriptにこだわる必要はないと思うので、非同期処理モナドがないJavaScriptのような古臭い言語をぶん殴るのに遠慮は要らないと思っています。そのJavaScriptさえasync/awaitが導入されて、もはや非同期処理でRxのような複雑なコードを書く理由が見当たりません。

「JavaScriptに非同期モナドが存在しないというのを考慮する」というのは、「JavaScriptには非同期処理モナドがないので、それにあわせてPureScriptでも非同期処理モナドを使わずに実装して比較するべき」ということなのでしょうか。まるで、東京から京都まで旅行する手段として電車と自転車のどちらがいいのかを比較するときに、自転車には電線とモーターがないので電車はそれに合わせて電力を使わずに比較するべき、というような話です。そのような比較に何の意味があるんでしょうか。

もちろん、どうしてもJavaScriptを使わなければならない、しかもasync/awaitも使えない、という縛りがあるなら、rxjsも使わないよりはずっとマシだとは思います。でも元記事では「JavaScriptを使わなければならずasync/awaitも仮想DOMも使えない時に限った話です」なんて書いてありませんでしたから、仮想DOMとモナドでぶん殴られるはめになったわけです。「rxjsを仮想DOMやモナドで殴ってはいけません!死んじゃいます!」って注意書きがしてあれば、こんなふうに殴られることはなかったと思います。

というか、ぶん殴るつもりはなかったのですが、本文中で述べたとおり、あとでシグナルを使って改良するつもりで一番最初に思いついた方法で当該のアプリケーションを粗雑に実装してみたところ、その粗雑な実装がシグナルよりはるかにわかりやすかったというだけのことです。超強いという噂の主人公を引き立てるために適当に噛ませ犬を用意したところ、噛ませ犬が一撃で主人公を叩き潰してしまって、あれ?主人公弱くね?って呆然としてしまった感じです。async/awaitや仮想DOMがあるのにRxを使っている人たちの反応は、私も訊いてみたいです。

【追記6】好き嫌いの話ではないです

これを筆者個人の好き嫌いの話だと捉えたひとも一部にいるようなのですが、まったく違います。もちろん元ネタの記事も自分の好みでRxを勧めているわけではありませんし、私も好き嫌いでRPの使い道がないと言っているわけではありません。本来は単に一行づつ逐次的に書けるコードが、リアクティブプログラミングではコード中を飛び回ったり分岐したりという読みにくいスパゲッティになるという、客観的に検証可能な根拠を説明しています。単なる好き嫌いの話ではないことを説明するために、わざわざこれだけの分量を割いてこの節で延々と説明をしたのですが、それでもこれを個人の好き嫌いの話だと捉える人が一部にいて、話が伝わっていないことには残念です。もちろん大半の人はそのような曲解はしてはいないのですが、これだけ説明してもなお個人の好き嫌いの話に押し込めてしまうひとがごく一部に見受けられたので、念の為に付け加えておきます。

rxjsが難しくて理解できなかったから使いたくない、なんていう話でもありません。たしかにrxjsは難しいですが、それならモナドだって十分難しい概念です。問題は、rxは習得が難しいばかりではなく、たとえ使いこなしたとしても決してコードは読みやすくはない、ということです。習得が難しくてもモナドを使えればコードがとても読みやすくなるのとは対照的です。覚えるのが難しくて覚えても難しい方法と、覚えるのは難しいが一旦覚えればわかりやすい方法、どちらを選ぶのが得策か、言うまでもありません。

別実装ください

ところで、この記事のお題(フォローのおすすめ表示)って、ユーザインターフェイスのプログラミングのお題としてすごく良いと思うんです。TodoMVCとかだと非同期処理がないのでちょっと簡単すぎて、サンプルプログラムとしてはあまりに現実離れしている気がします。それに対して、このFollow suggestion boxのお題は結構厄介な非同期処理が入り込んでいて、下手な書き方をすると一気にスパゲッティ化する難しさがありながら、その割には仕様が巨大すぎないので、UI+非同期処理のお題として最適だと思います。別にRxじゃなくていいし、どんな言語でもフレームワークでもいいと思うんですが、こんなふうに書けたよーっていうのがあったらぜひ教えてください。特にReact/Redux/Sagaなんかはわりとメジャーらしいので、その辺が得意な人が実装してみせてくれたりしたら嬉しいです。もちろんvueでもriotでもemberでもReactNativeでもなんでもおkです。仕様については元ネタの記事に準拠するということでお願いします。

まとめ

少なくとも、ユーザインターフェイスについてはリアクティブプログラミングの役目は終わったと思います

普通に命令的に書いたほうが楽

参考文献



  1. 『インポート文を除けば』というのは、PureScriptはめちゃくちゃモジュールが細かいのでインポート文の量が尋常ではないからです。インポートを含めれば、コードは倍近くになるんじゃないでしょうか。 

  2. よく考えたらこの指摘はちょっとおかしい部分がありました。このsubscribeの内部の処理は、仮想DOM版でいうところのレンダリング関数に相当するので、仮想DOM版にも同様の分岐は存在します。「subscribeで再び分岐する」というのは両者の差の指摘になっていないので訂正します。ストリーム版では状態更新とレンダリングがコードの見た目上分離されていないので、うっかりその部分まで含めて考えてしまっていました。 

  3. 機能が異なる言語やフレームワークを同列に比較するのはおかしいというような感想を抱いたひともいるようですが、それは動力源が違うから蒸気機関車と電車は比較してはいけないなどと言うくらいおかしいです。旅客や荷物を運ぶというような一致した目的さえあれば、それを実現するあらゆる手段が候補であり、比較の対象になりえます。一方は蒸気機関、もう一方は電気モーターだから差は歴然、それをあえて比べるの可哀想なんていうのはおかしいですし、PureScriptにはモナドがあってJavaScriptにはないという違いがあるのに同列に比較するのはおかしい、と思うほうがおかしいと思います。道具はそれぞれ機能や性能が異なるのは当然で、それがその道具の実力であり、各自がその実力を発揮してて同じ目的を達するために比較に臨むことが、不公平や不合理であるなどとは思えません。また、筆者が行っている比較はこき下ろすなどという感情に満ちたものではなく、淡白に事実を列挙しているだけのことです。ちょっと語数が多いのでそういう容赦無い言動に見えるのかもしれませんが。Elmちゃんは可愛いと思っています。