是什么让Lisp宏如此特别?

在编程语言中阅读保罗·格雷厄姆的散文,人们会认为Lisp宏是唯一的方法。作为一个忙碌的开发人员,在其他平台上工作,我还没有使用Lisp宏的特权。作为一个想要了解热门话题的人,请解释一下是什么让这个功能如此强大。

请将这一点与我从Python、Java、c#或C开发世界中理解的东西联系起来。

74412 次浏览

你会发现一场关于Lisp宏的全面辩论。

这篇文章的一个有趣的子集:

在大多数编程语言中,语法是复杂的。宏必须分解程序语法,分析它,并重新组装它。它们不能访问程序的解析器,因此必须依赖于启发式和最佳猜测。有时他们的降价分析是错误的,然后他们就崩溃了。

但是Lisp是不同的。Lisp宏可以访问解析器,它是一个非常简单的解析器。而Lisp程序真的很擅长分解列表并将它们组合在一起。他们每天都可靠地这样做。

下面是一个扩展的例子。Lisp有一个宏,叫做“setf”,它执行赋值。setf最简单的形式是

  (setf x whatever)

设置符号"x"的值;到表达“whatever”的价值。

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

现在,如果您想用一个新值替换列表的第一个元素,该怎么办?有一个标准函数可以做到这一点,令人难以置信的是,它的名字甚至比“;car"更糟糕。它是“rplaca”。但是你不需要记住“rplaca”,因为你会写

  (setf (car somelist) whatever)

设置某个列表的车。

这里真正发生的是“setf"是宏。在编译时,它检查它的参数,它看到第一个参数的形式是(car SOMETHING)。它对自己说:“哦,程序员正试图设置某个东西的汽车。为此使用的函数是'rplaca'."它会悄悄地重写代码:

  (rplaca somelist whatever)

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

expr1 && expr2 && expr3 ...

这句话的意思是:求expr1,如果是真的,求expr2,等等。

现在试着把这个&&变成一个函数…没错,你不能。像这样调用:

and(expr1, expr2, expr3)

将在产生答案之前计算所有三个exprs,而不管expr1是否为假!

使用lisp宏,你可以编写如下代码:

(defmacro && (expr1 &rest exprs)
`(if ,expr1                     ;` Warning: I have not tested
(&& ,@exprs)               ;   this and might be wrong!
nil))

现在你有一个&&,你可以像调用一个函数一样调用它,它不会计算你传递给它的任何表单,除非它们都为真。

要了解这是如何有用的,请进行对比:

