Common Lisp中的LET与LET *

时间:2009-02-16 23:05:34

标签: lisp common-lisp

我理解LET和LET *(并行与顺序绑定)之间的区别,作为理论问题,它非常有意义。但有没有你真的需要LET的情况?在我最近看过的所有Lisp代码中,您可以用LET *替换每个LET而不做任何更改。

编辑:好的,我理解为什么为什么某些人发明了LET *,可能是一个宏,回归的时候。我的问题是,鉴于LET *存在,是否有理由让LET留在身边?您是否编写了任何实际的Lisp代码,其中LET *不能像普通的LET一样工作?

我不买效率论据。首先,认识到LET *可以编译成像LET一样高效的情况,这似乎并不难。其次,CL规范中有很多东西似乎根本就不是围绕效率而设计的。 (当你最后一次看到带有类型声明的循环时?那些很难弄清楚我从未见过它们被使用过。)在20世纪80年代末的Dick Gabriel基准之前,CL 非常缓慢。

看起来这是另一种向后兼容的情况:明智地,没有人想冒险破坏像LET那样基本的东西。这是我的预感,但听到没有人有一个愚蠢的简单案例我很难过,因为LET比LET *更容易让事情变得更加容易。

17 个答案:

答案 0 :(得分:81)

LET本身并不是功能编程语言中的真正原语,因为它可以替换为LAMBDA。像这样:

(let ((a1 b1) (a2 b2) ... (an bn))
  (some-code a1 a2 ... an))

类似于

((lambda (a1 a2 ... an)
   (some-code a1 a2 ... an))
 b1 b2 ... bn)

但是

(let* ((a1 b1) (a2 b2) ... (an bn))
  (some-code a1 a2 ... an))

类似于

((lambda (a1)
    ((lambda (a2)
       ...
       ((lambda (an)
          (some-code a1 a2 ... an))
        bn))
      b2))
   b1)

你可以想象哪个更简单。 LET而非LET*

LET使代码理解更容易。人们可以看到一堆绑定,并且可以单独读取每个绑定,而无需了解“效果”(重新绑定)的自上而下/左右流动。使用LET*信号给程序员(读取代码的程序员),绑定不是独立的,但是存在某种自上而下的流程 - 这使事情变得复杂。

Common Lisp的规则是LET中绑定的值是从左到右计算的。只是如何评估函数调用的值 - 从左到右。因此,LET是概念上更简单的语句,默认情况下应该使用它。

LOOP 中的类型?经常使用。有一些原始形式的类型声明很容易记住。例如:

(LOOP FOR i FIXNUM BELOW (TRUNCATE n 2) do (something i))

上面将变量i声明为fixnum

Richard P. Gabriel在1985年发表了关于Lisp基准测试的书,当时这些基准测试也用于非CL Lisps。 Common Lisp本身在1985年是全新的 - CLtL1 一书描述了该语言刚刚于1984年出版。难怪当时的实现并没有得到很好的优化。实现的优化与之前的实现基本相同(或更少)(如MacLisp)。

但对于LETLET*,主要区别在于使用LET的代码对于人类更容易理解,因为绑定子句彼此独立 - 特别是因为它是糟糕的风格,以利用从左到右的评估(不将变量设置为副作用)。

答案 1 :(得分:34)

你不需要 LET,但你通常想要它。

LET表明你只是做标准的并行绑定,没有什么棘手的事情发生。 LET *引起对编译器的限制,并向用户建议有必要进行顺序绑定的原因。就风格而言,当您不需要LET *强加的额外限制时,LET会更好。

使用LET比使用LET *更高效(取决于编译器,优化器等):

  • 并行绑定可以并行执行(但我不知道是否有任何LISP系统实际执行此操作,并且init表单仍必须按顺序执行)
  • 并行绑定为所有绑定创建单个新环境(范围)。顺序绑定为每个单独的绑定创建一个新的嵌套环境。并行绑定使用较少内存并具有更快的变量查找

(上述要点适用于Scheme,另一种LISP方言.clisp可能有所不同。)

答案 2 :(得分:23)

我带着人为的例子。比较结果:

(print (let ((c 1))
         (let ((c 2)
               (a (+ c 1)))
           a)))

运行结果:

(print (let ((c 1))
         (let* ((c 2)
                (a (+ c 1)))
           a)))

答案 3 :(得分:10)

在LISP中,通常需要使用最弱的构造。例如,当您知道比较的项目是数字时,某些样式指南会告诉您使用=而不是eql。这个想法通常是指明你的意思,而不是有效地编程计算机。

