CLOJURE for the BRAVE and TRUEでClojure学習 part4

はじめに

Clojure の学習備忘録。 今回は、Chapter 5: Functional Programming では以下を学習する

  • pure functionとはなんであるか?
  • pure functionがなぜ便利なのか?

以下pure functionを純粋関数と呼ぶ。

目次

Pure Functions: What and Why

println, rand を除けば、今まで使ってきた関数はすべて純粋関数である。 純粋関数は、以下2つの特性をもつ。 

  • 渡された引数が同じならば、必ず同じ結果を返す これは 参照透過性 と呼ばれる
  • サイド・エフェクト(副作用)を生まない。 関数の外にある変数やファイルといった関数スコープ外にあるアクセス可能な対象を変更しないこと。

この2つの特性は、関数の独立化を可能にし、他のシステムに影響を与えないため、より論理的なコーディングができる。 「この関数を呼び出したら、他に影響しないだろうか」といったことで悩む必要はなくなるわけだ。

Pure Functions Are Referentially Transparent

純粋関数かどうかは、渡される引数と、イミュータブルな返り値という2点においてのみ決まる。

参照透過性の例は次の通り。

(+ 1 3)
: => 3

イミュータブルな値にのみ依存してるならば、その関数は参照透過性をもつといえる。 文字列 , Daniel-san もイミュータブルなので、次の関数も参照透過性をもつと呼べる。

(defn wisdom
  [words]
  (str words ", Daniel-san"))

(wisdom "Always bathe on Fridays")
; => "Always bathe on Fridays, Daniel-san"     

一方参照透過性を満たさないは次の通り。 実行するたびに異なる結果を返すため、乱数を利用する関数はみな参照透過性をもつことはない。

(defn year-end-evaluation
  []
  (if (> (rand) 0.5)
    "You get a raise!"
    "Better luck next year!"))     

ファイルを読み込む関数もまた参照透過性をもたない。というのは、ファイルの内容は常に一定だと保証されないからである。 よって analyze-file は参照透過性がないが、 analysis は参照透過性をもつ関数になる。

(defn analyze-file
  [filename]
  (analysis (slurp filename)))

(defn analysis
  [text]
  (str "Character count: " (count text)))     

Pure Functions Have No Side Effect

副作用は、名前と値の関連付けを変更するものであり、javascriptを例にみてみよう。

var haplessObject = {
  emotion: "Carefree!"
};

var evilMutator = function(object){
  object.emotion = "So emo :'(";
}

evilMutator(haplessObject);
haplessObject.emotion;
// => "So emo :'("

もっとも副作用がなければ、ファイルへの書き出しや画面のRGB変更などは実現できない。 しかし、それでも副作用は変数名やkey名の参照先が何なのかをわからなくさせるため潜在的な危険を残してしまう。 参照先がなぜ・どのように変更されたのか追うのが困難になって、デバッグもむずかしくなったりする。副作用のある関数は、呼び出したときに外部の変数を変更しないかだけではなく、副作用関数に依存しているすべての関数でも同じく注意をはらないといけない。 一方副作用のない関数ならば、インプットとアウトプットの関係のみを考えばよく、それ以外に変更されること心配しなくてよい。

というわけで、副作用のある関数は限定的に使用するのがよい。 Clojureは、コアとなるデータ構造がイミュータブルなので、副作用のない関数を作成しやすい。

Living with Immutable Data Structures

イミュータブルなデータ構造というのは、副作用がないということである。 しかし副作用なしでどうやってコードをかけばよいのだろうか?

Recursion Instead of for/while

javascriptで次のようなコードを書いたことはあるだろう。

var wrestlers = getAlligatorWrestlers();
var totalBites = 0;
var l = wrestlers.length;

for(var i=0; i < l; i++){
  totalBites += wrestlers[i].timesBitten;
}

ループの外にある変数(totalBites)だけでなく、ループ内の変数iにも副作用がある。 このような副作用は対して問題にならないが、Clojureはそれも許さない。

Clojureでループ処理を書くにはどうすればよいのか? map, reduce を使用するやり方もあるが、 ここでは再帰を利用した関数型プログラミングのやり方を紹介する。

