すなぶろ

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

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

Coders At Work (原著: Peter Seibel, 翻訳: 青木靖)という本があります。第一線級のハッカー達に「エディタは何を使ってます?」「デバッグはどうしてます?」といった基礎的な質問から、「プログラマ全員が読むべき本はありますか?」といった抽象的な質問まで、Peter Seibelががっつり食いついてインタビューしている濃い本なのですが、Erlang の開発者であるジョー・アームストロングがこんなことを言っていました。

ーー  コードを書く前に多くの時間を考えて過ごすということですが、そのときには実際どんなことをするんですか?
アームストロング  ああ、メモを書きます。ただ考えているわけではありません。紙にいろいろ落書きします。(中略)もう1つ重要なことは、同僚に「君だったらこれをどう解く?」と尋ねることです。同僚のところに行って、「こうやればいいのか、ああやればいいのか迷っている。AかBか選ばなきゃいけない」と言い、そのAとBについて説明している途中で、「ああ、Bだね。ありがとう。助かったよ」というようなことがよくあります。

これを読んで不思議な気持ちになりました。私も質問の前の説明段階で自己解決してしまうことはよくありますし、他の人が勝手に納得して席に戻っていくのも見たことがあります。「あるあるネタ」ではありますが、そういうことはプログラマーとして力をつけていくと、すっかり無くなることだと思っていたのです。世界レベルの言語とフレームワークを作り上げる人にも、私と同じように脳みそが入っているらしいということが、妙にリアルに感じられました。

ーー  あるコンピュータサイエンス学科では教官の部屋にぬいぐるみがあって、教官を患わせる前にそのぬいぐるみに向かって自分の問題を説明しなければならないという決まりになっているそうです。「あの、クマさん、私が取り組んでいるのはこういうことで、このようなアプローチをしているんですが、……そうか! 分かりました」
アームストロング  本当ですか? 私もやってみるべきですね。

ーー  あなたのあの猫に話されるといいですよ。
アームストロング  猫にね。いやまったく! 私より若干年上で非常に頭のいい人と一緒に仕事をしているのですが、私が彼の部屋に行って質問をすると、どの質問に対しても彼は、「プログラムはブラックボックスだ。入力と出力がある。そして入力と出力の間には関数的な関係がある。君の問題の入力は何? 出力は? その2つの間の関数的な関係は?」と聞くのです。そして会話の途中のいずれかの時点で、私は「ああ、君って天才だよ!」と言って部屋を飛び出すことになるのですが、彼のほうは驚いて頭を振りながら、「いったい問題は何だったんだろう。あいつちゃんと説明したためしがない」とつぶやくのです。だから彼は問題を説明して聞かせるクマと一緒ですね。

前回は記事本文を書きながら「コードをリファクタリングせねばならぬ……!」という謎の使命感に燃えておりました。文章化するからにはせめて自分で納得行くコードを載せたいという虚栄心がそうさせたのですが、動機がどうであれエディタの操作を覚えたり、イディオムを検索したのは事実。

そう考えると、私にとってのクマのぬいぐるみはこのブログであり、読者ということになります。Rich Hickey 効果で図らずも別ベクトルの注目を集めた気がする前回のことは忘れて、今回も「なんでこういう仕様にしたんだっけ」と自問自答しながら、ちまちまコードを載せていきます。

Coders at Work プログラミングの技をめぐる探求

Coders at Work プログラミングの技をめぐる探求

目次

-main 関数を実装する

lein new app コマンドでプロジェクトを作ると、 core.clj に最初から -main という関数が定義されています。 lein run したり Jar ファイルにしたときに最初に実行される関数です。接頭のハイフンは「エントリポイントであることを目立たせてるんだよ」という豆知識をどこかで見ました。

REPL 駆動開発もいいけど、とりあえず Jar ファイルにして実行してみたいと思うのは入門者の常……。誰かに迷惑をかけるわけでもないので、さっくり -main を実装してみます。

(ns unmo.core
  (:gen-class)
  (:require [clojure.string :as str]))

(defn -main [& args]
  (loop []
    (print "> ")
    (flush)       ; 直前の print をコンソールに表示するために必要
    (let [input (read-line)]  ; STDIN から入力を読み込んで input に束縛
      (if (str/blank? input)  ; input が "" なら
        (println "Quit.")     ; 終了
        (do (-> {:responder :what :input input}  ; 何か入力されていたら
                (respond)                        ; respond :what を呼び出し
                (get :response)                  ; :response の値を取得し
                (println))                       ; 出力
            (recur))))))      ; これを繰り返す