然而,只能说出你的意思,而不是使用更强大的结构,可以实际提高效率。如果您使用LET进行初始化,则可以并行执行,而LET*初始化必须按顺序执行。我不知道是否有任何实现实际上会这样做,但有些可能在未来。

答案 4 :(得分:9)

LET和LET *之间公共列表的主要区别在于LET中的符号是并行绑定的,而LET *中的符号是顺序绑定的。 使用LET不允许init-forms并行执行,也不允许更改init-forms的顺序。原因是Common Lisp允许函数产生副作用。因此,评估的顺序很重要,并且在表格中始终是从左到右。因此,在LET中,首先从左到右评估init-forms,然后并行创建绑定 left-to-right 。在LET *中,初始化形式被评估,然后从左到右依次绑定到符号。

CLHS: Special Operator LET, LET*

答案 5 :(得分:9)

我最近编写了两个参数的函数,如果我们知道哪个参数更大,那么算法表达得最清楚。

(defun foo (a b)
  (let ((a (max a b))
        (b (min a b)))
    ; here we know b is not larger
    ...)
  ; we can use the original identities of a and b here
  ; (perhaps to determine the order of the results)
  ...)

假设b较大,如果我们使用let*,我们会不小心将ab设置为相同的值。

答案 6 :(得分:8)

我更进一步使用bind来统一letlet*multiple-value-binddestructuring-bind等等,它甚至可以扩展。

通常我喜欢使用“最弱的构造”,但不喜欢使用let&朋友,因为他们只是给代码发出噪音(主观警告!没有必要尝试说服我相反的......)

答案 7 :(得分:4)

据推测,通过使用let,编译器可以更灵活地重新排序代码,可能用于空间或速度改进。

在风格上,使用并行绑定显示了绑定组合在一起的意图;这有时用于保留动态绑定:

(let ((*PRINT-LEVEL* *PRINT-LEVEL*)
      (*PRINT-LENGTH* *PRINT-LENGTH*))
  (call-functions that muck with the above dynamic variables)) 

答案 8 :(得分:4)

(let ((list (cdr list))
      (pivot (car list)))
  ;quicksort
 )

当然,这样可行:

(let* ((rest (cdr list))
       (pivot (car list)))
  ;quicksort
 )

而且:

(let* ((pivot (car list))
       (list (cdr list)))
  ;quicksort
 )

但重要的是这个想法。

答案 9 :(得分:2)

OP询问“实际上是否需要LET”?

当创建Common Lisp时,有各种方言的现有Lisp代码。设计Common Lisp的人们接受的简要介绍是创建一种Lisp方言,以提供共同点。他们“需要”将现有代码移植到Common Lisp中变得简单而有吸引力。让LET或LET *离开语言可能会带来其他一些优点,但它会忽略这个关键目标。

我优先使用LET *,因为它告诉读者有关数据流如何展开的信息。在我的代码中,至少,如果你看到LET *,你知道早期绑定的值将用于稍后的绑定。我是否“需要”这样做,不;但我认为这很有帮助。这就是说,我很少阅读默认为LET *的代码,LET的出现表明了作者真正想要它。即例如,交换两个变量的含义。

(let ((good bad)
     (bad good)
...)

有一个有争议的场景接近'实际需要'。它出现在宏中。这个宏:

(defmacro M1 (a b c)
 `(let ((a ,a)
        (b ,b)
        (c ,c))
    (f a b c)))

效果更好
(defmacro M2 (a b c)
  `(let* ((a ,a)
          (b ,b)
          (c ,c))
    (f a b c)))

因为(M2 c b a)不会有效。但由于种种原因,这些宏很邋;;这样就破坏了“实际需要”的论点。

答案 10 :(得分:1)

Rainer Joswig's答案外,还有纯粹主义或理论观点。让&让*代表两种编程范式;功能性和顺序性。

至于为什么我应该继续使用Let *而不是Let,好吧,你带着我回家的乐趣,用纯粹的功能语言思考,而不是顺序语言我花了大部分时间工作与:)

答案 11 :(得分:1)

让你使用并行绑定,

(setq my-pi 3.1415)

(let ((my-pi 3) (old-pi my-pi))
     (list my-pi old-pi))
=> (3 3.1415)

使用Let * serial binding,

(setq my-pi 3.1415)

(let* ((my-pi 3) (old-pi my-pi))
     (list my-pi old-pi))
=> (3 3)

答案 12 :(得分:1)

let运算符为它指定的所有绑定引入了单个环境。 let*,至少在概念上(如果我们暂时忽略声明)引入了多种环境:

也就是说:

(let* (a b c) ...)

就像:

(let (a) (let (b) (let (c) ...)))