次の例をみてほしい。 Clojureでは再代入の演算子がないので、新しいスコープを作らない限り新しい値を変数にバインドできない。

(def great-baby-name "Rosanthony")
great-baby-name
; => "Rosanthony"

(let [great-baby-name "Bloodthunder"]
  great-baby-name)
; => "Bloodthunder"

great-baby-name
; => "Rosanthony"     

はじめにグローバル空間に変数名 great-baby-nameResonthony をバインドする。 次に、 let で新しいスコープをつくり、そこで great-baby-nameBloodthunder をバインドする。 let 式の評価が終わると、グローバル空間にもどって great-baby-nameRosanthony として再び評価される。

このようにスコープが異なると同じ変数名に別の値をバインドできる性質は、再帰に利用され、次の例は再帰的な問題解決である。

(defn sum
  ([vals] (sum vals 0))        ; 1
  ([vals accumlating-total]
    (if (empty? vals)          ; 2 
      accumlating-total
      (sum (rest vals) (+ (first vals) accumlating-total)))))

sum関数は2つの引数をうけとり、ひとつはコレクション(vals)で、もうひとつは蓄積用変数(accumlating-total)で、 Arityのオーバロードを利用して、accumlating-totalの初期値は0にしてる(1)。

どんな再帰的な解決も処理を継続するかどうかのチェックがあるように、sum関数も引数のチェックをおこない、 vals が空かどうかチェックする。 もし空ならば(2)、コレクションの全要素の処理が完了したので、 accumlating-total を返す。 vals が空でないと、まだ未処理の要素が残ってるので、再帰的にsumを呼び出す。 そのときの第一引数は、 (rest vals) とvalsの残りの要素が渡され、第二引数には、 (+ (first vals) accumulating-total) となり、 valsの第一要素を蓄積用変数に追加したものが渡される。これでreduceと同じように合計数を計算できるわけだ。

ただパフォーマンスの都合上, 実際のプログラムでは次のように recur を使用したほうがよい。 これはClojureが、末尾再帰をサポートしてないからということであるが、末尾再帰については説明を割愛する。

(defn sum
  ([vals]
     (sum vals 0))
  ([vals accumulating-total]
     (if (empty? vals)
       accumulating-total
       (recur (rest vals) (+ (first vals) accumulating-total)))))

Function Compsition Instead of Attribute Mutation

オブジェクトの状態を完全なものに仕上げるとき、ミューテーション(状態変更)を行うことがある。 たとえばRubyで、 GlamourShotCaption オブジェクトが入力値のスペース除去・ "lol"の大文字化する例を考えよう。

class GlamourShotCaption
  attr_reader :text
  def initialize(text)
    @text = text
    clean!
  end

  private
  def clean!
    text.trim!
    text.gsub!(/lol/, "LOL")
  end
end

best = GlamourShotCaption.new("My boa constrictor is so sassy lol!  ")
best.text
; => "My boa constrictor is so sassy LOL!"     

Glamourshotcaption クラスは、cleanメソッドで処理をカプセル化する。 Glamourshotcaption オブジェクトをつくると、内部変数に文字列を割り当てて、このミューテーション(内部状態変更)処理が実行される。

Clojureの場合、次のようになる。

