Lisp宏有何特别之处?

357

阅读Paul Graham的文章关于编程语言,你会认为Lisp宏是唯一正确的选择。作为忙碌的开发者,在其他平台上工作,我没有使用Lisp宏的特权。作为一个想要理解这种功能强大之处的人,请解释一下是什么让这个特性如此强大。

请还将其与Python、Java、C#或C开发领域中的某些东西联系起来,以便我更好地理解。


3
顺便提一下,C# 有一个 LISP 风格的宏处理器叫做 LeMP: http://ecsharp.net/lemp/... JavaScript 也有一个叫做 Sweet.js:https://www.sweetjs.org/。 - Qwertie
@Qwertie sweetjs现在还能用吗? - fredrik.hjarner
我没有使用过它,但最近的提交已经是六个月前了...对我来说已经足够好了! - Qwertie
15个回答

373
为了简短回答,宏用于定义通用Lisp或特定领域语言(DSL)的语法扩展。这些语言嵌入到现有的Lisp代码中。现在,DSL可以具有类似于Lisp的语法(例如Peter Norvig的Common Lisp的Prolog Interpreter),也可以完全不同(例如Clojure的Infix Notation Math)。
以下是一个更具体的例子:
Python内置列表推导式。这为常见情况提供了简单的语法。该行
divisibleByTwo = [x for x in range(10) if x % 2 == 0]

生成一个包含0到9之间所有偶数的列表。回到Python 1.5时代,没有这样的语法;你需要使用更类似于以下内容的东西:

divisibleByTwo = []
for x in range( 10 ):
   if x % 2 == 0:
      divisibleByTwo.append( x )

这两者在功能上是等价的。让我们暂时忽略实际情况,假设Lisp有一个非常有限的循环宏,只能进行迭代而没有像列表推导那样简单的方式。
在Lisp中,你可以写如下代码。需要注意的是,这个人为制造的例子被选为与Python代码相同,但不是Lisp代码的好例子。
;; the following two functions just make equivalent of Python's range function
;; you can safely ignore them unless you are running this code
(defun range-helper (x)
  (if (= x 0)
      (list x)
      (cons x (range-helper (- x 1)))))

(defun range (x)
  (reverse (range-helper (- x 1))))

;; equivalent to the python example:
;; define a variable
(defvar divisibleByTwo nil)

;; loop from 0 upto and including 9
(loop for x in (range 10)
   ;; test for divisibility by two
   if (= (mod x 2) 0) 
   ;; append to the list
   do (setq divisibleByTwo (append divisibleByTwo (list x))))

在我继续之前,我最好解释一下什么是宏。它是由代码执行的代码转换。也就是说,一段代码被解释器(或编译器)读取,将代码作为参数输入,进行操作并返回结果,然后在原地运行。

当然,这需要大量的输入,而程序员很懒。所以我们可以为列表理解定义DSL。事实上,我们已经使用了一个宏(循环宏)。

Lisp定义了几种特殊的语法形式。引用(')表示下一个标记是文字。准引用或反引号(`)表示下一个标记是带有转义符的文字。逗号运算符表示转义。字面值'(1 2 3)相当于Python的[1,2,3]。您可以将其分配给另一个变量或在原地使用。您可以将`(1 2,x)视为Python的[1,2,x]的等效形式,其中x是先前定义的变量。这种列表表示法是宏的魔力的一部分。第二部分是Lisp阅读器,它智能地将宏替换为代码,但最好在下面进行说明:

因此,我们可以定义一个名为lcomp(列表推导式)的宏。其语法将与我们在示例中使用的Python完全相同:[x for x in range(10) if x % 2 == 0] - (lcomp x for x in (range 10) if (= (% x 2) 0))

(defmacro lcomp (expression for var in list conditional conditional-test)
  ;; create a unique variable name for the result
  (let ((result (gensym)))
    ;; the arguments are really code so we can substitute them 
    ;; store nil in the unique variable name generated above
    `(let ((,result nil))
       ;; var is a variable name
       ;; list is the list literal we are suppose to iterate over
       (loop for ,var in ,list
            ;; conditional is if or unless
            ;; conditional-test is (= (mod x 2) 0) in our examples
            ,conditional ,conditional-test
            ;; and this is the action from the earlier lisp example
            ;; result = result + [x] in python
            do (setq ,result (append ,result (list ,expression))))
           ;; return the result 
       ,result)))

