- Common Lispでカリー化を実現するにはクロージャを使うが、関数と変数の名前空間が分離したLisp-2の設計により、呼び出しにfuncallが必要になる。
- 部分適用を連鎖させるとfuncallが重なり、式の意図より構文のノイズが先に目に入る読みにくさが生じる。
- マクロを使うとコードを評価前に変換でき、with-partialでfuncallを不要にしたりpipelineで左から右へのデータフローを表現したりできる。
1. カリー化とは?
関数型プログラミングの基本概念の一つに、「カリー化」があります。
カリー化(currying)とは、複数の引数をとる関数を、引数を一つずつ受け取る関数の連鎖に変換する技法です。
数学者・論理学者のHaskell Brooks Curryにちなんでいます。
HaskellやMLでは言語レベルで自動的に行われます。
たとえば、Haskellでのカリー化は、こういう形で現れます。
isPrime :: Int -> Bool
isPrime n = all (\p -> n `mod` p /= 0) [2..isqrt n]
where isqrt = floor . sqrt . fromIntegral
primesUpTo :: Int -> [Int]
primesUpTo n = filter isPrime [2..n]
greaterThan10 :: [Int] -> [Int]
greaterThan10 = filter (> 10)Code language: Haskell (haskell)
filter (> 10) の (> 10) がカリー化です。
「10より大きいか判定する関数」をその場で作って filter に渡しています。
引数が足りない呼び出しは、言語レベルで自動的にカリー化されたクロージャになります。
1.1. カリー関数を作る(curry)
Common Lispも関数型言語と呼ばれ、カリー化のテクニックでコードを書くこともできますが、その実装には、難点もあります。
Common Lispでは、クロージャを使ってカリー化を書きます。
たとえば、クロージャを返す curry関数を作ってみます。
(defun curry (f &rest partial-args)
(lambda (&rest remaining-args)
(apply f (append partial-args remaining-args))))Code language: Lisp (lisp)
この関数を使うと、
(defun is-prime (n)
(every (lambda (p) (/= (mod n p) 0))
(loop for i from 2 to (floor (sqrt (float n)))
collect i)))
(defun primes-up-to (n)
(remove-if-not #'is-prime
(loop for i from 2 to n
collect i)))
(defvar greater-than-10 (curry #'< 10))
(remove-if-not greater-than-10 (primes-up-to 50))
; => (11 13 17 19 23 29 31 37 41 43 47)Code language: Lisp (lisp)
(curry #'< 10) で「10 < x」を判定するクロージャを作り、remove-if-not に渡しています。
Haskellの filter (> 10) と対応する部分です。
1.2. alexandriaのcurryとrcurry
ちなみにカリー化関数は、自前実装しなくても、SBCLで標準的に使える準標準ライブラリ alexandria に curry と rcurry が用意されています。
(ql:quickload :alexandria)
;; 左から部分適用
(defvar add5 (alexandria:curry #'+ 5))
(funcall add5 3) ; => 8
;; 右から部分適用
(defvar subtract-from-10 (alexandria:rcurry #'- 10))
(funcall subtract-from-10 3) ; => -7 (3 - 10)Code language: Lisp (lisp)
rcurry は右側の引数を先に埋めます。(- 3 10) のように引数の順序が意味を持つ関数で役立ちます。
2. Lisp-2という設計(funcall)
ただ、カリー化した変数は高階関数に渡すことはできますが、そのまま (add5 3) とは書けません。
呼び出しには funcall が必要です。
(defvar add5 (curry #'+ 5))
(mapcar add5 '(1 2 3 4 5)) ; => (6 7 8 9 10)
(funcall add5 3) ; => 8
(funcall add5 10) ; => 15Code language: Lisp (lisp)
この制約は、Common Lispの言語設計に由来します。
Common Lispは、関数の名前空間と変数の名前空間が別々に存在します。
defvar や let で束縛した値は変数として扱われます。
これを関数として呼び出すには funcall や apply を経由しなければなりません。
この設計を、「Lisp-2」と呼びます。
一方、Lisp方言でも、Schemeには違いがあります。
Schemeは「Lisp-1」で、関数と変数が同じ名前空間を使います。
(define (curry f . args)
(lambda rest
(apply f (append args rest))))
(define add5 (curry + 5))
(add5 3) ; => 8 ← そのまま呼べるCode language: Scheme (scheme)
Schemeでは、define で束縛した add5 を (add5 3) と書くだけで呼べます。
カリー化した関数を変数として持ち、自然な呼び出し構文で使えるかどうかという設計の違いは、このLisp-1・Lisp-2の違いにあります。
2.1. funcall が積み重なる
Common Lispは、関数と変数を分離することで得られる柔軟性のトレードオフとして、funcall が必要です。
部分適用を連鎖させるとさらに funcall は膨らみます。
;; 引数を一つずつ受け取るカリー化
(defun curry1 (f)
(lambda (x)
(lambda (y)
(lambda (z)
(funcall f x y z)))))
(defvar add3 (curry1 #'+))
;; 3段ネストのfuncall
(funcall (funcall (funcall add3 1) 2) 3) ; => 6Code language: Lisp (lisp)
これが、Schemeなら (((add3 1) 2) 3) と書けます。
ところが、Common Lispでは funcall が3回並びます。
構文上のノイズが先に目に入り、式を追いにくくなります。
あるいは、複数の関数を通したい場合はさらに読みにくくなります。
(funcall (curry #'+ 3) (funcall (curry #'* 2) 5))
; 5を2倍して3を足す、結果は13Code language: Lisp (lisp)
「5に何をするか」という意図より、funcall の入れ子の構造を先に読み解かないといけません。
マクロはそのトレードオフを必要な箇所だけ局所的に解消する手段として使えます。
カリー化を「書きにくい」と感じた場面が、マクロでDSLを書く動機になっています。
2.2. 他言語との比較——funcallの位置づけ
複数言語でのカリー化の呼び出し構文を並べると、Common Lispの制約がはっきり見えます。
| 言語 | カリー化した呼び出し |
|---|---|
| Haskell / Standard ML | f a a a |
| Python / JavaScript / Go | f(a)(a)(a) |
| Ruby | f.(a).(a).(a) |
| Scheme / Clojure | (((f a) a) a) |
| Common Lisp / Emacs Lisp | (funcall (funcall (f a) a) a) |
Schemeと比べても funcall が追加で必要になっています。
Lisp-1であるSchemeは関数と変数が同じ名前空間にあるので、クロージャをそのまま呼び出せます。
Common LispとEmacs Lispだけが funcall を必要とする形になっていて、Lisp-2の設計がカリー化の呼び出し構文に直接影響していることがわかります。
この制約は言語設計のトレードオフで、Common Lispでは関数名前空間と変数名前空間を分離することで、同じ名前を関数と変数に使える柔軟性を得ています。
カリー化を多用するスタイルでは with-partial や defcurried のようなマクロで局所的に吸収するのが現実的な対応です。
3. partialマクロでクロージャ生成する
Common Lispでは、マクロでクロージャを生成する方法もあります。
partial をマクロとして定義すれば、コードを評価する前に変換できます。
(defmacro partial (f &rest args)
`(lambda (&rest rest)
(apply ,f (list* ,@args rest))))Code language: Lisp (lisp)
展開されるコードが直接インライン化されるため、クロージャ経由の間接参照が減ります。
ただし、使い方は関数版と同じです。
(defvar double (partial #'* 2))
(funcall double 5) ; => 10Code language: Lisp (lisp)
3.1. with-partial(ローカルスコープ)
funcall を不要にするには、さらに flet を使って局所的に関数名前空間へ登録することです。
(defmacro with-partial ((name f &rest args) &body body)
`(flet ((,name (&rest rest)
(apply ,f (list* ,@args rest))))
,@body))
(with-partial (double #'* 2)
(mapcar #'double '(1 2 3 4 5)))
; => (2 4 6 8 10)Code language: Lisp (lisp)
これなら、double を funcall なしで #'double として渡せています。
Lisp-2の制約をマクロで局所的に回避した形です。
3.2. pipeline マクロ(データフローを左から右へ)
同じような発想で、pipelineマクロを作ることもできます。
マクロ展開時にreduce でネストを自動生成すれば、値を関数の連鎖に通す構文も作れます。
(defmacro pipeline (value &rest fns)
(reduce (lambda (acc fn) `(funcall ,fn ,acc))
fns
:initial-value value))
(pipeline 5
(partial #'* 2)
(partial #'+ 3))
; => 13 (5×2=10、10+3=13)Code language: Lisp (lisp)
このマクロは、展開されると (funcall (partial #'+ 3) (funcall (partial #'* 2) 5)) になります。
書くのは人間に読みやすい左から右の形で、実行されるのは正しいネストです。
pipeline は、カリー関数と組み合わせることもできます。
(pipeline (primes-up-to 100)
(curry #'remove-if-not (curry #'< 10))
(curry #'mapcar (curry #'* 2)))
; 11以上の素数を2倍する
; => (22 26 34 38 46 58 62 74 82 86 94)Code language: Lisp (lisp)
3.3. 高階関数には渡せない
ただし、今度は mapcar には渡せません。
pipeline 自体を別の高階関数へ引数として渡すことはできないため、その場合はクロージャに戻す必要があります。
;; mapcarに渡したいなら関数として包む
(mapcar (lambda (x) (pipeline x (partial #'* 2) (partial #'+ 3)))
'(1 2 3 4 5))
; => (5 7 9 11 13)Code language: Lisp (lisp)
値として扱いたいならクロージャ、呼び出し構文を変えたいならマクロ、という判断になります。
4. 引数が足りない関数呼び出し(defcurriedマクロ)
ただ、Haskellと同じ書き味にはなっていません。curry を明示的に呼ぶ必要があり、funcall も残っています。
これはマクロで局所的に隠せますが、根本的な解決にはなりません。
Haskellでは、引数が足りない関数呼び出しはエラーではなく、カリー化されたクロージャを返すことが言語仕様です。(> 10) と書くだけで「10より大きいか判定する関数」が得られます。
Common Lispで同じことをするには、引数の数を実行時にチェックして足りなければクロージャを返す、という動作を関数ごとに仕込む必要があります。defcurried マクロで近づけることができます。
(defmacro defcurried (name args &body body)
`(defun ,name (&rest given)
(if (>= (length given) ,(length args))
(destructuring-bind ,args given ,@body)
(lambda (&rest rest)
(apply #',name (append given rest))))))
(defcurried add (x y) (+ x y))
(add 1 2) ; => 3
(funcall (add 1) 2) ; => 3
(defcurried greater-than (n x) (< n x))
(remove-if-not (greater-than 10) (primes-up-to 50))
; => (11 13 17 19 23 29 31 37 41 43 47)Code language: Lisp (lisp)
(greater-than 10) が引数不足を検知してクロージャを返します。
Haskellの (> 10) と構造的に同じ動きです。
ただし defcurried で定義した関数の合成を自然に書こうとすると、リーダーマクロかコンパイラマクロの領域に踏み込みます。
呼び出し構文の意味論を変えるには、言語レベルの変更が必要だからです。
4.1. どこまでマクロで対処できるか
Common Lispのマクロは強力で、with-partial や pipeline、defcurried のように局所的な書き味を大きく改善できます。
ただ、Haskellのように「引数が足りなければ自動でカリー化」という動作をすべての関数に透過的に適用することは、マクロだけでは実現できません。
マクロはコードを変換しますが、関数呼び出しの評価規則そのものは変えられないからです。
結局、Common Lispでカリー化を使うときの判断はシンプルです。
データとして渡したいならクロージャ、呼び出し構文を整えたいならマクロ、そして言語設計の制約を意識した上で書く場所を選ぶ、ということになります。