Lambda icon λ字を幾何学的なパスで再現したアイコン Common Lispでカリー化の実装を
考える
(クロージャとマクロ)

  • Common Lispでカリー化を実現するにはクロージャを使うが、関数と変数の名前空間が分離したLisp-2の設計により、呼び出しにfuncallが必要になる。
  • 部分適用を連鎖させるとfuncallが重なり、式の意図より構文のノイズが先に目に入る読みにくさが生じる。
  • マクロを使うとコードを評価前に変換でき、with-partialでfuncallを不要にしたりpipelineで左から右へのデータフローを表現したりできる。

関連記事

1. カリー化とは?

関数型プログラミングの基本概念の一つに、「カリー化」があります。

カリー化とクロージャ Common Lisp(Lisp-2) Scheme(Lisp-1) (defvar add5 (curry #’+ 5)) (funcall add5 3) ; => 8 funcall が必要 名前空間が分離 (define add5 (curry + 5)) (add5 3) ; => 8 そのまま呼べる 名前空間が共通 (defun curry (f &rest partial-args) (lambda (&rest remaining-args) (apply f …)))

カリー化(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で標準的に使える準標準ライブラリ alexandriacurryrcurry が用意されています。

(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は、関数の名前空間と変数の名前空間が別々に存在します。

defvarlet で束縛した値は変数として扱われます。
これを関数として呼び出すには funcallapply を経由しなければなりません。

この設計を、「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-1Lisp-2の違いにあります。

2.1. funcall が積み重なる

Common Lispは、関数と変数を分離することで得られる柔軟性のトレードオフとして、funcall が必要です。

funcall は積み重なる Common Lisp (funcall (funcall (funcall add3 1) 2) 3) ; 3段ネストのfuncall → 読みにくい vs Scheme (((add3 1) 2) 3) 構文ノイズ 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 MLf a a a
Python / JavaScript / Gof(a)(a)(a)
Rubyf.(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-partialdefcurried のようなマクロで局所的に吸収するのが現実的な対応です。

3. partialマクロでクロージャ生成する

Common Lispでは、マクロでクロージャを生成する方法もあります。

マクロで構文レベルから解決 partial コードをインライン 展開して生成 (partial #’* 2) with-partial flet で局所的に 関数名前空間へ登録 funcall 不要 pipeline 左→右の順で データを流す構文 reduce で展開 (pipeline 5 (partial #’* 2) (partial #’+ 3)) ; => 13 注意 マクロは高階関数に渡せない。mapcar へ渡すにはλで包む。

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)

これなら、doublefuncall なしで #'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-partialpipelinedefcurried のように局所的な書き味を大きく改善できます。
ただ、Haskellのように「引数が足りなければ自動でカリー化」という動作をすべての関数に透過的に適用することは、マクロだけでは実現できません。

マクロはコードを変換しますが、関数呼び出しの評価規則そのものは変えられないからです。

結局、Common Lispでカリー化を使うときの判断はシンプルです。
データとして渡したいならクロージャ、呼び出し構文を整えたいならマクロ、そして言語設計の制約を意識した上で書く場所を選ぶ、ということになります。