url.el custom 变量绑定问题

;; -*- lexical-binding: t; -*-
(let ((url-cache-directory "~/org/url/cache")
      (buffer nil))
  (with-current-buffer
      (url-retrieve
       "https://cn.bing.com/"
       (lambda (&rest args)
         (message "%S %S" ;; => "t ~/.emacs.d/url/cache"
                  (eq (current-buffer) buffer)
                  url-cache-directory)))
    (setq buffer (current-buffer))
    url-cache-directory ;; => "~/org/url/cache"
    ))

问题:为什么 callback 里的 url-cache-directory 与 末尾的 url-cache-directory 绑定的值不同。

注1: url-retrieve-synchronously 无此问题。

注2: 在 emacs -Q 的 ielm 环境中首次执行上述代码片段时,报: Defining as dynamic an already lexical var: url-cache-directory. (require 'url-cache) 后错误不在,但绑定的值依旧不同。

我没有尝试复现,但是看起来这个问题应该是autoload造成的。

autoload的函数内部的动态绑定只有在加载它的时候才被创建,也就是说第一次加载的时候,url-cache-directory的词法绑定是先于动态绑定的。此时把它定义成动态变量就会报这个错误。在你执行(require 'url-cache)之后动态变量已经创建,此时词法绑定就可以正常生效了。

1 个赞

是的,注2 看起来是 defvar 准备创建 url-cache-directory 时却发现它已经被 lexical 绑定了。

但是不知道为什么,url 异步 retrieve 过后,它又被设置回默认值。也许,在 retrieving 时,执行环境已经脱离了 let, 即使该变量是 dynamic binding, 但因为执行流已出 let, 所以 retrieve 时,该变量的值已经恢复为默认。

(let ((url-cache-directory "~/org/url/cache")
      (buffer nil)
      (start (float-time))
      (timeout 10)
      (done nil))
  (with-current-buffer
      (url-retrieve
       "https://cn.bing.com/"
       (lambda (&rest args)
         (message "%S %S" ;; => "t ~/org/url/cache"
                  (eq (current-buffer) buffer)
                  url-cache-directory)
         (setq done t)))
    (setq buffer (current-buffer))
    (while (and (not done)
                (< (- (float-time) start) timeout))
      (sit-for 1))))

试了下加 waiting, 这样看起来可以临时改变 url-cache-directory 了。

想起我遇到的一个问题,回调函数中取不到url的值,但emacs -q却又可以,其它人也能取到,现在也不知为什么我这里不行

let 绑定对异步执行的支持可能存在欠缺,或者说,我们使用了 elisp 所 undefined 的操作。

我上面的代码虽然让 主页面 缓存到我指定的位置,但主页面中的一些图片元素还是被缓存到了默认位置。

我猜:因为整个请求是异步的, let 绑定退出时如果请求还没结束,那后续的请求将使用默认值。

我还在看有无解决方法。

加个闭包?

Origin

(let ((url-cache-directory "~/org/url/cache")
      (buffer nil))
  (with-current-buffer
      (url-retrieve
       "https://cn.bing.com/"
       (lambda (&rest args)
         (message "%S %S" ;; => "t ~/.emacs.d/url/cache"
                  (eq (current-buffer) buffer)
                  url-cache-directory)))
    (setq buffer (current-buffer))
    url-cache-directory ;; => "~/org/url/cache"
    ))
;;Result:    "~/org/url/cache"
;;*Message*: t "d:/_D/msys64/home/26633/.emacs.d/url/cache"

New

(let ((url-cache-directory "~/org/url/cache")
      (buffer nil))
  (with-current-buffer
      (url-retrieve
       "https://cn.bing.com/"
       (let ((dir url-cache-directory))
         (lambda (&rest args)
           (message "%S %S" ;; => "t ~/.emacs.d/url/cache"
                    (eq (current-buffer) buffer)
                    dir))))
    (setq buffer (current-buffer))
    url-cache-directory ;; => "~/org/url/cache"
    ))
;;Result:    "~/org/url/cache"
;;*Message*: t "~/org/url/cache"
1 个赞

不行,这样没有解决我的问题。原谅我的表述有些 X-Y problem.

我的目的是将 url-cache 的存储路径临时地切换到我指定的目录下(一方面不想污染默认配置,另一方面我希望这些特定的 cache 没有 expired 时间)。我的根本目的是 cache 页面文本,只是因为不想自己写 cache, 想借 url-cache 减少工作量。

起初,我试着用 let 临时地改变 url-cache 的 custom 变量 url-cache-directory 来实现这一点,但随后我便遇到了本贴子的问题。

仔细想想,实际上,url-cache-directory 正和 defvar 定义的变量一样,是动态绑定的,所以,题干 callback 里的 url-cache-directory 实际已经不受词法范围约束了——这和 elisp 的定义是一致的,只是我预期 callback 里的 url-cache-directory 能受 let 约束。

因为它 (那个作为 callback 的 lambda) 看起来“就在 let 里头一样”。

1 个赞

我做了个实验,跟你的结果相同。

从打印顺序上看,url-retrieve里的lambda的执行是发生在你执行(setq buffer (current-buffer))之后的。

参考Emacs Lisp RFM 12.10.1

Lexical binding is only available in the modern Emacs Lisp dialect. (*Note Selecting Lisp Dialect::.) A lexically-bound variable has “lexical scope”, meaning that any reference to the variable must be located textually within the binding construct. Here is an example

 (let ((x 1))    ; ‘x’ is lexically bound.
   (+ x 3))
      ⇒ 4

 (defun getx ()
   x)            ; ‘x’ is used free in this function.

 (let ((x 1))    ; ‘x’ is lexically bound.
   (getx))
 error→ Symbol's value as variable is void: x

Here, the variable ‘x’ has no global value. When it is lexically bound within a ‘let’ form, it can be used in the textual confines of that ‘let’ form. But it can not be used from within a ‘getx’ function called from the ‘let’ form, since the function definition of ‘getx’ occurs outside the ‘let’ form itself.

这里的lambda如果没有被当作一个闭包处理,那么它里面的lexical binding应该是不生效的。所以我想应该传一个闭包进去,或者给它传参数进去。

1 个赞

预期而言, lambdalet 中,算 lexical. 但实际的执行效果反而像是:

;; 先执行完 let
(let ((url-cache-directory "~/org/url/cache")
      (buffer nil))
...
      (url-retrieve
       "https://cn.bing.com/"
       (lambda ...))
...
    ))

;; lambda 被异步执行
;; lambda 在上段代码中被求值为闭包
;; 捕获了 buffer 变量,但未捕获 url-cache-directory.
((lambda (&rest args)
   (message "%S %S" ;; => "t ~/.emacs.d/url/cache"
            (eq (current-buffer) buffer)
            url-cache-directory)))

上述 lambda 被求值后的闭包应该如下:

ELISP> (let ((buffer nil)
             (url-cache-directory "."))
         (lambda () (list buffer url-cache-directory)))
#f(lambda () [(buffer nil)] (list buffer url-cache-directory)) 
;; 没捕获 url-cache-directory

顺便一提, let 可以算作 lambda 的语法糖, 下面的方法可以完全 lexical, 即使 bindings 中有被定义为 special-variable 的 symbol.

ELISP> ((lambda (buffer url-cache-directory)
          (lambda () (list buffer url-cache-directory)))
        nil ".")
#f(lambda () [(url-cache-directory ".") (buffer nil)]
    (list buffer url-cache-directory))