现在我们可以在命令行中执行:
CL-USER> (lcomp x for x in (range 10) if (= (mod x 2) 0))
(0 2 4 6 8)

相当不错,是吧?但这还不止于此。你有一个机制,或者说像画笔一样的工具。你可以拥有任何你可能需要的语法,比如Python或C#的with语法,或.NET的LINQ语法。最终,这就是吸引人们使用Lisp的原因——极致的灵活性。


73
在Lisp中实现列表推导式,为什么不呢?+1 - ckb
14
实际上LISP标准库中已经有列表推导宏:(loop for x from 0 below 10 when (evenp x) collect x)更多示例在此处。但确实,loop只是一个宏(我曾经重新实现过它)。 - Suzanne Soy
11
我知道这跟主题无关,但我想问一下语法和解析的工作原理......假设我这样调用lcomp(将第三项从“for”改为“azertyuiop”): (lcomp x azertyuiop x in (range 10) if (= (% x 2) 0)) 宏还能像预期的那样正常工作吗?或者"for"参数在循环中使用,因此调用时必须是字符串"for"? - dader
5
其他语言的宏让我感到困惑的一件事是,它们的宏受限于宿主语言的语法。Lispy宏能否解释非Lispy语法呢?我的意思是,想象一下创建类似Haskell的语法(没有括号),并使用Lisp宏进行解释。这种做法可行吗?相比直接使用词法分析器和解析器,使用宏有什么优缺点呢? - CMCDragonkai
3
简单回答,是的,Lisp宏经常用于创建领域特定语言。主语言不可避免地会对你在宏中可以使用的语法施加某些限制。例如,显然不能将注释语法作为宏中的活动组件使用。 - gte525u
显示剩余8条评论

115
您将在此处找到关于Lisp宏的全面讨论:此处

该文章的一个有趣子集:

在大多数编程语言中,语法是复杂的。宏必须拆解程序语法、分析它并重新组装。它们无法访问程序的解析器,因此必须依赖启发式和猜测。有时它们的简易分析是错误的,然后它们会出错。

但是Lisp不同。Lisp宏确实可以访问解析器,而且它是一个非常简单的解析器。Lisp宏得到的不是字符串,而是形式为列表的预解析源代码,因为Lisp程序的源不是字符串,而是列表。 Lisp程序非常擅长拆解和重新组合列表,每天都可靠地执行这项工作。

这里是一个扩展示例。Lisp有一个名为“setf”的宏,用于执行赋值操作。 setf 的最简单形式为

  (setf x whatever)

这个代码用于将符号"x"的值设置为表达式"whatever"的值。

Lisp也有列表;你可以使用“car”和“cdr”函数分别获取列表的第一个元素或者剩余部分。

那么如果你想要用一个新值替换列表的第一个元素呢?有一个标准函数可以做到这一点,令人难以置信的是,它的名称甚至比“car”还糟糕。它就是“rplaca”。但你不必记住“rplaca”,因为你可以写

  (setf (car somelist) whatever)

设置 somelist 的 car。

这里实际上使用了一个宏 "setf"。在编译时,它检查其参数,并发现第一个参数的形式为 (car SOMETHING)。它会自言自语地说:"哦,程序员想要设置 something 的 car。用于此操作的函数是 'rplaca'。"然后它悄悄地就地重写了代码:

  (rplaca somelist whatever)

7
setf 是宏的强大示例,感谢包含它。 - Joel
2
我喜欢这个突出显示 ..因为Lisp程序的源代码不是字符串,而是列表!这是LISP宏比大多数其他宏更优越的主要原因吗? - Student

