Clojure言語基本の「き」(その2)

前回Clojure言語基本の「き」 - 達人プログラマーを目指してに引き続き、Clojure言語を新しく覚える際にポイントとなる事柄をまとめていきたいと思います。

逐次実行

(do 式1 式2 ... 式n)の形式を使うことで、処理を逐次実行させることができます。最終的に一番右側の式の結果が全体的な式の結果となります。この方法は副作用の存在を前提としているため、純粋な関数型のプログラムに対する手続き型プログラミングへの抜け道となる危険性がありますが、ClojureからJavaを呼び出す場合などに便利な場合があります。

user=>
(do
  (println "Hello")
  (println "World")
  (+ 1 2))
Hello
World
3

また、doと似た構造は、(後から説明するように)letやwhenなどClojureプログラムのさまざまな場面で暗黙的に表れるという点も重要です。

letによるローカル束縛

前回の記事でdefマクロを使うことで変数が定義できると説明しました。この変数は関数の外部でも参照できるため、グローバル変数のようなものです。一方、特定のスコープの中だけで参照可能なローカル変数的な変数を定義する(ローカル束縛)にはlet特殊形式が使えます。これは(let [バインディング*] 式*)という形になります。

user=>
(let [radius 5, pi 3.141592, area (* pi (* radius radius))]
  (println "半径 =" radius)
  (println "面積 =" area)
  area)

半径 = 5
面積 = 78.5398
78.5398
user=> radius
java.lang.Exception: Unable to resolve symbol: radius in this context (NO_SOURCE_FILE:0)

letの後のベクターの部分で順次radius、pi、areaというローカル束縛を作成することで、円の面積を計算しています。なお、letの最後の式の部分は、doと同様に複数の式を記述でき、最後の式の値がlet式の値となります。なお、letの外側ではローカル束縛を参照することができないため、エラーとなっています。

条件分岐

もちろん、いわゆるif文やswitch文に相当する機能もあります。
まず(if test then else?)の形のif特殊形式があります。以下の例では、式(< number 100)を評価することでパラメーターnumberが100未満なら"yes"、それ以外なら"no"を返す関数を定義しています。(一般的に、booleanを返す述語関数の名前の最後に?をつける慣習に注意してください。)

user=>
(defn is-small? [number]
  (if (< number 100) "yes" "no"))

#'user/is-small?

user=> (is-small? 50)
"yes"
user=> (is-small? 200)
"no"

Javaの場合と比較してif文というよりは3項演算子の方が概念的に近いかもしれません。

public String isSmall(int number) {
    return (number < 100) ? "yes" : "no";
}

javaのif-else文のようにthenやelseの部分で複数の処理を実行させたい場合は明示的にdoを入れ子にして利用する必要があります。
次に、ifと似たものとしてwhenマクロがあります。これは(when test 式*)の形式となります。

user=>
(defn is-small? [number]
  (when (< number 100) (println "number = " number) "yes"))

#'user/is-small?
user=> (is-small? 30)
number =  30
"yes"
user=> (is-small? 100)
nil

ifと違って、こちらはelseが記述できない代わりに、最後の部分が暗黙doによって複数の式が実行できることになります。この他、判定条件が逆転したif-notやwhen-notも時として便利です。
最後に、switchに相当するものとしてcondマクロが使えます。

user=> 
(defn number-size [number]
(cond 
  (< number 50) "small"
  (and (<= 50 number) (< number 100)) "midium"
  :else "large"))

#'user/number-size
user=> (number-size 30)
"small"
user=> (number-size 50)
"midium"
user=> (number-size 100)
"large"

コレクションに対する関数