(&& (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%")

这甚至还没有进入读者宏

希望这能有所帮助。

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

例如,在C语言中,switch/case结构只适用于整型,如果你想将它用于浮点数或字符串,你就只能使用嵌套的if语句和显式比较。你也不可能编写一个C宏来为你做这项工作。

但是,由于lisp宏(本质上)是一个lisp程序,它接受代码片段作为输入,并返回代码来替换宏的“调用”,因此您可以尽可能地扩展您的“原语”库,通常最终会得到一个更可读的程序。

要在C中做同样的事情,您必须编写一个自定义预处理器,它会吃掉您的初始(不完全是C)源代码,并吐出C编译器可以理解的东西。这不是一种错误的方法,但它不一定是最简单的。

lisp宏以程序片段作为输入。这个程序片段被表示为一个数据结构,可以按照您喜欢的任何方式进行操作和转换。最后,宏输出另一个程序片段,这个片段是在运行时执行的。

c#没有宏功能,但是如果编译器将代码解析为CodeDOM树,并将其传递给一个方法,该方法将其转换为另一个CodeDOM,然后将其编译为IL,则会有等效的宏功能。

这可以用来实现“糖”语法,如for each-statement using-clause, linq select-expressions等,作为转换为底层代码的宏。

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

下面是c#中用于实现using的lisp风格宏的伪代码:

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

想想在C或c++中可以用宏和模板做什么。它们是管理重复代码的非常有用的工具,但它们在相当严重的方面受到限制。

  • 有限的宏/模板语法限制了它们的使用。例如,不能编写扩展为类或函数以外内容的模板。宏和模板不容易维护内部数据。
  • C和c++复杂且不规则的语法使得编写非常通用的宏非常困难。

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

  • Lisp宏是用Lisp编写的。您拥有Lisp的全部功能来编写宏。
  • Lisp有一个非常规则的语法。

与任何精通c++的人交谈,问他们花了多长时间来学习模板元编程所需的所有模板。或者像现代c++设计这样的(优秀)书籍中所有疯狂的技巧,尽管语言已经标准化了十年,但仍然很难调试,并且(在实践中)在真实的编译器之间不可移植。如果用于元编程的语言与用于编程的语言相同,那么所有这些问题都消失了!

我不确定我能给每个人的(优秀的)帖子添加一些见解,但是……

Lisp宏工作得很好,因为Lisp语法的本质。

Lisp是一种非常普通语言(想想所有的东西都是列表);宏使您能够将数据和代码视为相同的(不需要字符串解析或其他技巧来修改lisp表达式)。你把这两个特性结合起来,就有了一种非常清洁的方式来修改代码。

我想说的是Lisp是homoiconic,这意味着Lisp程序的数据结构是用Lisp本身编写的。

因此,您最终可以在语言之上创建自己的代码生成器,使用语言本身的所有功能(例如。在Java中,你必须破解字节码编织的方法,尽管一些框架(如AspectJ)允许你使用不同的方法来做到这一点,但它基本上是一种破解)。

在实践中,使用宏可以在lisp之上构建自己的迷你语言,而不需要学习其他语言或工具,并使用语言本身的全部功能。

简而言之,宏是代码的转换。它们允许引入许多新的语法结构。例如,考虑c#中的LINQ。在lisp中,有类似的由宏实现的语言扩展(例如,内置循环构造,迭代)。宏显著地减少了代码重复。宏允许嵌入«小语言»(例如,在c#/java中可以使用xml进行配置,在lisp中可以使用宏实现同样的事情)。宏可能隐藏使用库的困难。

例如,在lisp中你可以写

(iter (for (id name) in-clsql-query "select id, name from users" on-database *users-database*)
(format t "User with ID of ~A has name ~A.~%" id name))

这隐藏了所有数据库的东西(事务,正确的连接关闭,获取数据等),而在c#中,这需要创建SqlConnections, SqlCommands,将SqlParameters添加到SqlCommands,在SqlDataReaders上循环,正确地关闭它们。

Lisp宏代表了几乎在任何大型编程项目中都会出现的一种模式。最终,在一个大的程序中,你会有一段代码,你会意识到,如果你写一个程序,把源代码输出为文本,然后你就可以粘贴进去,这会更简单,更不容易出错。

在Python中,对象有两个方法__repr____str____str__只是人类可读的表示。__repr__返回一个有效的Python代码表示,也就是说,可以作为有效的Python输入解释器。通过这种方式,您可以创建一些小的Python代码片段,生成可以粘贴到实际源代码中的有效代码。

在Lisp中,整个过程由宏系统形式化。当然,它允许您为语法创建扩展并执行各种奇特的事情,但它的实际用处可以从上面总结出来。当然,Lisp宏系统允许您使用整个语言的全部功能来操作这些“片段”是有帮助的。

我认为我从未见过Lisp宏比这个家伙解释得更好:http://www.defmacro.org/ramblings/lisp.html

简而言之,宏用于定义通用Lisp或领域特定语言(dsl)的语言语法扩展。这些语言直接嵌入到现有的Lisp代码中。现在,dsl可以具有与Lisp相似的语法(例如Peter Norvig的Prolog翻译用于Common Lisp),也可以完全不同(例如中缀符号数学用于Clojure)。

下面是一个更具体的例子:
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))))

在我进一步讨论之前,我应该更好地解释什么是宏。它是对代码通过 code执行的转换。也就是说,由解释器(或编译器)读取的一段代码,将代码作为参数,进行操作并返回结果,然后在适当的位置运行。

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

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

因此,我们可以定义一个名为lcomp的宏(列表理解的简称)。它的语法将完全类似于我们在示例[x for x in range(10) if x % 2 == 0] - (lcomp x for x in (range 10) if (= (% x 2) 0))中使用的python