所以从某种意义上说,let更原始,而let*是写let - s级联的语法糖。

但别介意。 (我将在下面给出理由,为什么我们应该“从不介意”)。事实是有两个运算符,在“99%”的代码中,使用哪一个并不重要。选择let而不是let*的原因很简单,就是它最后没有*悬挂。

每当你有两个运算符,并且其名称悬挂*时,如果它在那种情况下有效,请使用不带*的运算符,以保持代码不那么难看。

这就是它的全部内容。

话虽如此,我怀疑如果letlet*交换了意义,那么使用let*可能比现在更少见。串行绑定行为不会打扰大多数不需要它的代码:很少需要使用let*来请求并行行为(这种情况也可以通过重命名变量来修复以避免阴影)。

现在为了这个承诺的讨论。尽管let*在概念上引入了多个环境,但编译let*构造非常容易,因此可以生成单个环境。所以尽管(至少如果我们忽略ANSI CL声明),有一个代数相等,一个let*对应多个嵌套let - s,这使得let看起来更原始,实际上没有理由将let*扩展为let来编译它,甚至是个坏主意。

还有一点:注意Common Lisp的lambda实际上使用let* - 就像语义一样!例如:

(lambda (x &optional (y x) (z (+1 y)) ...)

此处,x的{​​{1}}初始化表单访问较早的y参数,类似地x指的是较早的(+1 y)可选项。在该语言的这个领域,明确倾向于在绑定中显示顺序可见性。如果y中的表单看不到参数,那么它就没用了;根据先前参数的值,可选参数不能简洁地默认。

答案 13 :(得分:1)

letlet* 之间肯定存在效率争论。但我们拥有 let 的主要原因是历史性的,因为与 lambda 的关系。

let代码遍历 Lisp 解释器中实现起来更容易、更简单、更高效。如果环境中有一些半体面的数据结构,而不仅仅是一个 assoc 列表,则尤其如此。

假设解释器将环境实现为一个对象链。因此对于 istance (let (a b) (let (c d) (let (e f)))) 将向环境链添加三个环境节点。这些新节点中的每一个都包含两个绑定(在单个列表、哈希表或其他任何内容中)。

当我们解释 let 形式时,我们可以评估传入环境链中的所有初始化表达式。我们可以在单个操作中为所有新绑定创建单个环境节点,并使用值填充绑定。

当我们解释 let* 形式时,我们不能这样做。对于 let* 中提到的每个连续绑定,我们必须调用 make-environment,填充它并将其添加到环境链中,以便我们在扩展环境中解释下一个初始化形式。

这会导致退化的运行时环境结构。 (let (a b c d ...)) 生成一个环境对象,其中包含一个不错的哈希表,而 (let* (a b c d ...)) 生成一个效率低下的链,需要 O(n) 遍历才能找到绑定。

我们可以消除 letlet* 的解释器性能之间的差异,但只能将 let 的性能拖到 let*。如果我们将环境链表示为一个简单的 assoc 列表,那么这个问题并不重要;所有变量查找都是线性搜索。实际上 let* 更容易实现:评估每个 init 表达式,并将新绑定推送到当前环境。

现在,将编译输入到图片中。 Lisp 编译器可以通过对 let* 的编译策略进行一些调整来使用一个邪恶的技巧来实现 let。为了编译 let*,我们可以为所有绑定分配一个单一的环境(这一举动会导致解释器中的范围不正确)。我们将该环境留空,并将其添加到编译时环境链中。因此,我们在新环境的范围内编译 init 表达式。当我们迭代 init 表达式来编译它们时,我们将每个对应的变量一一添加到该环境中,以便后续 init 表达式的编译将在范围内使用该变量。

let* 是一个简单的技巧,当您拥有处理 let 的 Lisp 编译器时,它变得显而易见。

Lisp 编译器可以轻松生成环境的高效表示,而不管范围规则如何,这对于解释器来说不一定如此。

既然解释器首先出现,这就解释了为什么 let 是平行的。至少部分。另一个原因是 let 被实现为 lambda 之上的语法糖。但是 lambda(最初)在它自己的范围内根本没有初始化表达式;它只是指定变量。 lambda 表达式生成一个运行时对象,以便在调用函数时在运行时将值绑定到参数。参数表达式的计算在一个完全不同的范围内。

现在在直接调用的 lambda 中,这仍然是正确的:表达式的范围完全在 lambda 之外:

((lambda (a b) (+ a b)) 1 2)

表达式12lambda 无关;它们没有包含在其中。

所以很明显,如果我们想要一个对应于上面的 let 糖符号,我们必须小心地保留这个属性:

(let ((a 1) (b 2))
  (+ a b))

如果我们希望这个 let 和前面的 lambda 一样,我们必须让它看起来好像 ab 是函数参数,而 {{ 1}} 和 1 是参数表达式。

如果您是使用具有 2 而没有 lambda 的语言工作的研究人员,并且渴望有一种更好的方式来编写立即调用的 let,那么您不太可能将发明 lambda 绑定语义。您将发明一些对您在整个代码中使用的现有结构具有明确转换策略的东西,以便您可以重构代码以毫无意外地使用它。

请注意,Common Lisp 等方言中的现代 let* 确实在其中嵌入了表达式:即可选参数和关键字参数!

lambda

这些默认值表达式 (lambda (a &optional (b x) (c y) ...)) x 在周围的词法作用域中计算,每当缺少参数时,每次调用函数时。那么,这些表达使用什么范围规则?为什么,串行,而不是并行!

y

所以,事情又回到了原点。一开始,有[1]> (defun foo (x &optional (y (1+ x)) (z (1+ y))) (list x y z)) FOO [2]> (foo 10) (10 11 12) LAMBDA 开始 LAMBDALET 产生了 LET,而 LET* 产生了更新的 LET*,并带有可选参数 init-forms 的顺序绑定。 :)