逐次、分岐の説明が終われば、普通の手続き型の言語ではループを使った繰り返し処理の説明になるのですが、Clojureの場合は強力なコレクション関数が使えるため、低レベルのループ(実際には再帰アルゴリズムを記述するケースは非常にまれになります。中でも、以下の関数が代表的です。

map関数

map関数を使うとコレクションの各要素をパラメータとして適用した結果を各要素とする新しいシーケンスが生成されます。たとえば、与えられた引数を2倍にする以下の関数を考えます。

user=>
(defn double-num [n] (* 2 n))
#'user/double-num
user=> (double-num 3)
6

そして、ベクター[1 2 3 4 5]の各要素を2倍にするには以下のように記述することができます。

user=>(map double-num [1 2 3 4 5])
(2 4 6 8 10)

ここで、map関数は関数型言語において高階関数と呼ばれるものの例になっていて、他の関数自体をパラメーターとして取得して処理している点に注意してください。それから、先の例ではベクターリテラルで数列を渡しましたがこのように連続した数値の列はrange関数を使って生成するのが便利です。以上の例は以下のようにも書けます。

user=>(map double-num (range 1 6))
(2 4 6 8 10)

以上の例ではわざわざdouble-num関数を別途defnで定義して実行していたのですが、JavaScriptと同様にfn特殊形式を使って無名関数を利用することもできます。今回のように単に値を倍にするだけなら以下のように書けます。

user=>(map (fn [n] (* 2 n)) (range 1 6))
(2 4 6 8 10)

(fn [n] (* 2 n))の部分が無名関数となっています。さらに、これはもっと短縮して以下のようにも記述できます。

user=> (map #(* 2 %) (range 1 6))
(2 4 6 8 10)

実際には#(* 2 %)の部分が(fn [n] (* 2 n))に展開されることになります。

reduce関数

mapと並んで重要な関数としてreduce関数があります。これはmapと比べて最初動作が理解しにくいのですが、2つのパラメーターを取る関数fに対して(reduce f coll)とした場合、collの最初の2つの要素に対してfを適用し、次にその結果とcollの3番目の要素にfを適用という順番で順次計算した結果を返します。結果はスカラー値になります。これを理解するには例を見るのが一番で、1から10までの数値を足し算するには以下のように記述できます。

user=> (range 1 11)
(1 2 3 4 5 6 7 8 9 10)
user=>(reduce + (range 1 11))
55
apply関数

前回のエントリでも説明したように実際には+関数は可変長の任意の数値を取ることができるので、実際にはreduceを使って2つずつ順番に足し合わせなくても、任意の個数の数値の合計を算出することができます。

user=> (+ 1 2 3 4 5 6 7 8 9 10)
55

でも、以下のようにするとエラーになってしまいます。

user=> (+ (range 1 11))
java.lang.ClassCastException (NO_SOURCE_FILE:0)

+関数は複数の数値をパラメーターとして取ることはできてもコレクションを取ることはできないからです。この場合、apply関数を使うと、コレクションの各要素を可変長パラメータとして取り出して渡すことができます。

user=> (apply + (range 1 11))
55
filerとremove

filer関数を使うと述語関数として指定した条件を満たす要素のみから構成されるシーケンス*1を作成できます。逆にremove関数を使うと、条件を満たさないシーケンスが生成されます。

user=>(def col (range 1 11))
#'user/col
user=> col
(1 2 3 4 5 6 7 8 9 10)
user=> (filter #(> % 5) col)
(6 7 8 9 10)
user=> (remove #(> % 5) col)
(1 2 3 4 5)

recurとloopによる再帰

前節で説明したコレクションに対する高階関数をうまく使うことで、難しい再帰アルゴリズムを書くケースは減るはずですが、再帰こそがLispの一種であるClojureらしいところでもあります。再帰の詳しい説明は別の機会にすることにしてここではごく基本的な例のみ紹介します。
単純にパラメーターで与えられた数値まで自然数を足し合わせる処理を再帰によって実装するには以下のような関数が定義できます。

user=>
(defn sum-down-from [sum x]
  (if (pos? x)
    (recur (+ sum x) (dec x))
    sum))

#'user/sum-down-from
user=> (sum-down-from 0 10)
55

recur特殊形式を使うと自分の関数を再帰的に呼び出すことができます。以上の例では、xの値が正の間(pos?関数がtrueの間)再帰的にxを一つ減らした値で呼び出され、最後にsumが返されるため、結果として合計が計算されます。なお、素直に考えると以下でも同じような気がしますし、実際これでも同じ結果になります。

user=>
(defn sum-down-from2 [sum x]
  (if (pos? x)
    (sum-down-from2 (+ sum x) (dec x))
    sum))

#'user/sum-down-from2
user=> (sum-down-from2 0 10)
55

以上の例ではrecur特殊形式を使う代わりに普通に自分自身を再帰的に呼び出しています。しかし、内部的な動作は両者で異なり、recurを使った場合は、末尾再帰最適化されるため、大量のスタック領域を使うことを避けられるという違いがあります。

user=> (sum-down-from 0 10000)
50005000
user=> (sum-down-from2 0 10000)
java.lang.StackOverflowError (NO_SOURCE_FILE:0)

なお、再帰する際に関数の先頭に戻りたくない場合、ループする範囲をloop特殊形式で指定することができます。loopはrecurの対象となる点を除くとletとまったく同じ形をしています。以下は階乗を計算しています。

user=>
(defn factorial [n0]
  (loop [fact 1, n n0]
    (if (pos? n)
      (recur (* fact n) (dec n))
      fact )))

#'user/fact
user=> (factorial 3)
6
user=> (factorial 5)
120

loopを使うことで呼び出し側には公開したくないローカルな変数が使えます。

クオート

一般的にはClojureの式は直ちに評価されます。特にシンボルはvarに対する束縛があればvarの値になるし、そうでなければエラーになります。また、(a b c)の形のリストは今での例で見てきたように(特殊形やマクロを除いて)関数呼び出しとして解釈されます。

user=> (def a "test")
#'user/a
user=> a
"test"
user=> b
java.lang.Exception: Unable to resolve symbol: b in this context (NO_SOURCE_FILE:0)
user=> (1 2 3)
java.lang.ClassCastException: java.lang.Integer cannot be cast to clojure.lang.IFn (NO_SOURCE_FILE:0)

ここで、最初のaは束縛されているvarの値"test"として評価されていますが、bは束縛が存在しないためエラーとなっています。最後のエラーは奇妙ですが、1が関数名として解釈されてしまった結果、これが関数型にキャストできないためエラーになっています。このような式の評価を抑制させるためにはquote特殊形式を使うことができます。

user=> (quote a)
a
user=> (quote b)
b
user=> (quote (1 2 3))
(1 2 3)

なお、リーダーマクロという仕組みにより一重引用符「'」を先頭につけるとquoteに展開されるため、同じ意味になります。

user=> 'a
a
user=> 'b
b
user=> '(1 2 3)
(1 2 3)

なお、似て非なる概念としてバックティック記号「`」を使った構文クオートという仕組みがありますがここでは説明しません。

varと名前空間

今までdefマクロを使うことで「変数」が定義できると説明してきました。

user=> (def a 100)
#'user/a

これをもう少し詳しく説明すると、defにより、値100を保持するvarがデフォルトの名前空間であるuserに生成され、それがシンボルaによって束縛されたということになります。*2この場合シンボルaはvarの単純名のように、現在の名前空間にしたがって一旦user/aとして解決されてから評価されます。ですから、以下の結果は同じになります。

user=> a
100
user=> user/a
100

名前空間Javaのパッケージのような感じで、同じ単純名のvarを別々の名前空間に生成できるので、大規模なプログラムを作成する際に名前の競合を避けることができます。現在の名前空間を変更するにはin-ns関数が使用できます。*3

user=> (in-ns test)
nil
test=> a
java.lang.Exception: Unable to resolve symbol: a in this context (NO_SOURCE_FILE:0)

現在の名前空間が変更されると、もう単純名aでもともとのvarを参照することはできなくなります。シンボルaがtest/aとして解決されるようになるためです。そして、以下のように同じ単純名aで別のvarをtest名前空間に作成することができます。この場合、もともとのuserに属していたvarなそのまま残っているため、完全名で参照することもできます。

test=> (def a "TEST")
#'test/a
test=> a
"TEST"
test=> user/a
100
test=> 

完全名が名前空間名/単純名の形式になっていることからもわかるように、名前空間ファイルシステムのフォルダーのようなものと考え、varは値を保持するファイルのようなものでその名前がdefでシンボルと束縛されていると考えると理解しやすいのではないでしょうか。もちろん、名前空間は階層化できます。

test=> (in-ns test/child)
nil
test/child=> (def a "Hello World")
#'test/child/a
test/child=> 

useとrequire

Javaのimportと同じように他の名前空間にあるシンボルを自分の名前空間で単純名で参照させることができます。頻繁に使用する他の名前空間に属しているシンボルを単純名で参照できると便利です。他の名前空間の読み込みに関連する関数としてrequireとuseがあり、最初は両者の使い分けがなかなか頭に入りにくいところがありますが、

という意味になります。以下の実行結果を見てください。(requireやuseで引数に対してクオートを使っている点に注意)

user=> (clojure.contrib.math/round 1.7)
java.lang.ClassNotFoundException: clojure.contrib.math (NO_SOURCE_FILE:0)
user=> (require 'clojure.contrib.math)
nil
user=> (clojure.contrib.math/round 1.7)
2
user=> (round 1.7)
java.lang.Exception: Unable to resolve symbol: round in this context (NO_SOURCE_FILE:14)
user=> (use 'clojure.contrib.math)
nil
user=> (round 1.7)
2

requireをする前は読み込み自体がされていないので、完全名を指定しても実行できません。require実行後は完全名を指定したときのみ実行できます。最後にuseを利用するとround関数自体がuser名前空間で単純名として参照できるようになります。以上のようにuseするとclojure.contrib.mathにある全シンボルが現在の名前空間から参照できるようになってしまうため、以下のように特定のシンボルのみuseすることもできます。

user=> (use '[clojure.contrib.math :only (round)])
nil

なお、REPLで対話的に名前空間を読み込むのではなく大きなプログラムをClojureソースコードとして作成する場合はプログラムの先頭でnsマクロを使って宣言するのが一般的なようです。

(ns test
  (:use [clojure.string :only [capitalize]]))

以下の文献を参考にしました。

The Joy of Clojure: Thinking the Clojure Way

The Joy of Clojure: Thinking the Clojure Way

プログラミングClojure

プログラミングClojure

*1:シーケンスとはベクター、リストなどのコレクションの違いを抽象化した概念

*2:全てのスレッドから参照できるのでルート束縛と呼ばれる。

*3:代わりにnsマクロを使うこともできる。この場合はデフォルトでインポートされるパッケージが異なる。nsマクロはREPLの対話的処理でなく、Clojureのソース中で名前空間を宣言する場合に便利。