ELISP> 

不过,它不能完成我想要实现的效果,因为 url-retrieve 是异步的, lexical 绑定在这里无法发挥作用。

简单概括的话, elisp 的 let 还不完全等价 cl-lib 中的 lexical-let 吧。

请教个问题:

(let bindings &rest body)body 里有一句 (url-retrieve url (lambda ...)).

这里的 lambda 算不算 located textually within the binding construct?

还是说 lambda 必须作为 body list 的 element?

算的,但是这个问题的情况似乎比较特殊。

考虑这个简单的例子:(lexical-binding默认开启)

(let ((x 1))
  (lambda () x))

求值得到#[nil (x) ((x . 1))]let的词法绑定生效。

但是如果前面加上

(defvar x 0)
(let ((x 1))
  (lambda () x))

得到是#[nil (x) (t)]x变成了动态绑定的,let的词法绑定不生效。

我想这是defvar的问题。defvar的文档里说:

If INITVALUE is provided, the ‘defvar’ form also declares the variable as “special”, so that it is always dynamically bound even if ‘lexical-binding’ is t. If INITVALUE is missing, the form marks the variable “special” locally (i.e., within the current lexical scope, or the current file, if the form is at top-level), and does nothing if ‘lexical-binding’ is nil.

那么defcustom是不是有这个问题呢?它的文档里说:

This macro uses defvar' as a subroutine, which also marks the variable as \"special\", so that **it is always dynamically bound** **even when lexical-binding’ is t**.

相关问题我看到有一个相关的帖子之前讨论过:

1 个赞

