【问题标题】:LET versus LET* in Common LispCommon Lisp 中的 LET 与 LET*
【发布时间】:2010-10-07 23:37:28
【问题描述】:

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

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

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

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

【问题讨论】:

  • parallel 是一个不好的词选择;只有以前的绑定是可见的。并行绑定更像是 Haskell 的“... where ...”绑定。
  • 我的目的不是混淆;我相信这些是规范使用的词。 :-)
  • 平行是正确的。这意味着绑定同时变得生动,彼此不可见,也不相互影响。在任何时候都不存在包含 LET 中定义的一些变量但不包含其他变量的用户可见环境。
  • Haskells 的绑定更像 letrec。他们可以看到同一范围级别的所有绑定。
  • 询问“是否有需要let 的情况?”有点像问“是否存在需要具有多个参数的函数的情况?”。 letlet* 不存在是因为它们存在一些效率概念,因为它们允许人类在编程时将意图传达给其他人。

标签: lisp common-lisp


【解决方案1】:

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

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

【讨论】:

  • 好点。尽管由于 Lisp 是一种如此高级的语言,但这让我想知道为什么“最弱可能的构造”在 Lisp 领域是一种如此理想的风格。你不会看到 Perl 程序员说“好吧,我们不需要在这里使用正则表达式......”:-)
  • 我不知道,但有明确的风格偏好。喜欢尽可能使用相同形式的人(比如我)有点反对(我几乎从不写 setq 而不是 setf)。它可能与表达你的意思的想法有关。
  • = 运算符既不强也不弱于 eql。这是一个较弱的测试,因为0 等于0.0。但它也更强大,因为非数字参数被拒绝。
  • 您所说的原则是使用最强适用的原语,而不是最弱。例如,如果要比较的东西是符号,请使用eq。或者,如果您知道要分配给一个象征性的地方,请使用setq。然而,这个原则也被我的许多 Lisp 程序员拒绝了,他们只想要一种高级语言而不需要过早优化。
  • 实际上,CLHS says绑定 [并行完成]”但是“表达式 init-form-1, init-form-2,依此类推,[被评估]以[特定的,从左到右(或自上而下)]的顺序”。因此必须按顺序计算这些值(在计算完所有值之后 建立绑定)。这也是有道理的,因为类似 RPLACD 的结构突变是语言的一部分,并且在真正的并行性下,它会变得不确定。
【解决方案2】:

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

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

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

【讨论】:

  • 在 90% 的代码中,使用 LETLET* 没有区别。因此,如果您使用*,您将添加一个不必要的字形。如果LET* 是并行绑定器,LET 是串行绑定器,程序员仍然会使用LET,并且只有在需要并行绑定时才拉出LET*。这可能会使LET* 变得罕见。
  • 其实,CLHS specifies the order 评估 let's init-forms.
【解决方案3】:

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

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

运行结果如下:

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

【讨论】:

  • 关心开发为什么会这样?
  • @John:在第一个例子中,a 的绑定是指c 的外部值。在第二个示例中,let* 允许绑定引用以前的绑定,a 的绑定引用 c 的内部值。洛根并没有说这是一个人为的例子,甚至没有假装有用。此外,缩进是非标准的且具有误导性。在这两种情况下,a 的绑定应该是一个空格,与c 对齐,而内部let 的“主体”应该距离let 本身只有两个空格。
  • 这个答案提供了一个重要的见解。当一个人想要避免有第二个绑定(我的意思不是第一个)引用第一个绑定时,一个人会特别使用let,但是你确实想要隐藏以前的绑定——使用它的以前的值来初始化你的一个辅助绑定。
  • 虽然缩进已关闭(我无法编辑它),但这是迄今为止最好的示例。当我查看 (let ...) 时,我知道 none 的绑定将相互构建并且可以单独处理。当我查看 (let* ...) 时,我总是小心翼翼地接近并非常仔细地查看哪些绑定正在被重用。仅出于这个原因,除非您绝对需要嵌套,否则始终使用 (let) 是有意义的。
  • (6 年后...)设计是错误且具有误导性的缩进,旨在作为 gotcha?我倾向于对其进行编辑以修复它...我不应该吗?
【解决方案4】:

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

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

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

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

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

【讨论】:

  • 注意:请参阅this answer(和/或 hyperspec 的链接部分),了解为什么您的第一个 pullet 点是误导性的,比如说。 bindings 并行发生,但 forms 是按规范顺序执行的。
  • 并行执行不是 Common Lisp 标准以任何方式处理的事情。更快的变量查找也是一个神话。
  • 差异不仅仅对编译器很重要。我使用 let 和 let* 作为对正在发生的事情的提示。当我在我的代码中看到 let 时,我知道绑定是独立的,当我看到 let* 时,我知道绑定相互依赖。但我只知道这一点,因为我确保始终使用 let 和 let*。
