if/when-let(*) 和 and-let* 的演变历史(也就是 foo-let(*))

最近因为一直在等 Emacs 30 的发布,就时不时看看最新的 commit,之前的一条提交包含了大量的改动,主要是从 if/when-let 替换成了 if/when-let*Mark if-let and when-let obsolete · emacs-mirror/emacs@8903106。这两个宏我基本上没怎么用过。

今天闲来无事看了一下 emacs-devel 里面的相关讨论,我发现一个比较有意思的事情是 foo-let(*) 历史上似乎也有过变化,也有一些关于是否需要不带 * 的版本的宏的争论。这个帖子会整理一下从 foo-let(*) 出现到现在的变化过程。希望我写完帖子的时候 Emacs 30 已经正式发布了。

5 个赞

很多包的维护者表示反对 obsolete if-let 和 when-let。

目前我知道的有 magit 作者和Corfu作者都不赞成这个更改。很多人认为这个更改是没经过充分讨论的。

参考 https://debbugs.gnu.org/cgi/bugreport.cgi?bug=+73853#130

3 个赞

改得莫名其妙……这样的话只绑定一个值也得按 Shift 输入星号了。不过 Emacs 的 when-letif-let 实现本来就是反直觉的,不带星号的应该表示同时绑定,也就是后面绑定的值不应当受到前面值的影响(无论是否为空),在 Emacs 中它们统统展开为 if-let*,这在 CL 中好歹也是有作区分的:

(when-let ((a 1)
           (b 2))
  (+ a b))
;; (let ((a 1) (b 2))
;;   (when (and a b) (+ a b)))

(when-let* ((a 1)
            (b 2))
  (+ a b))
;; (let ((a 1))
;;   (when a
;;     (let ((b 2))
;;       (when b (progn (+ a b))))))
1 个赞

我也感觉,所以这个帖子我尝试找找这些宏是怎么变化的。

实现确实如此,就很怪。

另外,29 里面还加了个 while-let ,这个宏直觉上的用法是:

(while-let ((run t))
  ....
  (if (some-condition) (setq run nil)))

但实际上它每次循环都会重新对绑定求值,宏内部的修改不起作用,最后会死循环。 Is this a bug in while-let or do I missunderstand it?

这个倒符合直觉,人家叫 while-let 而不叫 let-while ,顾名思义应该是 letwhile 里,每次循环都进行一次新的绑定。

1 个赞

确实改得莫名其妙,没有任何意义,还产生了一大堆告警。总之,没有讨论清楚。

foo-let 在 Lisp 系语言中的出现时间远早于 Emacs 中 if-let 等宏的引入(Emacs 在 25 中添加了 if-letwhen-let,可以在 NEWS.25 中找到)。你可以在 Common Lisp,Scheme 和 Clojure 等语言中找到它。

Scheme

早在 1999 年,在 SRFI(Scheme Requests for Implementation)中就出现了 and-let* 提案:SRFI 2: AND-LET*: an AND with local bindings, a guarded LET* special form

Like an ordinary AND, an AND-LET* special form evaluates its arguments – expressions – one after another in order, till the first one that yields #f. Unlike AND, however, a non-#f result of one expression can be bound to a fresh variable and used in the subsequent expressions. AND-LET* is a cross-breed between LET* and AND.

AND-LET* can be thought of as a combination of LET* and AND, or a generalization of COND’s send operator =>. An AND-LET* form can also be considered a sequence of guarded expressions. In a regular program, forms may produce results, bind them to variables and let other forms use these results.

简单来说,and-let*let*and 的结合体,我们可以在短路求值的过程中绑定某些值到名字,并在之后利用它们。文档给出了以下例子:

(and-let* ((my-list (compute-list)) ((not (null? my-list))))
  (do-something my-list))

(define (look-up key alist)
    (and-let* ((x (assq key alist))) (cdr x)))

(or
 (and-let* ((c (read-char))
            ((not (eof-object? c))))
   (string-set! some-str i c)
   (set! i (+ 1 i)))
 (begin (do-process-eof)))

and-let* 的语法如下:

AND-LET* (CLAWS) BODY

CLAWS ::= '() | (cons CLAW CLAWS)
CLAW  ::=  (VARIABLE EXPRESSION) | (EXPRESSION) |
           BOUND-VARIABLE

(顺带一提,这个文档中还提到了 anaphoric macro aand,不过 and-let* 允许多个绑定而不只有 it,以下是 aand 的定义和一些例子: )

(defmacro aif (test-form then-form &optional else-form)
  `(let ((it ,test-form))
     (if it ,then-form ,else-form)))

(defmacro aand (&rest args)
  (cond ((null args) t)
	((null (cdr args)) (car args))
	(t `(aif ,(car args) (aand ,@(cdr args))))))

(aand 1 (+ it 1))
⇒ 2

(aand 1 (= it 1) (booleanp it))
⇒ t

关于什么是 anaphoric macro 可以参考这份文档,这是 On Lisp 的第 14 章。

我在 chez 和 Racket 里面没有找到 and-let* 这个宏。

Clojure

Fun with macros: If-let and When-let 这篇博客介绍了如何在 Common Lisp 中实现这一系列 FOO-let ,作者在开头提到他首先从 Clojure 中了解到这些宏的存在的。在 clojure.core 中可以找到 when-letif-let 。这两个宏出现在 Clojure 1.0 中,而 Clojure 1.0 发布于 2009 年 5 月 4 日。

