Code Aquarium

minazoko's blog -*- 水底のブログ -*-

(Clojure)when-let と some->>

タイトルを見て何のことか想像つく人には多分つまらない話です。ついでに nil と seq というキーワードも追加します。おそらく想像通りの内容です。あしからず。

少し長い前置き

前のエントリで逆引き天気予報のプログラムを書きましたが、書いていて何か違和感を感じていました。この部分です。

(defn report*
  [re id]
  (let [rss (rss-zip (city-url id))]
    (when-let [fcs (-> (search-forecast re rss) seq)]
      (let [city (title-city rss)]
        (->> fcs (map (fn [fc]
                        {:forecast fc
                         :city city
                         :id id})))))))

解説します。
reが予報の検索条件で正規表現のpatternオブジェクトを渡します。idは地域を表す番号です。このへんはどうでもいいです。
最初のletで束縛するrssは、Webから取得したRSSデータです。そのRSSデータから search-forecastで条件 re に該当するものを抽出して fcs を束縛します。fcs は when-let による束縛なので値が「真」の場合のみ内側の式が評価されます。「偽」ならば内側は評価されずwhen-letの値、延いてはreport*の値が nil になります。

さて、ここにClojureならではの小細工があります。この部分。

    (when-let [fcs (-> (search-forecast re rss) seq)]
      (->> fcs expr ...))

search-forecastは条件に合ったすべての予報をコレクションで返します。この返却されたコレクションが空か空でないかをwhen-letで判定したいのですが、Clojureでは「空のコレクション」はBooleanの文脈で「真」と評価されてまうんです。それじゃあ困る。「偽」になってくれないと内側の式を実行してしまう。
そこで登場するのが seq です。seq はコレクションをシーケンスに変換する関数ですが、空のコレクションに適用すると nil を返すという特性を持っています。nilならば偽値なので、めでたくwhen-letが期待どおりの動作、つまり「空の場合内側をスキップ」してくれる、という訳です。

when-letをsome->>で書き換える

when-letは対象の真偽値によって後続の処理を取捨選択します。Clojure1.5でこれとよく似た役割を持ったマクロが追加されました。some-> と some->> です。some->>を使って上記when-letのコードを書き替えると...

    (some->> (search-forecast re rss) seq
             expr ...)

スッキリしましたね。束縛 fcs が消えました。
some->> はマクロ ->> と同様に式を順に評価してゆき、評価値がnilになるとそれ以降の式は評価せず、最終的に nil を返します。この例では、search-forecast が空のコレクションを返し、seqによって nil となったところで評価が終わります。最初に書いた when-let に期待する動作とほぼ同じ挙動です。
when-let に対する違和感の正体はこれでした。こっちの方がスッキリ書けたんです。

(title-city rss)の置き場所

もう一度最初のコードのwhen-let以降を見てみます。

    (when-let [fcs (-> (search-forecast re rss) seq)]
      (let [city (title-city rss)]
        (->> fcs (map (fn [fc]
                        {:forecast fc
                         :city city
                         :id id})))))

先ほど書いたコードではわざと無視しましたが、実はこのプログラムではwhen-let と (->> fcs expr ..) の間に let があります。
title-city は rss から唯一の値 city を取り出す関数です。cityは予報対象の都市(地域名)です*1。コレクションfcsは map によって「【3つのキーを持つマップコレクション】を要素とするシーケンス」に変換されます。その「マップコレクション」に city を含めています。rssにおいてcityは唯一の値なので、title-cityは一度評価すればよく、以降生成するマップコレクションには同じcityを渡してゆけばよいことになります。

では、これを some->> で書きなおす場合、cityはどこで取得すればよいのでしょうか?
分かりやすいのはこうでしょう。

    (let [city (title-city rss)]
      (some->> (search-forecast re rss) seq
               (map (fn [fc]
                      {:forecast fc 
                       :city city
                       :id id}))))

これは when-let のコードと同じではありません。city取得の式を外側に移動したため、search-forecastの返却値が空であってもtitle-cityが評価されます。返却値が空の場合 city は不要なので無駄になります。
じゃあどこに置いたらいいのか考えたんですが、結局こうなりました。

    (some->> (search-forecast re rss) seq
             (map (fn [city fc]
                    {:forecast fc 
                     :city city
                     :id id})
                  (repeat (title-city rss))))

うわっ、微妙。実に微妙。
可読性というのは人それぞれですが、これを分かりやすいかと聞かれたら個人的にはギリギリセーフくらいかなぁと思います。微妙だ。
でも嫌いじゃない。

おわりに

しょうもない些細なことをうだうだと書いてみました。
パフォーマンスを追及しているわけでもガイドラインを提案しているわけでもなく、何となく持っているこだわりみたいな物ですかね。
これもまたClojureならではのお話かと。

追記

だったらもうこれでいい気がしました。

(defn report*
  [re id]
  (let [rss (-> id city-url rss-zip)]
    (map (fn [city fc] {:forecast fc :city city :id id})
         (repeat (title-city rss))
         (search-forecast re rss))))

*1:ここで扱っている天気予報のRSSは10日間予報です。そのため、対象都市1つに対して複数の予報が存在しています