女子力botをもっとイケてるClojureっぽく

(このエントリは、しょぼちむアドベントカレンダー 15日目の記事です)

はじめに

Clojureアイドルことしょぼちむさんが先日開催されたClojure夜会で、Clojureで書かれた女子力botなるものについて発表していました。Clojure歴3週間とのことでしたが、そうとは思えないほどよく書けています。しかし、Clojureのidiomaticな書き方の中には、時間を書けて慣れていかないとなかなかすんなり書くことが難しいものもあります。

そこで、今回は女子力botを題材によりClojureっぽいイケてる書き方を紹介します。

書き換えてみよう

今回ピックアップする項目は以下のとおりです。

  • clojure-contribは使わないようにしよう
  • シーケンス処理に持ち込もう
  • *-in関数を使おう
  • 状態をloopで持ち回そう
  • 参照を公開しないようにしよう

(ここで取り上げていないすべての変更を含めた最終版はこちらから確認できます。)
それでは順番に見て行きましょう。

clojure-contribは使わないようにしよう

まず最初に注目するのはこちら。

core.clj

(ns jyoshiryoku-bot.core
  (:import [twitter4j TwitterFactory Twitter Paging])
  (:require [clojure.contrib.str-utils :as str-utils]
            [clojure.java.io :as io]
            [jyoshiryoku-bot.kaiseki :as kaiseki])
  (:gen-class))

core.clj

