すなぶろ

Pythonistaを目指しているつもりが、Hyのおかげで無事脱線。Clojurian目指すことになりました。

Clojure 入門者による【チャットボットづくり】 Part1

最近 Clojure にハマっています。JVM 上で動くバイトコードコンパイル可能な Lisp 族で、 なんか書いてて楽しい です。とりあえず作者である Rich Hickey のアツい一言をご覧ください(参考1参考2)。

コンパイラ相手に「型が合っています」と言わせて満足するのがプログラマーなのか? 違うだろ? あくまでもユーザーの要望に応えるのが仕事じゃないか。『ユーザーが意図・期待するものを作り出す』のが本質なわけで、少なくとも俺は顧客に「型チェックが通りませんよ」なんて言われたことはない。


静的型システムそのものは面白いと思うよ。ただ単純に俺から見ると「それはプログラミングそのものではないよね」ってこと。(バートランド・ラッセルの言い分は)要するに「コンピュータ科学分野において数学の地位が向上している」という発言であって、それ以外の何物でもない。仮にペースメーカーの内蔵ソフトウェアが型安全だからといって、ペースメーカーそのものが安全とは限らないだろ? 数学という分野から逸脱してしまうから安全だと断言できない。それが、静的型システムはプログラミングではないと思う根拠だ。


コンパイルが通ったからたぶん動く」という言葉は真実だ。C++でも Haskell でも。ただしそれは「満足の行くプログラムが書けた」とイコールにはならない。解くべきは型パズルではなく、現実問題なのだから。


そもそも Maybe 型ってなんだよ。社会保障番号を表現するのは String であって、Maybe String であるわけがないだろ。したがって、Maybe なんて一銭の得にもならない。これ系のことをさんざん考えさせられた結果、俺は「静的型システムはアンチパターンである」という結論に至った。

もうね…… まるでテロリストですよ。 静的型付け言語(というか主に Haskell)に真っ向から喧嘩を売るなんて私には恐ろしくてとてもできませんが、こういう人だからこそ Clojure は魅力的な言語になっているのかもしれません。一応宣言しておくと、私は Maybe 型を初めて見たとき「見事な解法だなぁ」と思ったクチです。でも Rich Hickey の言うことにも一理あると思います。言語開発者って、すごい。

というわけで、そんな Clojure を使って以前 Python で書いたチャットボット sandmark/unmo を再実装してみました。 勉強用なので間違った説明がある危険があります。ツッコミ歓迎です。 完全なソースコードsandmark/unmo-clojure にあります。


目次

準備

  • Leiningen 2.8.1
  • Clojure 1.10.0
    • 1.8 でもいいんだけど、見やすくなったエラーメッセージを試したかった
  • Spacemacs (develop)
  • bigml/sampling
    • 思考エンジンの選択に使うサンプリングライブラリ。統計とかそういう分野で使うっぽいけど、他に確率を指定してランダム選択するものが見つからなかった…
  • fipp
    • 辞書ファイルの pprint に使う。組み込みの pprint は動作が遅かった
  • Sudachi
    • Java で実装された形態素解析ライブラリで、Clojure から直接 Java のクラスを呼び出して使う。こういう力技がまかり通るのがよい感じ

まずはプロジェクトを作って、 project.clj の編集:

$ lein new app unmo
$ cd unmo/
$ edit project.clj
(defproject unmo "0.1.0-SNAPSHOT"
  :description "A japanese legacy chatbot"
  :url "https://github.com/sandmark/unmo-clojure/"
  :license {:name "Eclipse Public License"
            :url "http://www.eclipse.org/legal/epl-v10.html"}
  :repositories [["Sonatype" "https://oss.sonatype.org/content/repositories/snapshots"]]
  :dependencies [[org.clojure/clojure "1.10.0"]
                 [bigml/sampling "3.2"]
                 [fipp "0.6.14"]
                 [com.worksap.nlp/sudachi "0.1.1-SNAPSHOT"]]
  :main ^:skip-aot unmo.core
  :target-path "target/%s"
  :profiles {:uberjar {:aot :all}})

あとは lein repl するか、Spacemacs 上で project.clj を開いて M-RET ' して cider-jack-in コマンドを呼び出します。Lisp 系の言語は REPL 駆動開発 が一般的で、エディタだけで完結できる後者をおすすめします。

respond 関数を作る

Python 版では、5つの思考エンジンを5つのクラスとして実装していました。Clojure では「特に Java と相互運用する必要性がない限り、クラスやそのインスタンスを生成するメリットはない」くらいの意気込みで良いみたいです。実際にはクラスを実装する手段が5つもあるのですが、いずれも Java との互換性を確保するためのものであったり、低レベルな処理で使うもの。これには理由があって、Clojure のスタンスはこんな感じ。

オブジェクト指向は「クラス」という名の新しいデータ型をわざわざ定義させるくせに、そのインスタンスの扱いがお世辞にも得意とは言えない。オブジェクトが数万格納されているコレクションの走査やコピーには大きなオーバーヘッドが発生する。オブジェクトそのもののサイズが可変である上、メソッドの実装によってはもっとひどいことになる。

Clojure は「10 種類の型を扱う 10 個の命令よりも、1 種類の型を扱う 100 個の命令があるほうがはるかに良い」という格言通り、基本的にユーザーに新しいデータ型を定義させない。そのかわり、高機能かつ爆速なデータ型を用意してあるからそれを使え。後方互換性を維持し、アップデートでさらに便利で速いものになる(予定)。

というわけでクラスではなく関数を作ります。 unmo/src/unmo/core.clj にこう書きます。

(ns unmo.core
  (:gen-class))

;; 引数によって動作を変えるマルチメソッドを定義する
;; この場合、与えられた Map の :responder の値で分岐する
(defmulti respond :responder)