60

Common Lisp宏本质上扩展了您的代码的“语法原语”。

例如,在C中,switch / case结构仅适用于整数类型,如果您想将其用于浮点数或字符串,则只能使用嵌套的if语句和显式比较。您也无法编写C宏来完成该任务。

但是,由于Lisp宏(本质上)是将代码片段作为输入并返回要替换宏“调用”的代码的Lisp程序,因此您可以扩展自己的“基元”库,通常会得到更易读的程序。

要在C中实现相同的功能,您需要编写一个自定义预处理器,它会解析您的初始(不完全符合C标准)源代码,并输出一些C编译器可以理解的内容。这并不是错误的方法,但它可能不是最容易的方法。


2
但是,由于Lisp宏是在代码编译期间展开的,因此它们可以使用任何Lisp功能来生成代码。这意味着它们可以非常高效地生成复杂的代码结构,而无需在运行时进行计算或重复编写相似的代码。因此,Lisp的宏系统是其强大的编程语言的一个关键组成部分。+! - Avrohom Yisroel

48

Lisp宏允许您决定何时(如果有的话)将评估任何部分或表达式。举个简单的例子,想一下C语言中的:

expr1 && expr2 && expr3 ...
这段内容的意思是:评估expr1,如若为真,则评估expr2等等。
现在试着把这个 && 转换成一个函数... 是的,你做不到。调用类似下面的东西:
and(expr1, expr2, expr3)

无论 expr1 是否为假,都会在返回答案之前评估所有三个 exprs

使用Lisp宏,您可以编写类似以下的代码:

(defmacro && (expr1 &rest exprs)
    `(if ,expr1                     ;` Warning: I have not tested
         (&& ,@exprs)               ;   this and might be wrong!
         nil))
现在您有一个 &&,您可以像调用函数一样调用它,除非传递给它的所有表达式都为 true,否则它不会计算这些表达式。
为了说明其有用性,我们可以对比以下内容:
(&& (very-cheap-operation)
    (very-expensive-operation)
    (operation-with-serious-side-effects))

并且:

and(very_cheap_operation(),
    very_expensive_operation(),
    operation_with_serious_side_effects());

宏还可以用于创建新的关键字和/或小语言(请查看(loop ...)宏作为例子),将其他语言集成到Lisp中。例如,您可以编写一个宏,使您能够像这样说:

(setvar *rows* (sql select count(*)
                      from some-table
                     where column1 = "Yes"
                       and column2 like "some%string%")

这还未涉及到 读取宏

希望这有所帮助。


我认为应该是:"(apply && ,@exprs) ; 这个 and 可能是错误的!" - Svante
1
@svante - 有两个问题:首先,“&&”是一个宏,而不是函数;apply仅适用于函数。其次,apply需要传递参数列表,因此您需要使用其中之一:“(funcall fn,@exprs)”,“(apply fn(list,@exprs)”或“(apply fn,@exprs nil)”而不是“(apply fn,@exprs)”。 - Aaron
(and ...) 将评估表达式,直到其中一个表达式评估为 false,注意由 false 评估生成的副作用将发生,只有后续的表达式将被跳过。 - ocodo

34

3
特别是如果您有Java/XML背景。 - sunsations
1
在一个周六下午躺在沙发上阅读这篇文章真是一种享受!写得非常清晰和有条理。 - Colin
愿上帝保佑你和作者。 - João Fé
1
这是一篇长文,但非常值得阅读——其中很多内容都可以归纳为:1)Lisp S表达式可以像XML一样表示代码或数据,2)宏不会像函数那样急切地评估它们的输入,因此可以操作输入作为代码或数据的S表达式结构。令人震惊的时刻是,即使是像“待办事项列表”这样平凡的表示形式,也可以通过实现一个能够将待办事项数据结构视为项目宏的代码输入的宏而成为武器。这在大多数语言中都不是你会考虑到的,而且非常酷。 - Phil