(str-utils/re-sub #"(@.*?\s)+" "" (.getText mention))

3行目でclojure.contrib.str-utilsがrequireされています。clojure.contrib.str-utilsというのは、いわゆるレガシーcontribあるいはモノリシックcontribと呼ばれるライブラリの一部です。
レガシーcontribというのは、Clojure contribライブラリの過去の形態で、Clojure標準には含まれない準標準的な機能をひとまとまりのJARとして配布していたものです。レガシーcontribはすでにdeprecatedな扱いになっていて、保守もされていません。
レガシーcontribの多くの機能はClojure標準に取り入れられているか、同様の機能を提供するモジュラーcontribが用意されているので、特別な理由がなければそれらを使うようにすれば問題ありません。ここで使われているstr-utilsも、多くの機能が現在のClojure標準におけるclojure.stringから提供されていて、str-utils/re-subにはclojure.string/replaceが相当します。
「レガシーcontribのこのネームスペースに相当する機能はどこに行ったんだろう?」と疑問に思った場合にはこちらのドキュメントが役に立つかもしれません→Where Did Clojure.Contrib Go

*-in関数を使おう

つづいて注目するのは次のコードです。

kaiseki.clj

(defn inc-map-value [m k]
  (if (get m k)
    (update-in m [k] inc)
    (assoc m k 1)))

(defn register-word [m word1 word2]
  (let [word2-map (get m word1 {})]
    (assoc m word1 (inc-map-value word2-map word2))))

この関数register-wordは以下のように使います。

user=> (register-word {} "hoge" "fuga")
{"hoge" {"fuga" 1}}
user=> (register-word *1 "hoge" "fuga")
{"hoge" {"fuga" 2}}
user=> (register-word *1 "hoge" "piyo")
{"hoge" {"piyo" 1, "fuga" 2}}
user=> (register-word *1 "fuga" "piyo")
{"fuga" {"piyo" 1}, "hoge" {"piyo" 1, "fuga" 2}}
user=> 

register-wordは、ネストしたマップを使って、2つの単語が連続して出現する回数を管理します。Clojureではこのように、ネストしたマップやベクタを非破壊的に更新していくような使い方をよくします。
ネストを辿るたびにgetやassocを繰り返すのは煩雑です。そこで、Clojureはこういったネストしたデータを扱うのに便利な関数(get-in/assoc-in/update-in)を準備しています。

; ネストしたマップからキーを引く
user=> (get-in {:hoge {:fuga 42}} [:hoge :fuga])
42
; ネストしたマップに値をassocする
user=> (assoc-in {:hoge {:fuga 42}} [:hoge :piyo] 101)
{:hoge {:piyo 101, :fuga 42}}
; ネストしたマップの値を関数で更新する
user=> (update-in {:hoge {:fuga 42}} [:hoge :fuga] inc)
{:hoge {:fuga 43}}
user=> 

さきほどのregister-wordはupdate-inを使うと次のように書くことができます。

(defn register-word [m word1 word2]
  (update-in m [word1 word2] (fnil inc 0)))

シーケンス処理に持ち込もう

次はこちら。

kaiseki.clj

(defn load-text [file-name]
  (let [text (slurp file-name)
        tokens (tokenize text)]
    (reduce (fn [m [token1 token2]]
              (let [word1 (token-word token1)
                    word2 (token-word token2)]
                (if (= word1 "")
                  m
                  (register-word m word1 word2))))
            {} (partition 2 1 tokens))))

reduceに引数として渡しているfnの中身がやや複雑です。ここでやっているのは、

  • 隣り合う2つのトークンに対してtoken-word(空白の除去等をする)を適用し、
  • 最初のトークンが空でなければregister-wordで2つのトークンを登録する。それ以外の場合は何もしない

といった処理です。

これを整理してみると、以下のことが分かります。

  • token-wordはすべてのトークンに対して適用しているので、fnの中ではなくてreduceに渡すシーケンスにあらかじめmapしておけばいい
  • 隣り合うトークンの組からなるシーケンスから、最初のトークンが空の組をフィルターしておけばfnの中で条件分岐しなくて済む

このようにfnの中の処理を整理すると、複雑だった処理がより単純なシーケンス処理の組み合わせとして書き直すことができます。Clojureはシーケンスに対する便利な関数を非常にたくさん持っているので、複雑に絡んだ処理もシーケンス処理に持ち込むことで、組込み関数の組み合わせで書くことができるようになるかもしれません。
load-text関数を上の考えのしたがって書き直すとこのようになります。

(defn load-text [filename]
  (->> (tokenize (slurp filename))
       (map token-word)
       (partition 2 1)
       (remove #(= (first %) ""))
       (reduce #(apply register-word %1 %2) {})))

適宜スレッディングマクロ(->>)を使うことで、処理の見通しがさらによくなり、バグを埋め込みにくくなります。

状態をloopで持ち回そう

さらにこちら。

core.clj

(let [info (atom (mentionInfo))]
  (while true
    (if-not (= @info (mentionInfo))
      (do (reset! info (mentionInfo))
          (tweettimeline (str ".@" (:userName @info) " "
                              (kaiseki/create-sentence @kaiseki/*words* (searchword))))))
    (Thread/sleep (* 1000 60 2))))

atomを使って、繰り返しごとに変化する状態を管理しています。
よりidiomaticなClojureコードでは、繰り返しで変化する値をループで持ち回すことで状態を管理します。

(loop [old (mentionInfo)]
  (let [new (mentionInfo)]
    (when-not (= old new)
      (let [sentence (kaiseki/create-sentence @kaiseki/*words* (searchword))
            message (format ".@%s %s" (:userName info) sentence)]
        (tweettimeline message)))
      (Thread/sleep (* 1000 60 2))
      (recur new))))

ループで書くとコードが著しく複雑になるようなケースを除けば、上のように非破壊的に状態を更新していくやり方の方が好まれます。

参照を公開しないようにしよう

最後はやや設計に絡む話。

kaiseki.clj

(defn init [tweettxt]
  (dosync
    (ref-set *words*
             (load-text tweettxt))))

initの中では、ref型の*words*にロードしたデータを代入するようになっています。
これを利用する側は、initを呼び出した後に*words*を直接参照します。

core.clj

(defn -main []
  ...
  (kaiseki/init "tweet.txt")
  ...
  (kaiseki/create-sentence @kaiseki/*words* (searchword))))))
  ...)

Clojureには破壊的に変更できるatomやrefといった参照型が用意されています。参照型を使う場合には、「それがどこで変更されうるか」に気をつける必要があります。参照型を外部へ公開すると、外部で何の制約もなしに参照を変更される可能性があります。そのため、参照型をそのまま公開するのは極力避けた方がいいでしょう。

今回の場合には、initでロードしたデータを*words*に代入するのではなく、load-text関数をパブリックなインタフェースとし、ロードしたデータを保持することは利用する側の責任とすることで、参照型を公開するのを避けることができます。

(let [words (kaiseki/load-text "tweet.txt")]
  ...
  (kaiseki/create-sentence words (searchword))
  ...)

おわりに

今回は女子力botを題材によりClojureっぽい書き方について紹介しました。
もっと時間をかけて「じっくりとClojureのidiomaticな書き方を知りたい!」という場合は、Clojureの本に当たることをおすすめします。

これらの本は、Clojureらしい書き方を教えてくれるだけでなく、「なぜそう書くのか」についてもきちんと説明されているのでオススメです。