词法作用域是否具有动态方面?

5

看起来,在编译时(或通过静态分析器,因为我的示例是在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的值。
但是,如此处所述,当调用函数时,其本地框架扩展其父环境。因此,在运行时进行环境查找没有问题。但我不确定静态分析器是否总能在编译时找出引用的是哪个闭包。在上面的示例中,显然elvis有两个闭包,并且很容易跟踪它们,但其他情况可能不会那么简单。直观上,我担心尝试静态分析可能会在一般情况下遇到停机问题。
因此,词法作用域真的具有动态方面吗?源代码中的位置告诉我们涉及到封闭范围,但不一定知道引用的是哪个闭包吗?还是这是编译器中解决问题的方法,函数内部对闭包的所有引用都可以在编译时在详细静态地被工作出来?
还是答案取决于编程语言-在这种情况下,词法作用域不像我想象的那么强?

它还能意味着什么?编程语言必须是确定性的,否则我们就不会使用它们。事情已经够难了。 - wallyk
我不确定我理解这个问题。您是在询问是否可以使用静态分析来确定“f1”和“f2”的值吗? - Barmar
根据定义,词法作用域是纯粹的词法作用域。您只需查看封闭的词法函数及其封闭函数等即可。 - Barmar
“词法作用域”是指仅通过阅读源代码就可以确定的作用域。相反,如果作用域取决于程序状态,则被定义为“动态作用域”。 - jonrsharpe
(在帖子的编辑中回复了评论。) - gnarledRoot
3个回答

7

闭包可以用几种方式实现,其中一种是实际上捕获环境...换句话说,考虑下面的例子:

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

env-capturing解决方案如下:
1.输入foo,建立一个包含x、y、z、bar名称的本地框架。名称x绑定到参数,名称y和z绑定到1和2,名称bar绑定到闭包。
2.分配给bar的闭包实际上捕获了整个父级框架,因此在调用它时,可以在自己的本地框架中查找名称a,在捕获的父级框架中查找x和y。
通过这种方法(不是Python使用的方法),即使闭包没有引用它,变量z也将保持活动状态,只要闭包保持活动状态。
另一个稍微复杂一些的选项,则是按如下方式工作:
1.在编译时,分析代码并发现分配给bar的闭包捕获了当前范围内的名称x和y。
2.因此,将这两个变量分类为“单元格”,并从本地框架单独分配它们。
3.闭包存储这些变量的地址,并且每次访问它们都需要进行双重间接寻址(单元格是指向实际存储值的指针)。
这需要在创建闭包时多花点时间,因为每个捕获的单元格都需要被复制到闭包对象中(而不仅仅是复制父框架的指针)。但是,它的优点是不会捕获整个框架,因此例如z将在foo返回后不会保持活动状态,仅x和y会保持活动状态。
这就是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_DEREF”(将值写入单元格,因此使用双重间接寻址)将“1”存储到“y”中,并使用“STORE_FAST”将“2”存储到“z”中(“z”未被捕获,只是当前帧中的本地变量)。当“foo”的代码开始执行时,“x”已经被调用机制包装成一个单元格。

“bar”只是一个局部变量,因此使用“STORE_FAST”来写入它,但要构建闭包,则需要逐个复制“x”和“y”(它们在调用“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。无论一个变量在嵌套函数层次结构中定义多少级别的“向上”,它实际上只是双重间接性,因为构建闭包时需要付出代价。与本地变量相比,封闭的变量访问速度略慢(通过一个常数因子),但在运行时不需要遍历“作用域链”。

甚至更为复杂的编译器(例如生成本地代码的Common Lisp优化编译器SBCL)还执行“逃逸分析”以检测闭包是否确实能够存活于封闭函数中。当这种情况没有发生时(即如果bar仅在foo内部使用而不被存储或返回),单元格可以分配到堆栈中而不是堆中,从而降低运行时“consing”的数量(分配在堆上需要进行垃圾回收才能被重新获取的对象)。

这种区别在文献中被称为“向下/向上Funarg”,即捕获的变量是否只能在较低级别(即在闭包或在闭包内创建的更深层次的闭包中)可见,还是在更高级别(即是否我的调用者能够访问我的捕获的本地变量)。

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


优秀的阐述! - Patrick Maupin
这回答了一个更大的问题,我的问题只是其中的一小部分!提供了我所缺失的那个拼图(特别是下行-上行funarg的提及非常有帮助)。 - gnarledRoot
使用 dis 是一个启示性的奖励——这是我很高兴用 Python 编写我的示例代码的另一个原因。 - gnarledRoot

1
这是一个已解决的问题,Python使用纯词法作用域,闭包在静态时确定。其他语言允许动态作用域 - 闭包在运行时通过搜索运行时调用堆栈而不是解析堆栈来确定。 这个说明足够吗?

1
在运行时不确定的? - Barmar
面向对象编程。我更改了措辞,但没有完成编辑。现在已经修复。 - Prune

1
在Python中,如果一个变量被分配(出现在赋值语句的左侧)并且没有显式声明为全局或非本地变量,则会确定该变量是本地变量。
因此,可以通过逐级查找词法范围链来静态确定哪个标识符将在哪个函数中找到。但是,仍然需要进行一些动态工作,因为您可以任意嵌套函数,因此如果函数A包含函数B,函数B包含函数C,则对于函数C要访问函数A的变量,必须找到正确的A帧。(闭包也是同样的情况。)

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接