86

我了解 LET 和 LET* (并行与顺序绑定)之间的区别,从理论上讲,它非常有意义。但是在任何情况下你真的需要 LET 吗?在我最近查看的所有 Lisp 代码中,您可以将每个 LET 替换为 LET* 而无需更改。

编辑:好的,我明白为什么有些人发明了 LET*,大概是作为一个宏,早在什么时候。我的问题是,鉴于 LET* 存在,LET 是否有理由留下来?你写过任何实际的 Lisp 代码,其中 LET* 不能像普通的 LET 那样工作吗?

我不赞成效率论点。首先,识别 LET* 可以编译为与 LET 一样高效的情况似乎并不难。其次,CL 规范中有很多东西看起来根本不像是围绕效率设计的。(你最后一次看到带有类型声明的 LOOP 是什么时候?这些很难弄清楚我从未见过它们使用过。) 在 1980 年代后期 Dick Gabriel 的基准测试之前,CL非常缓慢。

看起来这是向后兼容的另一种情况:明智的是,没有人愿意冒险破坏像 LET 这样基本的东西。这是我的预感,但令人欣慰的是,没有人有一个我错过的愚蠢简单的案例,LET 使一堆事情比 LET* 容易得多。

4

16 回答 16

90

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为 a fixnum

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

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

于 2009-02-25T21:03:43.957 回答
39

不需要LET,但您通常需要它。

LET 建议您只是在做标准的并行绑定,没有任何棘手的事情发生。LET* 对编译器产生限制,并向用户建议需要顺序绑定是有原因的。在样式方面,当您不需要 LET* 施加的额外限制时,LET 会更好。

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

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

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

于 2009-02-17T00:21:23.913 回答
29

我是带着人为的例子来的。比较这个结果:

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

运行结果如下:

(print (let ((c 1))
         (let* ((c 2)
                (a (+ c 1)))
           a)))
于 2009-02-16T23:21:18.690 回答
11

在 LISP 中,通常希望使用最弱的结构。例如,一些样式指南会告诉您使用,=而不是eql当您知道比较的项目是数字时。这个想法通常是指定您的意思,而不是有效地对计算机进行编程。

但是,仅说出您的意思而不使用更强大的结构可能会提高效率。如果你有初始化LET,它们可以并行执行,而LET*初始化必须按顺序执行。我不知道是否有任何实现会真正做到这一点,但将来可能会有一些实现。

于 2009-02-16T23:18:38.777 回答
10

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

CLHS:特殊运算符 LET、LET*

于 2009-02-18T21:46:33.720 回答
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*,我们会不小心将a和设置b为相同的值。

于 2012-02-27T02:13:28.840 回答
8

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

通常我喜欢使用“最弱的构造”,但不喜欢let和朋友一起使用,因为他们只会给代码带来噪音(主观性警告!无需试图说服我相反......)

于 2009-02-17T00:31:49.223 回答
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
 )

但重要的是思想。

于 2010-05-19T08:54:30.993 回答
4

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

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

(let ((*PRINT-LEVEL* *PRINT-LEVEL*)
      (*PRINT-LENGTH* *PRINT-LENGTH*))
  (call-functions that muck with the above dynamic variables)) 
于 2009-02-16T23:21:11.437 回答
3

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

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

Alet*必须经过优化以折叠不必要的环境扩展,以便适合作为let. 必须有一个编译器可以计算出哪些表单正在访问什么,然后将所有独立的表单转换为更大的组合let.

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

您不能实现let*为单个 naive 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 这样的语言。

该推理不适用于letvs let*,因为let*相对于 . 而言显然不是更高级别的抽象let。它们大约是“同等水平”。使用let*,您可以引入一个错误,只需切换到let. 反之亦然。_ let*真的只是视觉上折叠let嵌套的一种温和的语法糖,而不是一个重要的新抽象。

于 2018-02-25T16:31:02.097 回答
2

With Let you use parallel binding,

(setq my-pi 3.1415)

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