嗯嗯,了解了。这个问题是想细化下我目前对 let 范围的理解,试澄清是只要是 let 的子孙节点,还是必须是直接子节点才会受到词法约束。

这个帖子的问题我大致有个理解轮廓了。

这贴子的讨论有点超出我的认知范围,我暂时按 defvar 理解 defcustom. 感觉, customize 接口和我当前以 Org Tangle 的方式管理 Emacs 配置的场景不太适配,所以暂时没研究。

今天我发现了一个可能的解释:

因为bytecode编译的时候,let绑定的变量其实还没有被定义,编译器会尝试把这个变量定义成词法绑定,当它进入let求值的时候,因为里面的函数被延迟加载了,所以绑定仍然是词法绑定,因为它还没有值。但是当这段代码被运行的时候,被延迟加载的代码开始被加载,此时检测到了这个变量的定义原来是动态绑定,和它在字节码里的类型冲突了,所以这段代码无法被安全执行。

规避的方法是使用let-compile而不是let,该函数通过declare提供一个绕过的方法。

Emacs 里好像没有 let-compile 这个宏?是哪个包里的吗?

其实最正确的做法不是规避,而就是用原帖的做法 (require 'url-cache)——除了 autoload 的函数外,所有用到的变量、宏都需要显式 require。(只用到变量的话还可以手动 defvar,但和 byte-compile 没什么关系,因为 defvar/defcustom 会检查是否有冲突的 lexical binding ,所以解释器运行也会同样报错。)

当然,这里的话 defvar 正确也没有任何用处,因为 url-http 硬编码了对应的参数,而 url-cache-directory 没在硬编码的列表里,所以它也没有不同请求不同 url-cache-directory 的设计。但传参使用的是 buffer-local 变量,所以我们可以在请求发出之后把对应的参数也传过去:

(with-current-buffer
    (url-retrieve "https://cn.bing.com"
                  (lambda (&rest _) (message "%S" url-cache-directory))) ; ~/org/url/cache
  (set (make-local-variable 'url-cache-directory) "~/org/url/cache"))

(感觉这种做法有个小小的没绑上的窗口期,但大多时候应该没问题。)

let-compilelisp-mode里的宏,直接用是没问题的。

autoload对这里是不起效的,它并不做太多declare的工作。这里合理的做法就是要declare变量的类型,这样就可以让编译器规避冲突情况。

…………就是我查了 28.2 到 30.2 到 master branch,都没有 let-compile 这个宏啊……倒是有个 let-when-compile 但是它语义完全不同,用的也不是 declare

declare 只对 byte-compile 有效,我不知道 let-compile 到底是什么,但只用 declare 的话解释执行 S-exp 依然会报错。标准做法就是 require

  • If a file requires certain other Lisp programs to be loaded beforehand, then the comments at the beginning of the file should say so. Also, use require to make sure they are loaded. See Features.

我的记忆出问题了,我昨天代码里写的是let-when-compile,但是当时我记忆里就是let-compile,我也不知道怎么解释这个。

let-when-compile也的确不是使用declare,而是把let给函数闭包化了,它会把let的词法绑定转写成闭包里的动态绑定,这样就规避掉编译器的类型冲突警告了。考虑到Emacs里实际并没有真正的词法变量,这个处理应该并不影响最终结果。

我不支持require,因为require显式破坏了延迟加载,一段代码的编译结果不一定立刻被使用,实际的加载应该被尽可能的推迟,直到求值真的需要发生。如果结果在编译后立刻就被需要,那么就在编译后只保留结果。let-when-compile可以满足这两点。

稍微写得长了点。

eval-when-compile 的用法

你可能理解错了 let-when-compile 的用法,它进行的是替换变量,而不会绑定变量。虽然它文档也不清不楚的就是了……直接看 macroexpand-all 结果吧:

  • 直接用 let-when-compile 替换 let,结果没有任何 let 绑定:
(macroexpand-all
 '(let-when-compile ((url-cache-directory "/root/cache"))
    (url-retrieve "http://example.com"
                  (lambda (&rest _)
                    (message "%S" url-cache-directory)))))
;; →
(url-retrieve "http://example.com" #'(lambda (&rest _) (message "%S" url-cache-directory)))
  • 当然上面的是错误用法,正确用法应该配合 eval-when-compile 使用:
(macroexpand-all
 '(let-when-compile ((url-cache-directory "/root/cache"))
    (url-retrieve "http://example.com"
                  (lambda (&rest _)
                    (message "%S" (eval-when-compile url-cache-directory))))))
;; →
(url-retrieve "http://example.com" #'(lambda (&rest _) (message "%S" '"/root/cache")))
  • 正确使用时:
    • 依旧没有任何 let 绑定,
    • 所有 eval-when-compile 的块会在 let-when-compile 的环境中执行,将变量或是表达式替换成一个常量值。
      • 例如上面的 (message "%S" (eval-when-compile url-cache-directory)) 就成了 (message "%S" '"/root/cache")
  • 其实 let-when-compile/eval-when-compile 主要就是用来手动进行编译时优化的;把 let 换成 let-when-compile 后不出错了不是因为延后加载了,而是宏展开/编译之后就没有变量绑定了。
  • 这种替换只对当前宏展开的代码块进行。这里输出倒是变了,但题主希望的是将 url-cache-directory 传到 url-cache.el 定义的函数里面去,而局域替换的值自然无法传递或是替换别的文件里的内容,也就无法实现缓存位置的更改。

延后加载

延后加载还比较麻烦,官方文档里好像也没有什么总结,我这里尝试从源码里汇总一下:

根据使用到的符号不同,有不同的延后加载手段,大致就分为用到了函数、用到了宏、用到了变量三类。

函数的延后加载

  • 最简单的情形解释对应的包已经提供了 autoload,此时直接使用函数即可。
  • 如果没有,有几种做法:
    • 自己调用 autoload,如 isearch.el
      (autoload 'char-fold-to-regexp "char-fold")
      
    • 在使用对应的函数前 (require 'xxx),基本也就是手动 autoload,如 editorconfig-find-current-editorconfig
      (eval-and-compile (require 'editorconfig-core))
      (when-let* ((file (editorconfig-core-get-nearest-editorconfig
                         default-directory)))
        (find-file file))
      
    • 如果觉得到了调用位置的时候包必然已经通过 autoload 之类的机制被加载了,那么可以不 require。此时编译器可能会有警告,可以通过 declare-function 消除警告:
      (declare-function shortdoc-function-groups "shortdoc")
      

变量的延后加载

所有包的可配置变量都不是 lexical 作用域的。这类变量普遍有“当前值”和“默认值”两个栏位,defcustom 设置的主要是“默认值”栏位,而 let 设置的是当前值栏位,所以我们倒不必担心二者间有冲突。那么剩下的问题就是:

  • 读取变量:

    此时我们需要确保变量的“默认值”有设置。但和函数不同,变量没有 autoload,因此必须手动 require,如 calc-aent.el

    (progn
      (require 'calc-ext)
      calc-arg-values)
    

    我们还需要确保变量使用的是动态作用域,一般使用不带初始值的 defvar 来说明是动态作用域:

    ;; calc-arg-values is defined in calc-ext.el, but is used here.
    (defvar calc-arg-values)
    
  • 写入变量:

    此时不需要初始值也就自然不需要 require 了,只需要 defvar 就可以了。或者,其实 Emacs 已经提供了一个 dlet 宏,自动加上 (defvar xxx) 并进行 let 绑定:

    (macroexpand-all
     '(dlet ((url-cache-directory "/root/cache"))
        (message "%S" url-cache-directory)))
    ;; →
    (let (_)
      (defvar url-cache-directory)
      (let ((url-cache-directory "/root/cache"))
        (message "%S" url-cache-directory)))
    

宏的延后加载

这个官方手册倒是有说了:

If a file foo uses a macro defined in another file bar, but does not use any functions or variables defined in bar, then foo should contain the following expression:

(eval-when-compile (require 'bar))

看源码里一般都是把 (eval-when-compile (require 'bar)) 放在顶层,避免编译后的多余加载。但是如果在编译前也想懒加载的话,似乎放在函数内部、宏调用前也可以。

太长不看版

懒加载:

  1. 不在乎懒加载:顶层用 (require 'xxx)
  2. 用到函数,希望懒加载:手动 autoload 或用到时再 require
  3. 用到变量,希望懒加载:defvardlet,读取的话用到时还需要手动 require 一下。
  4. 用到宏:(eval-when-compile (require 'bar))

对于题主的程序:

  1. 出错是因为 let (lexical)url-http.el(autoload ... "url-cache")defcustom (dynamic),最后的 defcustom 会检查 lexical 绑定,监测到最外面的 let 绑定,报错。
  2. 想要解决出错,建议直接用 dlet
  3. 但是这并不会保留 url-cache-directory 值,因为所有能够传递过去的参数都是硬编码的 buffer-local 值,而 url-cache-directory 并不在其中。
  4. 一种补丁方法是手动把变量绑定为 buffer-local:
3 个赞

是的!这里应该用dletlet-when-compile是用来手动编译优化的。