惰性求值 vs 宏

50

我已经习惯了Haskell的惰性求值,现在使用默认为急切求值的语言时会感到很烦恼。这实际上是非常有害的,因为我使用的其他语言通常使惰性求值变得非常麻烦,通常需要编写自定义迭代器等等。所以仅仅通过获取一些知识,我实际上使自己在原来的语言中效率更低了。唉。

但是我听说AST宏提供了另一种干净的方式来完成相同的事情。我经常听到像“惰性求值使宏变得多余”之类的声明,反之亦然,主要来自Lisp和Haskell社区。

我尝试过在各种Lisp变体中使用宏。它们似乎只是一种有组织地将代码块复制和粘贴到编译时处理的方法。它们肯定不是Lispers认为的圣杯。但几乎肯定是因为我无法正确使用它们。当然,使宏系统在与语言本身组装的相同核心数据结构上工作确实很有用,但它仍然基本上是一种组织代码的方式,就像复制和粘贴代码一样。我认识到,基于与允许完全运行时修改的语言相同的AST的宏系统是强大的。

我想知道的是,宏如何被用来简明扼要地做惰性求值?如果我想逐行处理文件而不将整个文件读入内存,我只需返回已将读取行例程映射到其上的列表。这是DWIM(我所想的)的完美例子。我甚至不必考虑它。

我显然不懂宏。我使用过它们,但鉴于炒作,没有特别印象深刻。因此,有些东西我在网上阅读文档时没有理解。有人能向我解释所有这些吗?


2
为什么你需要回去?也许在Haskell中恢复你缺少的东西会更好/更容易?Haskell中有TH和quasiquoting。另外,请发一个例子!! - gatoatigrado
例子?好的,通常是堆积起来的琐碎例子开始引起问题。例如,如果我在Perl 5中设置了一个大型的map/grep/sort管道,每个阶段都必须评估整个该死的东西,将整个东西写入内存,然后将其馈送到下一个阶段。而不是在每个步骤中存储一个计算元素,需要存储整个东西。如果你从文件或无限序列中检索某些东西,这变得很繁琐,通常会诉诸于状态,尽管是封装的状态。 - Louis
我看到很多关于宏的评论,比如“有组织地复制和粘贴代码”,但它们正是你后来所说的“我不理解宏”的东西。宏是编译器/求值器扩展,运行在编译/求值时的代码,使用通用Lisp宏,你可以从头开始实现所有的Haskell编译器。因此,说“宏是一种有组织的复制和粘贴代码的方式”就像说编译器都是一样的,就像一个JavaScript程序员说“我使用过编译器,并没有因为炒作而特别印象深刻”,这是荒谬的,也很无聊。 - kisai
5个回答

64
懒惰求值使宏变得多余。
这是纯粹的胡说八道(不是你的错;我之前听过这个说法)。确实,你可以使用宏来改变表达式求值的顺序、上下文等,但那只是宏的最基本用途,而且用临时宏来模拟懒惰语言比使用函数并不方便。所以,如果你从这个方向来看待宏,你确实会失望。
宏的作用是用新的句法形式来扩展语言。一些特定的宏功能包括:
1. 影响表达式求值的顺序、上下文等。 2. 创建新的绑定形式(即影响求值表达式的范围)。 3. 进行编译时计算,包括代码分析和转换。
做(1)的宏可以非常简单。例如,在Racket中,异常处理形式with-handlers只是一个宏,它展开为call-with-exception-handler、一些条件语句和一些继续代码。它的用法如下:
(with-handlers ([(lambda (e) (exn:fail:network? e))
                 (lambda (e)
                   (printf "network seems to be broken\n")
                   (cleanup))])
  (do-some-network-stuff))

该宏基于原始的call-with-exception-handler,在异常的动态上下文中实现了“谓词和处理程序子句”的概念,处理所有在其引发点的异常。
更复杂的宏的使用是实现LALR(1)解析器生成器。与需要预处理的单独文件不同,parser表单只是另一种类型的表达式。它接受语法描述,在编译时计算表格,并生成解析器函数。操作例程是词汇作用域的,因此它们可以引用文件中的其他定义甚至lambda绑定变量。您甚至可以在操作例程中使用其他语言扩展。
在极端情况下,Typed Racket是通过宏实现的Racket的类型化方言。它具有先进的类型系统,旨在匹配Racket / Scheme代码的习惯用法,并通过保护具有动态软件合同的类型化函数(也通过宏实现)与未键入的模块进行交互。它由“类型化模块”宏实现,该宏会扩展,类型检查和转换模块主体以及用于将类型信息附加到定义等的辅助宏。

