在前面,我们从buffer、window到file,再到major mode 、minor mode 以及刚刚结束的定时器和进程调用。可以说基本将elisp编程涉及到的概念都介绍了个七七八八了。本专题将会是整个系列的最后一个专题,关于elisp代码的组织、工程化以及调试相关的内容。我们先从elisp的一种语法开始
背景知识
虽然本系列我假设各位读者对elisp代码有着一定了解,相信各位读者对所谓的动态作用域和词法作用域有一定的了解。先给各位读者提个醒,如果读者知道这二者的区别以及了解如何开启词法作用域,那么可以跳过本节。
我们还是以下面这个经典例子开始(逢介绍动态作用域和词法作用域必会出现的例子):
(defun foo(x)
(getx))
(defun getx()
x)
(message "%s" (foo "hello,x")) ;; ==> "hello,x"或者这样的例子:
(let ((x "hello, x"))
(message "%s" (getx)))
(defun getx()
x)我们就以第一个例子为例来解释为何在动态作用域下能正常运行(第二个例子也是类似的效果)。调用函数 foo 时,进行符号 x 的变量绑定。后面调用 getx 的时候,getx 函数中没有对应的x 符号,此时它会往上找,找到了在 foo 中绑定的 x 变量,因此它会取这个 x 的值。在函数 foo 结束之后,函数中绑定的变量被销毁,函数调用结束之后就无法再使用里面绑定的变量。
动态作用域的绑定规则:作用域从绑定时生效,到这段语句块结束时失效。期间所有对变量的访问都是运行时的"调用栈"上查找。从当前语句块往外围找,直到直到。这种绑定方式它只看调用栈不看定义的语义结构。这种也被称为 special variable
你可以将这个过程想象成一个全局的栈,栈中的每一个成员都是一个符号名到绑定值得映射。当一个语句块定义了变量时往栈中压入一条记录,语句结束时销毁语句中绑定的所有变量。在查找变量时在栈中寻找,如果找到了就使用,否则报错。
这种绑定模式可以实现这种隐式传参,但是容易出bug,也不利于函数的独立,增加了代码的耦合。因此新版的Emacs 推荐使用词法绑定。
Emacs默认使用动态绑定,要开启语法绑定需要在文件的第一行用注释明确写上 -*- lexical-binding: t; -*- 。此时变量的作用域就跟我们熟悉的C/C++/Python之类的语言类似了。
但是,开启 lexical-binding 之后并不能一劳永逸的解决所有潜在问题,为了加深理解,我们来看下面的例子
;;; -*- lexical-binding: t; -*-
(defun my-make-counter (initial-step)
"创建一个闭包计数器,每次调用时以 INITIAL-STEP 递增。"
(let ((counter 0)) ; 这个局部变量会被返回的 lambda 闭包安全捕获并长期持有
(lambda (step)
(setq counter (+ counter step initial-step))
counter)))
(setq c1 (my-make-counter 1)) ;;=> 1
(funcall c1 5);; -> 6
(funcall c1 5);; -> 11我们来分析一下,这段代码在动态作用域和词法作用域下不同的表现。在动态作用域下,c1 会调用 my-make-counter 函数。此时会先在栈中定义一个counter 符号并绑定到值0, 并返回一个使用 counter 的lambda表达式,在函数结束之后 counter 就被销毁了。此时lambda 表达式被绑定到c1变量。后面我们直接通过 funcall 调用 c1 里面绑定的lambda,因为没有经过 my-make-counter 对 counter 变量绑定,所以在动态作用域下,会报错: (void-variable counter)
在词法作用域下,同样的 my-make-counter 函数会执行 counter 变量的绑定。并且在定义lambda 表达式时建立了对 counter 的引用。在函数退出时,因为lambda 中建立对 counter 变量的引用,所以该变量并不会因为函数退出导致被清理(请回忆一下垃圾回收的引用计数相关内容)。下一次在调用lambda表达式时它直接访问 counter 变量的当前值。完成我们想要的计数功能。
我们再来看看这样一个例子:
;;; -*- lexical-binding: t; -*-
(defun my-bad-loop-closures ()
(let ((fns '()))
(dolist (item '("A" "B" "C"))
;; 错误:这里的 item 是同一个变量的引用
(push (lambda () (message "Bad item: %s" item)) fns))
;; 运行结果全都是 "C"!
(dolist (f (nreverse fns))
(funcall f))))按照上面的思路来分析:在启用 lexcial-binding 之后,lambda 表达式中引用 item,但是循环执行完了之后,item因为被引用所以没有被销毁。在执行lambda 的时候item所在循环已经结束,此时item 的值是 C 所有上述三个lambda表达式输出的都是一样的结果。这也算是闭包的经典陷阱了。
我们可以使用下面的代码来修改它:
(defun my-good-loop-closures ()
(let ((fns '()))
(dolist (item '("A" "B" "C"))
;; 正确:用 let 引入一个新的词法绑定,截获当前循环的值
(let ((captured-item item))
(push (lambda () (message "Good item: %s" captured-item)) fns)))
;; 运行结果会正确输出 A, B, C
(dolist (f (nreverse fns))
(funcall f))))这里仅仅加了一层 let。为什么就解决问题了呢?
let 实际上可以理解为创建变量并执行绑定,而dolist 每一次循环都相当于执行一次 setq。所以它们的区别就很明显了。使用 dolist 的时候只有一个变量,对这一个变量执行了3次 setq ,最终闭包中实际引用的是同一个变量。而使用 let 相当于建立了三个变量的绑定,但是三个变量都是用同一个变量名。因此在真正执行的时候,读取的是三个变量的值,结果自然就不同了。
总结
本节我们通过几个示例讨论了动态绑定和词法绑定在执行过程中的区别。并演示了词法绑定在闭包中要注意的问题。
为了使各个模块独立,互补影响,我们应该养成在代码中使用 lexcial-binding 的习惯,毕竟你不想出现,外部随便定义一个变量,结果第三方库就出问题了。
评论 (0)