And with Let* serial binding,

(setq my-pi 3.1415)

(let* ((my-pi 3) (old-pi my-pi))
     (list my-pi old-pi))
=> (3 3)
于 2010-09-28T22:02:09.813 回答
2

OP询问“曾经真正需要LET”吗?

在创建 Common Lisp 时,有大量现有的 Lisp 代码以各种方言形式出现。设计 Common Lisp 的人们接受的简报是创建一种能够提供共同点的 Lisp 方言。他们“需要”让将现有代码移植到 Common Lisp 中变得容易且有吸引力。将 LET 或 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 cba) 行不通。但是出于各种原因,这些宏非常草率。这样就破坏了“实际需要”的论点。

于 2013-02-23T03:04:29.153 回答
2

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

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

假设解释器将环境实现为对象链。所以 for 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) 遍历才能找到绑定。

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

现在,在图片中输入编译。 Lisp 编译器let*只需对let. 为了编译let*,我们可以为所有绑定分配一个单一的环境(这会导致解释器中的范围不正确)。我们将该环境留空,并将其添加到编译时环境链中。因此,我们在该新环境的范围内编译 init 表达式。当我们遍历 init 表达式来编译它们时,我们将每个对应的变量一一添加到该环境中,以便后续 init 表达式的编译将在范围内具有该变量。

let*是一个简单的 hack,当你有一个处理let.

Lisp 编译器很容易生成环境的有效表示,而不管范围规则如何,解释器不一定如此。

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

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

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

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

所以很明显,如果我们想要一个let与上述相对应的糖表示法,我们必须小心地保留这个属性:

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

如果我们想让 thislet和之前的一样lambda,我们必须让它看起来好像aandb是函数参数,1and2是参数表达式。

如果您是一名研究人员,正在使用一种有lambda和没有s 的语言,并且let渴望一种更好的方式来编写立即称为lambdas 的语言,那么您不太可能发明let*绑定语义。您将发明一些对您在整个代码中使用的现有结构具有清晰翻译策略的东西,以便您可以重构代码以毫无意外地使用它。

请注意,lambda像 Common Lisp 这样的现代方言中确实嵌入了表达式:即可选参数和关键字参数!

(lambda (a &optional (b x) (c y) ...))

这些默认值表达式在周围的词法范围xy进行评估,无论何时缺少参数,每次调用函数时。那么,这些表达式使用什么范围界定规则呢?为什么,串行,而不是并行!

[1]> (defun foo (x &optional (y (1+ x)) (z (1+ y)))
       (list x y z))
FOO
[2]> (foo 10)
(10 11 12)

于是,事情就这样循环了。起初,有LAMBDA. LAMBDA开始LETLETbegat LET*,并通过可选参数 init-forms 的顺序绑定LET*开始更新。LAMBDA:)

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

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

可以编译成:

 (let ((x a) (y b))   ;; we put the fixed params into a let
   (let* ((z c))      ;; z arg present, so refer to c, not x
          (w (1+ z))) ;; w arg absent, use (1+ z)
     ...))
于 2021-04-15T17:24:58.770 回答
1

除了Rainer Joswig 的回答,从纯粹主义者或理论的角度来看。Let & Let* 代表两种编程范式;分别是功能性和顺序性。

至于我为什么要继续使用 Let* 而不是 Let,嗯,你让我回家后用纯函数式语言思考的乐趣,而不是我大部分时间都在使用的顺序语言:)

于 2010-08-11T22:59:17.630 回答
-1

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

于 2009-02-20T14:33:23.280 回答
-1

谁想再次重写 letf vs letf*?解除保护电话的数量?

更容易优化顺序绑定。

也许它会影响环境

允许动态范围的延续?

有时 (let (xyz) (setq z 0 y 1 x (+ (setq x 1) (prog1 (+ xy) (setq x (1- x))))) (values () ))

[我认为可行] 要点是,有时更简单更容易阅读。

于 2011-12-24T04:52:53.187 回答