【解决方案5】:

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

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

【讨论】:

  • 哦,整洁。我现在要去和 BIND 一起玩。感谢您的链接!
【解决方案6】:

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

CLHS: Special Operator LET, LET*

【讨论】:

  • 看来这个答案可能从对this answer的回应中汲取了一些能量?此外,根据链接的规范,据说 bindingsLET 中并行完成,即使您正确地指出 init-forms 是串行执行的。我不知道这在任何现有的实现中是否有任何实际差异。
【解决方案7】:

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

【讨论】:

    【解决方案8】:

    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 的代码更易于人类理解,因为绑定子句是相互独立的 - 特别是因为利用它是不好的风格从左到右的评估(不将变量设置为副作用)。

    【讨论】:

    • 不,不! Lambda 不是一个真正的原语,因为它可以用 LET 和一个较低级别的 lambda 替换,它只提供一个 API 来获取参数值:(low-level-lambda 2 (let ((x (car %args%)) (y (cadr args))) ...) :)
    • 这个答案并不正确,因为 lambda 参数没有在周围环境中评估的初始化表达式。也就是说(lambda (a b c) ...)在这方面并不等同于letlet*lambda 表达式生成一个运行时对象,并且参数的绑定在调用该对象时延迟完成。产生值的表达式在一个完全不同的范围内,可能是另一个编译文件。 [继续]
    • Common Lisp 和类似方言中有一种情况,其中 lambda 参数 do 具有初始化表达式:(lambda (&optional (a x) (b y) ...))这些可选参数遵循let* 顺序绑定,而不是let 并行绑定。。因此,综上所述,如果我们在lambda中引入带有默认值表达式的可选参数,那么并行与顺序的问题就出现了,这只是一个利弊的实现选择;两者都不比另一个更低或更基本。
    【解决方案9】:
    (let ((list (cdr list))
          (pivot (car list)))
      ;quicksort
     )
    

    当然,这是可行的:

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

    还有这个:

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

    但重要的是思想。

    【讨论】:

      【解决方案10】:

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

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

      【讨论】:

        【解决方案11】:

        有了让你使用并行绑定,

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

        并且使用 Let* 串行绑定,

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

        【讨论】:

        • 是的,它们就是这样定义的。但是你什么时候需要前者?我假设您实际上并没有编写需要以特定顺序更改 pi 值的程序。 :-)
        【解决方案12】:

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

        更容易优化顺序绑定。

        也许它会影响 env

        允许动态范围的延续?

        有时(让 (x y z) (setq z 0 是 1 x (+ (setq x 1) (编1 (+ x y) (setq x (1- x))))) (值 () ))

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

        【讨论】:

          【解决方案13】:

          我最近在写一个有两个参数的函数,如果我们知道哪个参数更大,那么算法可以最清楚地表达。

          (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 设置为相同的值。

          【讨论】:

          • 除非您稍后在外部 let 中需要 x 和 y 的值,否则可以更简单(也更清楚)通过:(rotatef x y) 来完成此操作——这不是一个坏主意,但它似乎仍然有些牵强。
          • 确实如此。如果 x 和 y 是特殊变量,它可能会更有用。
          【解决方案14】:

          OP 询问“是否真的需要 LET”?

          在创建 Common Lisp 时,有大量现有的各种方言的 Lisp 代码。设计 Common Lisp 的人们接受的简报是创建一种能够提供共同点的 Lisp 方言。他们“需要”让将现有代码移植到 Common Lisp 中变得容易且有吸引力。将 LET 或 LET* 排除在语言之外可能还有其他一些优点,但它会忽略这个关键目标。

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

          (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) 行不通。但是出于各种原因,这些宏非常草率。这样就破坏了“实际需要”的论点。

          【讨论】:

            【解决方案15】:

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

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

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

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

            您不能将 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 嵌套的温和语法糖,而不是一个重要的新抽象。

            【讨论】:

              【解决方案16】:

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

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

              假设解释器将环境实现为对象链。所以对于不是这样(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* 是一个简单的 hack,当你有一个处理 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是函数参数,而12是参数表达式.

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

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

              (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)
              

              所以,事情就这样转了一圈。一开始是LAMBDALAMBDA 开始LETLET 开始 LET*LET* 开始更新 LAMBDA 与可选参数 init-forms 的顺序绑定。 :)

              结果是将现代直接调用的 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)
                   ...))
              

              【讨论】:

                猜你喜欢
                • 2015-06-04
                • 1970-01-01
                • 1970-01-01
                • 1970-01-01
                • 1970-01-01
                • 1970-01-01
                • 2017-05-20
                • 2019-12-28
                • 2020-02-19
                相关资源
                最近更新 更多