12

由于现有答案提供了解释宏实现及其作用的良好具体示例,因此将一些关于为什么宏设施相对于其他语言是一个重大收益的想法汇集在一起可能会有所帮助;首先来自这些答案,然后是来自其他地方的一个伟大答案:

……在C中,您必须编写自定义预处理器[这可能符合足够复杂的C程序]……

Vatine

与掌握C ++的任何人交谈,并问问他们花了多长时间学习他们需要进行模板元编程的所有模板调整 [这仍然不如强大]。

Matt Curtis

……在Java中,您必须使用字节码编织来进行黑客攻击,尽管像AspectJ这样的一些框架允许您使用不同的方法来执行此操作,但它从根本上讲仍然是一种黑客攻击。

Miguel Ping

DOLIST类似于Perl的foreach或Python的for。Java在Java 1.5中添加了一种类似的循环结构,称为“增强型”for循环,作为JSR-201的一部分。宏的作用是显而易见的。Lisp程序员注意到他们代码中的一个常见模式后,可以编写一个宏,以便在源级别抽象出该模式。Java程序员则需要说服Sun该特定抽象值得添加到语言中。然后Sun必须发布JSR并召集全行业的“专家组”来讨论所有内容。根据Sun的说法,这个过程平均需要18个月。之后,编译器编写者都必须升级他们的编译器以支持新功能。即使Java程序员喜欢的编译器支持Java的新版本,他们可能仍然不能使用新功能,直到允许与旧版本的Java不兼容。因此,Common Lisp程序员可以在五分钟内解决的问题困扰Java程序员多年。

——彼得·塞伯尔,《实用Common Lisp》


12

想一想你可以在C或C++中使用宏和模板来完成什么。它们是管理重复代码的非常有用的工具,但它们在很多方面都有严格的限制。

  • 有限的宏/模板语法限制了它们的使用。例如,您无法编写一个将扩展为除类或函数之外其他内容的模板。宏和模板不能轻松地维护内部数据。
  • C和C++的复杂、非常不规则的语法使得编写非常通用的宏变得困难。

Lisp和Lisp宏解决了这些问题。

  • Lisp宏是用Lisp编写的。您可以充分利用Lisp的功能来编写宏。
  • Lisp具有非常规则的语法。

与精通C++的任何人交谈,并问他们花费了多长时间学习所有需要进行模板元编程的模板技巧。或者像《现代C++设计》这样的(优秀)书籍中的所有疯狂技巧,即使语言已经标准化十年,它们在实践中仍然难以调试并且(实际上)在真实世界的编译器之间不可移植。如果您用于元编程的语言与您用于编程的语言相同,则所有这些问题都会消失!


11
公平地说,C++ 模板元编程的问题不在于元编程语言是“不同”的,而是它非常可怕——它并不是被设计出来的,而是在原本意图更为简单的模板功能中被发现的。 - Brooks Moses
1
@Brooks 当然可以。新出现的特性并不总是坏事。不幸的是,在一个缓慢的委员会驱动的语言中,当这些特性出现问题时很难修复。令人遗憾的是,C++许多现代有用的新特性都是用一种很少有人能够阅读的语言编写的,普通程序员和“高级祭司”之间存在巨大的差距。 - Matt Curtis
2
@downvoter:如果我的回答有什么问题,请留下评论,这样我们就可以共享知识。 - Matt Curtis

12

Lisp宏以程序片段作为输入。该程序片段表示为可以任意操作和转换的数据结构。最终,宏输出另一个程序片段,并在运行时执行该片段。

C#没有宏机制,但等效的方法是,编译器将代码解析为CodeDOM树,并将其传递给一个方法,该方法将其转换为另一个CodeDOM,然后编译为IL。

可以使用此方法实现“语法糖”语法,例如for each-语句、using-子句、linq select表达式等,这些都是将宏转换为基础代码。

如果Java有宏,您可以在Java中实现Linq语法,而无需更改基本语言。

