Common Lisp 宏的 Catch-22 情况

7
通常当我尝试编写宏时,会遇到以下困难:在生成宏的扩展时调用帮助函数处理之前,需要对传递给宏的一个表单进行求值。在下面的示例中,我们只关心如何编写宏来发出所需的代码,而不关心宏本身的无用性:
想象一下(请耐心等待),Common Lisp 的 lambda 宏的一个版本,只有参数数量很重要,参数的名称和顺序并不重要。让我们称之为 jlambda。它将被使用如下:
(jlambda 2
  ...body)

其中2是返回函数的元数。换句话说,这将产生一个二元运算符。

现在想象一下,给定元数,jlambda会生成一个虚拟的lambda列表,然后将其传递给实际的lambda宏,类似于以下内容:

(defun build-lambda-list (arity)
  (assert (alexandria:non-negative-integer-p arity))
  (loop for x below arity collect (gensym)))

(build-lambda-list 2)
==> (#:G15 #:G16)

上述对 jlambda 的调用扩展将如下所示:
(lambda (#:G15 #:16)
  (declare (ignore #:G15 #:16))
  …body))

假设我们需要jlambda宏能够接收一个Lisp表达式作为其arity值,该表达式会评估为非负整数(而不是直接接收非负整数),例如:

(jlambda (+ 1 1)
  ...body)

表达式 (+ 1 1) 需要求值,然后将结果传递给 build-lambda-list 进行求值,最终结果将插入到宏扩展中。
(+ 1 1)
=> 2
(build-lambda-list 2)
=> (#:G17 #:18)

(jlambda (+ 1 1) ...body)
=> (lambda (#:G19 #:20)
     (declare (ignore #:G19 #:20))
       …body))

这里有一个版本的jlambda,当输入参数数量以数字形式直接提供时能够工作,但是当数量作为一个需要被求值的表单传递时不能工作:

(defun jlambda-helper (arity)
  (let ((dummy-args (build-lambda-list arity)))
  `(lambda ,dummy-args
     (declare (ignore ,@dummy-args))
       body)))

(defmacro jlambda (arity &body body)
  (subst (car body) 'body (jlambda-helper arity)))

(jlambda 2 (print “hello”))  ==> #<anonymous-function>

(funcall *
         'ignored-but-required-argument-a
         'ignored-but-required-argument-b)
==> “hello”
    “hello”

(jlambda (+ 1 1) (print “hello”)) ==> failed assertion in build-lambda-list, since it receives (+ 1 1) not 2

我可以使用井号点读宏来评估(+ 1 1),如下所示:

(jlambda #.(+ 1 1) (print “hello”)) ==> #<anonymous-function>

但是表单不能包含对词法变量的引用,因为在读取时评估时它们不可用:

(let ((x 1))
  ;; Do other stuff with x, then:
  (jlambda #.(+ x 1) (print “hello”))) ==> failure – variable x not bound

我可以引用传递给jlambda的所有主体代码,将其定义为函数,然后eval返回的代码:

(defun jlambda (arity &rest body)
  (let ((dummy-args (build-lambda-list arity)))
  `(lambda ,dummy-args
     (declare (ignore ,@dummy-args))
       ,@body)))

(eval (jlambda (+ 1 1) `(print “hello”))) ==> #<anonymous-function>

但我不能使用eval,因为像sharp-dot一样,它会抛弃词法环境,这是不好的。
所以jlambda必须是一个宏,因为在jlambda扩展建立正确上下文之前,我不希望函数体代码被评估; 但它也必须是一个函数,因为我希望先评估第一个表格(在本例中是arity form),然后将其传递给生成宏扩展的辅助函数。如何克服这个困境?
编辑
回应@Sylwester的问题,以下是上下文的解释:
我正在编写类似于Common Lisp中DSL实现的“奇特编程语言”。这个想法(尽管愚蠢但有潜力很有趣)是尽可能迫使程序员仅使用point-free style编写。为此,我将做几件事:
使用curry-compose-reader-macros提供大部分编写点自由风格的功能,以便在CL中进行编程。 强制执行函数的元数——即覆盖允许函数变异态的CL的默认行为。 不像Haskell一样使用类型系统来确定函数何时被“完全应用”,只需在定义时手动指定函数的元数。
所以我需要一个自定义版本的lambda来定义这种愚蠢语言中的函数,并且——如果我无法理解那个版本——自定义版本的funcall和/或apply来调用那些函数。理想情况下,它们将只是正常CL版本的皮肤,稍微改变一下功能。
这种语言中的函数将以某种方式跟踪其元数。然而,为了简单起见,我希望该过程本身仍然是可调用的CL对象,但真的很想避免使用元对象协议,因为它比宏更加令人困惑。

一个可能简单的解决方案是使用闭包。每个函数可以简单地关闭绑定一个变量,该变量存储其元数。当调用时,元数值将确定函数应用的确切性质(即完整或部分应用)。如果必要,闭包可以是“全局的”,以便提供对元数值的外部访问; 可以使用 Let Over Lambda 中的 plambdawith-pandoric 实现。

通常情况下,我的语言中的函数将表现如下(潜在有缺陷的伪代码,仅为说明):

Let n be the number of arguments provided upon invocation of the function f of arity a.
If a = 0 and n != a, throw a “too many arguments” error;
Else if a != 0 and 0 < n < a, partially apply f to create a function g, whose arity is equal to an;
Else if n > a, throw a “too many arguments” error;
Else if n = a, fully apply the function to the arguments (or lack thereof).

g的元数等于a - n是导致jlambda问题的原因所在:需要像这样创建g

(jlambda (- a n)
  ...body)

这意味着访问词法环境是必要的。

3
Catch-22是一种自相矛盾的境况,使个人因矛盾规则无法摆脱困境。例如:为了得到工作,你需要有几年的经验,但是为了获得经验,你首先需要得到工作。 - Stefan Hanke
1
有一件事情让我有点困扰:你如何使用那些参数?对于那些lambda函数,它们的主体应该是什么样子的?这对我来说有点混淆。 - coredump
1
基于包括 (declare (ignore ,@ dummy-args)) 的示例代码,我认为重点在于该函数具有特定的arity,但使用其参数。有点像CL的constantly,它可以获取任意数量的参数,但始终返回相同的值,这些函数将获取一定数量的参数,但始终评估相同的主体。 - Joshua Taylor
3个回答

6

这是一个非常棘手的情况,因为没有明显的方法可以在运行时创建特定数量参数的函数。如果没有办法做到这一点,那么最简单的方法可能是编写一个函数,接受一个元数和另一个函数,并将该函数包装在一个新函数中,该函数要求提供特定数量的参数:

(defun %jlambda (n function)
  "Returns a function that accepts only N argument that calls the
provided FUNCTION with 0 arguments."
  (lambda (&rest args)
    (unless (eql n (length args))
      (error "Wrong number of arguments."))
    (funcall function)))

一旦你拥有了这个,编写你想要的宏就变得容易了:

(defmacro jlambda (n &body body)
  "Produces a function that takes exactly N arguments and and evalutes
the BODY."
  `(%jlambda ,n (lambda () ,@body)))

它的表现方式大致符合您的期望,包括允许arity成为不能在编译时得知的内容。

CL-USER> (let ((a 10) (n 7))
           (funcall (jlambda (- a n)
                      (print 'hello))
                    1 2 3))

HELLO 
HELLO
CL-USER> (let ((a 10) (n 7))
           (funcall (jlambda (- a n)
                      (print 'hello))
                    1 2))
; Evaluation aborted on #<SIMPLE-ERROR "Wrong number of arguments." {1004B95E63}>.

现在,您可能能够在运行时执行某些操作,可能是间接地使用coerce调用编译器,但是这不会让函数体能够引用原始词法作用域中的变量,尽管您将获取错误数量的参数异常:

(defun %jlambda (n function)
  (let ((arglist (loop for i below n collect (make-symbol (format nil "$~a" i)))))
    (coerce `(lambda ,arglist
               (declare (ignore ,@arglist))
               (funcall ,function))
            'function)))

(defmacro jlambda (n &body body)
  `(%jlambda ,n (lambda () ,@body)))

这在SBCL中可以实现:
CL-USER> (let ((a 10) (n 7))
           (funcall (jlambda (- a n)
                      (print 'hello))
                    1 2 3))
HELLO 

CL-USER> (let ((a 10) (n 7))
           (funcall (jlambda (- a n)
                      (print 'hello))
                    1 2))
; Evaluation aborted on #<SB-INT:SIMPLE-PROGRAM-ERROR "invalid number of arguments: ~S" {1005259923}>.

虽然这在SBCL中可以工作,但我不确定它是否确实保证可以工作。我们正在使用coerce编译一个包含文字函数对象的函数。我不确定这是可移植的还是不可移植的。


太棒了!这绝对是一个简单而优雅的解决方案。还要感谢您提到可以将coerce转换为函数,我不知道这一点。 - Andy Page
我赞同“棒极了”的评论。我之前也试图做类似的事情,但最终放弃了。感谢Joshua Taylor提供的见解! - David Hodge

4
NB: 在你的代码中使用了奇怪的引号,例如(print “hello”)并不会输出hello,而是输出变量“hello”所评估出的结果。而(print "hello")则按照预期功能执行。

我的第一个问题是为什么?通常你知道自己需要在编译时使用多少个参数,或者至少让它具备多元性。使一个函数拥有n个参数只是一种附加特性,当使用错误数量的参数传递时,它会产生错误,但也会带来使用eval和相关函数的缺点。

这不能作为宏来解决,因为你将运行时与宏展开时间混合使用。想象一下这种用法:

(defun test (last-index)
  (let ((x (1+ last-index)))
    (jlambda x (print "hello"))))

当评估此表单并将内容替换为函数分配给test时,宏将被展开。此时,x根本没有任何值,因此宏函数只获取符号,以便结果需要使用此值。lambda是一种特殊形式,因此在展开jlambda之后立即进行扩展,也在任何使用该函数之前。

由于此操作在程序运行之前发生,因此不会发生任何词法活动。如果在加载文件之前执行此操作,那么如果您加载它,它将加载已经展开的所有宏形式。

使用compile,可以从数据生成函数。它可能像eval一样邪恶,因此您不应该将其用于常见任务,但它们存在有其理由:

;; Macro just to prevent evaluation of the body 
(defmacro jlambda (nexpr &rest body)
  `(let ((dummy-args (build-lambda-list ,nexpr)))
     (compile nil (list* 'lambda dummy-args ',body))))

因此,第一个示例的扩展变成了以下内容:

(defun test (last-index)
  (let ((x (1+ last-index)))
    (let ((dummy-args (build-lambda-list x))) 
      (compile nil (list* 'lambda dummy-args '((print "hello")))))))

这看起来可能可行。让我们进行测试:
(defparameter *test* (test 10))
(disassemble *test*)
;Disassembly of function nil
;(CONST 0) = "hello"
;11 required arguments <!-- this looks right
;0 optional arguments
;No rest parameter
;No keyword parameters
;4 byte-code instructions:
;0     (const&push 0)                      ; "hello"
;1     (push-unbound 1)
;3     (calls1 142)                        ; print
;5     (skip&ret 12)
;nil

可能的变化

我创建了一个宏,接受一个字面数字并从 a …中创建可以在函数中使用的绑定变量。

如果您不使用参数,为什么不创建一个可以这样做的宏:

(defmacro jlambda2 (&rest body)
  `(lambda (&rest #:rest) ,@body))

该结果可以接受任意数量的参数,只是忽略它:
(defparameter *test* (jlambda2 (print "hello")))
(disassemble *test*)
;Disassembly of function :lambda
;(CONST 0) = "hello"
;0 required arguments
;0 optional arguments
;Rest parameter <!-- takes any numer of arguments
;No keyword parameters
;4 byte-code instructions:
;0     (const&push 0)                      ; "hello"
;1     (push-unbound 1)
;3     (calls1 142)                        ; print
;5     (skip&ret 2)
;nil

(funcall *test* 1 2 3 4 5 6 7)
; ==> "hello" (prints "hello" as side effect)

编辑

现在我知道你在做什么,我可以给你一个答案。你的初始函数不需要依赖于运行时,因此所有函数实际上都有固定的arity(参数个数),因此我们需要进行柯里化或部分应用。

;; currying
(defmacro fixlam ((&rest args) &body body)
  (let ((args (reverse args)))
    (loop :for arg :in args
          :for r := `(lambda (,arg) ,@body)
                 :then `(lambda (,arg) ,r)
          :finally (return r))))

(fixlam (a b c) (+ a b c)) 
; ==> #<function :lambda (a) (lambda (b) (lambda (c) (+ a b c)))>


;; can apply multiple and returns partially applied when not enough
(defmacro fixlam ((&rest args) &body body)
  `(let ((lam (lambda ,args ,@body)))
     (labels ((chk (args)
                (cond ((> (length args) ,(length args)) (error "too many args"))
                      ((= (length args) ,(length args)) (apply lam args))
                      (t (lambda (&rest extra-args)
                           (chk (append args extra-args)))))))
       (lambda (&rest args)
         (chk args)))))

(fixlam () "hello") ; ==> #<function :lambda (&rest args) (chk args)>

;;Same but the zero argument functions are applied right away:
(defmacro fixlam ((&rest args) &body body)
  `(let ((lam (lambda ,args ,@body)))
     (labels ((chk (args)
                (cond ((> (length args) ,(length args)) (error "too many args"))
                      ((= (length args) ,(length args)) (apply lam args))
                      (t (lambda (&rest extra-args)
                           (chk (append args extra-args)))))))
       (chk '()))))

(fixlam () "hello") ; ==> "hello"

使用 COMPILE 还会有一个问题,函数无法引用周围的词法变量。因此,(let ((foo "hello")) (jlambda 1 (print foo))) 将不起作用。 - jkiiski
首先,感谢@Sylwester提供的有用答案!混淆运行时和宏展开时肯定是一个问题。(不确定您所说的“奇怪引号”是什么意思:当我运行它时,“hello”这个单词被打印出来了。也许我在某个地方打错了字?)其次,我将我的回答作为原始问题的编辑发布,以回答您的“为什么?”问题,因为它可能有助于讨论,并且太长了无法在此处发布。 - Andy Page
@jkiiski 这是正确的,因为它和 coerce 只是伪装成 eval,然而参数的数量可以是一个表达式,引用了词法变量,在这种情况下使它比 OP 版本稍微好一些,但并不多。 - Sylwester
@AndyPage 我添加了一些可能有帮助的内容。 - Sylwester

1
如果您只需要可以部分或完全应用的lambda函数,我认为您不需要显式地传递参数量。您可以像这样做(使用Alexandria):
(defmacro jlambda (arglist &body body)
  (with-gensyms (rest %jlambda)
    `(named-lambda ,%jlambda (&rest ,rest)
       (cond ((= (length ,rest) ,(length arglist))
              (apply (lambda ,arglist ,@body) ,rest))
             ((> (length ,rest) ,(length arglist))
              (error "Too many arguments"))
             (t (apply #'curry #',%jlambda ,rest))))))


CL-USER> (jlambda (x y) (format t "X: ~s, Y: ~s~%" x y))
#<FUNCTION (LABELS #:%JLAMBDA1046) {1003839D6B}>
CL-USER> (funcall * 10)  ; Apply partially
#<CLOSURE (LAMBDA (&REST ALEXANDRIA.0.DEV::MORE) :IN CURRY) {10038732DB}>
CL-USER> (funcall * 20)  ; Apply fully
X: 10, Y: 20
NIL
CL-USER> (funcall ** 100) ; Apply fully again
X: 10, Y: 100
NIL
CL-USER> (funcall *** 100 200) ; Try giving a total of 3 args
; Debugger entered on #<SIMPLE-ERROR "Too many arguments" {100392D7E3}>

编辑: 这里还有一个版本,可以让您指定函数的参数数量。但实话实说,我不知道这有什么用。如果用户无法引用参数,并且没有自动执行任何操作,则不会对它们进行任何操作。它们可能不存在。

(defmacro jlambda (arity &body body)
  (with-gensyms (rest %jlambda n)
    `(let ((,n ,arity))
       (named-lambda ,%jlambda (&rest ,rest)
         (cond ((= (length ,rest) ,n)
                ,@body)
               ((> (length ,rest) ,n)
                (error "Too many arguments"))
               (t (apply #'curry #',%jlambda ,rest)))))))


CL-USER> (jlambda (+ 1 1) (print "hello"))
#<CLOSURE (LABELS #:%JLAMBDA1085) {1003B7913B}>
CL-USER> (funcall * 2)
#<CLOSURE (LAMBDA (&REST ALEXANDRIA.0.DEV::MORE) :IN CURRY) {1003B7F7FB}>
CL-USER> (funcall * 5)
"hello" 
"hello"

编辑2: 如果我理解正确,您可能正在寻找类似于这样的东西(?):

(defvar *stack* (list))

(defun jlambda (arity function)
  (lambda ()
    (push (apply function (loop repeat arity collect (pop *stack*)))
          *stack*)))


CL-USER> (push 1 *stack*)
(1)
CL-USER> (push 2 *stack*)
(2 1)
CL-USER> (push 3 *stack*)
(3 2 1)
CL-USER> (push 4 *stack*)
(4 3 2 1)
CL-USER> (funcall (jlambda 4 #'+)) ; take 4 arguments from the stack 
(10)                               ; and apply #'+ to them
CL-USER> (push 10 *stack*)
(10 10)
CL-USER> (push 20 *stack*)
(20 10 10)
CL-USER> (push 30 *stack*)
(30 20 10 10)
CL-USER> (funcall (jlambda 3 [{reduce #'*} #'list])) ; pop 3 args from 
(6000 10)                                            ; stack, make a list
                                                     ; of them and reduce 
                                                     ; it with #'*

这是一个很有前途的想法,但由于我的语言的重点是强制使用无点风格,所以我的想法是避免(实际上是防止)程序员命名函数参数并在函数体内引用这些名称。我还不知道这是否有效。使用curry-compose-reader-macros的粗略示例: - Andy Page
更正:这个(未经测试的)使用curry-compose-reader-macros的例子应该返回10:(funcall(jlambda 1 {reduce #'+})(list 1 2 3 4)) - Andy Page
@AndyPage 但是为什么在那里使用JLAMBDA? 简单的(funcall {reduce #'+} (list 1 2 3 4))就可以工作了。 我不确定它实际上应该如何工作。 Lambda只返回柯里化的函数; 它从未调用它(与我的代码中的(funcall (jlambda (x) {reduce #'+}) (list 1 2 3 4))基本相同)。 想法是主体的返回值将自动使用参数进行FUNCALL吗? - jkiiski
是的,有点像那样。我的大致计划是编写一个自定义REPL,它会自动调用用这种语言表达的函数(这些函数看起来越来越不像Lisp,可能更像Haskell或基于堆栈的语言,比如Forth,其中函数的参数不需要显式地提到,因为它们每个都从堆栈顶部消耗一些值并在顶部留下一些值)。也许我的想法有缺陷。我非常感谢您的意见! - Andy Page

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接