在Lisp中,使用let*还是setf更符合惯用语?

5

当计算一个值需要多个步骤时,我有使用(let*)来声明多个变量版本的倾向,如下所示:

(let* ((var 1)
       (var (* var 2))
       (var (- var)))
 (format t "var = ~a~%" var))

与其他语言中所期望的更多的命令式风格不同:
(let ((var 1))
 (setf var (* var 2))
 (setf var (- var))
 (format t "var = ~a~%" var))

(显然这只是一个过于简单的例子,我通常会将它们合并为单个声明。)
我想我更喜欢第一个版本,因为我从未真正改变状态。它看起来更加“功能性”或干净 - 当然,也可能更加线程安全。
但是,我意识到后者在内存分配或执行周期方面可能更“便宜”。
其中一种做法是否[a]比另一种更符合惯用法; 或[b]更有效地使用内存或时钟周期?
编辑:代码中的实际示例。
(let* ((config-word      (take-multibyte-word data 2 :skip 1))
       (mem-shift-amount ...)
       (config-word      (translate-incoming-data config-word mem-shift-amount blank-value)))

  ...)

(let* ((reserve-byte-count (get-config :reserve-bytes))
       (reserve-byte-count (and (> reserve-byte-count 0) reserve-byte-count))
  ...)

(let* ((licence                (decipher encrypted-licence decryption-key))
       (licence-checksum1      (coerce (take-last 16 licence) '(vector (unsigned-byte 8))))
       (licence                (coerce (drop-last 16 licence) '(vector (unsigned-byte 8))))
       (licence-checksum2      (md5:md5sum-sequence (coerce licence '(vector (unsigned-byte 8)))))
       (licence                (and (equalp licence-checksum1 licence-checksum2)
                                    (decode licence))))
  ...)

两种形式在现代优化编译器中,在执行时间或空间占用方面没有实际显着差异。您可以根据您对程序可读性和样式偏好的想法来选择它们之间的区别。个人而言,我更喜欢第二种解决方案,因为它更好地传达了对某些数据应用一系列转换的想法。请注意,“let*”是一个顺序结构,因此在此情况下无法应用多线程考虑。此外,我在其他人的代码中更常见第二种形式而不是第一种。 - Renzo
@Renzo 如果 let* 子句中的计算包含自由变量,那么与内存相关的事情是否会改变。那么 setf 将更加内存高效吗?编译器真的会优化掉所有东西吗? - Gwang-Jin Kim
3个回答

3

正如评论中所提到的,除非你知道这是个问题,否则不要担心性能。不能安全地假设你知道编译器会如何处理你的代码,或者机器将如何处理编译器生成的代码。

我应该提前指出我的Lisp风格可能很特别:我写了很长时间的Lisp,相当早就不再关心其他人对我的代码有什么看法(是的,我独自工作)。

还要注意,这整个答案都是关于风格的观点:我认为在编程中风格问题很有趣也很重要,因为程序不仅是与机器交流,也是与人类交流,而且在Lisp中尤为重要,因为Lisp的人们一直以来都明白这一点。但是,关于风格的观点是存在差异的,这是合理的。

我会选择像下面这样的形式:

(let* ((var ...)
       (var ...)
       ...)
  ...)

最好情况下,这看起来很别扭,因为它像个错误。 另一种选择是

(let ((var ...))
  (setf var ...)
  ...)

在我看来,这更加诚实,尽管它显然很卑琐(任何任务都会让我感到紧张,虽然有时候显然是必需的,而我不是某种功利主义者)。但是,在这两种情况下,我都会问自己为什么要这样做?特别是如果你有像这样的东西:

(let* ((var x)
       (var (* var 2))
       (var (- var)))
  ...)

这是您的原始格式,除了我将var绑定到x以使整个表达式在编译时不被知晓。那么,为什么不直接写出您想要的表达式,而不是其中的一部分呢?

(let ((var (- (* x 2))))
  ...)

更易于阅读且含义相同。虽然在某些情况下,您可能无法轻松实现这一点:

(let* ((var (f1 x))
       (var (+ (f2 var) (f3 var))))
  ...)

更普遍地说,如果某个中间表达式的值被多次使用,则需要将其绑定到某个变量上。诱人的想法是,你可以将以上形式转化为如下形式:

(let ((var (+ (f2 (f1 x)) (f3 (f1 x)))))
  ...)

但这并不安全:如果我们假设f1很昂贵,那么编译器可能能够合并对f1的两个调用,也可能不能,但是它肯定只能在知道f1是无副作用和确定性的情况下才能这样做,这可能需要英勇的优化策略。更糟糕的是,如果f1实际上不是函数,则此表达式甚至不等同于先前的表达式:

(let ((x (random 1.0)))
  (f x x))

这不同于

(f (random 1.0) (random 1.0))

即使不考虑副作用,这也是真实的!

但是我认为唯一需要有中间绑定的情况是某个子表达式的值在表达式中被使用多次:如果它只被使用一次,那么你可以像我在你上面的示例中所做的那样将其拼接到后面的表达式中,并且我会始终这样做,除非表达式特别难懂。

而且,在子表达式被使用多次或表达式特别复杂的情况下,我会为中间值使用不同的名称(想出名称并不难),或者在需要时将其绑定。 因此,从这里开始:

(let* ((var (f1 x))
       (var (+ (f2 var) (f3 var))))
  ...)

我将把它转化为以下内容:

我会把它改成这样:

(let* ((y (f1 x))
       (var (+ (f2 y) (f3 y))))
  ...)

这段代码存在一个问题,即y在函数主体中没有任何有意义的绑定,因此我可能会将其修改为:

(let ((var (let ((y (f1 x)))
             (+ (f2 y) (f3 y)))))
  ...)

这种方式仅在需要绑定中间值时才确切且只绑定它。 我认为它的主要问题是那些来自命令式编程背景并不真正熟悉表达式语言的人很难阅读。 但是,我无所谓他们的想法。


当宏生成代码时,我可能会使用 (let* ((var ...) (var ... var ...)) ...) 这种写法。但我从不期望必须去读那段代码。

我永远不会接受 (let* ((var ...) (var ...)) ...) 或者使用下面这个 successively 宏的写法 (successively (var ... ...) ...),除非多个变量的绑定涉及到语义上不同的内容,可能有一个明显为中间值的表达式用一个语义匿名的名称引用它(例如,在某些复杂算术运算中的 x,在该上下文中,x 并不代表坐标)。我认为在同一表达式中将不同的名称用于不同的内容是很糟糕的。

以题目中的这个表达式作为例子:

(let* ((licence                (decipher encrypted-licence decryption-key))
       (licence-checksum1      (coerce (take-last 16 licence) '(vector (unsigned-byte 8))))
       (licence                (coerce (drop-last 16 licence) '(vector (unsigned-byte 8))))
       (licence-checksum2      (md5:md5sum-sequence (coerce licence '(vector (unsigned-byte 8)))))
       (licence                (and (equalp licence-checksum1 licence-checksum2)
                                    (decode licence))))
  ...)

并将其转化为以下表达式

(let* ((licence-with-checksum (decipher encrypted-licence decryption-key))
       (given-checksum (coerce (take-last 16 license-with-checksum)
                               '(vector (unsigned-byte 8))))
       (encoded-license (coerce (drop-last 16 license-with-checksum)
                        '(vector (unsigned-byte 8))))
       (computed-checksum (md5:md5sum-sequence encoded-license))
       (license (if (equalp given-checksum computed-checksum)
                            (decode encoded-license)
                          nil)))
  ...)

我已经移除了至少一个不必要的 coerce 调用,但是我会担心其中一些:是否已经正确地使用了 license-with-checksum?如果是这样,那么 take-lastdrop-last 有什么问题,导致它们的结果需要再次强制转换?
这段代码有四个不同的绑定,代表了四种不同的含义,并以其含义命名:
- license-with-checksum 是带有附加校验和的许可证,我们将检查和解码它; - given-checksum 是从 license-with-checksum 得到的校验和; - encoded-license 是从 license-with-checksum 中编码的许可证; - computed-checksum 是我们从 encoded-license 计算出的校验和; - 如果校验和匹配,则 license 是解码的许可证,否则为 nil
如果我更了解涉及的操作语义,我可能会更改其中一些名称。
当然,如果 license 最终为 nil,我们现在可以使用任何或所有此信息报告错误。
考虑到这一点,我写了一个叫做 successively 的宏,以一种我认为易于阅读的方式完成了其中的部分。如果您说
(successively (x y
                 (* x x) 
                 (+ (sin x) (cos x)))
  x)

然后宏展开为
(let ((x y))
  (let ((x (* x x)))
    (let ((x (+ (sin x) (cos x))))
      x)))

甚至更好的是你可以说:

(successively ((x y) (values a b) (values (+ (sin x) (sin y))
                                          (- (sin x) (sin y))))
  (+ x y))

您将获得:

(multiple-value-bind (x y) (values a b)
  (multiple-value-bind (x y) (values (+ (sin x) (sin y)) (- (sin x) (sin y)))
    (+ x y)))

这还是挺不错的。

下面是宏代码:

(defmacro successively ((var/s expression &rest expressions) &body decls/forms)
  "Successively bind a variable or variables to the values of one or more
expressions.

VAR/S is either a single variable or a list of variables.  If it is a
single variable then it is successively bound by a nested set of LETs
to the values of the expression and any more expressions.  If it is a
list of variables then the bindings are done with MULTIPLE-VALUE-BIND.

DECLS/FORMS end up in the body of the innermost LET.

You can't interpose declarations between the nested LETs, which is
annoying."
  (unless (or (symbolp var/s)
              (and (listp var/s) (every #'symbolp var/s)))
    (error "variable specification isn't"))
  (etypecase var/s
    (symbol
     (if (null expressions)
         `(let ((,var/s ,expression))
            ,@decls/forms)
       `(let ((,var/s ,expression))
          (successively (,var/s ,@expressions)
            ,@decls/forms))))
    (list
     (if (null expressions)
         `(multiple-value-bind ,var/s ,expression
            ,@decls/forms)
       `(multiple-value-bind ,var/s ,expression
          (successively (,var/s ,@expressions) 
            ,@decls/forms))))))

我认为这展示了将Lisp适应为您想要的语言有多么容易:编写此宏只需大约五分钟。


你知道吗,我从来没有想过以这种方式在另一个let中使用let。你认为这将是更可取的选择吗?(Lisp是一种如此陌生的语言。) - Sod Almighty
@SodAlmighty:我认为,如果要写出我需要一个比你的更现实的例子(我试图在我的答案中发明)。我认为,只有在你有重要的命令式编程语言的经验时,Lisp才是陌生的。 - user5920214
Will add a real example. - Sod Almighty
我希望我能像你一样擅长编写宏。虽然我不是很糟糕,但当我尝试做这样的事情时,我总是感觉头脑混乱。如果要修改宏以自动返回“x”,而无需告诉它,那会有多难呢?毕竟,你可能总是想要返回第一个变量-否则为什么要使用宏呢? - Sod Almighty
@SodAlmighty:我认为你的许可证示例是一个典型的案例,我会对语义不同的对象使用相同的名称感到非常不舒服(我现在已经在我的答案中添加了一条注释)。我并不是要冒犯你的代码:这最终是一个风格问题,但我永远不会这样做。 - user5920214
显示剩余5条评论

3
我认为以上都不是正确答案,正确答案应该是:
(let* ((v0 1)
       (v1 (* v0 2))
       (v2 (- v1)))
 (format t "var = ~a~%" v2)

如有必要,请替换有意义的名称。好的Lisp编译器必须能够消除临时变量,因为宏会大量生成以gensyms形式出现的临时变量。通常,这些gensym出现在不实际需要它们的情况下;它们绑定到的是不会导致任何名称冲突并且没有多次评估问题的内容。

例如,在以下扩展中,X本身可以安全地用于替代#:LIMIT-3374 gensym。多次计算X不会引起问题,而循环也不会改变X

[6]> (ext:expand-form '(loop for i below x collect i))
(BLOCK NIL
 (LET ((#:LIMIT-3374 X) (I 0))
  (LET ((#:ACCULIST-VAR-3375 NIL))
   (TAGBODY SYSTEM::BEGIN-LOOP (WHEN (>= I #:LIMIT-3374) (GO SYSTEM::END-LOOP))
    (SETQ #:ACCULIST-VAR-3375 (CONS I #:ACCULIST-VAR-3375)) (PSETQ I (+ I 1))
    (GO SYSTEM::BEGIN-LOOP) SYSTEM::END-LOOP
    (RETURN-FROM NIL (SYSTEM::LIST-NREVERSE #:ACCULIST-VAR-3375)))))) ;

在同一个结构中绑定相同的名称很少有好的理由,即使在该类型的结构中允许这样做。

通过不将额外的符号合并到编译器环境中,您只能节省一些字节的空间,而且前提是这些符号已经没有出现在其他地方。

唯一好的理由是所涉及的变量是一个特殊变量,必须多次绑定才能出现在从init表达式调用的函数的动态范围内:

(let* ((*context-variable* (foo)) 
       (*context-variable* (bar)))
  ...)

无论是 foo 还是 bar 都会对当前值为 *context-variable* 的变量做出反应; foo 计算一个可以用作 *context-variable* 的值,我们希望 bar 能看到该值而不是 foo 暴露的那个值。

或者类似于:

(let* ((*stdout* stream-x)
       (a (init-a)) ;; the output of these goes to stream-x
       (b (frob a))
       (*stdout* stream-y)
       (c (extract-from b)) ;; the output of this goes to stream-y
       ...)
  ...)

关于特殊变量的聪明观点,但实际上你会写成 (let ((*special* ...)) (let ((*special* ...)) ...)) 吗?我认为这样更清晰! - user5920214

2
我会使用有意义的名称来命名中间步骤,如果没有合适的名称,则会使用线程宏(例如来自“箭头”(厚颜无耻的广告))。我通常不会使用 setf 设置本地变量,但这大多是一种风格选择。(我有时会在包含在本地变量中的结构上使用 setf 以避免分配新内存。)
有些编译器比另一些更擅长编译本地 let 绑定而不是 setf 修改,但这也可能不是选择其中一个的最好原因。

我去看了一下“箭头”。它似乎很有趣,但有点令人困惑。我会进一步了解它的。 - Sod Almighty

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