词法作用域有动态方面吗?

Does lexical scope have a dynamic aspect?

词法作用域的访问可以在编译时(或通过静态分析器,因为我的示例在 Python 中)简单地基于源代码中的位置来计算,这似乎是司空见惯的事。

这是一个非常简单的例子,其中一个函数有两个闭包,它们具有不同的值 a

def elvis(a):
  def f(s):
    return a + ' for the ' + s
  return f

f1 = elvis('one')
f2 = elvis('two')
print f1('money'), f2('show')

我认为当我们阅读函数 f 的代码时,当我们看到 a 时,它没有在 f 中定义,所以我们没有问题弹出封闭函数并在那里找到一个,这就是 f 中的 a 所指的。源代码中的位置足以告诉我 f 从封闭范围获取 a 的值。

但是如here所述,当一个函数被调用时,它的局部框架扩展了它的父环境。所以在 运行 时间进行环境查找是没有问题的。但我不确定的是,静态分析器总能在代码 运行 之前计算出 在编译时引用了哪个 闭包。在上面的例子中很明显 elvis 有两个闭包并且很容易跟踪它们,但其他情况就不会那么简单了。直觉上,我很担心静态分析的尝试通常会 运行 变成一个停止问题。

词法作用域是否真的具有动态方面,源代码中的位置告诉我们涉及封闭作用域但不一定涉及哪个闭包?或者这是编译器中已解决的问题,并且函数中对其闭包的所有引用真的可以静态地详细计算出来吗?

或者答案是否取决于编程语言——在这种情况下,词法范围并不像我想象的那么强大?

[编辑@评论:

就我的例子而言,我可以重申我的问题:我读过像 "Lexical resolution can be determined at compile time," 这样的声明,但想知道如何在 f1f2 中引用 a 的值可以计算出 statically/at 编译时间(一般)。

解决办法是,词法作用域并没有要求那么多。 L.S。可以告诉我们,在编译时,只要我在 f 中,就会定义名为 asomething(这显然可以静态计算;这是词法作用域的定义),但确定它实际采用的(或者,哪个闭包是活动的)是 1) 在 L.S 之外。概念,2) 在 运行 时间完成(不是静态的)所以在某种意义上是动态的,当然 3) 使用不同于动态范围的规则。

引述@PatrickMaupin 的外卖信息是"Some dynamic work still has to be done."]

这是一个已解决的问题......无论哪种方式。 Python 使用纯词法作用域,闭包是静态确定的。其他语言允许动态范围界定——闭包是在 运行 时间内确定的,在 运行 时间调用堆栈而不是解析堆栈中向上搜索。

这个解释是否充分?

在 Python 中,如果变量曾被赋值(出现在赋值的左轴)并且未明确声明为全局或非局部变量,则该变量被确定为局部变量。

因此可以沿着 词法 范围链来静态确定哪个标识符将在哪个函数中找到。但是,仍然需要做一些动态工作,因为您可以任意嵌套函数,所以如果函数 A 包含函数 B,函数 B 又包含函数 C,那么对于函数 C 访问函数 A 的变量,您必须找到正确的框架A.(闭包也是一样。)

闭包可以通过多种方式实现。其中之一是实际捕获环境……换句话说,考虑示例

def foo(x):
    y = 1
    z = 2
    def bar(a):
        return (x, y, a)
    return bar

环境捕获解决方案如下:

    输入
  1. foo 并构建包含 xyzbar 名称的本地框架。名称 x 绑定到参数,名称 yz 绑定到 1 和 2,名称 bar 绑定到闭包
  2. 分配给 bar 的闭包实际上捕获了整个父框架,因此当它被调用时,它可以在其自己的本地框架中查找名称 a 并且可以查找 xy 而不是在捕获的父框架中。

使用这种方法(即 而不是 Python 使用的方法)只要闭包仍然有效,变量 z 就会保持有效,即使它没有被闭包引用。