结果是将现代直接称为 lambda 的转换为 LAMBDA 非常复杂。例如:

let

可以编译成:

(funcall (lambda (x y &optional (z x) (w (1+ z))) a b c)

答案 14 :(得分:0)

let下,所有变量初始化表达式都会看到完全相同的词汇环境:围绕let的环境。如果这些表达式碰巧捕获词法闭包,它们都可以共享相同的环境对象。

let*下,每个初始化表达式都在不同的环境中。对于每个连续的表达式,必须扩展环境以创建新的环境。至少在抽象语义中,如果捕获了闭包,它们就有不同的环境对象。

let*必须经过充分优化,以折叠不必要的环境扩展,以便适合作为let的日常替代品。必须有一个编译器可以使哪些表单访问内容然后将所有独立的表单转换为更大的组合let

(即使let*只是一个发出级联let表单的宏运算符,也是如此;优化是在那些级联let s上完成的。

您无法将let*实现为单个天真let,并使用隐藏变量赋值进行初始化,因为缺少适当的范围将会显示:

(let* ((a (+ 2 b))  ;; b is visible in surrounding env
       (b (+ 3 a)))
  forms)

如果将其变为

(let (a b)
  (setf a (+ 2 b)
        b (+ 3 a))
  forms)

在这种情况下不起作用;内部b正在遮蔽外部b,因此我们最终将{2}添加到nil。如果我们对所有这些变量进行alpha重命名,那么这种转换可以完成。然后环境很平坦:

(let (#:g01 #:g02)
  (setf #:g01 (+ 2 b) ;; outer b, no problem
        #:g02 (+ 3 #:g01))
  alpha-renamed-forms) ;; a and b replaced by #:g01 and #:g02

为此,我们需要考虑调试支持;如果程序员使用调试器进入这个词法范围,我们是否希望他们处理#:g01而不是a

所以基本上,let*是一个复杂的结构,必须进行优化,以便在let可以减少到let的情况下执行{。}}。

单凭let优于let*是不合理的。我们假设我们有一个很好的编译器;为什么不一直使用let*

作为一般原则,我们应该支持更高级别的构造,这些构造使我们更有成效并减少错误,而不是容易出错的低级构造,并且尽可能地依赖于更高级别构造的良好实现,以便我们很少为了表现,必须牺牲他们的使用。这就是为什么我们首先使用像Lisp这样的语言。

这种推理并不适用于letlet*,因为let*显然不是相对于let的更高级别的抽象。他们是“平等”。使用let*,您可以引入一个只需切换到let即可解决的错误。而反之亦然let*实际上只是一种温和的语法糖,用于在视觉上折叠let嵌套,而不是一个重要的新抽象。

答案 15 :(得分:-1)

谁想重新写一次letf vs letf *?放松保护电话的数量?

更容易优化顺序绑定。

也许会影响 env

允许使用动态范围延续吗?

有时候(让(x y z))                (setq z 0                      是1                      x(+(setq x 1)                           (PROG1                    (+ x y)                   (setq x(1- x)))))     (values()))

[我认为有用]重点是,更简单有时更容易阅读。

答案 16 :(得分:-1)

我主要使用LET,除非我特别需要LET *,但有时我会编写明确需要 LET的代码,通常是在进行各种(通常是复杂的)默认操作时。不幸的是,我手边没有任何方便的代码示例。