以下是一种在C#中实现using的Lisp风格宏的伪代码:

define macro "using":
    using ($type $varname = $expression) $block
into:
    $type $varname;
    try {
       $varname = $expression;
       $block;
    } finally {
       $varname.Dispose();
    }

既然现在C#已经有了类似Lisp风格的宏处理器,我想指出一个using的宏会长成这样:链接 ;) - Qwertie

11

我不确定能否给大家(优秀的)的帖子提供一些见解,但是...

Lisp宏之所以效果很好是因为Lisp语法的本质。

Lisp是一种非常规则的语言(认为一切都是一个列表);宏使您能够将数据和代码视为相同的内容(不需要对lisp表达式进行字符串分析或其他骚操作即可修改),您将这两个特性结合起来,就可以用非常简洁干净的方式修改代码。

编辑:我的意思是Lisp是同构的,这意味着Lisp程序的数据结构本身就是用Lisp编写的。

因此,使用该语言本身及其全部功能(例如,在Java中,您必须通过字节码编织来处理,尽管某些框架如AspectJ采用不同的方法,但它本质上仍然是一种hack),最终可以创建自己的代码生成器。

在实践中,使用宏,您最终会在Lisp之上构建自己的迷你语言,而无需学习其他语言或工具,并且可以使用该语言本身的全部功能。


1
这是一个有见地的评论,然而,“一切皆为列表”的想法可能会吓到新手。要理解列表,你需要了解cons、car、cdr和cell。更准确地说,Lisp是由S表达式构成的,而不是列表。 - ribamar

8
Lisp宏代表了几乎所有规模较大的编程项目中都会出现的一种模式。在大型程序中,你可能会发现有一段代码,你意识到写一个程序以文本形式输出源代码将更简单、更少出错,然后你只需将其粘贴进去即可。
在Python中,对象有两个方法__repr__和__str__。__str__是人类可读的表示形式。__repr__返回一个有效的Python代码表示形式,也就是说,可以输入到解释器中作为有效的Python代码。这样,你就可以创建一些小的Python片段,生成可以粘贴到实际源代码中的有效代码。
在Lisp中,整个过程已经被宏系统正式化了。当然,它可以让你创建语法扩展和做各种花哨的事情,但它的实际用途如上所述。当然,Lisp宏系统允许你使用整个语言的全部功能来操作这些“片段”,这也是它的优点之一。

2
你的第一段非常清晰易懂,对于一个不熟悉Lisp的人来说尤其重要。 - Wildcard
你省略了太多内容。在你的第一段中,粘贴代码的整个方面实际上是问题的一部分。每个人实际上都在复制代码!现在,当你想要修复或增强你已经粘贴到各处的代码时,你必须去亲自维护无数个副本,它们不断地分叉,因为这是你的标准做法。Lisp宏保持干净,你可以一次性修复它们,这起初可能更难,但会变得更容易。使用复制和粘贴,一开始很容易,但随着代码在自身复杂性下崩溃,它变得越来越困难。 - MicroservicesOnDDD
此外,不要低估简单所带来的好处。Lisp并没有太多的语法,这有助于在使用宏自动生成代码时避免干扰——你不必像在C/C++/C#/Java中那样跳过花括号的障碍或完美地缩进(Python)。当你有多层生成时,确保每行都以分号结尾真的很难,而轻量级的语法负担会使这变得更容易(不那么费力)。 - MicroservicesOnDDD
此外,同形式编程是一个巨大的优势——一切都是相同的形状。C和C++的模板语言最终成为了完全不同的语言,看起来非常不同和晦涩。甚至不要尝试多层代码生成。并不是说模板宏不强大——它们有其存在的意义——但它们感觉像是被添加上去的,一个事后的想法,没有很好地集成,笨重,现在是一个必要的恶。一旦您的Lisp宏到位,一切都变得更容易,因为您有了一个新的原子语言可以使用。 - MicroservicesOnDDD

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