另一个选项,实施起来稍微复杂一些,而不是像:

  1. 在编译时分析代码并发现分配给 bar 的闭包从当前范围捕获名称 xy
  2. 这两个变量因此被分类为"cells"并且它们与局部帧分开分配
  3. 闭包存储这些变量的地址,每次访问它们都需要双重间接(单元格是指向实际存储值的位置的指针)

这需要在创建闭包时花费一些额外的时间,因为每个捕获的单元格都需要在闭包对象中复制(而不是仅仅复制指向父框架的指针),但具有不捕获的优点整个框架,例如 zfoo returns 之后不会保持活动状态,只有 xy 会。

这就是 Python 所做的...基本上是在编译时发现闭包(命名函数或 lambda)时执行子编译。在编译期间,当查找解析为父函数时,变量被标记为单元格。

一个小烦恼是,当捕获参数时(如 foo 示例中),还需要在序言中执行额外的复制操作以转换单元格中传递的值。这在 Python 中在字节码中是不可见的,而是由调用机制直接完成的。

另一个烦恼是,即使在父上下文中,每次访问捕获的变量都需要双重间接寻址。

优点是闭包只捕获真正引用的变量,当它们不捕获任何时,生成的代码与常规函数一样高效。

要查看它在 Python 中的工作原理,您可以使用 dis 模块检查生成的字节码:

>>> dis.dis(foo)
  2           0 LOAD_CONST               1 (1)
              3 STORE_DEREF              1 (y)

  3           6 LOAD_CONST               2 (2)
              9 STORE_FAST               1 (z)

  4          12 LOAD_CLOSURE             0 (x)
             15 LOAD_CLOSURE             1 (y)
             18 BUILD_TUPLE              2
             21 LOAD_CONST               3 (<code object bar at 0x7f6ff6582270, file "<stdin>", line 4>)
             24 LOAD_CONST               4 ('foo.<locals>.bar')
             27 MAKE_CLOSURE             0
             30 STORE_FAST               2 (bar)

  6          33 LOAD_FAST                2 (bar)
             36 RETURN_VALUE
>>>

如您所见,生成的代码使用 STORE_DEREF1 存储到 y(写入单元格的操作,因此使用双重间接寻址),而是存储 2 使用 STORE_FAST 转换为 zz 未被捕获,只是当前帧中的局部)。当 foo 的代码开始执行时 x 已经被调用机制包装到一个单元格中。

bar只是一个局部变量,所以STORE_FAST是用来写它的,但是构建闭包xy需要单独复制(在调用 MAKE_CLOSURE 操作码之前将它们放入元组中)。

闭包本身的代码可见于:

>>> dis.dis(foo(12))
  5           0 LOAD_DEREF               0 (x)
              3 LOAD_DEREF               1 (y)
              6 LOAD_FAST                0 (a)
              9 BUILD_TUPLE              3
             12 RETURN_VALUE

你可以看到在返回的闭包中 xy 是通过 LOAD_DEREF 访问的。无论在嵌套函数层次结构中定义了多少层 "up" 一个变量,它实际上只是一个双向间接访问,因为在构建闭包时已经付出了代价。就局部变量而言,封闭变量的访问速度(按常数因子)只是稍微慢一些……不需要在运行时遍历"scope chain"。

像 SBCL(用于生成本机代码的 Common Lisp 的优化编译器)这样更复杂的编译器也会执行 "escape analysis" 来检测闭包是否真的可以在封闭函数中存活下来。 当这没有发生时(即,如果 bar 仅在 foo 内部使用,而不是存储或返回),可以在堆栈而不是堆上分配单元格,从而减少运行时间 "consing"(在堆上分配需要垃圾回收才能回收的对象)。

这种区别在文献中称为"downward/upward funarg";即,如果捕获的变量仅在较低级别可见(即在闭包中或在闭包内部创建的更深的闭包中)或在较高级别(即如果我的 caller 将能够访问我俘虏的当地人)。

要解决向上函数参数问题,需要垃圾收集器,这就是为什么 C++ 闭包不提供此功能的原因。