TL;DR

  • 純粋関数はイミュータブルなデータと切り離せず、Clojure は更新前後のバージョンで構造を共有する永続的データ構造で、コピーのコストを抑えている。
  • 遅延評価は参照透過性に依存し、参照透過な関数はメモ化や自動並列化とも相性が良い。FP のコードは読み書きしやすく、テストしやすく、再利用しやすい。
  • Clojure の FP には 6 つのガイドラインがあり、特に「シーケンスライブラリを熟知する」と「問題を分割する」が大事である。

内容

純粋関数

プログラムは純粋関数(副作用を持たない関数)を組み合わせて作る。副作用を持たないとは、引数以外のものに依存せず、外界に影響を与えるのは戻り値を通してだけということである。

標準出力は純粋ではない

プログラムの出力は純粋ではない。たとえば println を使うと、出力ストリームにデータを流し込んで、外の世界を変化させる。また、println の結果は関数の外の世界に影響を受ける。標準出力ストリームはリダイレクトされるかもしれないし、すでに閉じているかもしれないし、エラーを起こすかもしれない。

純粋関数とイミュータブルなデータ

純粋関数はイミュータブルなデータと切り離せない。

(defn mystery [input]
	(if input data-1 data-2))

この関数が純粋であるためには、data-1 と data-2 はイミュータブルでなければならない。もしミュータブルならば、同じ input に対して異なる結果を返すようになる。

1 つのデータがミュータブルになるだけで、一連の関数呼び出しがすべて純粋でなくなる。純粋関数を実装すると決めたならば、アプリケーションの大部分でイミュータブルなデータを使う必要がある。

永続的データ構造

イミュータブルなデータは、Clojure にとって FP と並行性の両方で極めて重要な役割を果たす。

  • FP: 純粋関数はミュータブルなデータ構造を更新するといった副作用を持てない
  • 並行性: 並行プリミティブは、スレッドセーフな操作を保証するために、データがイミュータブルであることを必要とする

パフォーマンス上の懸念

ただし、この場合はパフォーマンスを考慮する必要がある。一般的に、すべてのデータがイミュータブルである場合、「データを更新する」というのは「元のデータから、変えたい部分だけを変更したコピーを作る」ことを意味する。しかし、これではあっという間にメモリを食い尽くしてしまう。

永続的データ構造

Clojure はこのような「すべてをコピーする」というアプローチではなく、永続的データ構造を採用している。ここでいう永続的とは、データ構造が更新前のバージョンを取っておくことを意味する。その際、可能な限り効率よく、更新前後のバージョンで構造を共有している。

構造の共有

構造の共有はリストで示すと分かりやすい。

(def a '(1 2))

次に、a に要素を 1 つ追加した b を作る。

(def b (cons 0 a))

このとき、b は a の構造をコピーするのではなく、a をそのまま使える。

すべての Clojure のデータ構造は、可能な限り構造を共有するようになっている。もちろん、リスト以外のデータ構造における共有のメカニズムはもっと複雑である。

遅延評価と再帰

FP は再帰と遅延評価を多用する。再帰とは、直接的あるいは間接的に自分自身を呼び出すことを指す。遅延評価とは、式の計算を必要とされるまで待つことを指す。

Clojure では、関数と式は遅延評価されないが、シーケンスは多くの場合で遅延評価の対象となる。Clojure ではシーケンスを扱うことが多いので、プログラマは、すべてを遅延評価する言語の利点の多くを享受できる。特に遅延シーケンスを使うと、極めて複雑な式を組み上げて、必要のない計算をせずに済ませられる。

遅延評価は常に同じ値を返す純粋関数であることを前提としている。純粋関数でないと、呼び出すタイミングをプログラマが意識的に制御しなければならないが、これは遅延評価と相性が悪い。

参照透過性

遅延評価は「関数の呼び出しを、いつでもその結果の値と置き換えられる」という性質に依存している。この性質を参照透過性と呼ぶ。

参照透過性を持つ関数は、遅延評価だけでなく、次のようなテクニックとも相性が良い。

  • メモ化: 結果を自動的にキャッシュする
  • 自動並列化: 関数の評価を別のプロセッサやマシンに移動する

純粋関数は定義上、参照透過性を持つ。

FP の利点

必要な情報がすべて引数に渡ってくるので、グローバルスコープなどの様々な外的要因を考える必要がなく、読み書きしやすい。

読み書きしやすいコードは、自然とテストしやすい。加えて、FP におけるテストでは、引数をそろえる以外に整える必要のある環境がない。

FP のコードは再利用もしやすい。コードを再利用するには、次のことが必要不可欠である。

  • 便利なコードを見つけて理解する。読み書きしやすいので、この点はすでに満たしている
  • 再利用するコードと他のコードを組み合わせる

この「コードを組み合わせる」というのは難しい問題である。OOP では、カプセル化によって組み合わせ可能なコードを書く。カプセル化はコードを組み合わせる際の助けになるが、不十分である。たとえば、十分にカプセル化されているオブジェクト同士であっても、副作用によって予測できない相互作用が出てくる。

一方、純粋関数は副作用がないので、真の意味でのカプセル化ができ、組み合わせ可能である。

Clojure の FP における 6 つのガイドライン

Clojure の FP におけるガイドラインは、次の 6 つである。特に大事なのは 5 と 6 である。

  1. 直接の再帰を避ける。JVM は再帰呼び出しを最適化できないので、直接再帰している Clojure プログラムはスタックオーバーフローを引き起こす
  2. 要素数が固定かつ少ないシーケンスを作るときは recur を使う。Clojure はrecur を使っている呼び出しを最適化できる
  3. 要素数が可変または巨大なシーケンスを作るときは遅延評価を使う。recur は使ってはいけない。遅延評価にすると、コードを呼び出す側はシーケンスの必要な部分だけを使える
  4. 遅延シーケンスを必要以上に現実化しない。現実化とは、遅延評価の対象となった式を実際に評価することを指す
  5. シーケンスライブラリを熟知する。recur や遅延評価の API をまったく使わずに済むケースもある
  6. 問題を分割する。簡単に見える問題であっても、より小さな部品に分割すると、シーケンスライブラリを使ったより一般的で再利用可能な解法が見つかることがある

キーワード