今回も小ネタです。
Clojureの非同期処理というと、いまでは core.async が一番メジャーだと思います。Clojure、ClojureScriptの両方で動作し(Clojure CLRでも動作するポートが存在するらしい)、golangのgoブロックにインスパイアされた「goブロック」を備えています。
ところで、goブロック同士がデータをやりとりするためのチャネルには、nilを書き込むことが出来ません。nilはcloseされたチャネルをリードしたときの値として予約されており、まだ閉じられていないチャネルにnilを送信することは出来ません(nilを書き込むとエラーになります)。
Clojureでは割とラフにnilを使うので(多くの関数がnilを許容するのと、when関数で簡単に処理できるからだと思います)、チャネルにnilを送信したいこともあります。当初私は、たとえば :nil
のようなキーワードを書き込み、読み込み側で「もし値が :nil
だったら」という判定をする、といった地道な対応をしていました。ただ、このやり方では、どうしても判定漏れが怖いです。
ある日たまたま、Clojureとcore.asyncでFunctional Reactive Programmingを実現するためのライブラリ Reagi のソースコードを呼んでいたら、「box」「unbox」という関数を見つけました。
この関数、実に単純で、ある値をboxすると、その値をくるんだBoxedオブジェクトを生成するだけです。Boxedはdefprotocolで定義された型で、unboxという関数を持っています。box関数は、reifyを使ってBoxedを作り出しているだけです。そして、unbox関数はその値を取り出します。
(defprotocol ^:no-doc Boxed
(unbox [x] "Unbox a boxed value."))
(defn box
"Box a value to ensure it can be sent through a チャネル."
[x]
(if (instance? Completed x)
x
(reify Boxed (unbox [_] x))))
Reagiでは、このbox関数でラップしたオブジェクトをチャネルに送信することで、チャネルにnilを書けないという課題をクリアしてました。非常に単純ですが、boxされたオブジェクトしか書かないと決めてさえおけば、読み手は「必ずunboxする」という一律の手順で処理できますので、処理漏れが軽減されます。
(余談)reagiの場合、unbox処理もライブラリ内で行われる仕様の為、ユーザーからはこの当たりの処理は隠蔽されていました。
この仕組みの良いところは、goブロック内で発生した例外をチャネルのリーダー側に転送したい場合にも一律の手順で処理できることです。私はBoxedプロトコルをさらに変更して、例外をハンドリングする処理を追加しています。
(defprotocol Boxed
(unbox [this] "retrieve a value this Boxed object is holding, but if the value is an exception, throw the exception."))
(defn box
[value]
(reify
Boxed
(unbox
[this]
(if (instance? Throwable value)
(throw value)
value))
Object
(toString
[this]
(str "Boxed[value=" (pr-str value) "]"))))
box
関数で値をラップするのは同じですが、 unbox
するときに、ラップされた値が例外オブジェクトであれば、値を返すのではなく例外をスローします。goブロック内で例外が発生した場合はチャネルのリーダー側でその例外を処理したい、というケース(結構あります)では、unboxするだけでその処理が行えて重宝します。
ちょっとした小ネタですが、 Boxed
という型が付くことにより、プロトコルによる拡張ができるようになるので、なかなか便利に使えています。このような処理はチャネルでの値のやり取り以外にも使えそうです。
リアルワールドのウェブアプリケーション開発というのは、大量のリモートAPI呼び出し、大量のDBクエリ、そしてJSONやEDNといったデータ生成あるいはHTML文字列の生成の塊です。
Clojureを使ってウェブアプリケーションを開発する場合、このような単調だけど絶対必要なコードを、「Clojureだから」簡潔に書ける、という利点が欲しいところです。
今回はそういう小ネタとして、データベースのレコード群をClojureの遅延シーケンスを使って表現した話を書きます。
モチベーション
ウェブアプリの大半の操作は、DBにSQLクエリを投げて、その結果を(Clojureマップのリストとして)返すだけで済むので面白みはないんですが、たまに、「ある大きなテーブルのレコードが新しいもの順に必要だけど、何件必要になるかは事前にはわからない」というケースがあります。
たとえば、Shelfmapでは「ウォッチ・ストリーム」という機能があって、これはジャンルやタグ、人を「ウォッチ」していれば、その対象に合致する新しいパス(レコード)を10件程度ずつ、一覧で見られるという機能です。
もちろん、ストリームを表示するたびに、そのユーザのウォッチ条件に合わせてSQLクエリを投げる、という手もありますが、いろんなユーザーがいろんな条件でウォッチ登録をしていて、それぞれクエリを投げるのはあまり効率がよくありません。結局、このストリームは、全データをある条件でフィルタしたものに過ぎないので、全員、元は同じデータを共有しているわけです。だったらデータが変わらない限り、データをキャッシュしておいて、それをフィルタすればいい。
つまり、「全データのストリーム」があって、それを元に、各ユーザーのウォッチ条件に合わせてfilterをすれば、同じデータを元に全ユーザーのストリームが生成できるはずです。
ところで、たとえば1万件のレコードがあって、ユーザーのウォッチ条件に合致するレコードを10件取得したいとき、合致するレコードが先頭の100個に集まってる場合、あとの9,900件をロードする意味がありません。ただ、合致するレコードが先頭の100個に集まってるということは、事前には分かりません。
極論すれば、1件レコードを取得して、合致してれば採用して次のレコードをロード、合致してなければそのレコードはすてて次のレコードをロード、とすれば、本当に必要なレコードだけをロードできます。しかしそれでは効率が悪い。
そこで、全レコードを表すリストだけど、アクセスするまでデータをロードしない、ロードするときは20件とかのまとまった単位でロードする、というものがあれば? つまり遅延シーケンスです。
少しずつデータをロードする遅延シーケンス
小ネタだと書いたので、実装は簡単です。Clojureには遅延シーケンスが存在するので、このような「もしかしたら大量かもしれないデータをちょっとずつロードする」という処理は簡単に書けます。
(defn- fetch-records
"データベースから、offset位置からlimit数分だけのレコードを取得します"
[offset limit]
;; データベースアクセスのコード
)
(defn- count-all-records
"データベースから、レコードの総件数を取得して返します"
[]
;; データベースアクセスのコード
)
(defn make-lazy-records
"データベースのアクセス1回につきlimit数分のレコードを取得し、それらを
結合したような遅延シーケンスを作成して返します"
([offset limit max-record-count current-record-count]
(lazy-seq
(if (>= current-record-count max-record-count)
[]
(let [records (fetch-records offset limit)
total (+ current-record-count (count records))]
(concat records (make-lazy-records (+ offset limit) limit max-record-count total))))))
([max-record-count]
(make-lazy-records 0 20 max-record-count 0))
([]
(make-lazy-records (count-all-records))))
lazy-seqを作るときは再帰関数を書くので、再帰の終了条件が必要です。今回のケースでは、取得したレコード件数を(取得するたびに加算して)取っておき、その件数がトータルレコード数以上になったら終了、としてます。そのため、全レコード数を取得する関数(SQLのCOUNT文を投げます)を別に用意しておきます。 count-all-records
がそれです。
データは少しずつ(この例では20件ずつ)取得するので、OFFSETとLIMITを使ったSQLを投げる関数も必要です。こちらは fetch-records
関数です。
make-lazy-records
は、これら2つの関数を使って、lazy-seqを作り出します。多項関数として定義してますが、実際に使うときは、引数なしで呼び出します。
(make-lazy-records) ;全レコードの遅延シーケンス。ただしアクセスするまでロードしない
実装がやっていることは単純で、まず全体を lazy-seq
でくるみます。これで戻り値は(lazyな)seqになり、アクセスしたときに中身が実行されます。
中身では、まず終了条件をチェックし、条件に合っていたら、空の配列を返します。lazy-seqを実体化したら空のリストだった、というわけです。
まだ終了条件を満たしてない場合は、 fetch-records
を使って20件のレコードを取得し、 concat
を使って、取得した20件を頭に、その後ろに(再帰呼び出しで作り出した)別のlazy-seqを結合します。この後ろの遅延シーケンスは、最初の20件を除いた残りのデータを表しています。再帰呼び出しですので、終了条件を満たすまでは、20件ごとに新しい遅延シーケンスが結合されていきます。あくまで「遅延」シーケンスですので、この領域にアクセスして初めて実体化します。
これで完成です。1件目にアクセスすると20件取得するクエリが投げられ、21件目にアクセスするとさらに20件、41件目でさらに20件取得する遅延シーケンスが完成です。
Clojureではこのように、「まだ実体化していない、ちょっとずつ実体化するリスト」を簡単に作成できます。この特徴は、実際のウェブ開発でも活用できるものです。今回の記事は、その一例として書きました。
いままでClojureの紹介記事書こうかと思ってたんですが、でもClojureって結構本も出てるから、紹介記事なんかもう必要ないんだよなと気がついたので、Clojureをどう使ってるかを書いていったほうがいいかなと思いました。Shelfmap の開発でそれなりに長い間ClojureでWebアプリケーションを作っているので、そこで作ったものや、得たノウハウを共有するのは、Clojureコミュニティの役に立つかもしれないし、なにか改善点があれば教えてもらえる可能性もありますし。
ClojureでWebアプリケーションを書くと、サーバーでのURLルーティングはRing仕様に則って作るのが普通です。実装としてはCompojure (https://github.com/weavejester/compojure) が一番メジャーだと思う。
Compojureのリクエスト・ハンドラは、リクエストMapを引数にとって、レスポンスMapを返す、単純な関数です。単純な関数なのでテストも書きやすく、関数として独立しているし、引数も戻り値も単なるマップなので、単純な関数のテストとして書けます。これだけ単純だと、「書き方」も何もないと思いがちですが、リクエスト・ハンドラがやるべきことは多岐にわたります。
- 要求されたContent-typeに対応しているか。application/jsonを要求されたけどハンドラがtext/htmlにしか対応していないなら、HTTPステータスコード406 Not Acceptableを返すべき
- ログインしているか? してないなら401 Unauthorizedを返す
- 一時トークンは存在するか?存在するなら、正しいトークンか?
- パラメータは必要なものがそろっているか
- 指定されたリソースは存在するか?
などなど、いろんな共通処理があって、これらをなるべく完結に、統一された構文で書きたい、という気持ちになるわけです。
そこでShelfmapでは、関数をスレッドマクロ風にチェインさせることで、リクエスト処理で共通の処理を統一的な記述方法で書けるように工夫しました。
コンテキスト・ハンドラ
Shelfmapでのリクエスト・ハンドラは、すべて以下のような形で書かれています。
(defn count-user-path
[path-id-raw]
(let [handler (chain
(check-media-types "application/edn")
(check-parameters path-id-validator {:path-id path-id-raw})
:let [path-id (parse-long path-id-raw)]
(check-exists (fn [ctx] (path-exists? path-id)))
(handle-count-user-path path-id))]
(run-handler handler)))
chain
マクロの内側には、「contextマップを引数にとり、新しいcontextマップあるいはringレスポンスマップを返す関数」をずらずらと書くことができます。contextマップとは、以下のような構造のマップです。
{ :request Ringリクエストマップ }
:request
キーに、リクエストマップが結びついているマップです。Ringリクエストマップを直接使わないのは、ハンドラが新しいコンテキストを返すときに、Ringリクエストマップの既存のキーを誤って書き換えてしまう、という可能性について、いちいち考慮するのが、心理的に面倒くさく感じられたからです。このようにラップして、書き換えるのはあくまでもcontextマップだという形にすれば、誤ってRingリクエストマップを書き換えてしまう可能性がほぼ無くなりますし、逆に、書き換える意思があるのであれば、いつでも update
関数で書き換えることができます。
chain
マクロは、contextハンドラを先頭から処理し、その結果がRingレスポンスマップであれば、即座にそのレスポンスを返却し、以降のcontextハンドラを呼び出しません。レスポンスマップでなければ、戻り値は新しいcontextマップであると解釈し、次のcontextハンドラを呼び出します。これを繰り返すのみです。 chain
内の最後のcontextハンドラが、このハンドラが本来やりたかったタスクを実行して、レスポンスを返すことになります。
:let ブロック
chain
マクロの途中に、contextハンドラ関数以外に、 :let
というキーワードを置くこともできます。 :let
は、次のcontextハンドラを呼ぶ為には別の関数の結果が必要であったり、前のcontextハンドラが作ったcontextマップから、値を取り出す必要があるときに使います。その名の通り、Clojureの let
マクロと同じ動作をするよう、設計しています。
:let [{{{source-raw :source, position :position} :params, session :shelfmap-session} :request} %
(check-exists (path-info session source position))
このコードはあるchain処理の一部を抜き出したものです。 :let
部では特殊な値として %
を使えます。 %
には、常に最新のcontextマップが束縛されています。このコードでは、最新のcontextマップから(分配束縛を使って) session
オブジェクトと、いくつかのパラメータを取得し、それらを次のハンドラの引数として使っています。
contextハンドラ関数の共通化
check-media-types
や check-parameters
は高階関数で、 check-media-types
であれば、リクエストのacceptヘッダに、引数で指定したタイプが入っていなければ、 406 Not Acceptable レスポンスを返します。それ以外の場合は、元のcontextマップをそのまま返します。
check-parameters
は引数にバリデータ関数を受け取り、リクエストパラメータを検証し、エラーがあれば 400 Bad Request を返します(エラーでなければ、やはり元のcontextマップを返します)。
これら二つの関数では行ってませんが、contextハンドラはcontextマップに新しいキーを追加することで、次のハンドラに値を渡すこともできます。
仕組みとしてはこれだけの単純なものですが、すべてのリクエスト・ハンドラを統一の方法で書けることでハンドラ関数の再利用性が高まり、コードが書きやすくなりました。
残念ながらこれらのコードは、現在はアプリケーション内のネームスペースに定義されていて、ライブラリとして分離していない為、ここだけを外部公開することができない状態ですが、私が書く他のコードでも同じ仕組みは必要になるので、そのうちに外部化しようと考えてます。
Clojureには標準でもagent系のsend, send-offに加え、future関数というスレッド起動系関数があります。
core.asyncの登場で、ここにgoマクロとthreadマクロが加わりました。
これらはすべて、背後ではJavaのExecutorsを使ってスレッドプールを作り、一度生成したスレッドの再利用を行いますが、それぞれ使っているスレッドプールが異なります。さらに関数自体の機能も異なるため、どれをつかったらいいのか迷ってしまうことがあります。
自分用に整理したので、メモとしておいておきます。
IOバウンドとCPUバウンド
まず、Clojureのスレッド関連関数の用途は、大きく2種類にわけられます。それが、IOバウンドとCPUバウンドです。
IOバウンドな処理は、実行中の処理がCPUよりもIO処理に強く依存します。DBアクセスとかリモート通信とかですね。別スレッドでこの処理を実行した場合、スレッドは大部分を、IO処理待ち状態で過ごします。
CPUバウンドな処理は、途中にIO待ちのような「待機」が発生せず、CPUをぶん回し続けるような処理です。全データがメモリに載っていて、CPUがフル稼働でそれらを処理するようなケースです。
IOバウンドな処理は大半をIO待ちで過ごすため、CPUを占有しません。一方CPUバウンドな処理は、その名の通り、動いている間中、CPUを使い続けます。
CPUを使い続けるような処理は、CPU(コア)数以上のスレッドを起動してもあまり意味がありません。たくさんのCPU依存処理を起動する場合、全スレッドがタスク処理でCPUを占有しているのがもっとも効率の良い状態で、それ以上起動しても、単にスレッド切り替えコストが無駄になるだけだからです。
CPUバウンドな処理は、スレッド数をコア数に近い数にとどめ、ひとつひとつのタスクは小さくして、たくさんのタスクをどんどんコアで分散して処理していくのが効率がよいことになります。CPUバウンドな処理はCPUを使うしかないのだから効率良く使いたいわけです。
一方、IOバウンドな処理は、その大半は「IO待ち」だったりします。リモートAPIを呼ぶ処理は、大半を「レスポンスが返ってくるのを待つ」ことに費やしています。
ここでたくさんのIOバウンドな処理を、コア数分の固定数スレッドで実行したことを想像してください。スレッドが4つだとして、4つのIO処理を起動すると…すべてのスレッドが使われ、それ以降の処理は待つしかありません。
CPUバウンドな処理であれば、スレッドはCPUをフルに使って一所懸命にタスクを実行していることでしょう。だから待つしかありません。しかしIOバウンドな処理では、4つのスレッドは、おそらく、ただIO待ちをしているだけです。
だから、IOバウンドな処理でスレッド数をコア数近くに限定するのは、あまり意味がないということになります。スレッドがIO待ちをしている間に、ほかの処理が動けるかもしれないのですから。だから、コア数以上のスレッドを起動して、どんどんIO待ちさせ、IOが終わったスレッドから処理を行えばよいのです。
固定数スレッドプールとキャッシュ化スレッドプール
Clojureの関数は、その用途がCPUバウンドかIOバウンドかによって、使用するスレッドプールが異なっています。
agent実行関数sendが使うスレッドプールは固定数であり、JavaのExecutors.newFixedThreadPoolメソッドで作られます。
一方、send-offが使うスレッドプールはキャッシュ化された非制限プールで、Executors.newCachedThreadPoolで作られます。非制限といっても、キャッシュ化スレッドプールは、使われなくなったスレッドを60秒で破棄するので、たくさんのスレッドがゴミとして残ることはありません。
多くのClojure関係の本で、sendはCPUに依存する処理に、send-offはIOに依存する処理に使う、と書かれているのは、このように、背後で使っているスレッドプールがことなるからです。
背後で使われているスレッドプールの種類がわかれば、その関数が、CPUバウンドな処理を想定しているのか、IOバウンドな処理を想定しているのかがわかります。以下は、Clojureのマルチスレッド関数がどのスレッドプールを使っているのかの一覧です。
| poolの定義場所 | プールの種類 | スレッドプール生成方法 | スレッド数 |
---|
send | clojure.lang.Agent/pooledExecutor | 固定数 | Executors.newFixedThreadPool | 2+コア数 |
---|
send-off | clojure.lang.Agent/soloExecutor | キャッシュ化 | Executors.newCachedThreadPool | 制限なし |
---|
future / future-call / pmap / pcalls | clojure.lang.Agent/soloExecutor | キャッシュ | Executors.newCachedThreadPool | 制限なし |
---|
go | clojure.core.async.impl.exec.threadpool/the-executor | 固定数 | Executors.newFixedThreadPool | コア数 * 2 + 42 |
---|
thread / thread-call | clojure.core.async/thread-macro-executor | キャッシュ | Executors/newCachedThreadPool | 制限なし |
---|
reducers | clojure.core.reducers/pool | ForkJoinPool | new java.util.concurrent.ForkJoinPool | 自動制御 |
---|
futureのところにはpmapとpcallsも書いていますが、pcallsはpmapを、pmapはfutureを呼び出すので、すべてfutureと同じ扱いです。
まとめてみると、core.asyncの解説で必ず取り上げられるgoマクロは、固定数のスレッドプールを使っていることがわかります。つまり、goマクロはCPUバウンドな処理を前提としているわけです。
goマクロが「コア数 * 2 + 42」というよくわからないスレッド数を使っていることについて、特に42という謎の数値を指定していることについてははっきりしないのですが、+42は後から付け加えられたらしく、メーリングリストのポストなどを追跡すると、前述した、IOバウンドな処理に固定数スレッドプールを使った場合のような、IO待ちで全スレッドが停止して並行処理がスタックしてしまうことをある程度抑止したい、というのが意図のようです。goマクロはあくまでCPUバウンドな処理を扱うものであることは変わらないそうです。
42という数値については「すべての答え」から取ったのでは、という説もありますが、いまだ謎です。「すべての答え」ネタを知らない人はググってください。
goがCPUバウンドであるかわりにthreadマクロが用意されています。
threadマクロはgoマクロとほぼ同じ使い勝手で使えますが、キャッシュ化スレッドプールを使うため、IOバウンドな処理に向いています。goマクロと異なるところは一点だけで、チャネルの操作に <! と >! は使えず、ブロック型のチャネル操作関数 <!! と >!! を使う、ということです。<!!, >!! では呼び出した段階でスレッドがブロックしますが、そもそもthreadを使った場合はネイティブスレッドに処理が割り当てられていて、そのスレッドがブロックするだけなので、メインスレッドは止まらず、問題ありません。
goマクロで起動した並行処理は、単純にひとつのスレッドに丸ごと渡されるわけではなく、コンパイル段階で全処理が式単位に分解され、ステートマシンに変換されます。S式ならではです。そして<!, >!でチャネルへのアクセスごとにスレッドが切り替わる、といった動きをするようです。<!!, >!! をgoブロックで使うと、このスレッド切り替えがうまく動かなくなるので、>! か !< を使います。
彼らはこれをIoC Threadと読んでいますが、いやいやそれはIoCというよりも、昔の協調型マルチタスクと似たものだから「協調型スレッド」と呼ぶべきだという意見もあります。私も強調型だって意見に賛成ですが、たぶんIoCのほうがかっこいいってことなんだと思います)
reducersだけは特殊で、reducersは内部では並行処理をJava 7以降のFork/Join APIに処理を丸投げしています(JVMがJava7未満の場合は互換ライブラリを使っているようです)。Fork/JoinはJavaではとても使いにくいAPIで、Java 8でラムダ式とパラレルストリームが導入されてやっと本気出せるようになったのですが、ClojureではJava 8よりももっと前に、早々に対応していたわけです。よって性質としてはFork/Joinと同等でして、Fork/Joinのドキュメントによると、CPUバウンドな処理を前提にスレッド数を自動制御し、IOバウンドな処理が混ざるとうまく自動制御できないようです。
スレッドプールも、Java 7でFork/Joinとともに導入された、ForkJoinPoolを使っています。このプールは、初期値はCPUコアと同数のスレッドを用意し、ダイナミックにワーカースレッドを追加したり停止したりします。
つまり、reducersはFork/Joinにすべておまかせ、ということです。
そもそもFork/Joinは、要素数がとても多いデータ(10万とか100万とか)を高速並列処理するためのAPIなので、並列化が目的なら、数個程度の並列化ではreducersではなく別の機構をつかったほうがいいです。reducersの機能は並列化だけではないので、そっち目当てならよいですが。
プールの違い
表をよく見るとわかりますが、同じスレッド化プールを使っている関数でも、threadマクロだけは、プールが異なります。send-offとfutureは、ともにClojure標準関数なだけはあって、両方が同じスレッドプールを使っています。これはつまり、send-offで生成されたスレッドは、futureでも再利用できることを意味します。
core.async/thread は、そもそもcore.async自体がClojureの「外部ライブラリ」な位置づけですから、独自に定義したスレッドプールを使っています。よって、futureとthreadとは、互いに生成済みスレッドを再利用できません。ちょっとした差ではありますが、効率的ではないことは知っておいて損はないでしょう。
core.asyncを使う人は、おおむね、スレッド処理はcore.asyncばかり使う傾向があるので、今後はfutureの代わりにthreadを使うことにすれば落着、と行きそうですが、両者は機能にも違いがあるのでなかなかそうは行きません。
機能の違い
- send, send-off (agent系)
- future
- go, thread (core.async系)
この3種類は用途および使い方が違います。
sendとsend-off、goとthreadは、用途は同じですがCPUバウンドかIOバウンドかが異なります。
futureはどちらにも属しません。
sendとsend-offはどちらも、agent操作関数であり、目的はあくまでagentの実行と更新です。そもそもagentは、汎用的な並行処理起動のためにあるものではなく、かなり特殊な用途でつかうものなので、「ただスレッドを起動したい」だけでは使わないほうがいいです。
agentの特徴は、同じagentで起動した処理は「逐次実行される」点です。同じagentに何回もsend, send-offしても、それらが平行で処理されるわけではありません。sendやsend-offはagentのアクション実行キューにアクションを積むだけです(もちろん、複数のagentが存在すれば、それらは平行に動きます)。そもそもagentは「値」を持っていて、sendやsend-offで積んだアクションによって、agentの結果値が順番に変わっていく、というものだからです。
goとthreadはagentに比べてより汎用的な並行処理機構で、goやthreadブロックの処理は、スレッドプールの違いはあれ、すぐにスレッドに割り当てられて平行に動きます。いずれのマクロも、処理完了時の結果値が取り出せるチャネルを返します。とこれだけ書くと、threadはfutureと似ているように思えます。ともにIOバウンドな処理用で、結果値を取得できるオブジェクトを返します。futureを卒業して、core.asyncに「移行」すべきでしょうか?
futureは、処理をキャッシュ化スレッドプールに渡してくれる点でthreadと同じですが、futureはdelayオブジェクトでもある点が大きく異なります。
(let [result1 (future (my-remote-func1 ...))
result2 (future (my-remote-func2 ...))]
(my-long-processing-fn)
{:age (-> (:base @result1) (+ 20))
:address (str (:address @result1) " " (:address @result2))
:name (:name @result2)})
futureはderef(の省略記号アットマーク)によって非同期処理の実行結果を取得できますが、derefは何回でも使えます。最初のderef時にまだ処理が終わってない場合は処理完了を待機しますが、以降は、キャッシュした結果値を返し続けます。
上記例では、my-remote-func1とmy-remote-func2というリモート呼び出しを平行化するためにfutureを使い、さらにmy-long-processing-fnという長い処理を行う関数を呼びました。my-long-processing-fn実行中も、別スレッドでリモートコールは実行されています。
最後にマップを作るときに、futureの結果値を参照していますが、result1もresult2も、2回参照している点に注目してください。
threadマクロはdelayオブジェクトではなく、チャネルを返します。(<!! ch) によって結果値を取り出せますが、derefと違って、<!!を繰り返し読んでも同じ結果が返ってくるわけではありません。チャネルはキューの一種で、チャネルへの <!! は呼ぶたびに新しい値を返し、値がなくなるとnilを返すので、チャネルを、delayのように繰り返し参照すべきではありません。
(let [ch1 (thread (my-remote-func1 ...))
ch2 (thread (my-remote-func2 ...))]
(my-long-processing-fn)
(let [result1 (<!! ch1)
result2 (<!! ch2)]
{:age (-> (:base result1) (+ 20))
:address (str (:address result1) " " (:address result2))
:name (:name result2)}))
チャネルベースのthreadを、futureの代用として使う場合は、letを使ってチャネルからいったん値を取り出さなければいけない点で、使い勝手が異なってきます。
もちろん、ごく僅かな差ですし、go/threadには、複数のgo/threadブロックが共通の(しかもたくさんの)チャネルを介して値をやり取りしつつ並行処理を実行するという本来の目的がありますから、価値はいささかも減じません。ここで言いたいのは、threadはgoのIOバウンド版であって、全並行処理をcore.async化しようとして、futureのかわりにthreadを使おうというのは、アリではありますが、若干短絡的です。
core.asyncのパワーは、goあるいはthreadブロックが複数個起動していて、互いに(チャネルを介して)通信しあう時に発揮されます。もちろん、常に結果チャネルを返す点で汎用的なスレッド起動の仕組みとして使うことも配慮されていますが、上記のような違いを意識しておいたほうがよいでしょう。この例のように、複数のfutureでいくつもの並列処理が起動して、あとでその結果値を使う場合、threadの場合は、長いlet式でいったんチャネルをリードする必要があるかもしれません。
一方で、futureとthreadは使用するスレッドプールが異なるので、併用すると、互いにスレッドを共有してくれません。future同士はスレッドを共有しますし、thread同士も共有しますが、futureとthreadは共有しません。ここに若干のロスが存在します。
よって、用途に合わせてfutureとthreadと使い分けるか、あるいはスレッドプールの効率性を考えて片方に寄せるか(パワーを考えるとthreadの方が強力なので、ふつうはthreadに寄せるでしょう)は、正直、好みの次第です。実を言うと、私はfutureを使うシーンでもthreadを使うことがほとんどです。好みの問題です。
まとめ
- agentは単なる並列処理起動用の機能ではないので、ちょっと考えて使え
- reducersはすごい量のデータを処理でもしない限り、並列化機構だと思うな。
- いまやりたい処理がCPUバウンドかIOバウンドかはちゃんと考えろ
- futureにはちゃんとfutureに向いた処理がある。けどあえてthreadで代用も出来る。その場合、他の並列処理もなるべくcore.asyncを使うようにすれば、スレッドプールのキャッシュ効率は若干良い。
Yosemiteにバージョンアップして大して問題もなく過ごしてたんですが、ひさびさにPostgreSQLを使おうとしたら、なぜか繋がらない。
could not open directory "pg_tblspc": No such file or directory.
とか言われてしまう。
いろいろ調べたところ、どうも、Yosemiteにアップデートする過程で、なんでかしらないが空のディレクトリが削除されるのではないか?というポストを見つけました。
http://stackoverflow.com/questions/25970132/pg-tblspc-missing-after-installation-of-os-x-yosemite
結局、次のようにディレクトリを作成すれば、問題なく起動しました。
mkdir /usr/local/var/postgres/pg_tblspc
mkdir /usr/local/var/postgres/pg_twophase
mkdir /usr/local/var/postgres/pg_stat_tmp
同じ問題にハマった人向けにメモしておきます。
やらないといけないと思いながらも、ずぼらでずっと後回しにしてた、CIサーバ構築作業を最近やりました。というのも、JIRAを契約するついでにお試しに登録していた、Atlassian Bambooのお試し期間が過ぎてしまったから。お試しすらしていない…
とりあえず解約するにしても試してからにしようってことで本腰入れて使ってみることにしました。でも、ネットで検索してもCircleCIいいよってのが多く、Bambooは「どう設定していいのかぱっとみわからなかった」みたいなのが多い印象。
たしかに、ぱっと見どう使っていいのかなんか分かりにくい…
でも、分かってしまえば仕組みは簡単でした。
Bambooって結局なんなのか
Amazon EC2を実行サーバとして利用する、CI環境です。Bamboo上にソースリポジトリと「Builder」や「Tester」の設定をしておくと、ソースをリポジトリにpushすると、Bambooが指定したEC2インスタンスを自動起動して、ソースチェックアウトからビルド&テストしてくれます。EC2インスタンスは自動停止します(これについては後述)
ポイントは
- AmazonのAMIを指定するとそのサーバを起動して使うので、ビルド&テスト環境を自由に構築できる(自分でEC2上に環境構築して、そのインスタンスからイメージをつくればいいので)。自由度は高い
- Bambooのコストとは別にEC2のコストがかかる
- ビルトインで提供されているBuilderとかを使える状況であれば、出来合いのAmazonインスタンスを使えば即使える
って感じですか。
私はClojure+Leiningen+Midjeという環境が必要なので、サーバ設定を自前でできるのはありがたい。使ってみようという気が湧きました。
ただ、ドキュメントが猛烈に分かりにくいように思います。何ヶ所かつまって、検索すると、Atlassian自身のフォーラムに同じ質問が(英語で)上がってて解決したり、みたいなことがけっこうありました。
ワナ
私が一日無駄にしたワナが、Bamboo OnDemand(ダウンロード版)では、Atlassian提供のAmazon EC2イメージを使ってサーバ構築しないと、Bambooが接続するためのエージェントが起動しないってのがよくわかってなくて、それが分からなかったのが
tokyoリージョンにはイメージが提供されてない
ってことがわからなかったからです。なんとかビルドできる環境作っても、エージェント起動開始でずっとPending…ってなって困ってたのですが、そもそもエージェント・プログラムがサーバに入ってないのだからあたりまえです。
でもねえ、「Elastic Bamboo Global Settings」ってところに、いきなりリージョン選べってのがあるんですよ。そりゃTokyo選びませんか?
Global Settings画面
Bamboo OnDemandを使う場合は、ここは(ちゃんと動く環境ができるまでは)US East (Northern Virginia)固定でOK。デフォルトがたぶんそうなってるので、かえなきゃいいわけです。
すべて動くようになってから、EC2のイメージを(EC2の機能で)Tokyoリージョンにコピーして、Bamboo側もTokyoリージョンに変更、ということは可能ですが、Bambooインスタンスに接続するのはBamboo自身であって、自分でも顧客でもないのだから、インスタンスがTokyoで起動する意味もないわけで、ずっとUS Eastでもいいはず。
US Eastであれば、Bambooの「Image Configurations」画面に、すぐに使用可能なイメージがずらっと表示されます。Tokyoに変えていると、ここが空っぽです(空っぽなので、ほんとはここに既成イメージが表示されるなんてことが想像できない)
Elastic Bambooの設定画面でAWS access key idを設定済みであれば、Startボタンを押すと、EC2でインスタンスが起動します。あとはEC2インスタンスにSSHでログインして、ビルド環境を構築した後、EC2側でインスタンスからイメージを作成し、そのイメージを、Image Configurationsに追加してやれば、独自のビルドサーバが作成できます。
ただ、Java+Maven+JUnitとかRubyとかPHPとかは、実はビルドやテストを実行できるだけの設定がすでに既成イメージにされているので、上記から適当にひとつ選んでもいいかもしれません。ただLargeイメージを使う意味がない場合は、既成イメージのAMI idを使って、インスタンスタイプだけをMicroに変更したような定義を一個作った方が、財布に優しいと思います。
ともかく、US Eastリージョンで既成イメージをベースに環境構築する(あるいはそのまま使う)ってことだけ忘れなければ、そんなにはまることなくさっさと動かし始められると思います。ただEC2につないでるだけですから…
EC2インスタンスの自動停止
Bambooの設定に、リモートサーバのエージェントが何分以上アイドルになったらインスタンスを止めるかって設定があって、デフォルトが10分なんですが、10分経っても落ちません。実は、サーバの自動停止にはもうひとつ条件があって、なぜかドキュメントに書いてなくて、AtlassianフォーラムのQ&Aで見つけたのですが…
- EC2の支払いが最小になるように、EC2の最小課金時間(通常は1時間?)の間は起動し続ける
ということのようです。
EC2のインスタンスは、起動サーバ単位で1時間いくら、なんですが、この1時間というのは「1時間未満はすべて1時間とみなす」という条件がついてます。よって、1分で停止しても1時間分取られます。
Bambooは、ソースがプッシュされるたびに毎度サーバを起動&停止するのではなく、1時間以内であれば、同じインスタンスを使い回そうとします。アイドル時間が10分たっても、1時間以内にまた別のビルド要求が来るかもしれないので、ギリギリまでは起動し続けます。そのまま要求がこないと、最小課金時間が過ぎるちょっと前に(私が見てた限りでは55分くらいで)自動停止します。
なかなかがんばってるなーと思いました。
Capabilityの設定
Bambooに設定できる各種コマンドは「Capability」と呼んで、インスタンス設定画面の既成インスタンス一覧にある「View Capabilities」を押すと、そのイメージにあるコマンド等を設定できます。
Ant, Grails, Maven, Node.js, PHPUnit, JDK, Mercurial, Gitは始めから入っています。あと、設定画面には出ていないけど、サーバにログインしてみると、/optの下に既にJDK8やMaven 3.2が入ってたりもするので、それらは、サーバをいじらずに、Capabilityに定義を追加するだけで、使い始められます。
私はClojureソースをビルドするためにLeiningenをサーバに入れたので、次のように定義を足しました。
このようにCapabilityを足すと、この後のタスク設定なんかで、「Leiningen」を選択できるわけです。
ステージとジョブ、タスク
ビルドプランの設定はまあ直感的だと思います。
ステージ・ジョブ・タスクの区別が分かりにくいかなーと思いました。
- ひとつのプランは複数のステージを持てる
- ステージは順番に実行される
- ひとつのステージは複数のジョブを持てる
- ジョブは 並列に 実行される
- ジョブは複数のタスクを持てる。
- タスクは順番に実行される
という構造で、ジョブが並列に実行されるってことだけ分かってればいいかと。
以下の画像では、
- 「Build Project」という名前のステージが
- 「Build Source」というひとつのジョブを持っていて、そのジョブが
- 「Source Code Checkout」「Maven 3.x」「Command」という3つのタスクを持っている
という構造です。
タスクは、Bambooにもともといくつか用意されていて(「Source Code Checkout」とか)、それらを並べるだけでいいです。サーバイメージに自分でインストールしたコマンドについては、「Command」タスクを使って、Executableを指定できます。
こんな感じです。先にCapabilityを設定しているので、コンボボックスでLeiningenを選択できるようになっています。
上記イメージのように、JVM系コマンドの場合、JAVA_HOMEとPATHをせっていしなくちゃいけません。Bambooエージェント自体はJava 6で動いている(サーバ設定いじると変えられますが、エージェントが起動しなくなります)ので、タスクの設定で、使用したいJVMを指定してください。
上記画像ではJava 8を設定しています。ちなみに、Java 8は既成イメージの始めから入ってました(なぜかCapabilityには入ってないので、自分で足しましたが)
ビルドしたいソースが、チェックアウトしたソースのサブディレクトリの場合は、「Working sub directory」にリポジトリトップからの相対パスを書きます。私の場合、javaソースは「java」というサブディレクトリに、Clojureソースは「clojure」というサブディレクトリに入れているので、「clojure」を指定しています。
言葉で書くと長ったらしいですが、設定自体はさくさく終わります。
あとはビルドプランを一発手動実行して、動くようならOK。リポジトリにソースがプッシュされたら、勝手にビルドが走ります。
Bambooについて日本語で書いてるブログがほとんど見つからなかったので、書いてみました。
開発中、常に5つくらいのウインドウをtmuxで開いて作業しています。開くウインドウは毎回同じなのですが、毎回毎回5つウインドウを作って、リネームして、というのが面倒です。
たぶんコマンドから制御できるんだろうと思い調べたらやっぱりあったので、メモとして書いておきます。
DIR="/path/to/your/development/dir"
tmux new-session -d -s myproj -n project1 -c "$DIR/project1"
tmux new-window -n project2 -c "$DIR/project2"
tmux new-window -n project3 -c "$DIR/project3"
tmux new-window -n project4 -c "$DIR/project4"
tmux new-window -n project5 -c "$DIR/project5"
tmux attach -t myproj
これで、tmux.shを起動したら5つのウインドウがセッティングされた状態でtmuxが開きます。
スレッドマクロって?
スレッドマクロ(threading macro)は、Clojureのソースを人間に読みやすい形で書けるマクロで、現在のClojure 1.5.1には、結構な数のが用意されています。1.5で初めて追加されたものもありますし、まとめておくと、今後Clojureを始める人にも役立つかもしれないなあってことで、ブログ記事に書いておくことにしました。1.5で導入された新しいスレッドマクロも含めて既に知っている人には役に立たないのであしからず。
「スレッド」マクロといっても、並列プログラミングのスレッドとはまったく関係がないです。Clojureの -> や ->> のような、矢印系マクロの総称として使われています。Clojure 1.5.1では、次のスレッドマクロがあります。
- ->
- ->>
- as->
- some->
- some->>
- cond->
- cond->>
基本は -> と ->> で、他のものは、この基本スレッドマクロの便利版と思ってよいです。
スレッドマクロの役割は、関数ベースのClojureでは「実行する順」に書けない、もしくは書いても冗長になるコードを、簡便に、「実行する順」に書けるようにすることです。「AしてBしてCする」みたいな処理を簡単に書けるようにするわけです。
(func3 (func2 (func1 :arg) :arg) :arg)
これを、
(-> (func1 :arg)
(func2 :arg)
(func3 :arg))
と書けるようにします。
-> (thread-first) マクロ
-> はその性質から「thread-first macro」と呼ばれるようです。->マクロは、初期値と、スレッディング対象となる式を受け取ります。実物を見た方がわかりやすいです。
(-> {:type :person
:age 30}
(assoc :name "John Doe")
(assoc :city "tokyo")
(dissoc :age))
この例では、初期値は {:type :person, :age 30}
というマップです。この初期値が、次のフォームである (assoc :name "John Doe")
の第1引数の位置に挿入されます。
続けてこの (assoc 初期値 :name "John Doe")
が、2つ目のフォームである (assoc :city "tokyo")
の第1引数になります。さらにこの assoc
が、最後のフォームである (dissoc :age)
の第1引数になるのです。
(assoc
を2回呼ぶなら可変長引数でひとつにまとめろ、という話はちょっと脇へ置いておいてください)
式が挿入される位置を x
で表現してみると、次のように、
(assoc x :name "John Doe")
(assoc x :city "tokyo")
(dissoc x :age)
式が挿入される位置に書いた x
が、各フォームを貫いているように見えます。
このように、前の引数を次のフォームの同じ位置で使う場合に、読みやすいコードを書けるわけです。
もしスレッドマクロがなければ、前述のコードは、次のような関数の入れ子として書かなければいけません。
(dissoc (assoc (assoc {:type :person, :age 30} :name "John Doe") :city "tokyo") :age)
とても読みにくいです。無理矢理でも実行順に書こうとすると、let
を使って各結果値をシンボルに束縛しなければならないでしょう。
(let [init {:type :person, :age 30}
x (assoc init :name "John Doe")
y (assoc x :city "tokyo")]
(dissoc y :age))
let
の各シンボルは使い捨てみたいで、なんだか疲れます。スレッドマクロであれば、直前の結果を次の式ですぐに使う、というコードを、簡潔に記述できます。
-> はスレッドマクロの基本であり、ほかのものは、「-> とどこが違うのか」で説明できます。
->> (thread-last) マクロ
->>マクロと->マクロの違いは、引数が入る位置が、第1引数ではなく、最終引数の位置になる、という点だけです。Clojureの関数の多くは、関数の操作対象を第1引数で受け取るように作られているのですが、第1引数として関数を取る関数の多くは、操作対象を最終引数で受け取ります。map, filter, distinctといった、コレクション操作系の関数のほとんどが、そのような作りになっています。
->>マクロを使えば、コレクションを filter
して map
して distinct
する、といったコードを綺麗に記述できます。
(->> (my-great-func-returns-coll)
(filter #(not (nil? %))) ;nilをはぶく
(map :name) ;:nameだけのリストにする
(distinct)) ;重複をのぞいたリストを作る
->>マクロもスレッドマクロの基礎の一つで、このマクロがあるので、他のスレッドマクロの矢印の>が2つある場合は、フォームの最後に直前の式が挿入されるのだな、という暗黙の了解ができています。
as->マクロ
as->マクロはちょっと特殊で、->
マクロの不便なところを補う役割があります。
->
では、式が挿入される位置が「第1引数の位置」に固定されています。しかし、時には、式を別の位置に挿入したいこともあるはずです。そういう場合は、次のような、ちょっとアクロバティックなコードを書く必要がありました。
(-> {}
(assoc :type ::book)
(#(my-great-func arg1 % arg3))
(dissoc :result))
引数をひとつ受け取る匿名関数を生成して、それを呼び出すためにさらにカッコで囲うわけです。すると、その匿名関数の第1引数となる位置に、直前の式が挿入されるのです。
また、スレッドマクロの途中には関数しかおけないので、途中にログを出力する処理を挟みたいと思っても、ログを出力して引数をそのまま返すような関数を定義しなければいけません。簡単にかきたいからスレッドマクロを使っているわけで、ちょっと本末転倒なところがあります。
つまり、処理中の値(直前の式の結果値)を補足できればいいわけです。それを行うのが、as->
マクロの役割です。このような用途であるため、as->
マクロは、スレッドマクロの内側で使うことを前提にしています。もちろん、自分で第1引数に値を渡せばスレッドマクロ外でも動きますが、そうしても特に役立ちませんし。
さらに、as->
マクロは->
系統のスレッドマクロ (->, some->, cond->) でしか使えません(->>系統のマクロでは使えません。as->>というマクロはありません)。
(-> {:result "test"}
(assoc :type ::book)
(as-> x (my-great-func1 :a x :b)
(my-great-func2 x))
(dissoc :result))
as-> x
によって、直前の結果値を x
に束縛できます。あとは、シンボル x
を使って、my-great-funcの好きな位置に、直前の結果値を挿入できます
。さらに、my-great-func1
の実行結果は再び x
に束縛されます。my-great-func2
に渡される x
は、(assoc :type ::book)
の結果ではなく、my-great-func1
の結果になります。
as->
を使うと、スレッディングの途中の値を、ログ出力することも出来ます。
(-> {}
(assoc :test "1")
(as-> x (do (println (str "x = " x)) x))
(assoc :test2 "2"))
この例では、(assoc :test "1")
の結果を x
に束縛し、ログ出力したあと、そのまま x
を返しています。(assoc :test "1")
の結果はそのまま (assoc :test2 "2")
に渡ります。スレッドマクロの途中に、手軽にログ出力を挿入したわけです。
as->
が導入されたおかげで、アクロバティックなコードを書く必要がなくなりました。
some->マクロ、some->>マクロ
some->
マクロは、->
マクロのシンプルな拡張です。同様に、some->>
マクロは ->>
マクロの拡張です。
スレッディング処理を実行中に、途中で結果が nil
になることがあります。nilになったらもう後続の処理は実行する意味がないとか(Clojureの多くの関数は nil
を渡すと単に nil
を返しますが、確実ではないし、無駄でもあります)、後続にJavaのメソッドをコールする部分があって、nil
を渡すと NullPointerException
になってしまう、ということはあり得ます。
some->
は、スレッディング途中に結果が nil
になったら、そこで処理を打ち切って nil
を返却します。
(defn die [v]
(when (nil? v) (throw (NullPointerException.))))
(some-> [:a :b :c]
next
next
next
die)
ベクタ[:a :b :c]
に対して、「先頭要素を省いたシーケンスまたはnil
を返す」という動作をするnext
関数を3回使うと、結果はnil
になります。->
マクロであれば、最後にnil
がdie
関数に渡され、NullPointerException
がスローされます。
some->
であれば、3回目のnext
で結果がnil
になった段階でnil
を返すので、die
は実行されず、例外はスローされません。
実開発では、意外に使い勝手のいいスレッドマクロです。
cond->マクロ、cond->>マクロ
cond->
と cond->>
の違いも、some->, some->>
と同じく、引数の挿入位置だけです。それぞれ->
あるいは->>
と同じ場所に式を挿入します。
cond->
は、条件付きスレッドマクロです。次のように、条件式と、実行式のペアを記述していきます。
(let [v {:age 30, :place :japan}]
(cond-> v
(>= (:age v) 20) (assoc :adult true)
(= :us (:place v)) (update-in [:recommended] conj "Bank of America")
(= :japan (:place v)) (update-in [:recommended] conj "MUFG")))
;;結果→ {:age 30, :place :japan, :adult true, :recommended ["MUFG"]}
cond->
は、初期値以降に、まず条件式を、次にその条件が真だった場合に実行する式を書きます。かならず2つをペアで書かなければなりません。
直前の式は、右側の式の第1引数の位置に挿入されます。左側(条件式)には挿入されないので注意が必要です。
cond
式と異なり、cond->
では、全ての条件式が評価され、真であれば、右の式を評価します。このとき、右の式に対してスレッディング処理が行われます(第1引数もしくは最終引数の位置に、直前の式が挿入される)。条件式が真でない場合は、対となる右側の式はスキップされ、次の条件式が評価されます。
前述の例なら、(= :us (:place v)
は真ではないので、(update-in [:recommended] conj "Bank of America")
は実行されず、最終結果の:recommended
にも"Bank of America"
は入っていません。
スレッドマクロを使いたいのだけど、スレッディング内の式の一部だけを条件付きで実行したいときに便利です。意外と、そういうことは多くあります。特にcond->>
マクロであれば、コレクション操作を条件付きで実行できるので便利です(特定のパラメータがオンのときだけ、最後にソートを実行する、など)。
まとめ
スレッドマクロは、関数スタイルで書くととても読みにくくなるコードを、劇的に読みやすくしてくれる便利なマクロで、人気も高いようです。標準のマクロ以外にも、オープンソースで公開されている独自マクロもあったりします。やはり「AしてBしてC」という処理は、同じ順序に書いたほうが分かりやすいですからね。中には、かなり実用的なものもあります。ここにある、Diamond Wandなんかはかなり使えます。
ただ、便利だからといってたくさん導入すると、コードが矢印だらけになって、読むのが大変になるかもしれません。Clojureに標準で存在するスレッドマクロを基本にして、ほかのものはチームと相談しつつ導入、というほうがよいかもしれないですね。
->
と->>
はClojureの初期から存在したので知っていても、cond->
やsome->, as->
は知らない、ということもあるかと思います。とても便利なので知っておいて損はないでしょう。
マクロは自分で作るもんなのか?
Clojureでばりばり書いている人からすると愚問で、「Yes」としか言いようがない質問だろうけども、前に、とある場所でClojureについてのLTをしたときに、「マクロとかagentとかは実際に使うもんなんですかね?」と質問を受けたことがあります。
『フレームワークで使うテクニックなんかはフレームワークを「使う側」が知る必要はない』みたいな話の延長なのか、あるいは、そもそも現場での使いどころがイメージできない、ってことなのか、つかみ損ねましたが、実例があると分かりやすいのかなあと思いまして、実際に自分はどういう風に使っているのか、ここに書いておこうと思いました。
文法をつくれ!
やっぱり、実際に使う一番多い用途はこれだとおもうんですよね。新しい文法を作る。
とはいえ、ほとんどのClojureのテキストブックには、マクロに対するClojureの立場みたいなもんが書いてるはずです。つまり「必要ないならマクロを使うな!」と。
同じ書き方で関数でできることを、わざとマクロでやるのはいいかっこしいなんですよ。関数で出来ないからマクロでやる、であるべき、ということでしょう。
(まあ、マクロはデバッグもしにくいですし)
でも、こう書きたいけど関数ではできない、というケースは、思ったよりたくさんあります。関数で書いてみて、関数ではできないとわかったら、マクロでやればいいのです。
誰でも「ああそういうケースありそう」と思える実例として、次のようなケースを考えてみましょう。
値がnilでない時のみ、その値をマップに入れる
私が実際に使っている例として、ある値がnilでないなら、その値をマップに格納したい、という用途があります。
引数がnilだったらマップには入れたくない、とかいろいろ状況は考えられますが、単純な例として「マップ同士の移し替え」を考えてみましょう。
とりあえず、以下のようなデータがあるとします。便宜上Clojureのマップとして表現します。各キーは、存在したりしなかったりします。
{:book_name "本の名前"
:isbn_code "ISBNコード"
:publish_date "発行日"
:publisher_name "発行会社"}
これを、次のようなマップに移し替えます。
{:type :book
:name "本の名前"
:isbn "ISBNコード"
:published "発行日"
:publisher "発行会社"}
:typeという独自キーを持っているのと、それぞれ微妙にキー名が異なるわけです。もしもとのマップに :book_name しか入っていなければ、次のようなマップを得たい。
{:type :book
:name "本の名前"}
変換後も、元のマップにないキーは存在しないわけです。単純な詰め替え処理を書くと、次のようなマップが得られてしまうはずです。
{:type :book
:name "本の名前"
:isbn nil
:published nil
:publisher nil}
assocするとキーは生成されてしまうからです。すぐ使い捨てるデータならこれでもいいのですが、nilだったらキー自体が無くなって欲しいということもあるでしょう。
普通に関数で書くと、以下のような冗長なコードになります。もとのマップはoriginalというシンボルでアクセスできるものとします。
(cond-> {:type :book}
(:book_name original) (assoc :name (:book_name original)
(:isbn_code original) (assoc :isbn (:isbn_code original)
(:publish_date original) (assoc :published (:publish_date original)
(:publisher_name original) (assoc :publisher (:publisher_name original))
cond-> 命令は、左の式が真だったら、右の式を実行します。右の式の第1引数には、直前の式の結果が渡されます。初期値は :type のみをキーにもつマップで、最初の判定が真だったらこれが最初の式の第1引数に渡り、次も真だったら最初の式の結果が2番目の式の第1引数に渡ります。すべて真だったら、4回 assoc が実行されるわけです。
同じコードが4回並んでいることがわかります。いかにもめんどくさい。4つならいいけど、フィールドが20個とかあるとしんどい。
で、もやもやと「こういうふうに書きたいなあ」と思ったとします。
(assoc-> {:type :book}
:name (:book_name original)
:isbn (:isbn_code original)
:published (:publish_date original)
:publisher (:publisher_name original))
右側の式が真だったら、右側の式の結果を、左側の値をキーにしてassocすることを繰り返すわけです。
「こうかけたらいいな」と思ったのなら、そう書けるようにすればいいわけです。元のコードがあり、「こう書きたい」というコードがあるなら、元のコードを、「こう書きたい」というコードに変換すればいいのです。コンパイル前にコードを書き換える機能、それがマクロです。
復習: マクロとはなにか
前述したようにマクロとは、コンパイル前に、ある式を別の式に変換する機構です。コンパイラは、変換後のコードをコンパイルします。
マクロとはClojureの関数で、引数は、Clojureのコードそのものです。また、結果としてClojureのコードを返します。ここで役に立つのが、Clojure(というかすべてのLISP)の特徴である、「プログラムコード自体がClojureのデータである」という性質です。
マクロには、引数としてClojureのコードそのものが渡ってきますが、これはソースという文字列の塊ではなく、「シンボルのリスト」として渡ってきます。たとえば、
(when (> age 19) (println "OK"))
whenはマクロです。whenマクロには、第1引数として(> age 19)というリストが、第2引数として、(println “OK”)というリストが渡されます。
それぞれはリストですので、Clojureのあらゆるリスト操作関数が使えます。第1引数に対して first を使えば、「>」というシンボルを得られます。secondなら「age」が得られます。
引数にリストとして渡ってきたコードを、マクロ内で再構成し、新しいコードを表すリストを返せば、コンパイラはそれをコンパイルするのです。
つまり、前述の例であれば、「こうかければいいな」とモヤモヤ考えていたassoc->というマクロを書き、マクロ内で、実際に動作する、めんどくさいcond->を使った式に書き換えて返せばいいわけです。
別の関数を使って書けることであれば、全部マクロで書けます。その「書けることを分かっている」式に変換すればいいのですから。
Clojureには、マクロを書きやすくするために、マクロテンプレートとして使える「構文クオート」という仕組みがあります。「`」(バッククオート)で式を始めれば、それ以降はマクロテンプレートになります。マクロテンプレートはマクロが返す値であり、テンプレート内には、「~」あるいは「~@」を使って、マクロ外から受け取った値を埋め込めます。
assoc->マクロを作る
構文クオートを使うと、assoc->マクロは次のように書けます。
(追記: convert-clases関数は、あとから分配束縛を使えばもっと簡単に書けることに気がついたので、最初にポストしたあとに今のものに書き換えました)
(defn- convert-clauses
[[k v & clauses :as data]]
(when (seq data)
(concat [v (list 'clojure.core/assoc k v)] (convert-clauses clauses))))
(defmacro assoc->
[rec & clauses]
(let [converted (convert-clauses clauses)]
`(cond-> ~rec
~@converted)))
assoc->マクロの第2引数以降のすべての式は、clausesに入ります。convert-clausesは、clausesリストを引数に、
[(:book_name o) (clojure.core/assoc :key1 (:book_name o)
(:isbn_code o) (clojure.core/assoc :key2 (:isbn_code o)]
というベクタを作り出します。convert-clausesの中で、Clojureソースコード自体を分配束縛(destructuring)でk, v, clausesというシンボルに分解したり、concatなどを使って操作していることが分かると思います。
assoc->マクロは~@を使って、このベクタを展開してテンプレート内に貼り付けます。これで晴れて、
(assoc-> {}
:key1 (:book_name o)
:key2 (:isbn_code o))
は、
(cond-> {}
(:book_name o) (clojure.core/assoc :key1 (:book_name o)
(:isbn_code o) (clojure.core/assoc :key2 (:isbn_code o))
といったcond->式に変換されます。
まとめ
Clojureのマクロは、Clojureのリストを引数に受け取って、Clojureの関数でコードを操作することができる仕組みです。
文字列を操作するのであれば、パース処理などが必要で大変ですが、Clojureであれば、「シンボルのリスト」をClojureで操作するだけで、コードを変換することが出来ます。
もし関数で書いてもうまく理想の書き方ができないなら、マクロでやってみればいいと思います。
自社開発ではClojureを中心に使っていて、ユーザーが増えて欲しい気持ちもあって、Clojureのテキスト的なものを書いていては挫折、などを繰り返してました。キリがないので、テキストのことは気長に構えて、開発中に気がついたことはどんどんブログに書いて行こうと思います。
手始めに、最近やっと「実用的」という意味で使えるようになったかなーと思えた、「プロトコル」について。
Clojureのプロトコルは、JavaでいうところのInterfaceのようなものです。型名称と関数定義だけを束ねたものです。defprotocolによってプロトコルを定義することができます。
Javaプログラマ歴が長いので「まあInterfaceなら余裕だな」と構えていましたが、意外と使い方が難しい。以下、Javaプログラマ的感覚でプロトコルを使うと使いにくくなりかねない点について書いてみます。
「標準実装」という概念はない
Javaの場合、すべてのプログラムはなんらかのクラスの中に書きますし、さらにクラスの継承関係、特に実装継承というものが当たり前に存在します。もう、空気のようなもんですからInterfaceを用意する場合も、そのInterfaceの標準実装を、クラスとして提供することを前提にしていたりします。DefaultXXXとかAbstractXXXとかそんなのですね。だから、次のようなInterfaceを定義しがちです。
public interface IComponent {
boolean isVisible();
boolean isEnabled();
List<Node> makeNodes(Session session);
List<String> getDependentCssFiles();
List<String> getDependentJavaScriptFiles();
}
isVisibleとisEnabledはほとんどのケースでは常に真であり、getDependentCssFilesとgetDependentJavaScriptFilesとは、デフォルトでは空のリストを返す、なんてことを空気のように想定していたりします。「デフォルトでは」と。
「デフォルト」がない!
これは実際に私がやらかしたことです。
前提として、私はClojureのような関数型言語であっても、データとその操作をする関数を束ねておく、という考え方はコードの書き方として重要だと思っています。「データとその操作」というコード整理の仕方はオブジェクト指向が普及するよりも前にすでに実践されていたわけだし(クラシックMacのAPIなんかは勉強になります)、なにより、どの関数が何をやっているのかが分かりやすい。ClojureはJavaと比べるとクラスに縛られないので、もう少し緩くコードをかけますが(クラスに結びつけられないものを無理矢理クラスにする、ということはせずに済む)、大枠としてはそういう構造を守りたいな、と思ってました。
ところが、人間は弱いもので、安直な書き方ばかりしてきてしまい、実際のコードとなると、各関数が操作するデータはまちまち、ひとつひとつ思い出さないとコードが読めない、という事態になってしまいました。
これはやばいということで、本気でちゃんと整理しようと思ったのが発端です。
Clojureにはデータ構造と操作を束ねる機構として、関数の集合を作るdefprotocol、データの集合とプロトコルを結びつけるdeftypeとdefrecordというマクロが用意されていて、これらを使えば、Cのころのように、頭を振り絞ってライブラリ構造を整理することもなく、すんなりと似たような構造を実現できそうです。それらのデータタイプが実装している関数群を示すのが、プロトコルです。
プロトコルは別にrecordとtypeに限定されたものではなく、Clojure(とJava)のあらゆるデータ構造は、プロトコルを「実装」できますが、ここでは関係ない話題なので、興味ある人はextend-protocolとか調べればいいかと。
私はWeb向けのアプリケーションを書いているので、まさに上記のようなComponentの概念が必要になりまして、次のような感じのプロトコルを定義したのです。このプロトコルを、ダイアログとか、画面上の部品なんかで使えばいいだろう、という感覚です。Java脳です。
(defprotocol Component
(css-files [this])
(js-files [this])
(visible? [this])
(enabled? [this])
(make-nodes [this session]))
ところが、うまく行きませんでした。前にも書いたように、css-files, js-files, visible?, enabled? には想定しているデフォルト動作があって、ほとんどのケースではそのデフォルト動作で済むのです。しかし、ClojureはMLなんかをみても、実装と型は明確に分離されているべき、実装は継承すべきではない、という考え方が強い言語でして、このプロトコルに「標準実装」を提供する方法がありません。毎回、同じような実装を書かないといけない。
(defrecord LoginDialog []
Component
(visible? [this] true)
(enabled? [this] true)
(css-files [this] nil)
(js-files [this] nil)
(make-nodes [this session] (create-login-dialog-nodes session))
この例であれば、visible?からjs-filesの4行はほとんどのケースで同じになります。毎回同じです。
実際には、「Clojure Programming」なんかを読むと「extend使って、実装を、キーが関数名、値が関数であるようなマップで提供すれば、『デフォルト実装関数マップ』を用意しておくだけで、なんと!merge関数一発でデフォルト実装を『継承』できてしまう!すごい」って誇らしげに書いてるのですが、正直コードがやぼったくなるし遅くもなるので、あんまりやりたくない。
プロトコルは小さくする
でいろいろどうすればプロトコルをいい感じに使えるのかなーと、いろんなClojureライブラリを読んでいたのですが、ふと、どのプロトコルもとても小さいことに気がつきました。プロトコルの関数は2個とか1個だったりするケースが多い。
ああ、そうか。プロトコルは小さくていいんだ。と当たり前のことに気がつきました。
小さなプロトコルをたくさん定義して、そのプロトコルについて、デフォルトとして想定している動作でない動作を提供したい時のみ、プロトコルを実装すればいい。あるデータ型がプロトコルを実装してないなら、デフォルトの動作をすればいい。
というわけで、次のような細かいプロトコルに分割しました。
(defprotocol Visible (visible? [this]))
(defprotocol Enabled (enabled? [this]))
(defprotocol CssFileProvider (css-files [this]))
(defprotocol JavaScriptFileProvider (js-files [this]))
(defprotocol NodeProvider (make-nodes [this session]))
常に実装すべき関数はmake-nodesだけなので、それについてだけは、汎用関数を用意しておきます。
(defn make-base-nodes
[c session]
(when (instance? Visible c)
(when-not (visible? c) [])
(when (instance? Enabled c)
...
(when ...
(when ...
みたいな感じで、ここで「もしプロトコルAを実装してれば…」みたいな判定を行い、実装してなければデフォルトの動作をすればよい。
こうすれば、実際に型定義をするときは、必要なプロトコルだけを実装すればいい。
(defrecord LoginDialog []
NodeProvider
(make-nodes [this session] (create-login-dialog-nodes session)))
シンプル。
同じ方式で、プログラムの大部分を改修したら、かなりプログラムの見通しがよくなりました。
まとめ
実装継承のような仕組みがない世界では、大きなプロトコルは避けないと、同じコードを毎回書くことになってしまいます。プロトコルは小さくまとめて、必要な時だけ実装するのが良い、というのは、ちょっとした発想の転換でした。
追記
コメントで、マルチメソッドを使えばいいって話が出ました。たしかに、マルチメソッドはデフォルト実装も持てますし、形象関係の定義すらできるので、よさそうですね。どうも私はマルチメソッドは「便利なcond」としてしか使ってないみたい…
プロトコルには、「関数を束ねて管理する」という利点があると思いますが、今回紹介したケースでは、すべてを関数ひとつだけのプロトコルに分割してしまいましたから、マルチメソッドでやればいいように感じます。
マルチメソッドは仕組み上ちょっと遅いようですが、そこまで速度が要求されるところでもないですし。