变量作用域与绑定

defvar 为什么”只在没定义时才赋值”、letlet* 差在哪、配置里为什么必须在第一行写 lexical-binding: t,以及 buffer-local 变量的层级关系。


一、变量就是符号的值单元

Elisp 的变量不是内存地址的名字,而是符号对象上的一个槽位。每个符号有两处可以挂东西:值单元(value cell)存变量值,函数单元(function cell)存函数定义。同一个符号可以既是变量又是函数,互不干扰。

;; 符号 car 的"函数单元"里有定义
(car '(1 2 3))     ; 结果 1
 
;; 符号 car 的"值单元"是空的
car                ; 报错 (void-variable car)

围绕值单元的操作函数如下,它们都接受符号而不是变量的值,因此参数要加 '

(set 'my-sym 10)          ; 把符号 my-sym 的值设为 10,等价于 (setq my-sym 10)
(symbol-value 'my-sym)    ; 结果 10,读取符号当前的值
(boundp 'my-sym)          ; 结果 t,判断值单元是否有绑定
(makunbound 'my-sym)      ; 解除绑定
(boundp 'my-sym)          ; 结果 nil

setsetq 的区别只有一个:setq 是宏,它不对第一个参数求值,所以可以直接写变量名;set 是函数,所有参数都要求值,所以必须传符号。

(setq my-sym 10)     ; 名字不求值
(set 'my-sym 10)     ; 传的是符号对象,效果相同
 
;; set 的第一个参数可以是算出来的,setq 不行
(set (intern "dynamic-name") 1)   ; 合法,给 dynamic-name 赋值

在写配置时,99% 的情况用 setq;只有变量名本身是运行时才知道的时候才用 setsymbol-value 是读取时的对应物,同样用于名字动态计算的场景。

需要额外提醒的是,函数单元与值单元完全独立(fboundp 'car) 为真、(boundp 'car) 为假,这两件事同时成立并不矛盾。写代码时如果看到 void-variable 却确信自己”明明定义过这个函数”,多半就是把这两处搞混了:defun 写的是函数单元,读变量仍然会失败。

另一个实践细节是 makunbound 的用途。它不是为了”清理内存”,而是用来测试一个变量是否已被初始化:先解除绑定,再 (boundp 'sym),就能判断某个功能有没有在启动过程中动过这个变量。Emacs 内置的若干初始化函数正是这么用的。

练习:在 M-: 里依次执行 (set 'foo 1)(symbol-value 'foo)(makunbound 'foo)(boundp 'foo),观察四次结果。


二、定义变量的六个手段

Elisp 里”定义变量”这件事被拆成了多个宏,各自语义不同。混用是配置出问题的头号原因,下表先给出全貌。

形式是否总是赋值作用范围典型用途
defvar否,仅在未绑定时赋值全局(动态特殊变量)声明包自己的变量
defconst是,每次求值都赋值全局声明不会变的常量
defcustom遵循初始化规则全局或 buffer-local声明用户可定制选项
setq是,每次执行都赋值当前绑定所在的层级配置里改值
setq-default默认值,绕过 buffer-local设置全局默认
setsetq变量名动态时使用

2.1 defvar:只在未定义时赋值

这是最容易被误解的一条语义。defvar 的行为是:如果符号已经有值,就什么都不做(只补文档字符串);只有在值为 void 时才写入默认值。

(defvar my-option 10 "我的选项,默认 10。")
my-option                       ; 结果 10
 
(defvar my-option 99 "改了默认值")   ; 已有值,这次赋值被忽略
my-option                       ; 结果仍然是 10

这条规则的存在有充分理由:包作者在源码里写 (defvar my-option 10),用户在配置里写 (setq my-option 20)。加载顺序上,用户的 setq 可能先执行。若 defvar 每次都赋值,用户配置就会被覆盖。

反过来说,这也意味着一个常见困惑:改了包源码里的 defvar 默认值,重启 Emacs 却不生效,因为值被 Customize 存进了 custom-set-variables 或者被你自己的配置覆盖了。排查时用 C-h v 看当前值,用 M-x customize-option 看保存的值。

defvar 的第二个作用比赋值更重要:它把符号标记为特殊变量(special variable),也就是动态绑定的变量。

(princ (special-variable-p 'my-option))   ; 结果 t,defvar 声明过
 
(setq my-plain 1)                          ; 只 setq,没有 defvar
(princ (special-variable-p 'my-plain))     ; 结果 nil,不是特殊变量

不带值、只带文档的 (defvar my-option) 也是合法的,用于纯声明:告诉字节编译器”这个变量是特殊变量,别当成词法变量优化掉”。

2.2 defconst:每次求值都赋值

(defconst my-version "1.0" "当前版本号。")
my-version                ; 结果 "1.0"
 
(defconst my-version "2.0" "新一轮求值")
my-version                ; 结果 "2.0",defconst 会覆盖

defconst 的语义是”这是常量,请不要改”。它不会阻止 setq 修改,Emacs 也不做强制检查;它只是表达意图,并让字节编译器知道这个值在编译期就能确定。

这里要澄清一个来自其它 Lisp 方言的误解:Emacs Lisp 没有 Common Lisp 里的 constantp 函数,也没有”常量属性”可供查询。想知道一个符号是不是常量,只能查手册或看它的定义处是否用了 defconst。判断它当前是否有值,仍然用 boundp

2.3 defcustom:面向用户的选项

defcustomdefvar 的区别在于它同时登记到 Customize 系统里,从而拥有类型、分组、界面和保存机制。

(defcustom my-greeting "hello"
  "启动时打印的问候语。"
  :type 'string
  :group 'my-package)
 
my-greeting    ; 结果 "hello"

细节在第 2.8 节展开。这里先记住一条:面向用户的选项用 defcustom,包内部的变量用 defvar

2.4 setq:配置里的主力

setq 是宏,接受任意多对「变量 值」,从左到右依次求值并赋值,返回最后一个值。

(setq a 1 b 2 c 3)     ; 一次设置三个变量
a                       ; 结果 1
c                       ; 结果 3

setq 不只用于全局变量,它作用于”当前生效的那个绑定”。在 let 内部,setq 修改的是 let 建立的局部绑定,出了 let 就恢复原值。

(defvar my-counter 0 "计数器。")
(let ((my-counter 100))
  (setq my-counter 200)   ; 改的是 let 建立的局部绑定
  my-counter)             ; 结果 200
my-counter                ; 结果 0,全局值没动

2.5 setq-default:绕过 buffer-local

当一个变量在当前缓冲区里有 buffer-local 绑定时,setq 改的是那份局部值,setq-default 改的是全局默认值。两者的差别在第五节会看得很清楚。

(setq-default indent-tabs-mode nil)   ; 修改默认值,对所有尚未局部化的缓冲区生效

2.6 defvar 与 setq 的常见误用

误用后果正确做法
在包里用 setq 声明变量字节编译警告”引用自由变量”defvar
setq 给用户选项设默认值用户无法用 Customize 覆盖defcustom
defvar 覆盖用户已设的值值不生效,误以为配置没加载这是设计如此,用 setq
set 却传了变量的值wrong-type-argument symbolp传符号:(set 'x 1)
setq 写成 set多求值一次参数名记住 setq 名字不求值

setqset 的混淆有一个经典表现:

(setq my-var 1)      ; 正确
(set my-var 1)       ; 报错 wrong-type-argument symbolp 1,因为 my-var 先被求值成 1
(set 'my-var 1)      ; 正确

练习:在 M-: 里执行 (defvar test-var 1),然后改写成 (defvar test-var 2) 再执行一遍,最后执行 test-var 看结果是不是仍然是 1。


三、let、let* 与 letrec

let 建立局部绑定,出了作用域自动恢复。它是 Elisp 里最重要的作用域工具,也是理解绑定语义的起点。

3.1 let:并行绑定

let 的绑定列表里,所有初始值表达式都在旧环境中求值,然后才一次性建立新绑定。

(defvar x 10 "外层 x。")
 
(let ((x 1)      ; 1 在旧环境求值,与外层 x 无关
      (y x))     ; 这里读到的 x 是外层的 10,不是刚绑定的 1
  (list x y))    ; 结果 (1 10)

这一点与 C 的块作用域直觉一致:{ int x = 1; int y = x; }y 拿到的是 1。Elisp 的 let 反而更像”同时声明”,所以才会拿到外层的 10。

3.2 let*:串行绑定

let* 里每个绑定都能看到前面已经建立的绑定,等价于嵌套的 let

(let* ((x 1)
       (y x))     ; 这里 x 已经是 1
  (list x y))     ; 结果 (1 1)

3.3 letrec:允许相互递归

letrec 先建立所有绑定的位置,再求值初始值,因此初始值可以引用同一组里的其他变量。它主要用于定义相互递归的局部函数。

(letrec ((even-p (lambda (n) (if (= n 0) t (odd-p (1- n)))))
         (odd-p  (lambda (n) (if (= n 0) nil (even-p (1- n))))))
  (list (funcall even-p 10) (funcall odd-p 10)))   ; 结果 (t nil)

同样的事用 let 会在求值 even-p 的初始值时找不到 odd-p,用 let* 则会在建立 even-p 时找不到 odd-p。注意 letrec 要求启用词法绑定(lexical-binding: t),在纯动态绑定下这套写法不成立。

三种 let 的差异可以概括成下图。

flowchart TD
    A["let 与 let 变体"] --> B["let 并行绑定"]
    A --> C["let* 串行绑定"]
    A --> D["letrec 递归绑定"]
    B --> B1["初始值全在旧环境求值"]
    B --> B2["适合互不依赖的临时变量"]
    C --> C1["后续绑定可见前面的绑定"]
    C --> C2["适合依赖链 如先取 buffer 再取 point"]
    D --> D1["先建位置 再求初始值"]
    D --> D2["适合互相递归的局部函数"]

3.4 什么时候用哪种

  • 独立的临时变量、绑定 buffer 对象与 point 位置:用 let
  • 后一个变量要用到前一个变量的结果:用 let*,这是日常最常用的形式。
  • 定义相互递归的局部函数:用 letrec,很少见。

练习:在 M-: 里对比 (let ((x 1) (y x)) y)(let* ((x 1) (y x)) y)。第一条会因为外层的 x 未定义而报 void-variable,第二条返回 1,这个对比最能说明两者的差别。


四、动态绑定与词法绑定

4.1 两种绑定是什么

Emacs Lisp 有两种变量绑定规则:

  • 动态绑定(dynamic binding):变量的值存在符号本身的值单元里。let 的做法是”把旧值存起来,写入新值,退出时恢复”。任何能读到这个符号的代码,无论写在哪里,看到的都是当前最内层的动态绑定值。
  • 词法绑定(lexical binding):let 在栈上建立一个词法环境,变量名只在词法可见的代码范围内解析到这个环境。函数被调用时,如果它的定义位置在某个 let 内部,它就记住了那个环境。

为什么 Emacs 长期以动态绑定为默认?因为早期 Lisp 方言普遍如此,而 Emacs 的内置代码大量依赖”用 let 临时改变一个全局开关,让调用链上所有函数都受影响”这种模式。前面出现过的 inhibit-read-only 就是典型:它必须能被 insert 内部读到,而 insert 定义在完全无关的文件里,词法绑定做不到这一点。

Emacs 24 引入了词法绑定,并且选择按文件切换方言,而不是直接换掉默认值。这个设计是为了兼容:几万个既有的 Elisp 文件依赖动态绑定,如果全局改默认,整个生态会瞬间崩掉。于是新文件显式声明 lexical-binding: t,旧文件保持原样,两种方言在同一个 Emacs 里共存。

这也解释了一个常见疑问:“既然都说词法绑定更好,为什么我 let 一个内置开关还能生效?“答案是那些开关是 defvar 声明过的特殊变量,方言设置对它们无效——这是第 4.5 节要讲的内容。

Emacs Lisp 默认是动态绑定。 要启用词法绑定,必须在文件第一行写文件局部变量(file-local variable):

;;; my-package.el --- 做某件事  -*- lexical-binding: t; -*-
 
;; 上面这一行必须在文件第一行,且形式必须是 -*- ... -*- 注释
;; 它告诉 Emacs 与字节编译器:本文件使用词法绑定的现代方言

lexical-binding 是一个文件级的设置,不能靠 setq 在运行时打开,也不能写在文件中间。它由读取器在读取文件之前读取,因此位置错了就等于没写。

4.2 为什么现代配置必须写这一行

三个理由,按重要性排序:

  1. Emacs 30 起,字节编译器会对缺少该 cookie 的 Elisp 文件发出警告,提示 Warn about missing 'lexical-binding' directive。较新的 Emacs 在加载这类文件时同样会给出提示。也就是说,不写这行会持续看到警告。
  2. 只有词法绑定才有真正的闭包。 回调、钩子函数、add-hook 里捕获的局部变量,在动态绑定下全部失效,见 4.4 的实例。
  3. 字节编译器能做得更好。 词法绑定下编译器可以确定变量的可见范围,因此能发现”引用自由变量""未使用的参数”这类错误,并生成更高效的代码。

如果你的代码确实无法迁移到词法绑定,可以在第一行写 ;;; -*- lexical-binding: nil; -*- 显式声明使用旧方言,从而消除警告。这是明确表态,而不是留空。

4.3 作用域对比图

flowchart TD
    A["文件第一行 lexical-binding 的值"] --> B["t 词法绑定"]
    A --> C["nil 或缺省 动态绑定"]
    B --> B1["let 建立词法环境"]
    B1 --> B2["lambda 捕获环境形成闭包"]
    B2 --> B3["回调与钩子能读到定义处的局部变量"]
    C --> C1["let 临时改写符号的值单元"]
    C1 --> C2["函数体只能看到调用时的当前值"]
    C2 --> C3["回调在 let 退出后读到全局值或报错"]

4.4 动态绑定的经典陷阱

下面这段代码在动态绑定下会出错,而且错得很隐蔽。假设文件没有 lexical-binding cookie:

;; 注意:这段代码演示"错误现象",故意不启用词法绑定
(defvar my-message "global")
 
(defun my-make-printer ()
  (let ((my-message "from let"))     ; 动态绑定,只在 let 体内有效
    (lambda () (message "%s" my-message))))   ; 返回一个函数
 
(defun my-use-printer ()
  (let ((printer (my-make-printer)))  ; 此处 let 已退出,动态绑定已恢复
    (funcall printer)))                ; 打印的是 "global",不是 "from let"

原因很简单:动态绑定下,那个 lambda 里根本没有”记住” my-message 的值,它只是运行时去查符号的值单元。等它被调用时,let 早就退出了。

改成词法绑定(文件第一行加 cookie)后,同一段代码打印的就是 "from let",因为 lambda 捕获了定义处的词法环境。

这个差别在配置里最常见的表现形式是:add-hook 的回调里读不到局部变量,或者 run-with-timer 的延迟函数读到已经被覆盖的值。写回调时如果发现”值不对”,第一个要检查的就是文件头有没有 cookie。

4.5 两种绑定下 let 语义的细微差别

还有一个容易忽略的点:在词法绑定下,let 只影响词法上可见的代码;在动态绑定下,let 影响调用链上所有代码。

;; -*- lexical-binding: t -*-
(defvar dyn-var "outer")
 
(defun reader () dyn-var)          ; 函数定义在 let 之外
 
(let ((dyn-var "inner"))
  (reader))                        ; 结果 "outer"
 
(defun reader2 () dyn-var)
(let ((dyn-var "inner"))
  (let ((f (lambda () dyn-var)))   ; lambda 定义在 let 之内
    (funcall f)))                  ; 结果仍然取决于 dyn-var 是否为特殊变量

注意第二条的细节:dyn-var 已经用 defvar 声明过,是特殊变量,所以即使在词法绑定文件里,它依然按动态绑定处理。这是一个非常关键的事实:defvar 声明过的变量永远是动态绑定的,lexical-binding: t 不会改变它。 词法绑定只作用于未被 defvar 声明过的局部变量。

这个规则的实际推论是:defvar 声明的变量可以被 let 临时改值并影响调用链上的所有函数,这在 Emacs 内置代码里被大量使用(例如 inhibit-read-onlyinhibit-modification-hooks)。而普通的局部变量在词法绑定下是真正的局部变量,不会被深层调用看到。

4.6 检查自己文件用的是哪种方言

lexical-binding    ; 在当前缓冲区求值,结果 t 或 nil

*scratch* 里求值得到的是该缓冲区的方言设置。用 C-h v lexical-binding 可以看到完整说明,包括它是如何被 -*- 行设置的。也可以用 M-x elisp-enable-lexical-binding 给当前缓冲区补上这一行。

练习:新建一个文件,第一行写 ;;; test.el -*- lexical-binding: t; -*-,把 4.4 的两段代码依次写进去并用 C-x C-e 求值,观察 *Messages* 里的输出。然后把 cookie 改成 nil 重新求值,对比结果。


五、buffer-local 变量

5.1 什么是 buffer-local 变量

同一个变量名,在每个缓冲区里可以有一份独立的值,这就是 buffer-local 绑定。它的动机很实际:缩进宽度、tab 宽度、填充列宽这些设置本来就该按文件类型和项目分别配置。

Emacs 里有两层值:

  • 默认值(default value):全局那一份,用 default-value 读。
  • 缓冲区局部值(buffer-local value):当前缓冲区那一份,直接读变量名得到的就是它。

读值的规则是:如果当前缓冲区有局部绑定就用局部值,否则回退到默认值。 下图给出这个层级关系。

flowchart TD
    A["读取变量 tab-width"] --> B{"当前缓冲区有 buffer-local 绑定"}
    B -->|"有"| C["返回该缓冲区的局部值"]
    B -->|"没有"| D["返回默认值 default value"]
    C --> E["setq 修改的是局部值"]
    D --> F["setq 修改的是默认值"]
    E --> G["setq-default 仍然修改默认值"]
    F --> H["此后新建的缓冲区继承该默认值"]

5.2 建立 buffer-local 绑定的三种方式

;; 方式一:在当前缓冲区里就地建立局部绑定,值取自当前默认值
(make-local-variable 'my-var)
(setq my-var "local")              ; 这一步才真的写入局部值
 
;; 方式二:让变量"一旦被赋值就自动变为缓冲区局部"
(make-variable-buffer-local 'my-var)
 
;; 方式三:最常用的简写,建立局部绑定并赋值
(setq-local my-var "local")

make-local-variablemake-variable-buffer-local 的区别是作用时机:前者只影响当前缓冲区这一次;后者是一个全局声明,此后任何缓冲区只要给这个变量赋值,都会自动建立局部绑定。内置的 tab-widthindent-tabs-modefill-column 都用的是第二种,所以你在任何缓冲区里 setq 它们,改的都只是那个缓冲区。

(local-variable-if-set-p 'tab-width)        ; 结果 t,已被声明为自动局部
(local-variable-if-set-p 'indent-tabs-mode) ; 结果 t

5.3 用 indent-tabs-mode 与 tab-width 观察

;; 假设默认值分别是 nil 与 8
(default-value 'indent-tabs-mode)   ; 结果 nil
(default-value 'tab-width)          ; 结果 8
 
(with-temp-buffer
  (setq-local tab-width 4)                       ; 只在这个缓冲区改成 4
  (list tab-width                                ; 结果 4,读到局部值
        (default-value 'tab-width)))             ; 结果 8,默认值没变
 
;; 出了这个缓冲区,默认值依然是 8
(default-value 'tab-width)          ; 结果 8

三个配套函数的使用场景:

(buffer-local-value 'tab-width (get-buffer "some-buffer"))  ; 读别的缓冲区的局部值
(kill-local-variable 'tab-width)                            ; 删掉当前缓冲区的局部绑定
(local-variable-p 'tab-width)                               ; 当前缓冲区是否有局部绑定
(buffer-local-boundp 'tab-width (current-buffer))            ; 兼容写法,等价于上一条

buffer-local-value 的第二个参数必须是缓冲区对象或缓冲区名,它不会切换到那个缓冲区去读。想读别的缓冲区的值又不想切过去,就用它。

注意 default-value 在变量未绑定时会报 void-variable,稳妥的写法是先查 default-boundp

(default-boundp 'tab-width)                  ; 结果 t
(default-value 'never-set-variable)          ; 报错 void-variable

5.4 let 绑定 buffer-local 变量

let 绑定一个 buffer-local 变量时,改的是当前缓冲区的局部值,而且只在 let 体内生效,退出后恢复。

(with-temp-buffer
  (setq-local tab-width 4)
  (let ((tab-width 8))
    tab-width)                ; 结果 8
  tab-width)                  ; 结果 4,let 退出后恢复

如果当前缓冲区没有局部绑定,let 绑定的是默认值,同样在退出后恢复。这一点让 let 成为临时改变行为的首选工具。

;; 临时关闭只读限制,常用于程序化修改只读缓冲区
(let ((inhibit-read-only t))
  (insert "这段文本即使缓冲区只读也能插入"))
 
;; 临时抑制修改钩子,批量修改时避免反复触发
(let ((inhibit-modification-hooks t))
  (delete-region (point-min) (point-max)))

需要强调两件事:第一,inhibit-read-onlyinhibit-modification-hooks 都是 defvar 声明过的特殊变量,因此即使在 lexical-binding: t 的文件里,let 也按动态绑定生效,能影响 insert 内部的代码——这正是它们的设计目的。第二,不要用 setq 去改它们然后忘记改回来,let 会自动恢复。

5.5 buffer-local 变量的继承

有两类变量会被新缓冲区继承:

  • make-variable-buffer-local 声明为自动局部的变量,新缓冲区仍然是”未局部”状态,第一次赋值才建立局部绑定。
  • 通过 major mode 机制建立的局部变量(C-h v 文档里写着 “This variable automatically becomes buffer-local when set”),在切换 major mode 时还会被 kill-all-local-variables 清掉。
;; 演示:mode 切换会清掉非 permanent 的局部变量
(with-temp-buffer
  (setq-local my-temp-var 1)
  (local-variable-p 'my-temp-var)   ; 结果 t
  (kill-all-local-variables)        ; major mode 函数内部会调用它
  (local-variable-p 'my-temp-var))  ; 结果 nil,普通局部变量已被清除

如果希望某个局部变量在 mode 切换后仍然保留,需要给它加上 permanent-local 属性:

(put 'my-temp-var 'permanent-local t)   ; 标记为永久局部,mode 切换时不清除

这也是 defcustom:local 'permanent 参数所做的事,见 6.3。

练习:在 M-: 里执行 (progn (setq-local tab-width 2) tab-width),然后执行 (default-value 'tab-width),对比两个结果。再用 C-h v tab-width RET 看文档里关于自动局部的说明。


六、特殊变量与常量

6.1 不能改的常量

nilt 是常量,赋值会直接报错:

(setq t 1)      ; 报错 (setting-constant t)
(set 'nil 1)    ; 报错 (setting-constant nil)

报错类型是 setting-constant,意思是”试图给常量赋值”。nil 同时是假值与空列表,t 是标准的真值。还有一批内置常量:

most-positive-fixnum    ; 结果 2305843009213693951,64 位平台上最大的 fixnum
most-negative-fixnum    ; 结果 -2305843009213693952
float-pi                ; 结果 3.141592653589793

这些是真正的常量,不要去 setqmost-positive-fixnum 最常见的用途是判断一个数会不会溢出到 bignum,以及做位运算掩码。

6.2 特殊变量的判定

“特殊变量”是 Elisp 的术语,等价于”动态绑定的变量”。判断方式有三种,效果一致:

(defvar my-special 1 "特殊变量。")
(special-variable-p 'my-special)      ; 结果 t
(special-variable-p 'car)             ; 结果 nil,car 不是变量
(boundp 'my-special)                  ; 结果 t

special-variable-p 是写宏和生成代码时的关键判据:只有特殊变量才能被 let 动态地影响调用链。注意 setq 一个从未 defvar 过的变量不会让它变成特殊变量:

(setq my-plain-var 1)
(special-variable-p 'my-plain-var)    ; 结果 nil

这正是字节编译器会警告”assignment to free variable”的原因:你在给一个未声明的变量赋值,编译器无法判断这是笔误还是有意为之。

6.3 defvaralias 与废弃声明

变量别名让两个符号共享同一个值单元。改其中一个,另一个跟着变。

(defvar my-old-name 1 "旧名字。")
(defvaralias 'my-new-name 'my-old-name)   ; my-new-name 成为 my-old-name 的别名
 
(setq my-new-name 42)
my-old-name                                ; 结果 42,共享同一个值单元

字段别名常用于改名兼容。正式做法是 define-obsolete-variable-alias,它同时打上废弃标记,让 C-h v、字节编译器和 M-x customize 都能提示用户。

(define-obsolete-variable-alias 'my-old-name 'my-new-name "2.0")

它的完整参数形式是 (OBSOLETE-NAME CURRENT-NAME WHEN &optional DOCSTRING)WHEN 是一个表示起止版本的字符串。对应的函数版本有 define-obsolete-function-alias。文档里有一条重要提示:别名语句应当写在 defcustom 之前,否则用户保存的定制值会被忽略,因为 defvaralias 的工作方式决定了定制值必须在目标变量初始化之前应用。

6.4 一些容易被误当成普通变量的内置变量

变量说明
lexical-binding当前缓冲区的方言,由文件第一行决定
load-file-name当前正在加载的文件的绝对路径
buffer-file-name当前缓冲区关联的文件名,未关联时为 nil
default-directory当前缓冲区的默认目录,自动 buffer-local
major-mode当前缓冲区的主模式符号
inhibit-read-only非 nil 时忽略只读限制
inhibit-modification-hooks非 nil 时跳过修改钩子
standard-output输出目标,默认是回显区

读它们的文档用 C-h v 变量名 RET,比翻手册快得多。

练习:在 M-: 里执行 (special-variable-p 'major-mode)(special-variable-p 'lexical-binding)(boundp 'buffer-file-name),看哪些是特殊变量。


七、defcustom 与 Customize

7.1 一个完整的 defcustom

(defcustom my-package-auto-format t
  "非 nil 表示保存时自动格式化当前缓冲区。
 
这个选项只影响 my-package 提供的格式化命令,不会改动其他包的行为。"
  :type 'boolean
  :group 'my-package
  :safe #'booleanp
  :set (lambda (symbol value)
         (set-default symbol value)
         (when (fboundp 'my-package-refresh)
           (my-package-refresh)))
  :version "1.0")

参数依次是:变量名、默认值、文档字符串,然后是若干关键字参数。文档字符串的规范与函数一致:第一行是完整的句子,概括取值含义;空一行后可以补充细节。

7.2 常用 :type 一览

:type对应值界面表现
'booleantnil开关(Toggle)
'integer整数数字输入框
'number整数或浮点数字输入框
'string字符串单行输入框
'symbol符号单行输入框
'function函数或 lambda函数选择框
'file文件名文件选择框
'directory目录名目录选择框
'regexp正则字符串输入框(带高亮)
'color颜色名或 #RRGGBB取色器
'faceface 名face 选择框
'coding-system编码系统下拉框
'(choice A B ...)多个候选类型之一单选下拉
'(repeat TYPE)TYPE 组成的列表可增删的列表
'(alist :key-type K :value-type V)关联列表键值对表格
'(plist :options (...))属性列表键值对表格
'(set A B ...)若干选项的组合多选框组
'hook钩子变量函数列表编辑器

choiceset 的区别值得记牢:choice 是”从候选里选一个”,值是候选之一;set 是”多选组合”,值是选中项组成的列表。

(defcustom my-package-backend 'auto
  "后端选择。"
  :type '(choice (const :tag "自动" auto)
                 (const :tag "本地" local)
                 (const :tag "远程" remote))
  :group 'my-package)

7.3 其余关键字

关键字作用
:group归属的定制组,决定在 Customize 界面里的位置
:type值类型与界面控件,几乎每个 defcustom 都该给
:set通过 Customize 修改值时要调用的函数,签名是 (SYMBOL NEWVALUE &optional BUFFER-LOCAL)
:initialize初始化方式,默认是 custom-initialize-reset
:get读取值的方式,默认是 default-toplevel-value
:localt 表示自动 buffer-local;permanent 还标记为永久局部;permanent-only 只标记不局部化
:safe设置 safe-local-variable 属性,函数式地声明”这个值对文件局部变量是安全的”
:risky设置 risky-local-variable 属性,声明该变量的文件局部值有风险
:tag在界面上显示的友好名字,替代变量名
:optionshookplistalist 类型提供常用候选值
:set-after保存的定制值应用顺序,声明”要先设置这些变量”
:require变量被设置时自动 require 某个 feature
:version / :package-version该选项引入的版本号,供文档与工具使用

:safe:risky 的存在理由值得解释一句。Emacs 遇到文件局部变量(文件末尾的 Local Variables: 块,或第一行的 -*- 行)时会询问用户是否应用,因为恶意的局部变量可以执行任意代码。:safe 让包作者用谓词声明”这个变量的值只要满足这个谓词就安全”,从而免去每次询问;:risky 则相反,把连接文件信息(如 load-patheval 相关变量)标记为高风险。

7.4 Customize 界面

M-x customize-group RET my-package RET    进入某个分组,看到该组所有选项
M-x customize-option RET my-package-auto-format RET   直接跳到某个选项
M-x customize-apropos RET 关键字 RET       搜索选项
M-x customize-saved RET                    查看已保存但未生效的值
M-x customize-unsaved RET                  查看已设置但未保存的值

在界面里点 “Save for future sessions” 会把值写进 custom-file(默认是 init 文件末尾的 custom-set-variables 块)。这引出一个实践建议:把 custom-file 单独指向一个文件,避免 Customize 自动生成的代码混在自己的配置里。

;; 让 Customize 把自动生成的内容写到单独的文件
(setq custom-file (expand-file-name "custom.el" user-emacs-directory))
(when (file-exists-p custom-file)
  (load custom-file))       ; 文件确实存在才加载,避免启动报错

注意 load 的第二个可选参数 NOERROR 是位置参数而不是关键字参数,写 (load file t) 才表示”文件不存在时不要报错”。写 (load file :no-error) 虽然也能工作,但那只是因为符号 :no-error 非 nil,属于误读文档的写法。

Emacs 29 起还可以在配置里用 setopt 宏来修改用户选项,它比 setq 更”正式”:setopt 会检查该变量确实是用 defcustom 声明的,并在需要时调用它的 :set 函数。

(require 'cus-edit)
(setopt my-package-auto-format nil)   ; 推荐写法,Emacs 29 起可用

Emacs 31 进一步增加了 buffer-local 版本的 setopt-local,并让 C-h v 在文档里提示”这个选项应当用 setopt 设置”。

练习:在 M-: 里执行 (get 'tab-width 'custom-type)(get 'tab-width 'standard-value),看一个内置选项登记了哪些属性。再执行 M-x customize-group RET editing RET,浏览编辑相关选项的分组方式。


八、用 C-h v 查看变量文档

C-h vdescribe-variable)是查变量的第一工具。它的输出包含四部分:变量的值、文档字符串、属性表、以及”在哪个文件里定义”。读懂它需要知道几个不成文的约定:

  • 文档字符串第一行如果写着 “Non-nil means …”,说明这是布尔选项。
  • 如果写着 “This variable automatically becomes buffer-local when set.”,说明它是自动 buffer-local 变量,setq 只会影响当前缓冲区。
  • 如果末尾写着 “This variable is safe as a file-local variable if …”,说明它带了 :safe 谓词。
  • 属性表里的 standard-valuedefcustom 的默认值,saved-value 是 Customize 保存的值,customized-value 是本次会话里改过的值。三者不一致时就能解释”为什么我的默认值不生效”。

在 Lisp 缓冲区的变量名上按 C-h v 或直接 C-h v 输入名字都可以。Emacs 29 起 C-h v 的输出里还能直接点击 customize 链接跳转。

练习C-h v indent-tabs-mode RET,在输出里找到”automatically becomes buffer-local”那句话,再用 C-h v tab-width RET 对比两者的属性差异。


九、命名约定

Emacs Lisp 的命名约定不只是风格,其中一部分会影响工具行为。

形式含义例子
my-package-xxx包的公开接口,前缀是该包的命名空间magit-status
my-package--xxx包内部使用,双连字符表示”不对外”macroexp--fgrep
xxx-option--用户选项,应当用 defcustomorg-startup-folded
xxx-mode-hook钩子变量,值是函数列表prog-mode-hook
xxx-function值是函数的变量,间接调用点font-lock-fontify-region-function
xxx-p / xxx-p谓词,返回布尔值string-empty-p
inhibit-xxx非 nil 时禁止某行为,通常是特殊变量inhibit-read-only
xxx-alist / xxx-plist关联列表 / 属性列表auto-mode-alist

两条容易被忽视的规则:

第一,双连字符 -- 是给工具看的checkdoc 与若干 lint 工具会据此判断该符号是否公开;用 -- 命名的函数在 C-h f 里仍能看到,但按约定使用者不应依赖。

第二,用户选项不要带 --。因为 M-x customize 会展示这个名字,带 -- 会让用户以为不该改。内部变量反过来要带 --,避免用户误以为可以改。

此外,buffer-local 变量没有额外的命名标记。判断一个变量是否 buffer-local 要看文档字符串里的自动局部说明,或者用 local-variable-if-set-p 查询,不能靠名字。

还有一条容易被忽略的约定:钩子变量以 -hook 结尾,且其值永远是函数列表,单个函数也要写成列表或由 add-hook 处理。与之相对,以 -functions 结尾的变量表示”由这些函数依次处理”,语义与钩子相似但由包自己调度,不经过 run-hooks。这两类变量的定义必须用 defvar 并配 nil 初值,用 defcustom 时要选 'hook 类型。

最后一类是”间接调用点”变量,例如 font-lock-fontify-region-function。它们不是钩子,而是一个单一的函数槽位,包在需要时调用它。改这类变量相当于替换实现,语义比钩子更强,覆盖时要谨慎——一旦设成自己的函数,原实现就被彻底替换掉了。

练习:在 M-: 里执行 (local-variable-if-set-p 'fill-column)(local-variable-if-set-p 'load-path)(local-variable-if-set-p 'inhibit-read-only),看看哪些是自动局部变量。


十、常见错误与排查

10.1 void-variable

Symbol's value as variable is void: my-thing

含义是”这个符号的值单元是空的”。三种成因:

  • 拼写错误。用 C-h v 确认名字。
  • 该变量由某个包定义,但那个包还没加载。用 (require 'some-package) 或把配置放进 with-eval-after-load
  • let 之外读了一个只在 let 内绑定的词法变量,本质是作用域错误。

第三种在迁移到词法绑定时最常见:旧代码依赖”函数能读到调用者的局部变量”,词法绑定后读不到了。

10.2 字节编译警告

Warning: assignment to free variable 'my-var'
Warning: reference to free variable 'my-var'
Warning: defvar ignored because my-var is let-bound
  • “assignment to free variable”:给一个未 defvar 的变量赋值。修法是补 defvar 声明。
  • “reference to free variable”:读一个未声明的变量。同样补 defvar,或者确认是不是拼写错误。
  • “defvar ignored because … is let-bound”:在 let 体内调用 defvar,声明会被忽略。把 defvar 移到文件顶层。

这些警告默认只是警告,不妨碍运行,但它们是真实的 bug 信号。写包时应当把它们全部消除。

10.3 setq 未定义变量的后果

(setq undefined-var 1)        ; 运行期合法,会创建全局绑定
(special-variable-p 'undefined-var)   ; 结果 nil,但它有全局值
(boundp 'undefined-var)               ; 结果 t

也就是说,setq 一个未声明变量在运行时不会报错,只在编译时警告。这意味着一个拼错的变量名会安静地创建一个新全局变量,然后原来的变量保持原值。配置”看起来没生效”时,这往往是真凶。

排查手段:C-h v 确认正确的变量名;用 M-x elisp-lint 或者至少 M-x byte-compile-file 看警告;写配置时对每个非内置变量都补一句 defvar

10.4 把 setq 写成 set

(setq my-flag t)     ; 正确
(set 'my-flag t)     ; 正确,传的是符号
(set my-flag t)      ; 报错 wrong-type-argument symbolp t

第三行的报错信息容易让人困惑,因为它说”需要一个符号,却收到了 t”。原因就是 my-flag 被求值成了它的值 t,而 t 不是可赋值的符号。

10.5 let 绑定与 setq 的混淆

(defvar config-value 1 "配置值。")
 
;; 想临时改,结果改了全局
(defun bad-temp ()
  (setq config-value 99)     ; 没有 let,全局值被永久改掉
  config-value)
 
;; 正确:用 let 建立临时绑定
(defun good-temp ()
  (let ((config-value 99))
    config-value))           ; 退出后 config-value 恢复

练习:在 *scratch* 里写一个函数,故意引用一个未定义的变量,然后 M-x byte-compile-fileM-x emacs-lisp-byte-compile 看警告内容。

10.6 配置不生效时的排查顺序

把前面几节串成一套可执行的排查流程,按顺序做,通常三步之内就能定位。

第一步,确认变量名拼对了。在 C-h v 里输入你写的名字,如果提示 “No match”,说明这个名字根本不存在,多半是拼错或者属于一个尚未加载的包。

第二步,确认变量在运行时到底是什么值。M-: 里输入变量名回车,看到的就是当前生效值。如果这里是对的但行为不对,问题不在变量而在读取它的代码。

第三步,区分默认值与局部值。M-: 执行 (default-value '变量名),再执行 C-h v 看文档里有没有 “automatically becomes buffer-local” 这句话。有这句话而你在 setq,那么改动只对当前缓冲区生效,其它缓冲区自然”不生效”。

第四步,检查是否被 Customize 覆盖。C-h v 输出里的 saved-value 如果非空,说明界面上保存过一份值;它会在 custom-set-variables 里被应用,从而覆盖你源码里的 defvar 默认值。

第五步,检查文件头的 lexical-binding。如果报的是 void-variable,而该变量确实在某个 let 里绑定过,那么这就是方言问题,见第四节。

现象最可能的原因验证方式
void-variable名字拼错、包未加载、方言不对C-h v、检查文件第一行
值改了但行为没变变量是 buffer-local,只改了当前缓冲区C-h v 看自动局部说明
改了 defvar 默认值无效变量已有值或 Customize 保存过C-h vsaved-value
setq 后完全没反应变量名拼错,安静地创建了新全局变量M-x byte-compile-file 看警告
只在某些文件里正常该变量由 major mode 设置成 buffer-locallocal-variable-if-set-p

这套顺序同样适用于给包提交 issue:报告问题时附上 C-h v 的完整输出,维护者一眼就能看到默认值、局部值和保存值三项,省去大量来回。

练习:随便挑一个你配置里改过但”感觉没生效”的变量,按上面五步走一遍,记录每一步的输出。


小结

  • 变量是符号的值单元;defvar 只在未绑定时赋值并顺带把符号标记为特殊变量,这正是它”改了默认值不生效”的原因。
  • let 并行绑定、let* 串行绑定、letrec 递归绑定;lexical-binding: t 必须在文件第一行,且 defvar 声明过的变量永远是动态绑定。
  • buffer-local 变量有两层值,setq 改当前缓冲区的局部值,setq-default 改默认值;let 绑定 buffer-local 变量是最安全的临时修改手段。

相关章节


参考资料