(require '[clojure.string :as s])
(defn clean
  [text]
  (s/replace (s/trim text) #"lol" "LOL"))

(clean "My boa constrictor is so sassy lol!   ")
; => "My boa constrictor is so sassy LOL!"

Clojureでは状態変更はまったく行われない。 clean 関数は、イミュータブルな値を受けとると、純粋関数の s/trim でスペースをトリミングし、 さらにその結果を 純粋関数 s/replace に渡して、大文字化した結果を返す

このように関数を組み合わせるやり方は、関数合成とよばれる。 先程の再帰が同じ関数を引数を変えて何度も呼び出したのに対して、関数合成は異なる関数を組み合わせる。 一般的に関数型プログラミングでは、このようにシンプルな関数を組み合わせてより複雑な処理を構築する。

Cool Things to Do With Pure Functions

データから新しいデータを生み出すのと同じように、関数から新しい関数を生み出すことができる。 前chapterで登場した partial もそれであるが、ここではさらに comp, memorize を紹介する。

comp

前節でみたように、入力と出力の関係にだけに注目すればよいので、純粋な関数同士の合成は常に安全になる。 関数合成はよく使われるので、Clojureでは comp 関数で合成関数をつくれる。

((comp inc *) 2 3)    
; => 7

ここでは、 inc, * から匿名合成関数をつくっている。この関数に引数2,3を渡すと、 2 * 3が行われた後、それに+1した結果を返す。 数学的な用語で説明すると、f1, f2, …, fnから合成関数gをつくったとき g(x1, x2, …, xn)は、f1(f2(fn(x1, x2, …, xn)))と同義である。

注意すべきは、 最初に適用される * は引数をいくつでも受け取れるが、 それ以外の関数は1つしか引数が取れないということである。

次の例では、 comp を使ってロールプレーイングゲームのキャラクタの属性を抜き出せる。

(def character
  {:name "Smooches McCutes"
   :attributes {:intelligence 10
                :strength 4
                :dexterity 5}})
(def c-int (comp :intelligence :attributes))
(def c-str (comp :strength :attributes))
(def c-dex (comp :dexterity :attributes))

(c-int character)
; => 10

(c-str character)
; => 4

(c-dex character)
; => 5

匿名関数を使っても同じことができるが、 comp を使ったほうがコード量が少なくエレガントである。 これら3つの関数はすべて引数が1つであるが、合成したい関数のなかに2つ以上の引数をもつものがあるときどのようにすればよいだろうか? そういうときは匿名関数を使える。

(defn spell-slots
  [char]
  (int (inc (/ (c-int char) 2))))

(spell-slots character)
; => 6

このspell-slotsは、はじめ2で割り、それに1を足し、最後に小数をなくすため整数にする関数である。 割り算は引数を2つもっているので、 comp を使って同じ処理をさせるには次のようにする。

(def spell-slots-comp (comp int inc #(/ % 2) c-int))

2で割るために、匿名関数で包むわけだ。

memoize

純粋関数を使う時にもうひとつ便利なのがメモイズであり、特定の関数の呼び出し結果を記録することができる。 メモイズは、関数の引数と戻り値を保存することができる、2回目同じ引数で関数を呼び出したとき、すぐさま結果を受け取れる。 処理に時間かかる関数を使うときには便利である。たとえば、処理に1秒かかる関数がメモイズされていない場合は次の通り。

(defn sleepy-identity
  "Returns the given value after 1 second"
  [x]
  (Thread/sleep 1000)
  x)
(sleep-identity "Mr. Fantasitico")
; => "Mr. Fantasitico" after 1 second

(sleep-identity "Mr. Fantasitico")
; => "Mr. Fantasitico" after 1 second

2回目の呼び出しも1秒かかる。しかし、メモイズを利用すれば1秒かかるのは、1回目呼び出しだけになる。 それ以降の呼び出しは、即座に結果を返す。

(def memo-sleepy-identity (memorize sleepy-identity))
(meom-sleepy-identity  "Mr. Fantasitico")
; => 1秒後に"Mr. Fantasitico"を返す

(meom-sleepy-identity  "Mr. Fantasitico")
; => すぐに"Mr. Fantasitico"を返す

さいごに

はじめに書いた内容を再度整理すると、

  • pure functionとはなんであるか?
    同じ引数ならば同じ結果を返し、副作用がない関数のこと。

  • pure functionがなぜ便利なのか?
    クラスのメソッドならば別の箇所で利用しにくいが、関数ならば簡単に転用できる
    関数合成を使えば、シンプルな関数を組合わせてより複雑な処理を実現できる

これはClojureに限った話ではないなとあらためておもう。 jsでもループに再帰関数を利用できるわけなので、他の言語を利用するときも可能な限り純粋関数で書くようにしよう。 個人的には、純粋関数で書くことのなによりもメリットは単体テストが書きやすいことだと思う。