定義の末尾の閉じ括弧で C-c C-e すると REPL 上で評価され、そのまま実行できるようになります。 SPC m s s で Cider バッファに移動して (-main) と実行すると、動作しているかどうか確認できます。

loop

Clojure でループを表現するには、その名の通り loop フォームを使います。 (loop [変数名 値] 式...) という形式ですが、まだ変数は必要ないので [] を指定しています。 recur を呼び出すとループの先頭に戻って繰り返すのですが、はじめのうちは書き忘れて「なんで?」と思ったりしました。S 式ゆえに CommonLisp や EmacsLisp の loop マクロと混同したりして苦労しましたが、今ではいい思い出です。

感覚的には末尾再帰だと思えば良いようです。むしろ Clojure は末尾再帰を最適化 しません ので、末尾再帰をしたいときは必然的に recur を使うことになるようです。

これは Clojure の問題ではなく JVM の仕様だそうで、 recur で明示しないと jump に変換できないそうです。セキュリティを重視する動きで、スタックフレームの破損を防いでいるのだとか(ようするに関数が『別の場所』へ return しないようにしている)。

厳密には、Clojure が行わないのは「末尾での自身の呼び出しの最適化」であって、相互末尾再帰であれば JVM の制約に引っかからず、スタックオーバーフローは発生しないそうです(参考)。

私がそんな頭のいいアルゴリズムを書くわけがないので、とりあえずいま知ったことは忘れて、これまで通り recur を使う決意を新たにしました。 map 関数に自身を渡すような再帰アルゴリズムなら大丈夫ってこと……かな?(要検証)

str/blank?