;; :responder :what を指定されたときの挙動
;; 入力(:input)の末尾に "ってなに?" と付加し、
;; その結果を :response キーに設定して返す
(defmethod respond :what [response]
  (assoc response :response (str (:input response) "ってなに?")))

;; :responder の値が指定されなかった、または実装がないときの挙動
;; 定義していない responder が指定されたときは「存在しない」という例外を投げ、
;; 指定されなかったときは「指定しろ」という例外を投げる。
;;   -> let は左辺 responder に右辺 (:responder response) を束縛する式
;;   -> Clojure では Java の例外クラスを流用するのが定番らしい
(detmethod respond :default [response]
  (throw (IllegalArgumentException.
          (let [responder (:responder response)]
            (if responder
              (str "存在しない Responder です (" responder ")")
              (str "Responder を指定してください"))))))

書いたら SPC m s B で評価・ CIDER バッファに移動し、次の式を評価します。

unmo.core> (respond {:responder :what :input "てすと"})
{:input "てすと", :responder :what, :response "てすとってなに?"}

respond :what の引数 response の中身は {:responder :what :input "てすと"} ですので、戻り値を見る限りちゃんと :response というキーが追加されています。ただ、ちょっと表示が長いので :response だけを取り出してみましょう。Cider では *1 というシンボルが「直前に評価された式の値」を意味します。

unmo.core> (:response *1)
"てすとってなに?"

:response に束縛された文字列だけを取り出すことができました。Clojure の Map オブジェクトからは、 (キー map-object) または (map-object キー) とすることで値を取り出すことができます。

この形は関数の評価と似ていませんか? 実際そうなのです。Clojure で呼び出し可能なオブジェクトは関数だけではなく、多くのデータ型それ自体が呼び出し可能となっています。上記ではキーを呼び出し、Map を引数に指定しています。この仕組みは IFn と呼ばれるもので、 IFn が実装されているデータ型ならすべて関数のように呼び出せます(これは (ifn? {})(ifn? :keyword) などとすることで確認できます)。

あるいは get 関数を使って (get *1 :response) と書くこともできます。どちらを採用するかの基準は ClojureStyleGuide に書いてありました(いま調べた)。

また (respond {:responder :not-found})(respond {}) を評価して、例外が正しいメッセージとともに投げられているかどうか確認してみてください(ポップアップは q キーで閉じることができます)。

リファクタリング: スレッドマクロ

ところで respond :what は処理を展開すると以下のようになります。

(assoc response :response (str (:input response) "ってなに?"))
=> (assoc response :response (str "てすと" "ってなに?"))
=> (assoc response :response "てすとってなに?")
=> (assoc {:responder :what :input "てすと"} :response "てすとってなに?")
=> {:input "てすと", :responder :what, :response "てすとってなに?"}

ちょっと括弧が多いです。 「式の最後に閉じ括弧がいっぱいあるのはいいけど、式の途中に混ざってると読みにくい」という人間の心理だか工学だかが発動するので、他の言語では禁呪とされているマクロを使ってどうにかします。手でどうにかしても良いのですが、Spacemacs では自動的に整形してくれます。

  1. (assoc...( にカーソルを持っていって、
  2. SPC m r t l (あるいは M-x clojure-thread-last-all ) を実行

すると

(defmethod respond :what [response]
  (->> "ってなに?"
       (str (:input response))
       (assoc response :response)))

のように変換されたはずです。これは評価時に先ほどのコードに再変換されるのですが、人間の目には見やすいように思えます。例えば (loop (print (eval (read)))) よりも (->> (read) (eval) (print) (loop)) と書いてあったほうがずっと読みやすいはずです。これは スレッドマクロ と呼ばれるもので、他にも ->as-> などがありますが、ここでは詳しく触れません。EmacsLisp にも輸入されているようで、一度慣れると病みつきになります。私はこのおかげで Ruby のメソッドチェインや Haskell$ 関数が恋しくなくなりました。

リファクタリング: 分配束縛 (destructuring-bind)

CommonLisp 本である On Lisp では『構造化代入』と訳されていましたが、Clojure 界隈では『分配束縛』と言うみたいです。 respond :default では (:input response) という形で :input の値を取り出していますが、要はこれを引数の部分でやってしまおうという機能。難しく聞こえますが、やってみると簡単です。

;; :responder の値が指定されなかった、または実装がないときの挙動
;; 定義していない responder が指定されたときは「存在しない」という例外を投げ、
;; 指定されなかったときは「指定しろ」という例外を投げる。
;;   -> Clojure では Java の例外クラスを流用するのが定番らしい
(detmethod respond :default [{:keys [responder]}]
  (throw (IllegalArgumentException.
          (if responder
            (str "存在しない Responder です (" responder ")")
            (str "Responder を指定してください")))))

response 引数と responder キーワードが消え、結果として let もなくなりました。代わりに {:keys [...]} が追加されています。

{:keys [responder]} というフォームは、引数として受け取った Map オブジェクトの :responder キーに束縛されている値を、同名の変数( responder )に束縛せよという意味になります。 responder への束縛を引数フォームでやっているため let が消え、全体の式としてはインデントが浅くなって読みやすくなっている……気がするような、しないような……。

スタイルガイドにまだ目を通していないので微妙なラインなんですが、短く一度しか使われない関数本体のために分配束縛するのは、逆に読みやすさを損ねているとも言えます。とりあえず私は慣れだと思って今のところは積極的に使っています。

同様に respond :what分配束縛が使えますので、調べて手を加えてみてください。

次回はメインループを実装するよ

思いのほか長くなったので疲れました。今回はこのくらいで。続きます。たぶん。