FWIW,还有Lazy Racket,它是Racket的惰性方言。它并不是通过将每个函数转换为宏来实现的,而是通过重新绑定lambdadefine和函数应用语法到创建和强制执行承诺的宏。

总之,惰性求值和宏有一个小的交点,但它们是非常不同的东西。而且宏肯定不被惰性求值所包含。


7
我认为这种误解主要源于缺乏想象力。来自一种严格、渴望语言,其元编程支持较差的想法是,“if语句”不需要内置,似乎相当革命!在某种程度上确实如此,但懒惰求值和宏都不仅仅是因为你可以重新实现控制结构这么简单。 - C. A. McCann
LALR(1)解析器生成器的链接已经失效。我相信“so294”是Scott Owens(现在在肯特大学)。他有一系列文档化的解析器工具,可以在https://download.racket-lang.org/releases/5.92/doc/parser-tools/LALR_1__Parsers.html找到。 - preferred_anon

24

懒惰求值可以替代某些宏的使用(那些延迟求值以创建控制结构的宏),但反过来并不完全正确。您可以使用宏使延迟求值结构更透明-有关如何的示例,请参见 SRFI 41(流):http://download.plt-scheme.org/doc/4.1.5/html/srfi-std/srfi-41/srfi-41.html

除此之外,您也可以编写自己的惰性IO原语。

根据我的经验,在严格语言中普遍存在的惰性代码会引入开销,与从一开始就设计为高效支持惰性代码的运行时相比较而言 - 请注意,这实际上是一个实现问题。