多くの Lisp 族と同じく、Clojure で偽になりうるのは nilfalse だけです。空リストも真 (boolean '()) => true なので、真偽値の扱いは Scheme に似ています。リストやベクタの空チェックは (empty? []) などで行いますが、空文字列の判定には clojure.string/blank? という関数を用います。 (clojure.string/blank? "\n") => true と判定してくれる気さくなやつです。

厳密には clojure.string という名前空間に定義されている blank? という関数を呼び出すのですが、 clojure.string がそもそも長いので、業界的には str/s/ と略すのがセオリーな様子。そのためには ns:require を追加しなければなりませんが、プログラマーたるもの「行頭に戻って何か追加して戻るなど面倒だ」と感じなければなりません。怠惰な人間ほど良いプログラマーになるのだと偉い人が言ってました。

Spacemacs の clojure-layer を使うと clojure-mode, cider-mode, clj-refactor-mode などがまとめて入ってくるので、これを自動でやってくれます。例えば

  1. (s/blank? と書いて ESC
  2. SPC k k とすると
  3. ( にカーソルが飛んでいき
  4. SPC m r a m または M-x cljr-add-missing-libspec すると
  5. (:require [clojure.string :as s])ns に追加され
  6. 自動的に REPL に反映されるとともに
  7. 元のカーソル位置まで戻ってきて
  8. s/blank? の引数をヒントで表示してくれる

とかいう、数ストロークでちょっと意味わかんないことになります。初めて見たとき普通に「ちょww」とか言ってしまいました。

Clojure で開発するのが好きな理由のひとつが、エディタが狂ったように親切にしてくれるところです。「そうでなきゃこんな括弧まみれの言語でコーディングしないよね」とか言うと Lisp エイリアンに拉致されそうなのでやめておきます。

do

do は複数の式をひとつの式にまとめるフォームで、CommonLisp の progn, Schemebegin と同じです。 ifthen, else 節にはひとつの式しか与えられませんが、 println などの副作用をともなう関数を使うとどうしても式がふたつ以上になるので、そんなときにどうぞ。

スレッドマクロ -> は、以下のように展開されます。

before: (-> {:responder :what :input input}
            (respond)
            (get :response)
            (println))

after:  (println (get (respond {:responder :what :input input}) :response))

手続きっぽい例: (let [params [:responder :what :input input]
                     result (respond params)
                     response (:response result)]
              (println response))

after の式はもう私の中で printf デバッグに近いところに位置しているので、本当にひとつひとつ値の受け渡しを確認したいとき以外にはじっくり見ません。今となっては「ここはスレッドマクロにできないか? できないな。……待てよ? 引数の順番を変えれば可能なのでは? 」と、手段と目的が入れ替わっている節さえあります。ただ、そういう場合は得てして「我ながらよく書けた気がする」という結果になるので、今ではその直観を信じています。

一番下は戯れに出した例ですが、未だにこういうコードを書いてしまうときがあるので気をつけなければ……。 let で直前の変数(シンボル)に束縛された値を参照しても怒られないのは Clojure の懐の深いところですが、「そういうときは let* を使え。しかしなるべく使わずに済むコードを考えろ。 何しろ処理系レベルの最適化が大変だからな! 」という文化(Schemeとも言う)で育ってきたので、やっぱりこの悪癖は治したいところ。

それにしても、一番下を見てから一番上を見ると、まるで値を空中から取り出しているように見えて不思議です。

Jar ファイルにしてみる

やたら前置きが長くなりましたが、目的は Jar ファイルにコンパイルすることでした。しかしその前に lein run で様子を見ましょう。

lein run

lein run は Jar ファイルにする前に、プロジェクトを逐次解釈して動作を確認するためのコマンドです。コンパイルしないので動作は遅いですが、今回のチャットボット程度であればパフォーマンスにそれほど影響はしません。

$ lein run
> こんにちは
こんにちはってなに?
> あいさつだよ
あいさつだよってなに?
> (#^ω^)
(#^ω^)ってなに?
>
Quit.

道は遠い……。

lein uberjar

lein run と違って、こちらは Jar ファイルを作ってくれます。

  • プロジェクト(unmo)と依存関係のみを含むもの
  • プロジェクト(unmo)が必要とする依存関係を全て含んだスタンドアローン

この2種類を自動生成してくれます。前者はライブラリとして配るとき、後者はアプリケーションとしてデプロイするときに便利なんだと思います(あんまり詳しくない)。ではさっそく。

$ lein uberjar
Release versions may not depend upon snapshots.
Freeze snapshots to dated versions or set the LEIN_SNAPSHOTS_IN_RELEASE environment variable to override.

んっ……?

怒られました。調べてみると、 project.clj で依存している Sudachi のバージョンに SNAPSHOT が含まれているからで、「SNAPSHOT なライブラリをリリースビルドに含めるのは危ないよ」と警告してくれているようです。ごもっともですね。

ごもっともなんですが、私は SNAPSHOT ではない Sudachi の在り処を突き止めることができませんでしたので、環境変数 LEIN_SNAPSHOTS_IN_RELEASE を上書きして無理やりビルドしてしまいます。

$ LEIN_SNAPSHOTS_IN_RELEASE=1 lein uberjar
Compiling unmo.core
Created D:\Users\sandmark\repos\clojure\unmo\target\uberjar\unmo-0.1.0-SNAPSHOT.jar
Created D:\Users\sandmark\repos\clojure\unmo\target\uberjar\unmo-0.1.0-SNAPSHOT-standalone.jar

target/uberjar/ ディレクトリ以下に Jar ファイルを作ってくれたようです。副作用として私が Windows ユーザーだということが暴露されましたが、もうなんか別にいいやって感じです。

スタンドアローン版には、Clojure 本体をはじめいろいろなものが入っています。unmo の依存パッケージや、その依存パッケージがさらに依存するパッケージなどを含めて、私の環境では 11MB になりました。それらを含まない unmo-0.1.0-SNAPSHOT.jar のサイズはわずか 500KB 程度ですが、実行時にクラスパスやらいろいろなものを指定する必要がある上、(多分だけど)実行時に Clojure のランタイムがないと動かないと思うので、いまいちどういうときに使うのかわかりません。

実行してみる

早速実行してみましょう。振る舞いは lein run と同じはずですが、 java コマンドを介して動作するので文字コード関連の問題が発生するかもしれません。いま msys2 経由で実行したら盛大に文字化けしてひどい目に遭いました。REPL に引きこもっているほうがいいみたいです。

$ java -jar target/unmo/unmo-0.1.0-SNAPSHOT.jar
> ねんがんの .jar を てにいれたぞ!
ねんがんの .jar を てにいれたぞ!ってなに?
> 殺してでもうばいとる
殺してでもうばいとるってなに?
>
Quit.

悲しくなったので終了しました。

こういう子ども、いるよね。 大人を困らせることにベストを尽くす系の小学生、いるよね。 私もそうでした。ごめんなさい。許してください。

次回は respond :random と辞書を実装するよ

オウム返しがチャットボットだと言えるでしょうか。私にはそうは思えません。

しかし完全な失敗作とも言えません。日常会話でも「それどういう意味?」と聞き返されることはよくあります。球種が多いから会話のキャッチボールが楽しくなるのです。

というわけで、次回はユーザーの発言を記憶し、その中からランダムで選んで応答する respond :random を実装してみます。お茶を濁している感がすごいですが、地道に球種を増やしていけばそれなりに楽しくなるでしょう(願望)。