Common Lisp

老实说我没怎么写过 Common Lisp,可以搜到的是 Lispworks 支持 when-letwhen-let* 34 The LISPWORKS Package。此外还有一个叫做 Alexandria (亚历山大里亚)的库,它支持 if-letwhen-let(*)

很难说这些 FOO-let 出现的具体时间是什么时候,Alexandria 最初添加这些宏的时间是 18 年前(2006 年 11 月 7 日),那么它们的出现时间肯定早于 2006 年。不过在 2008 年 6 月 1 日 if-let*移除了。也许作者认为 if-let* 相对于 when-let* 来说是多余的。

2 个赞

既然都说到这了不如顺便介绍下 Alexandria 是怎么实现这三(四)个宏的。

在最初的提交中,这四个宏的结构基本一致,可以用下面的代码来表示:

(defmacro if-let/let* (bindings then-form &optional else-form)
  (let* ((binding-list (if (and (consp bindings) (symbolp (car bindings)))
                           (list bindings)
                           bindings))
         (variables (mapcar #'car binding-list)))
    `(let/let* ,binding-list
	       (if (and ,@variables)
		   ,then-form
		   ,else-form))))

(defmacro when-let/let* (bindings &body forms)
  (let* ((binding-list (if (and (consp bindings) (symbolp (car bindings)))
                           (list bindings)
                           bindings))
         (variables (mapcar #'car binding-list)))
    `(let/let* ,binding-list
	       (when (and ,@variables)
		 ,@forms))))

可见它们全都是先对 binding 求值后再判断所有的 binding 是否都非空来执行后续表达式,不存在短路逻辑。经过上面提到的 if-let* 被移除的 commit 后, when-let* 的实现变为了如下代码:

(defmacro when-let* (bindings &body forms)
  (let ((binding-list (if (and (consp bindings) (symbolp (car bindings)))
                          (list bindings)
                          bindings)))
    (labels ((bind (bindings forms)
               (if bindings
                   `((let (,(car bindings))
                       (when ,(caar bindings)
                         ,@(bind (cdr bindings) forms))))
                   forms)))
      `(let (,(car binding-list))
         (when ,(caar binding-list)
           ,@(bind (cdr binding-list) forms))))))

此后经过修正得到了最终的 when-let*

(defmacro when-let* (bindings &body body)
  (let ((binding-list (if (and (consp bindings) (symbolp (car bindings)))
                          (list bindings)
                          bindings)))
    (labels ((bind (bindings body)
               (if bindings
                   `(let (,(car bindings))
                      (when ,(caar bindings)
                        ,(bind (cdr bindings) body)))
                   `(progn ,@body))))
      (bind binding-list body))))

最终的 binding.lisp 如下:

binding.lisp
(in-package :alexandria)

(defmacro if-let (bindings &body (then-form &optional else-form))
    "Creates new variable bindings, and conditionally executes either
THEN-FORM or ELSE-FORM. ELSE-FORM defaults to NIL.

BINDINGS must be either single binding of the form:

 (variable initial-form)

or a list of bindings of the form:

 ((variable-1 initial-form-1)
  (variable-2 initial-form-2)
  ...
  (variable-n initial-form-n))

All initial-forms are executed sequentially in the specified order. Then all
the variables are bound to the corresponding values.

If all variables were bound to true values, the THEN-FORM is executed with the
bindings in effect, otherwise the ELSE-FORM is executed with the bindings in
effect."
    (let* ((binding-list (if (and (consp bindings) (symbolp (car bindings)))
                             (list bindings)
                             bindings))
         (variables (mapcar #'car binding-list)))
    `(let ,binding-list
       (if (and ,@variables)
           ,then-form
           ,else-form))))

(defmacro when-let (bindings &body forms)
    "Creates new variable bindings, and conditionally executes FORMS.

BINDINGS must be either single binding of the form:

 (variable initial-form)

or a list of bindings of the form:

 ((variable-1 initial-form-1)
  (variable-2 initial-form-2)
  ...
  (variable-n initial-form-n))

All initial-forms are executed sequentially in the specified order. Then all
the variables are bound to the corresponding values.

If all variables were bound to true values, then FORMS are executed as an
implicit PROGN."
  (let* ((binding-list (if (and (consp bindings) (symbolp (car bindings)))
                           (list bindings)
                           bindings))
         (variables (mapcar #'car binding-list)))
    `(let ,binding-list
       (when (and ,@variables)
         ,@forms))))

(defmacro when-let* (bindings &body body)
  "Creates new variable bindings, and conditionally executes BODY.

BINDINGS must be either single binding of the form:

 (variable initial-form)

or a list of bindings of the form:

 ((variable-1 initial-form-1)
  (variable-2 initial-form-2)
  ...
  (variable-n initial-form-n))

Each INITIAL-FORM is executed in turn, and the variable bound to the
corresponding value. INITIAL-FORM expressions can refer to variables
previously bound by the WHEN-LET*.

Execution of WHEN-LET* stops immediately if any INITIAL-FORM evaluates to NIL.
If all INITIAL-FORMs evaluate to true, then BODY is executed as an implicit
PROGN."
  (let ((binding-list (if (and (consp bindings) (symbolp (car bindings)))
                          (list bindings)
                          bindings)))
    (labels ((bind (bindings body)
               (if bindings
                   `(let (,(car bindings))
                      (when ,(caar bindings)
                        ,(bind (cdr bindings) body)))
                   `(progn ,@body))))
      (bind binding-list body))))

这三个宏中只有 when-let* 有短路逻辑,不过我不是太清楚为什么要删掉 if-let*

1 个赞

草,找相关资料时在 stack overflow 上找到了上古帖子 LET versus LET* in Common Lisp - Stack Overflow,有点好奇坛友日常使用 let 多一点还是 let* 多一点,我一般用 let,因为可以不用打 *

  • 总是使用 let*,尽可能避免使用 let
  • 倾向于使用 let*,但是在绑定之间没有顺序关系时使用 let
  • 倾向于使用 let,但在绑定存在明确顺序关系时使用 let*
  • 总是使用 let,尽可能避免使用 let
0 投票人

优先使用let的问题是,在存在顺序关系时有时候忘掉打* ,不知道有没有人和我一样 :joy:。总是使用 let* 是不是就一劳永逸了。

感觉是的,但是我很懒 :rofl:

草,我是不是应该在帖子开头就先介绍一下这几个宏比较好,免得看的一头雾水。

这里使用当前最新 Emacs 中的 foo-let 进行介绍,希望能让不熟悉的读者简单了解其使用方法与内部实现。这些宏包括 if-let/let*, when-let/let*, and-let* 和 Emacs 29 新添加的 while-let

if-let*

and-let* 外,其他的 foo-let 都在内部使用了 if-let* 。这是它的参数列表和 docstring

(defmacro if-let* (varlist then &rest else)
  "Bind variables according to VARLIST and evaluate THEN or ELSE.
Evaluate each binding in turn, as in `let*', stopping if a
binding value is nil.  If all are non-nil return the value of
THEN, otherwise the value of the last form in ELSE, or nil if
there are none.

Each element of VARLIST is a list (SYMBOL VALUEFORM) that binds
SYMBOL to the value of VALUEFORM.  An element can additionally be
of the form (VALUEFORM), which is evaluated and checked for nil;
i.e. SYMBOL can be omitted if only the test result is of
interest.  It can also be of the form SYMBOL, then the binding of
SYMBOL is checked for nil."
  ...)

可见, if-let*iflet* 的结合体,首先对 VARLIST 进行 let* 绑定,然后根据绑定的值判断执行 THEN 还是 ELSE 表达式。 if-let* 对绑定求值的描述是“逐个求值且在遇到空值时停止,若绑定无空值则返回 THEN 的值,否则返回 ELSE 的值或空值”。在 VARLIST 中的绑定除了是 (SYMBOL VALUEFORM) 形式外,还可以省略掉绑定名字为 (VALUEFORM) 。以下例子可以简单说明 if-let* 的用法:

(if-let* ((x 1)
	  (y (+ x 1)))
    (+ x y) x)
=> 3

(if-let* ((x 1)
	  ((booleanp x)))
    x (+ x 1))
=> 2

需要注意的是,“遇到空值时停止”意味着 if-let*短路求值 的,它并不会对所有绑定求值后再判断绑定是否全为真,以下宏展开结果可以说明这一点:

(macroexpand '(if-let* ((x 1) (y 2)) (+ x y) x))
=> (let* ((x (and t 1))
	  (y (and x 2)))
     (if y (+ x y) x))

最后, if-let* 表达式的值是 THENELSE 表达式的值,若绑定中存在空值且无 ELSE 表达式,那么 if-let* 表达式的值为空值:

(if-let* ((nil)) 1) => nil

when-let*and-let*

when-let* 就是不带 ELSEif-let* ,它的实现如下所示:

(defmacro when-let* (varlist &rest body)
  "Bind variables according to VARLIST and conditionally evaluate BODY.
Evaluate each binding in turn, stopping if a binding value is nil.
If all are non-nil, return the value of the last form in BODY.

The variable list VARLIST is the same as in `if-let*'.

See also `and-let*'."
  (declare (indent 1) (debug if-let*))
  (list 'if-let* varlist (macroexp-progn body)))

and-let* 的实现与 if-let* 非常相似,但是存在一些细微差异,它的 docstring 说明了这一点:

(defmacro and-let* (varlist &rest body)
  "Bind variables according to VARLIST and conditionally evaluate BODY.
Like `when-let*', except if BODY is empty and all the bindings
are non-nil, then the result is the value of the last binding.

Some Lisp programmers follow the convention that `and' and `and-let*'
are for forms evaluated for return value, and `when' and `when-let*' are
for forms evaluated for side-effect with returned values ignored."
  ;; ^ Document this convention here because it explains why we have
  ;;   both `when-let*' and `and-let*' (in addition to the additional
  ;;   feature of `and-let*' when BODY is empty).
  ...)

可见若 and-let*BODY 为空且绑定全非空时它将返回绑定的最后一个表达式,而不是返回空值。 docstring 也提到了 and-let* 更关注表达式的值,而 when-let* 更关注副作用。以下是一些简单的 and-let* 例子:

(and-let* ((x 1) (y 2) ((+ x y))))
=> 3
(and-let* ((x '(1 2 3))
	   ((not (null x))))
  (cdr x))
=> (2 3)

if-letwhen-let

就像本文开头提到的那样, if-letwhen-let 在最新的 Emacs 中被废弃了,说不好会在几个 Emacs 主版本后被移除。以下是它们的实现:

(defmacro if-let (spec then &rest else)
  "Bind variables according to SPEC and evaluate THEN or ELSE.
This is like `if-let*' except, as a special case, interpret a SPEC of
the form \(SYMBOL SOMETHING) like \((SYMBOL SOMETHING)).  This exists
for backward compatibility with an old syntax that accepted only one
binding."
  (declare (indent 2)
           (debug ([&or (symbolp form)  ; must be first, Bug#48489
                        (&rest [&or symbolp (symbolp form) (form)])]
                   body))
           (obsolete if-let* "31.1"))
  (when (and (<= (length spec) 2)
             (not (listp (car spec))))
    ;; Adjust the single binding case
    (setq spec (list spec)))
  (list 'if-let* spec then (macroexp-progn else)))

(defmacro when-let (spec &rest body)
  "Bind variables according to SPEC and conditionally evaluate BODY.
Evaluate each binding in turn, stopping if a binding value is nil.
If all are non-nil, return the value of the last form in BODY.

The variable list SPEC is the same as in `if-let'."
  (declare (indent 1) (debug if-let)
           (obsolete "use `when-let*' or `and-let*' instead." "31.1"))
  ;; Previously we expanded to `if-let', and then required a
  ;; `with-suppressed-warnings' to avoid doubling up the obsoletion
  ;; warnings.  But that triggers a bytecompiler bug; see bug#74530.
  ;; So for now we reimplement `if-let' here.
  (when (and (<= (length spec) 2)
             (not (listp (car spec))))
    (setq spec (list spec)))
  (list 'if-let* spec (macroexp-progn body)))

与各自的 let* 版本不同的是, if/when-let 支持当仅存在一个绑定时去掉 VARLIST 的外层括号,即允许如 (if-let (x 1) x 0) 的写法。在当前这个版本这大概是唯一的区别了,其他没什么好说的。

while-let

while-let 相比起前面几个宏就比较新了,在 29.1 才加入 Emacs,它的实现如下:

(defmacro while-let (spec &rest body)
  "Bind variables according to SPEC and conditionally evaluate BODY.
Evaluate each binding in turn, stopping if a binding value is nil.
If all bindings are non-nil, eval BODY and repeat.

The variable list SPEC is the same as in `if-let*'."
  (declare (indent 1) (debug if-let))
  (let ((done (gensym "done")))
    `(catch ',done
       (while t
         ;; This is `if-let*', not `if-let', deliberately, despite the
         ;; name of this macro.  See bug#60758.
         (if-let* ,spec
             (progn
               ,@body)
           (throw ',done nil))))))

这里有两点需要注意:(1) while-let 使用了 let* 而不是它名字中的 let bug#60758: 29.0.60; while-let uses if-let* convention in contradiction t ;(2) let* 绑定发生在 while 内而不是 while 外,这与 if/when/and-let* 行为并不一致,第一次使用时可能会感觉非常反直觉:Is this a bug in while-let or do I missunderstand it?

(while-let ((run t))
  (setq run nil))
=> infinite loop...

根据 while-let 的实现来看,每次循环都会重新执行 if-let* 绑定,直到绑定中出现空值。这意味着我们无法在绑定完成后通过 直接修改绑定值 来终止循环。如果想要仅通过绑定为空来终止循环,绑定对应的表达式需要最终变化为空值,而不能全是非空常值:

(let* ((ls1 (list 1 2 3))
       (ls2 (copy-sequence ls1)))
  (list (let (curr-v (res 0))
	  (while (setq curr-v (pop ls1))
	    (cl-incf res curr-v))
	  res)
	(let ((res 0))
	  (while-let ((curr-v (pop ls2)))
	    (cl-incf res curr-v))
	  res)))
=> (6 6)

当前 Emacs 中使用 while-let 的代码主要集中在 ERC 中,读者可以找找更复杂的使用例子:

2 个赞

if/when-let 的实现看,这俩宏确实没必要存在了。但是只绑定一个变量的时候用 xxx-let* 单从视觉效果上看有点奇怪,不能搞个 alias 欺骗一下眼睛么。

只能说 if/when-let 有比较“奇妙”的发展过程。

就我目前找到的资料来看, Emacs Lisp 在 fgallina 添加 if/when-let 时很大程度上受到了 Clojure 的影响。Clojure 直接就没有 let* ,它的 let 是顺序绑定的,而且绑定没有外层括号:

(let [x 1 y (+ x 1) z (+ y 1)]
  (+ x y z))
=> 6

在 Emacs 中,初版的 if/when-let 就是 let* 语义,和 Clojure 一致。在遇到单个绑定时可以省略绑定括号应该也是受了 Clojure 的 if/when-let 只允许一个绑定的影响:

(if-let (x 1) (+ x 1) 1) => 2

后续的 if/when-let 变化我感觉很大程度上就是 letlet* 之争了,我还在慢慢看。

1 个赞

看到的坛友可以去投下票

if/when-let 的诞生

if-letwhen-let 最早出现于 Emacs 25.1,在 commit c08f8be 中:New if-let, when-let, thread-first and thread-last macros.,提交时间是 2014 年 6 月 30 日(Emacs 25.1 发布于 2016 年 9 月,隔了两年多),提交者是 fgallina(Fabián Ezequiel Gallina)。该实现位于 subr-x.el 中。

在 Emacs 中,最初版本的 foo-let 仅包含 if/when-let ,且 when-let 几乎可以认为是不含 ELSE 子表达式的 if-letif-let 内部使用了 let* 而不是 let 。需要注意的是,此时的实现里 binding 就已经是 短路求值 的了,读者可以参考 internal--build-binding实现来明白这一点。

如果我是 2014 年的 fgallina,此时我能参考的代码有 Clojure, Alexandria 和 dash.el (Clojure 对它影响很大),于是“我”选择采用 Clojure 的顺序 let 和 Alexandria 的短路求值。

顺带一提,fgallina 是 python.el 的作者,不过他已经很久没有活动过了。

let/let* 之争

在 fgallina 提交 if/when-let 两个月后,emacs-devel 上就出现了为什么名字不带 * 的讨论:if-let and when-let: parallel or sequential [2014-08-09]。

I noticed that the new `if-let’ and `when-let’ in trunk’s subr-x create their bindings sequentially (like `let*’ rather than `let’). Would there be any interest in renaming these to `if-let*’ and `when-let*', and adding parallel-binding versions under the current names?

It’s obviously a tiny matter in the scheme of things, but I do think it’s worth sticking to the existing naming convention given the history and context.

(If this change would be welcome, and nobody beats me to it, I would be happy to submit a patch and copyright assignment.)

对此,Stefan Monnier 表示“并行”的版本很难想到有什么用,Harald Hanche-Olsen 注意到了 if/when-let短路求值的。这一次讨论没有什么结果,从 14 年到 16 年间 if/when-let 的实现几乎没有什么变化。

在 17 年初关于 Anaphoric macros 的讨论中, if/when-let 又被重新提起:Anaphoric macros: increase visibility [2017-01-13]。Michael Heerdegen 表示使用 if-let* 而不是 if-let 会更好,同时他也希望使用 and-letand-let* 替换掉 when-let 。对此 Tino Calancha 表示赞同并给出了自己的修改建议:

A possible starting point is puting all together in
subr-x.el after a heading

;;; Anaphoric macros.

and perform the improvements in naming discussed in this thread:

1) Move `ibuffer-aif', `ibuffer-awhen' to subr-x.el and rename as
   `if-it', `when-it'.  Add aliases to them `aif', `awhen'.

2) `if-let' --> `if-let*'.  Add alias `if-let' for
   backward compatibility.

3) `when-let' --> `when-let*' (or `and-let*').  Add alias.

Once they are all together is easier to see what macro is missing
which could be useful.

holomorph(Mark Oteiza)同样赞同 Michael Heerdegen 的改名建议,他在 2017 年 2 月 4 日对 if/when-let 进行了重命名:Rename to if-let* and when-let*,同时将 if/when/len-let 作为 if/when-let* 的别名:

(defalias 'if-let 'if-let*)
(defalias 'when-let 'when-let*)
(defalias 'and-let* 'when-let*)
Further, I think it's even more bizarre that if-let and when-let grew
the single tuple special case, where one can write

  (if-let (foo bar) (message "%S" foo) (message "oh no"))
          ^^^^^^^^^

What makes these binding things special? May as well add brackets and
whatever else from other lisps.

Mark Oteiza --- https://lists.gnu.org/archive/html/emacs-devel/2017-01/msg00255.html

(Tino Calancha 的回复可能是 while-let 第一次出现在 emacs-devel 中的地方。)

and-let* 的独立实现

6 个月后,Mark Oteiza 突然想起来自己实现的 and-let* 还没放到 subr-x.el 中:bug#28254: 26.0.50; SRFI-2 and-let*。由于最初的实现与 when-let* 存在较大区别,Mark 不太愿意把它和其他 foo-let 放到一起。不过在他和 Michael Heerdegen 的不懈努力下总算是完成了:Implement and-let* [2017-09-13],这是一个很大的提交,所有的 foo-let 实现都有变化,这当然也包括新的 and-let* 实现。

实现变化后, if-let* 不能像初始的 if-let 那样在仅有单个 binding 时可以省略掉所有 binding 外面的括号了(比如 (if-let (x 1) x 2) )。除此之外,现在的 if-let* 允许 binding 仅包含表达式,也就是说除了 (var expr) 外还允许 (expr) ,比如 (if-let* ((1) (2)) 3 4) 。emacs-devel 上对这一行为进行了讨论:Re: [Emacs-diffs] master 4612b2a 1/2: Implement and-let*。值得一说的是这一提交让绑定中的单个符号成为待求值项而不是会绑定到 nil ,具体的讨论可以参考 Mark 和 Michael 的讨论,具体来说就是以下代码执行的不同:

;; https://lists.gnu.org/archive/html/bug-gnu-emacs/2017-09/msg00054.html
;; origin
(if-let* (x) "dogs" "cats") => "cats"
(if-let* (x (y 2)) "dogs" "cats") => (void-function y)
(if-let* (x (y 1) (z 2)) "dogs" "cats") => "cats"

;; fixed
(if-let* (x) "dogs" "cats") => (void-variable x)
(if-let* (x (y 2)) "dogs" "cats") => (void-variable x)
(if-let* (x (y 1) (z 2)) "dogs" "cats") => (void-variable x)

在这个提交中, and-let*if-let* 具有及其相似的实现,它与 if-let* 的最大不同是若 body 不存在或 body 的值为 nil 时会使用 and-let* 的最后一个 binding 值作为整个表达式的返回值。重新实现的 if/when-letwhen-let* 都在内部使用了 if-let* ,且 if/when-let标记为废弃

在这一提交的一个月后,emacs-devel 上有一条没什么实质内容的讨论:Something weird with when-let*/if-let*

1 个赞

撤销废弃 if/when-let

2018 年 1 月 9 日,Damien Cassou 在 26.0.90 上测试包时发现 when-let 被废弃了:bug#30039: 26.0.90; [26.1] Making my code warning free is impossible with when-let。他建议在 25 中引入 when-let* 或在 26 中暂时不废弃 if/when-let 并在之后废弃它们。Nicolas Petton 表示前者几乎不可能同时支持后者,他希望通过修改 byte-compiler 来消除掉某些 warning,不过 Eli 拒绝了这个 patch

2018 年 2 月 11 日,Stefan Monnier 再次讨论起了 foo-letif-let/if-let*/and-let/…,他主要谈到两个问题:(1) 既然已经有了 when-let*and-let* 是否多余?(2) let 的“并行性”对 foo-let 没有太大意义,不如不要并行绑定的版本?此外他还提到由于先前的 commit 废弃了 if/when-let 导致第三方包出现了许多烦人的废弃警告(obsolescence warnings)。Mark 对在 foo-let 名字中使用 * 的解释是“不带 * 非常令人困惑且我们考虑之后移除掉不带 * 的版本”。对此 Stefan 的回应是:

> Is the benefit of slightly reducing confusion (I really find it hard to
> believe the confusion is serious, since the dependencies between the
> different steps would make it rather inconvenient to provide a real
> "parallel-let" semantics) worth the burden of those
> compatibility/obsolescence issues (I'd also mention the confusing
> aspect of having an extra * for a construct that doesn't exist without
> a *, even though traditionally the * is used to mark an "alternative"
> definition, as in list*, mapcar*, ...).

简单来说,Stefan 认为在 foo-let 这个存在明显前后依赖的绑定步骤中引入“并行 let”语义非常不方便,而且他也提到 Lisp 传统中 * 通常表示另一种选择(而不一定就是顺序绑定,比如 cl-lib 中的 cl-list* )。fgallina 当时选择 let 而不是 let* 在这个意义上倒是很能说得通。

对于问题 (1),Michael 认为 and/when/if 都应该存在;对于问题 (2),他认为带 * 更符合直觉一些,即使 * 传统上表示的是另一种选择。Mark 也认为应该保留 and-let*foo-let* 后缀。

Stefan 的提议是将 foo-let 作为 foo-let* 的别名或者反过来。Mark 提到这样可能会破坏 if-let 的兼容性(上面我们提到了 if-let* 不支持单个绑定的特殊语法,如果新实现的 if-let 不支持这一语法就会出现兼容问题)。Stefan 对此的观点是保留这一语法,Michael 表示支持,同时他也希望 if-let 能回到 Clojure 那样的仅允许单个绑定的实现,但这一不兼容的改动可能会破坏第三方包。

在 2018 年 2 月 21 日,Michael Heerdegen 告知 Damien 他将取消掉 if/when-let 的废弃,Damien 很高兴。2018 年 3 月 6 日,Michael Heerdegen 将所有的 foo-let* 定义为 foo-let 的别名:Define if-let* and derivatives as aliases for if-let etc。这一改动使得 if-let* 也支持仅存在单个 binding 时可以忽略括号。 至此,上半场结束 。如果这一改动能保留到现在可能就不会出现 foo-let 被废弃的问题了。

在 Michael 安装 patch 后,Eli 希望 Michael 解释为什么要在 26.1 RC1 阶段提出这个修改。Michael 的回复算是对整个事件进行了总结,感兴趣的同学可以去看看。对此,Eli 表示理解,然后表示“为什么不仅仅取消掉废弃呢”,Michael 表示这样会偏离将 if-let* 作为 if/when-let 别名的目标,因为先前的 if/when-let* 并不支持特殊的单绑定语法。对此他的建议是 (I) 保留他的提交 (II) 撤销提交并取消 if/when-let 的废弃,随后在 26.2 版本再进行修改。对此,Eli 建议暂时保持实现不变然后收集意见,Michael 表示这不是经验的问题,他不希望现存的 foo-let/let* 之间的细微差别带来不必要的误解。

最终,Eli 认为 if-let/let* 的差异不是什么大问题,Michael 也同意了这一决定。在 2018 年 3 月 7 日,关于 foo-let 的 commit 被 revert,Michael 在随后的 3 月 27 日去掉了 if-letwhen-letobsolete 标记:De-obsolete if-let’ and when-let’

这一次改动的唯一成果就是取消了 if/when-let 的废弃。在 2018 年 and-let* 的实现还有一次小修改:bug#31840: and-let* expands to if instead of when [2018-06-19],之后从 18 年到 22 年邮件列表上就没有什么关于 foo-let 的讨论了。2022 年 4 月 30 日,larsmagne(Lars Ingebrigtsen)将 FOO-let 系列从 subr-x.el 移动到了 subr.el ,似乎是为了避免 bootstramp 问题:Move the when-let family of macros to subr.el

while-let 的诞生

2022 年 9 月 28 日,larsmagne 添加 while-let 到 Emacs 中:Add new macro ‘while-let’。最初它在内部使用了 if-let ,后面修正了:Tweak while-let definition [2022-09-29] 。Philip Kaludercic 顺便问 larsmagne 为什么不使用 while-let* 作为名字,以下是 larsmagne 的回答:

Philip Kaludercic [email protected] writes:

In that case ought the macro not be called `while-let*'?

Nope. We’re pretending that the * versions of these macros don’t exist (by not mentioning them in the manual), and we’re likewise pretending that if-let doesn’t have wider semantics than the * version (by not mentioning that, either).

Re: master 12f63c18f6 1/2: Add new macro 'while-let'

这可能说明 larsmagne 在 if-letif-let* 两个名字间更喜欢前者。后面他们还讨论了 and-let* 是否必要,不过这和 while-let 没什么关系了。

从 bug#60758 来看,Daniel Mendler 希望 while-let 内部使用 if-let 而不是 if-let 来支持 if-let 的单绑定语法 :bug#60758: 29.0.60; while-let uses if-let* convention in contradiction to the docstring [2023-01-12]。Sean Whitton 表示同意并提交了修改:083badc [2023-01-13]。随后 Michael Heerdegen 指出 if-let 的单绑定语法只是为了兼容性,并希望新的代码中不要再使用它,在后续的讨论中 Michael 提到了先前关于 foo-let 的讨论。最后, Sean 的修改被 revert 了。

在 2024 年 11 月 8 日,arthur miller 表示 while-let 似乎不是很好理解:Is this a bug in while-let or do I missunderstand it?,总的来说他希望 while-letif/when-let 那样先 letif/when ,而不是先 whilelet 。由于这一讨论和 let/let* 不太相关,这里我就不详细介绍了。

再次废弃 if/when-let

从 2022 年到 2024 年 foo-let 似乎没有什么大动静,除了 bugfix:bug#69108: false-positive warning “variable ‘_’ not left unused” in if-let* and if-let

2024 年 10 月 17 日, Stefan Monnier万 恶 之 源 )打破了寂静,他又开始讨论起了 and-let* 存在的必要性:bug#73853: 31.0.50; and-let* is useless,他在 2018 年已经这样做过一次了。对此 Michael Heerdegen 再一次解释到: and-let* 更多用于表达式求值而 when-let* 用于副作用,不过他也同意现在 (if|when|and)-let(*) 这些名字的共存只是暂时的:

[ I think we have too many (if|when|and)-let(*) for our own good: we
    should pick some winners and deprecate the other ones.   ]
-- Stefan

AFAIR the non-star versions exist for backward compatibility only - so I
would rather get rid of these.  Parallel existence of these non-star
vs. star versions should be a temporary state, it complicates the matter
for an epsilon gain.
-- Michael

对于 Stefan 想要废弃 non-star 版本这一想法,Augusto Stoffel 认为应该移除的反而是带 * 的版本,没有 non-star 名字对应的 something* 显得很奇怪。Michael Heerdegen 还是保留他之前的观点:带 * 更能体现求值顺序。Sean Whitton 也认为应该保留的是 foo-let*

在经过一些讨论(读者有兴趣可以读完这个列表,我就不展开了)后,2024 年 10 月 24 日,由 spwhitton(Sean Whitton)创建的 commit 真正废弃了 if-letwhen-letMark if-let and when-let obsolete。这是一个很大的 commit,Emacs 源代码中所有的 if-letwhen-let 都被替换为了带 * 版本, if-letwhen-let 被标记为 obsolete 。从这个 commit 到 2024 年 12 月之间还有两个小改进:Improve marking if-let and when-let obsoleteReimplement so as to avoid bug#74530.某种意义上来说,Michael 想在 Emacs 26.1 做到的事总算在 31.0.50 完成了。

对于这一改动,Jonas Bernoulli(tarsius,magit 作者)用了很长的回复来表达他的不满:bug#73853: 31.0.50; Should and-let* become a synonym for when-let*?。他认为 emacs-devel 总是仓促地做出决定导致了不幸的错误。后续 11 月的讨论在这里

FWIW, I don't see a huge rush here.  Emacs 31 is still far away, and
while some package authors are meticulously tracking master, an effort
that is of course greatly appreciated, the overwhelming majority don't.
So this won't affect the lions share of Emacs Lisp users any time soon.

Stefan Kangas
https://lists.gnu.org/archive/html/bug-gnu-emacs/2024-11/msg00038.html

就这一系列变化的暂时结果来看,Emacs 抛弃了 (if-let (var val) ...) 的语法,只使用 if-let*when-let*and-let* 。如果我们忽略掉 when-let*and-let* 的细微区别的话,可以认为剩下的只有 if-let*when-let* 了。

总结

草,看了这么一大圈总算是看完了,简单做个总结吧:

  1. if/when-let 由 Fabián Ezequiel Gallina 在 Emacs 25.1 中引入:c08f8be。该实现不含带 * 的版本,这一决定可能受到了 Clojure 的影响,也可能是作者认为 * 表示 alternative,即(原形式的)替代选择。
  2. Mark Oteiza(和 Michael Heerdegen)将 if/when-let 重命名为 if/when-let*be10c00。他们认为 let* 相比 let 更能体现 foo-let 的绑定顺序求值,随后他们实现了 and-let*4612b2a,并将 if/when-let 标记为废弃,这一举动引起了一些使用 if/when-let 的包作者的不满。
  3. 在 Stefan Monnier 的建议下,Michael Heerdegen 尝试将 foo-let* 实现为 foo-let 的别名,并取消 if/when-let 的废弃:af4697f。但由于此时 Emacs 26.1 即将发布,此提交被回退,他只能做到取消 if/when-let 的废弃标记:441fe20
  4. Lars Ingebrigtsen 实现了 while-let12f63c1
  5. 经过讨论后,Sean Whitton 再次废除了 if/when-let8903106

我认为最理想的情况是从一开始我们就只有 if/when/and-let ,而不是现在的 if/when/and-let* 。但是“ let 表示并行绑定而 let* 表示顺序绑定”这个概念太过深入人心以至于 foo-let 会被自然而然地认为是并行绑定,从这个意义上来说使用 if/when/and-let* 反倒是件好事(但是要多打一个 * :angry:)。

那么 Emacs 会一直保持这样不再变化了吗?至少我希望是这样,不要再因为命名问题破坏兼容性了,今天刚更新完最新 Emacs 就发现配置文件中使用的 if-let 导致了 warning。

foo-let 的变化大致就是这样一个过程。

草,看到这里的去投个票。

1 个赞

保留 if/when-let 作为 if/when-let* 别名是最妥善的方案。

2 个赞

可惜被撤回了。

最后再附上 magit 作者的邮件 GPT 翻译:

你好,大家,

你们选择以如此仓促的方式弃用 if-letwhen-let 令人非常失望。2018 年也曾做过同样的事情并且被撤回,这次也有很多相同的参与者。我很惊讶你们会再次犯同样的无谓错误。

阅读这次和过去的对话,很明显大家没有达成共识关于最终目标是什么。但据我所知,几乎没有人对当前(30.0.*)的情况感到完全满意。大家似乎也一致认为过去做了一些不幸的错误,这限制了我们现在的选择。

如果更多的人(包括非 debbugs 和非 emacs-devel 的常客)有机会思考这个问题,并有时间表达他们的担忧和建议,在既成事实之前,这些问题本可以被避免。甚至如果过去参与对话的人花更多的时间实际讨论事情,也会有所帮助。

每次提到 foo-let 形式的不满意状态时,都可以这样做,但相反的是每次都在仓促中做出了新的决定。

如果不停止这种破坏性的模式,你们将无法解决这个混乱。

我的短期建议是:

  • 撤销弃用并删除新闻条目。即使你们后来决定继续弃用,做、撤回和重做几行代码造成的“损害”也是最小的。(即便如此,也许可以先讨论几天再撤回。)这将有助于避免那些当前使用 foo-let 并希望继续使用的包作者不必要地感到疏远,如果最终决定不弃用的话。
  • 不要撤销 Emacs 本身中从使用 foo-let 到使用 foo-let* 的更改。你们最终可能会决定继续弃用,如果那样的话,来回切换几千行代码将是不幸的。
  • 重读过去的对话。想一想 你们 理想的解决方案是什么(这里可以大胆设想)。想一想你们最好的 可行 解决方案是什么。想一想你们愿意做出哪些妥协。想一想你们 愿意做出哪些妥协,并说明原因。想一想别人说了什么,他们为了满足你们的位置和他人的位置需要做出哪些妥协。尝试理解他们的出发点。你们不必同意他们的动机,只需理解他们为了同意你们认为的最佳可行解决方案和你们认为的可接受的妥协,需要做出多大的让步。特别考虑一下,实现你们的目标/妥协是否需要他们认输。考虑一下,坚持当前(30.0.*)现状是否可能是我们能达到的最好的妥协。
  • 不要只在你们内部进行这次对话。你们在这里做出的任何改变都会影响许多包及其作者和用户。积极地吸引受影响的社区参与。在多个渠道上进行沟通,并给予人们时间思考这个问题并分享他们的想法。我指的是几个月,而不是几周甚至几天。
  • 除了考虑你们最终希望达到的状态,还要考虑过渡过程。是否应该分几步进行,如果是这样,包作者会有什么后果?是否可以在不迫使包作者多次更改代码的情况下进行?是否可以采用类似于 lexical-binding 的变量作为可行选项?
  • 如果你们认为这个建议过头了,试着从外部包维护者的角度考虑。考虑整个事件的历史。意识到有些人以前已经被伤害过,如果被迫再次更改他们的包,可能会感到不满,甚至可能认为这是倒退。即使你们认为那些不同意你们的人只是错误的和/或缺乏品味,也要考虑是否值得为了这个问题疏远人们。

为了帮助启动一个知情决策的过程,我已经在 Emacsmirror(GNU ELPA + NonGNU ELPA + MELPA 的超集)中搜索了这些形式:

| grep pattern        | hits |
|---------------------+------|
| "(if-let\( \|$\)"   | 1853 |
| "(if-let\*"         |  422 |
| "(when-let\( \|$\)" | 4260 |
| "(when-let\*"       | 1162 |
| "(and-let\*"        |  288 |

此致,

Jonas

1 个赞