(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吸引人们的地方——极致的灵活性。

在python中,你有装饰器,你基本上有一个接受另一个函数作为输入的函数。你可以做任何你想做的事情:调用函数,做其他事情,将函数调用包装在资源获取释放中,等等,但是你不能窥视函数内部。假设我们想让它更强大,假设你的decorator以列表的形式接收了函数的代码,那么你不仅可以按原样执行函数,还可以执行部分函数,重新排序函数行等等。

我从通用的lisp烹饪书中得到了这个,我认为它解释了为什么lisp宏是有用的。

宏是一段普通的Lisp代码,它对另一段假定的Lisp代码进行操作,将其翻译成(更接近于)可执行的Lisp。这听起来可能有点复杂,所以让我们举一个简单的例子。假设您想要一个版本的setq,将两个变量设置为相同的值。所以如果你写

(setq2 x y (+ z 3))

z=8 x和y都设置为11时。(我想不出这有什么用,但这只是一个例子。)

显然,我们不能将setq2定义为函数。如果x=50y=-5,此函数将接收值50、-5和11;它不知道应该设置什么变量。我们真正想说的是,当你(Lisp系统)看到(setq2 v1 v2 e)时,将它等同于(progn (setq v1 e) (setq v2 e))。实际上,这不是很正确,但现在可以了。宏允许我们精确地做到这一点,通过指定一个程序来转换输入模式(setq2 v1 v2 e)"(progn ...)."

如果你觉得这很好,你可以继续读这里: http://cl-cookbook.sourceforge.net/macros.html < / p >

由于现有的答案给出了很好的具体例子来解释宏实现了什么以及如何实现,也许它会帮助收集一些关于为什么宏设施是一个重要的增益的想法。首先是这些答案,然后是其他地方的一个很棒的答案:

... 在C语言中,你必须编写一个自定义的预处理器[它可能符合足够复杂的C程序]…

本;Vatine

与任何精通c++的人交谈,问他们花了多长时间来学习模板元编程(仍然不是那么强大)所需要的所有模板。

本;马特·柯蒂斯

... 在Java中,你必须破解字节码编织的方法,尽管像AspectJ这样的框架允许你使用不同的方法来做到这一点,但它基本上是一种破解。

本;米格尔平

DOLIST类似于Perl的foreach或Python的for。作为JSR-201的一部分,Java在Java 1.5中使用“增强的”for循环添加了类似的循环构造。请注意宏造成的差异。Lisp程序员如果注意到代码中的公共模式,就可以编写宏来给出该模式的源代码级抽象。注意到相同模式的Java程序员必须说服Sun,这种特殊的抽象值得添加到语言中。然后Sun必须发布一个JSR,并召集一个全行业的“专家组”来解决所有问题。孙说,这个过程平均需要18个月。在那之后,编译器编写者都必须升级他们的编译器来支持新特性。即使Java程序员最喜欢的编译器支持新版本的Java,他们可能“仍然”不能使用新特性,直到他们被允许打破与旧版本Java的源代码兼容性。因此,Common Lisp程序员可以在五分钟内自行解决的烦恼困扰了Java程序员多年。

本;Peter Seibel,在《实用通用Lisp》中

虽然上面都解释了宏是什么,甚至还有很酷的例子,但我认为宏和普通函数之间的关键区别在于,LISP在调用函数之前首先计算所有参数。对于宏则相反,LISP将未计算的参数传递给宏。例如,如果将(+ 1 2)传递给一个函数,该函数将接收到值3。如果你将它传递给宏,它将收到一个List(+ 12).这可以用来做各种令人难以置信的有用的事情。

  • 添加一个新的控制结构,例如循环或列表的解构
  • 度量执行传入函数所花费的时间。对于函数,将在将控制传递给函数之前计算参数。使用宏,您可以在秒表的开始和停止之间拼接代码。下面在一个宏和一个函数中有完全相同的代码,输出是非常不同的。注意:这是一个虚构的示例,选择实现是为了更好地突出差异。

    (defmacro working-timer (b)
    (let (
    (start (get-universal-time))
    (result (eval b))) ;; not splicing here to keep stuff simple
    ((- (get-universal-time) start))))
    
    
    (defun my-broken-timer (b)
    (let (
    (start (get-universal-time))
    (result (eval b)))    ;; doesn't even need eval
    ((- (get-universal-time) start))))
    
    
    (working-timer (sleep 10)) => 10
    
    
    (broken-timer (sleep 10)) => 0
    

一行程序回答:

最小语法=>宏over表达式=>简洁性=比;抽象=比;权力


Lisp宏只是以编程方式编写代码。也就是说,在展开宏之后,您得到的只不过是没有宏的Lisp代码。所以,原则上,他们没有实现任何新的东西。

然而,它们与其他编程语言中的宏不同,它们在表达式级别上编写代码,而其他宏在字符串级别上编写代码。这在lisp中是独一无二的,这要感谢他们的括号;或者更准确地说,它们的最小的语法,这多亏了它们的括号。

正如本线程中的许多示例所示,以及Paul Graham的在Lisp, lisp宏可以成为使代码更加简洁的工具。当简洁性达到一定程度时,它为代码提供了新的抽象级别,使其更加简洁。回到第一点,他们没有提供任何新的东西,但这就像说,既然纸和铅笔(几乎)组成了图灵机,我们不需要一台真正的计算机。

如果你懂点数学,想想为什么函子和自然变换是有用的想法。原则上,他们没有提供任何新的东西。然而,通过将它们扩展到较低层次的数学中,你会发现几个简单想法的组合(就范畴理论而言)可能需要10页才能写下来。你喜欢哪一种?