6
关于“相反命题并不完全成立”的回复,我认为你的其余回答明显与此相矛盾。懒惰和宏的效用存在交集,但两者都不是对方的子集。 - acfoltzer
对于某些控制结构,我本以为会很简单。如何使用惰性求值编写CASE(而不是多条件)跳转表? - Vatine
我看到了一个完整的80年代BASIC,它是用Haskell DSL编写的,具有GOTO、IF和其他所有功能。当然,宏也可以做到这一点。但如果Haskell的惰性求值能够产生整个编程语言,我想它也可以做像switch语句等的东西。 - Louis
1
@Louis:据我所知,Augustuss的BASIC DSL(http://augustss.blogspot.com/2009/02/more-basic-not-that-anybody-should-care.html)并不是真正关于惰性计算,而主要是基于Haskell轻量级语法、重载Num字面量和单子绑定语法支持。 - Peaker

23
懒惰是指示性,而宏不是。 更准确地说,如果在指示性语言中加入非严格性,结果仍然是指示性的,但如果加入宏,则结果不是指示性的。 换句话说,在懒惰的纯语言中,表达式的含义仅取决于组成部分表达式的含义;而宏可以从语义上相等的参数产生语义上不同的结果。
从这个意义上说,宏更强大,而懒惰在语义上更易处理。 编辑:更准确地说,宏在不考虑恒等/平凡指称(其中“指示性”的概念变得无意义)时是非指示性的。

1
Sam:根据Landin(请参见上面的链接),我使用“指示性”一词的含义比仅仅“组合”的含义更为具体。例如,宏在语法上是组合的,但在指示上则是非组合的。 - Conal
3
翻译:我理解你在这里的观点,特别是“宏……使表达式的语法成为其语义的一部分”,这将破坏所有非平凡的等式属性。 - Conal
2
这是一个更直接的链接,指向Landin推荐的“指示性”的术语和概念。 - Conal
1
Peaker:这取决于你所做的推理类型。如果你在推理你正在编写的Haskell程序,那么把“if-then-else”看作语言的一部分是可以的。如果你正在编写一个Haskell静态分析器,那么你应该遵循定义,即宏扩展,并分析该扩展的结果。 - Sam Tobin-Hochstadt
1
Sam:我仍然看到你在这里与你的想象争论。我不认为你说的是我所想的,也不会,因为我不带有主观观念,如“适当”的概念。我也不希望宏参数(ASTs)被解释。据我所知,过滤掉主观性/观点,我们都在说宏将语法(ASTs)映射到语法,而不是像函数一样应用(非恒等)解释。如果你想说宏是(空洞地)关于恒等/平凡指称的,那对我来说没问题。 - Conal
显示剩余17条评论

10

Lisp起源于上个千年的50年代末。请看《关于符号表达式递归函数及其机器计算》。宏不是 Lisp 的一部分。这个想法是使用符号表达式进行计算,符号表达式可以表示各种公式和程序:数学表达式、逻辑表达式、自然语言句子、计算机程序等等。

后来Lisp宏被发明出来,它们是将上述想法应用到Lisp本身的一种方法:宏使用完整的Lisp语言作为转换语言,将Lisp(或类Lisp)表达式转换成其他Lisp表达式。

你可以想象,使用宏可以实现强大的预处理器和编译器,作为Lisp用户。

典型的Lisp方言使用严格求值方式:在函数执行之前,所有参数都要进行求值。Lisp还有几个内置形式,它们有不同的求值规则。例如IF。在Common Lisp中,IF是一种所谓的特殊运算符

但是我们可以定义一个新的类Lisp语言,使用惰性求值,并编写宏将该语言转换为Lisp。这是宏的一个应用,但远非唯一。

一个(相对较旧的)例子是Lisp扩展SERIES,它使用宏实现了提供惰性求值数据结构的代码转换器。


5

宏可以用于处理惰性计算,但那只是其中的一部分。宏的主要点在于,多亏了它,语言中基本上没有固定的东西。

如果编程就像玩乐高积木,使用宏可以更改积木的形状或它们所建造的材料。

宏不仅仅是延迟评估。历史上,fexpr(一种lisp的宏先驱)支持延迟评估。宏是关于程序重写,而fexpr只是一个特殊情况...

举个例子,假设我在业余时间编写一个小型的 LISP 到 JavaScript 编译器,最初(在JavaScript内核中)我只支持带有&rest参数的lambda函数。现在,由于我在LISP本身中重新定义了lambda函数的含义,因此支持了关键字参数。

现在我可以这样写:

(defun foo (x y &key (z 12) w) ...)

并使用以下方式调用该函数:

(foo 12 34 :w 56)

执行该调用时,函数体中的w参数将绑定到56,z参数将绑定到12,因为它没有被传递。如果向函数传递了不支持的关键字参数,我还会收到运行时错误。甚至可以通过重新定义表达式编译的方式(即添加检查,以确保“静态”函数调用形式将正确的参数传递给函数),添加一些编译时检查支持。
核心问题在于最初(内核)语言根本不支持关键字参数,而我能够使用语言本身来添加它。结果就像从一开始就有一样;它只是语言的一部分。
语法很重要(即使技术上可以只使用图灵机)。语法塑造你的思维方式。宏(和读取宏)让您完全控制语法。
一个关键点是,代码重写代码不使用像C++模板元编程那样的残缺愚蠢的类似brainf**k的语言(在那里,仅仅做一个if就是一个惊人的成就),或者像C预处理器那样的更愚蠢的小于正则表达式的替换引擎。
代码重写代码使用相同的全面(可扩展的)语言。这是完全的lisp ;-)
当然,编写宏比编写常规代码更难;但这是问题的“本质复杂性”,而不是人为的复杂性,因为您被迫使用像C++元编程那样的愚蠢半语言。
编写宏更加困难,因为代码是一个复杂的东西,当您编写宏时,您编写了构建复杂事物本身的复杂事物。甚至很常见再上升一级,编写生成宏的宏(这就是旧的lisp笑话“我正在编写编写编写我被支付的代码”的来源)。
但是宏的能力是无限的。

我认为那个问题不会得到解决。SO不是新闻组,而是一个只有少量互动的问答网站。我的观点是,仅仅知道Java语言并不足以编写Java程序。你必须掌握框架才能以正确的方式进行正确的调用。在大型软件中,相同的知识需求也出现在更高层次中(例如,如果您触及那个表的那一列,您也应该碰触另一个表中的那一列,这种数据在这里,那种数据在那里)。如果这种知识或代码可以用不同的语法更好地表达,为什么不这样做呢? - 6502
@Louis:任何足够先进的宏使用或懒惰行为都可以描述为用户定义语言;实际上,您在对sclv答案的评论中也这样做了。您所描述的问题与宏或懒惰无关,甚至与比较两者无关。这是高度抽象的问题,即一个程序员认为合理的抽象可能对另一个程序员来说是难以理解的。这值得讨论,但正如6502所说,也许不适合在SO上讨论。 - acfoltzer
@Louis,不,这并不是一个可维护性问题本身。如果有一种真正轻松的扩展语言的方法,您的维护成本将会降低,因为您将能够摆脱大量的预处理器和歪曲的特定于每个像样的大型工业代码库的代码生成器。 - SK-logic
编写宏实际上并不比使用高阶函数更难。大多数人都是用错了方法,但这并不是借口。 - SK-logic
谷歌搜索“全程都是Lisp”会给你带来很多有趣的阅读材料...... :) - Will Ness
显示剩余5条评论

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