英文原文:http://dmitrysoshnikov.com/ecmascript/es5-chapter-3-1-lexical-environments-common-theory
这一节,我们会讨论词法环境的细节,它是在一些编程语言中用于管理静态作用域的一种机制。为了确保能充分理解这一主题,我们会简要讨论下其对立面:动态作用域(并没有直接用于 ECMAScript)。我们会看到环境是如何管理代码中的词法嵌套结构,以及为闭包提供全面支持。
ECMA-262-5 规范初次引入了词法环境,尽管这一术语独立于 ECMAScript,在很多编程语言中被使用。
实际上,与这一主题相关的技术部分,我们在 ES3 系列文章中就讨论过(在讨论 变量与激活对象 和 作用域链 时)。
严格来说,词法环境在这个情况下,更像是之前 ES3 观念的技术适配和高度抽象的替代品。ES5 以后,在讨论和阐述 ECMAScript 时,我推荐使用这些新的定义。当然,更一般性的术语,例如调用栈(ES 中的执行上下文栈) 的激活记录(ES3 中的激活对象),这些还是会被使用,不过已经是低抽象层面的讨论了。
这一节专注于环境的通用理论,也会涉及有关编程语言理论(PLT)的一些方面。我们会考虑不同语言中这一主题的多个视角与实现,以便于理解为什么词法环境是必要的,以及这些结构是如何创建的。事实上,如果我们对通用作用域理论有充分理解,关于理解 ES 中的环境和作用的问题也就不存在了,这个话题也就很变得很清晰了。
我们之前说过,所有这些 ES 中使用的术语(例如:激活对象、作用域链、词法环境等等)都与一个基本术语作用域有关。提到的 ES 定义只是有关作用域实现的技术和术语。为了理解这些技术,我们先来回顾作用域这一技术术语和其类型。
典型地,作用域用于管理程序不同部分中变量的可见性与可访问性。
一些包装抽象(例如命名空间、模块等等)与作用域有关,通过它们来提供更好的系统模块化并且避免命名变量冲突。对等地,有函数的局部变量和代码块的局部变量。这些技术用于提高抽象和包装内部数据(使用户不用关心这些实现的细节和额外的内部变量名)。
有了作用域,我们可以在一个程序中使用相同名称但可能有不同含义和值的变量。来看这个概念:
作用域是一个闭合的上下文,其中包含的变量与一个值相关。
我们可以说,这是一个逻辑界限,其中的变量(甚至是表达式)有自己的含义。例如,全局变量、局部变量等等,只是反映了变量生命周期(或者范围)的一个逻辑区间。
代码块和函数概念将我们引向主要的作用域属性: 嵌套其他作用域或被嵌套。我们会看到,并非所有实现都允许嵌套函数,也不是所有实现都提供了块级作用域。
来看如下的 C 语言的例子:
// 全局 "x"
int x = 10;
void foo() {
// "foo" 函数的局部 "x"
int x = 20;
if (true) {
// if 语句块的局部 "x"
int x = 30;
printf("%d", x); // 30
}
printf("%d", x); // 20
}
foo();
printf("%d", x); // 10
如下图所示:
图 1. 嵌套作用域
ECMAScript 在版本 6 (也叫 ES6 或 ES2015)之前并不支持块级作用域:
var x = 10;
if (true) {
var x = 20;
console.log(x); // 20
}
console.log(x); // 20
ES6 规定 let
关键字用于创建块级作用域变量:
let x = 10;
if (true) {
let x = 20;
console.log(x); // 20
}
console.log(x); // 10
之前的“块级作用”可以被实现为立即执行函数(IIFE):
var x = 10;
if (true) {
(function (x) {
console.log(x); // 20
})(20);
}
console.log(x); // 10
另一个作用域的主要属性是变量求值的方法。尽管一个项目中的多个程序员可能会使用相同的变量名(例如,循环中的 i
),我们要知道如何根据同样名称的标志符获取对应的正确的值。主要有两种方式,也对应两种作用域类型:静态和动态。让我们来搞清楚。
静态作用域中,标志符指向其最近的词法环境。“词法”一词对应程序文本的属性。例如,从词法上变量在源代码中出现的地方(也就是代码中的具体的位置),在这个作用域中,变量在运行时被关联到作用域上。“环境”这个词也暗示词法上包围着变量的定义。
“静态”指可以在程序的解析过程就决定作用域。这是指,如果我们(通过解析代码)在启动程序之前就能确定变量对应的作用域,那么我们是使用了静态作用域的方式。
来看一个例子:
var x = 10;
var y = 20;
function foo() {
console.log(x, y);
}
foo(); // 10, 20
function bar() {
var y = 30;
console.log(x, y); // 10, 30
foo(); // 10, 20
}
bar();
例子中,变量 x
词法上定义在全局作用域中,这意味着在运行时就会对应到全局作用域,其值是 10
。
名称 y
有两个定义。不过我们说过,最近的词法作用域才是包含的变量对应的。包含的作用域有最高优先级,被优先考虑。所以,在函数 bar
中,y
变量值是 30
。函数 bar
的局部变量 y
是全局作用域中的同名变量 y
的影子。
但是,名称 y
的值是函数 foo
中是 20
,在函数 bar
中被调用时,bar
中有另一个 y
。标识符的求值独立于调用者所在环境(例子中,bar
是 foo
的调用者,foo
是被调用者)。这也是因为在函数 foo
定义的时刻,最近的词法上下文的 y
是位于全局上下文。
今天静态作用域在很多语言中使用:C、Java、ECMAScript、Python、Ruby、Lua 等等。
后面,我们会提到有关词法作用域实现的机制,并且讨论与一等函数同时使用的情况。接下来,我们先看另一种情况,动态作用域。这有助于了解两者的不同,以及为什么动态作用域不能用于实现闭包。我们也会看到 ECMAScript 其实提供了一些动态作用域的特性。
相比静态作用域,动态作用域假定我们不能在解析阶段就确定变量对应的值(对应的环境)。这意味着,变量不能在词法环境求值,而是动态构造全局的变量栈。每遇到一个变量声明,只是将变量名放到栈上。在变量对应的作用域(生命周期)结束后,变量从栈上移除。
这意味着,对于单个函数,我们对变量名可能有无数种求值方法,取决于函数调用时的上下文。
例如,和上文类似,但使用动态作用域的情况。我们使用类 pascal 的伪代码语法:
// 伪代码 - 使用动态作用域
y = 20;
procedure foo()
print(y)
end
// 在栈上的名称 "y"
// 目前对于的值是 20
// {y: [20]}
foo() // 20, OK
procedure bar()
// 现在的栈上,有两个 "y" 的值:
// {y: [20, 30]}
// 取第一个值(从栈顶)
y = 30
// 所以:
foo() // 30!不是 20
end
bar()
可以看到,调用者所在环境影响变量的求值。由于函数可以在很多不同的地方和不同状态下被调用,很难在解析阶段静态地判定执行的具体环境。这也是为什么这种类型的作用域是动态的。
也就是说,动态作用域下的变量在执行环境中求值,而不是静态作用域下那种在定义的环境中。
动态作用域的一个好处是,可以为系统不同状态应用相同代码。不过,这需要考虑到函数执行的所有可能状态。
显然,动态作用域下不可能为变量创建闭包。
今天,多数现代编程语言不使用动态作用域。不过,在有些语言中,特别是 Perl(或 Lisp 的一些方言),程序员可以选择如何定义变量,使用静态还是动态作用域。
看下 Perl 的例子。关键字 my
在词法上捕获了一个变量,而关键字 local
使得变量采用动态作用域:
# Perl 示例:静态和动态作用域
$a = 0;
sub foo {
return $a;
}
sub staticScope {
my $a = 1; # 词法(静态)
return foo();
}
print staticScope(); # 0 (来自保存的全局帧)
$b = 0;
sub bar {
return $b;
}
sub dynamicScope {
local $b = 1; # 动态
return bar();
}
print dynamicScope(); # 1 (来自调用方的帧)
我们说过,ECMAScript 也不使用全局作用域。不过,有的 ES 指令可以认为是给静态作用域带来了动态特性。所以,这些指令可以认为与动态作用域有关。不过再次注意,并非是采用动态作用域定义中的全局变量栈的方式,而是由于无法在解析阶段确定变量的值。这些指令是 with
和 eval
。它们为 ECMAScript 静态作用域带来的影响称为“运行时作用域扩大”。
看下面的例子:
var x = 10;
var o = {x: 30};
var storage = {};
(function foo(flag) {
if (flag == 2) {
eval("var x = 20;");
}
if (flag == 3) {
storage = o;
}
with (storage) {
// "x" 可以在全局作用域中求值 - 10,
// 也可以在函数局部作用域中求值 - 20(通过"eval"函数),
// 甚至在 "storage"对象中求值 - 30
alert(x); // ? - "x" 的作用域在编译时决定
}
// 递归调用 3 次
if (flag < 3) {
foo(++flag);
}
})(1);
接下来我们会看到,静态作用域提高了效率,而 with
和 eval
相比之下,可能会在实现层面降低词法环境存储和变量查找的性能。所以,with
语句被从 ES5 严格模式下 彻底删除。而 eval
函数在严格模式下 可能不会 在调用上下文中创建变量。也就是说,严格模式在 ES 下提供了完全词法作用域的环境。
本章后面,我们会只会讨论词法(静态)作用域以及其实现细节。不过在这之前,我们看简要了解下名称绑定,这在环境相关概念中会经常用的。
使用高度抽象的编程语言时,我们通常不会通过底层的地址来引用内存上的数据,而是通过给予合适的变量名(标志符),来映射相应的数据。
名称绑定就是标志符与对象的组合。
标志符可以是绑定的或未绑定的。如果标志符绑定到一个对象,那么它引用了这个对象。接下来通过使用标识符可以获得绑定到的对象。
绑定的概念涉及两个主要操作(常常在讨论传参数、赋值是传引用还是传值时让人困惑),分别是重绑定和修改。
重绑定与标志符有关。这个操作将标志符从一个老对象解绑(如果之前已经绑定),然后绑定到另一个对象(也就是另一块存储区域)。通常(特别是在 ECMAScript 中)重绑定通过简单的赋值操作实现。
例如:
// 将 "foo" 绑定到对象 {x: 10}
var foo = {x: 10};
console.log(foo.x); // 10
// 绑定 "bar" 到相同的对象
// 和标志符 "foo" 绑定对象相同
var bar = foo;
console.log(foo === bar); // true
console.log(bar.x); // OK,也是 10
// 重新绑定 "foo" 到新对象
foo = {x: 20};
console.log(foo.x); // 20
// "bar" 仍旧指向老对象
console.log(bar.x); // 10
console.log(foo === bar); // false
重绑定常常与传引用赋值混淆。有人会觉得,通过向变量 foo
赋值新的对象,变量 bar
也应该指向新的对象。不过,我们看到,bar
仍旧指向老对象,而 foo
重绑定到了新的存储区域。下图显示了这两个动作:
图 2. 重绑定
不要把绑定看作传引用,而是(以 C 的视角)传指针(或者传共享)操作。所以它也被称为是传值的特例,值为地址。赋值只是改变(重绑定)指针的值(地址),将一个变量赋值给另一个时,其实是拷贝了相同对象的地址给新的变量。这样两个标志符可以说是共享了一个对象,这也就是传共享的意思。
和重绑定相比,修改操作也会影响对象的内容。
看下面的例子:
// 将一个数组绑定到标志符 "foo"
var foo = [1, 2, 3];
// 这是对数组对象的修改
foo.push(4);
console.log(foo); // 1,2,3,4
// 继续修改
foo[4] = 5;
foo[0] = 0;
console.log(foo); // 0,2,3,4,5
代码执行如下图所示:
图 3. 修改
可以在 第 8 章 求值策略 了解更多有关绑定和求值策略(传引用、传值、传共享)的信息。
现在我们可以讨论有关环境的细节了,来看下它们是如何构建的。
这一节我们会提到有关词法作用域实现有关的技术。由于我们在高抽象层面操作并讨论词法作用域,后面我们主要使用术语环境而非作用域,因为这正是在 ES5 中使用的术语。例如,函数的全局环境、局部环境等等。
我们提到过,环境决定了表达式中标志符(符号)的含义。实际上,离开特定的环境信息,讨论类似 x + 1
这样的表达式的值毫无意义,因为环境提供了符号 x
的含义(甚至是符号 +
,如果把它视为一个简单的加法函数的语法糖的话)。
ECMAScript 通过使用调用栈模型来管理函数的执行,这里称之为 执行上下文栈。我们来看一些基本的存储变量(绑定)的模型。有闭包和没闭包的系统。
如果我们没有一等函数(例如,函数可以作为数据使用,后面会讨论到)或者压根不考虑内部函数,最简单的存储局部变量的方式是通过调用栈。
调用栈上的特殊数据结构 激活记录(activation record) 被用于存储环境中的绑定。有时候也被称为调用栈帧。
每次函数被激活时,它的激活记录(包括参数和局部变量)被压入调用栈。这样,当函数调用其他函数时(或者递归调用自身),栈上会被压入另一个栈帧。上下文完成时,激活记录被从栈上移除(出栈),此时所有局部变量被销毁。这种模型在诸如 C 语言中使用。
例如:
void foo(int x) {
int y = 20;
bar(30);
}
void bar(x) {
int z = 40;
}
foo(10);
然后调用栈进行如下修改:
callStack = [];
// "foo" 函数激活记录入栈
callStack.push({
x: 10,
y: 20
});
// "bar" 函数激活记录入栈
callStack.push({
x: 30,
z: 40
});
// "bar" 函数激活时的调用栈
console.log(callStack); // [{x: 10, y: 20}, {x: 30, z: 40}]
// "bar" 函数执行结束
callStack.pop();
// "foo" 函数执行结束
callStack.pop();
下图中我们看到两条激活记录被入栈,也就是函数 bar
激活时的状态:
图 4. 有激活记录的调用栈
ECMAScript 中也使用了相同的函数执行过程。不过,有些非常重要的不同。
首先,我们知道,调用栈表示执行上下文栈,激活记录对应(ES3)激活对象。
主要的区别是,与 C 不同,如果存在闭包,ECMAScript 不会从存储中移除激活对象。最重要的情况是,如果闭包是使用了外部函数的变量的内部函数,并且该内部函数被返回到了外部。
这意味激活对象不能存储在栈上,而是要放在堆(动态分配内存,有时候这样的编程语言被称为基于堆的语言,相比于基于栈的语言)上。并且它会被存储到不再有闭包使用激活对象上的变量为止。进一步讲,不仅激活对象被保存,如果有必要(在多层嵌套的情况下)所有父级激活对象都会被保存。
var bar = (function foo() {
var x = 10;
var y = 20;
return function bar() {
return x + y;
};
})();
bar(); // 30
下图显示了基于堆的激活记录的抽象表示。可以看到如果 foo
函数创建了一个闭包,那么执行结束后它的帧也不会从内存中移除,因为它在闭包中仍被引用。
图 5. 基于堆的调用帧
理论上对于这些激活对象使用的术语叫作环境帧(类比调用栈帧)。我们使用这个术语来强调实现上的不同,环境帧在没有闭包引用时继续存在。我们也用这个术语来强调高抽象概念(例如,相比关注底层的栈和地址结构,我们称之为环境),以及其实现机制,这已经是派生的问题了。
如上所述,在 ECMAScript 中,相比 C,我们有内部函数和闭包。并且,所有函数在 ES 中都是一等的。让我们会想这种函数的定义,以及在函数式编程中的其他定义。我们会看到这些术语与词法环境模型有紧密的关系。
我们也会明白,闭包的问题(或者函数参数问题,后面会提到)就是词法环境的问题。这也是为什么我们在这一节中主要在说函数式编程的基本概念。
一等函数可以作为数据,例如,在词法上的运行时被创建,作为参数传递,或者从另一个函数中作为值返回。
简单的例子:
// 在运行时,动态创建一个函数表达式
// 绑定到标志符 "foo"
var foo = function () {
console.log("foo");
};
// 将其传递给另一个函数,也是在运行时创建并
// 在创建后立即被调用,然后传入的函数并绑定
// 到标志符 "foo"
foo = (function (funArg) {
// 激活 "foo" 函数
funArg(); // "foo"
// 作为值返回
return funArg;
})(foo);
一等函数可以进一步细分。
当一个函数作为参数传递是,被称作“函数参数”(funarg),缩写自 functional argument。
接收函数参数的函数,被称作高阶函数(HOF),或者,像数学那样,称为算子。
返回另一个函数的函数,被称作函数值函数(值为函数的函数)。
有了这些概念,我们来看下所谓的“函数参数”问题。我们马上会看到,这个问题的解决方案正是闭包和词法环境。
在上面例子中,函数 foo
是一个函数参数,被传递给了一个匿名的高阶函数(接收函数参数 foo
,作为参数 funArg
)。这个匿名函数返回一个函数值,同时也是 foo
函数自身。而这些都被这个一等函数定义所包括。
另一个与一等函数相关并且我们需要回顾的概念是:自由变量。
自由变量是有函数使用,但既不是参数也不是函数局部变量的变量。
换句话说,自由变量是没有在自身环境中,而是可能在外部环境中的变量。注意,自由变量同样支持绑定(例如,在某个外部环境中找到)和解绑定。后一种情况在 ECMAScript 中会触发 ReferenceError
。
来看这个例子:
// 全局环境那(GE)
var x = 10;
function foo(y) {
// 函数 "foo" 的环境(E1)
var z = 30;
function bar(q) {
// 函数 "bar" 的环境(E2)
return x + y + z + q;
}
// 返回 "bar" 到外部
return bar;
}
var bar = foo(20);
bar(40); // 100
这个例子中有三个环境:GE
、E1
、E2
,对应全局对象、函数 foo
和函数 bar
,
然后,对于函数 bar
,x
、y
和 z
是自由变量,既不是形式参数,也不是局部变量。
注意,函数 foo
没有使用自由变量。不过,由于变量 x
在函数 bar
中使用,并且函数 bar
是在函数 foo
执行时创建,后者需要保存其父环境的绑定,使得能够向其内部嵌套函数传递绑定 x
(在 bar
中)。
函数 bar
执行后正确返回的 100
表明,函数 bar
的确记住了函数 foo
执行时的环境(也就是函数 bar
创建的环境),即使 foo
上下文已经结束。再次重申,这是与 C 使用的基于栈的激活记录模型的区别。
显然,如果允许内部函数并且想要静态(词法)作用域,同时让这些函数是一等的,我们需要在函数创建时保存函数使用的所有自有变量。
最直接和简单的实现这样的机制的方式,是在创建时保存完整的父环境。然后,在自身执行时(例子中是函数 bar
执行时),创建自身的环境,填充局部变量和参数,并配置外部环境,从而在那里查找自由变量。
可以将环境一词既用在单个绑定对象,也可以用在到当前嵌套层级的所有绑定对象列表上。后一种情况下,我们可以将绑定对象称为环境的帧。观点如下:
环境是一系列的帧。每一帧都是一条绑定记录(可能为空),将变量名与其对应的值关联起来。
注意,由于这是一个一般性的定义,我们使用了抽象的记录的概念,而没有指定具体的实现结构,可以是堆上的哈希表,或者栈内存,甚至是虚拟机上的寄存器,等等。
例如,示例中环境 E2
有三个帧:自己的 bar
、foo
和 global
。环境 E1
包含两个帧:foo
(自身)和 global
帧。全局环境 GE
只包含一个 global
帧。
图 6. 带有帧的环境
单个帧对于任何变量最多有一个绑定。每个帧都有一个到自己包围(或外部的)环境的指针。全局帧的外部引用是 null
。一个环境中变量的值,由第一个包含该变量的绑定的帧中的值给出。如果没有任何帧包含这样的绑定,那么变量被认为是未绑定到环境(ReferenceError
)。
var x = 10;
(function foo(y) {
// z使用自由绑定的变量 "x"
console.log(x);
// 自身绑定的变量 "y"
console.log(y); // 20
// 自由未绑定的变量 "z"
console.log(z); // ReferenceError: "z" is not defined
})(20);
例如,回到作用域的概念,这些环境帧的序列(或者换个视角,环境组成链表)构成了我们称之为作用域链的东西。并不意外,ES3 就定义了这个术语:作用域链。
注意,一个环境可能是多个内部环境的包围环境:
// 全局环境(GE)
var x = 10;
function foo() {
// "foo" 环境(E1)
var x = 20;
var y = 30;
console.log(x + y);
}
function bar() {
// "bar" 环境(E2)
var z = 40;
console.log(x + z);
}
伪代码:
// 全局
GE = {
x: 10,
outer: null
};
// foo
E1 = {
x: 20,
y: 30,
outer: GE
};
// bar
E2 = {
z: 40,
outer: GE
};
下图说明了这些关系:
图 7. 一般的父环境帧
也就是说,环境 E1
中的绑定 x
覆盖了全局帧中的同名绑定。
综上我们有了关于创建和使用(调用)函数的一般规则:
函数相对于给定的环境创建。返回的函数对象由包含的代码和到函数创建的环境的指针组成。
代码:
// 全局 "x"
var x = 10;
// 函数 "foo" 相对于全局环境创建
function foo(y) {
var z = 30;
console.log(x + y + z);
}
对应伪代码:
// 创建函数 "foo"
foo = functionObject {
code: "console.log(x + y + z);"
environment: {x: 10, outer: null}
};
如下图所示:
图 8. 一个函数
注意,函数引用其环境,而环境中的一个绑定,也就是函数,引用回函数对象。
函数通过一组参数被调用,构造新的帧,绑定函数形参到调用的实参,创建当前帧的局部变量的绑定,然后在新的环境中执行函数体。新的帧关联到函数创建时包围的环境。
看应用:
// 函数 "foo" 以参数 20 调用
foo(20);
对应如下伪代码:
// 用形参和局部变量创建新的帧
fooFrame = {
y: 20,
z: 30,
outer: foo.environment
};
// 执行函数 "foo" 的代码
execute(foo.code, fooFrame); // 60
下图展示了函数通过环境进行调用的过程的:
图 9. 函数调用
结论的第一点直接引出了闭包的定义。
闭包由函数代码和函数创建时所在的环境组成。
上面提到,闭包是作为函数参数问题的解决方案引入的。让我们回想一下,以便加深理解。
函数参数问题可以分为两个与作用域、环境、闭包有关的子问题。
首要的函数参数问题,是将内部函数返回到外部的复杂性,例如,如果函数使用了其创建时的父环境的自由变量,如果实现返回函数?
(function (x) {
return function (y) {
return x + y;
};
})(10)(20); // 30
我们知道,在堆上保存包含帧的词法作用域,是回答的关键。在栈上保存绑定的策略(C 中使用)不再合适。再说一遍,这里保存了代码块和环境,也就是闭包。
接下来的函数参数问题,是关于将函数作为参数传给另一个函数时,其使用的自由变量的变量名如何解析。在哪个作用域中对这些自由变量进行求值,是函数定义的作用域,还是函数执行时的作用域?
var x = 10;
(function (funArg) {
var x = 20;
funArg(); // 10,而不是 20
})(function () { // 创建并传递一个函数
console.log(x);
});
也就是说,这个问题与本章开始时讨论的静态(词法)还是动态作用域的选择有关。我们知道,词法(静态)作用域就是答案。我们要保存完整的词法变量以避免歧义。并且,这里保存的词法变量和函数代码,就是我们称作的闭包。
所以我们最终的答案是什么?一等函数、闭包和词法环境是紧密相关的。而词法环境正是用于实现闭包和静态作用域的技术。
这里我们提到,ECMAScript 正是使用了环境帧的模型。不过,要结合我们将在其他节讨论的 ES 术语。
有关闭包的细节可以参考 ES3 系统文章的 第 6 章 闭包。
为全面理解,我们也介绍下再其他语言中的其他环境实现。
记住,为了深入理解一些具体的技术(例如,ECMAScript),我们需要首先理解通用理论的机制,以及其他语言如何实现这些技术。我们会看到这些一般的机制是如何在许多相似的语言中显而易见的。不过,不同语言的实现也会有区别。这一节我们关注像 Python、Ruby 和 Lua 这样的语言的环境。
保存所有自由变量的另一种方式是创建一个大的环境帧来包含所有,不过是从不同包围环境中收集的必要的自由变量。
显然,如果一些变量对于内部函数不需要,那就不必保存它们。看这个例子:
// 全局环境
var x = 10;
var y = 20;
function foo(z) {
// 函数 "foo" 的环境
var q = 40;
function bar() {
// 函数 "bar" 的环境
return x + z;
}
return bar;
}
// 创建 "bar"
var bar = foo(30);
// 创建 "bar"
bar();
可以看到没有函数使用全局变量 y
。所以,不必在 foo
的闭包或 bar
的闭包中包含它。
全局变量 x
没有在 foo
中使用,但是我们之前提到过,需要将其保存到 foo
的闭包,因为内部的函数 bar
在其创建环境中获得 x
的信息(也就是函数 foo
的环境)。
函数 foo
中的变量 q
有和全局变量 y
类似的情况,没有被使用,所以所以不需要在 bar
的闭包中保存。变量 z
则被保存在 bar
中。
这样,我们有了一个保存了所有需要的自由变量的函数 bar 的单个环境帧。
bar = closure {
code: <...>,
environment: {
x: 10,
z: 30,
}
}
类似的模型用于 Python 编程语言。由一个保存的环境帧的函数被简单称作 __closure__
(反映了词法环境的本质)。全局变量不包含在这个帧中,因为它们始终可以在全局帧中被找到。未被使用的变量也没有包含在 __closure__
中。来看这个例子:
# Python 环境示例
# 全局 "x"
x = 10
# 全局函数 "foo"
def foo(y):
# 局部 "z"
z = 40
# 局部函数 "bar"
def bar():
return x + y
return bar
# 创建 "bar"
bar = foo(20)
# 执行 "bar"
bar() # 30
# 函数 "bar" 保存的环境;
# 存储在其 __closure__ 属性中;
#
# 只包含 {"y": 20};
# "x" 不在 __closure__ 中,因为它
# 始终可以在全局被找到;
# "z" 也没有被保存,因为没有用到
barEnvironment = bar.__closure__
print(barEnvironment) # 闭包单元组成的元组
internalY = barEnvironment[0].cell_contents
print(internalY) # 20, "y"
注意,即使在使用 eval
的情况下也不会保存未使用的变量,如果不能确定一个变量是否会在上下文被使用。在下面例子中,内部的 baz
函数捕获了自由变量 x
,而函数 bar
没有:
def foo(x):
def bar(y):
print(eval(k))
def baz(y):
z = x
print(eval(k))
return [bar, baz]
# 创建函数 "bar" 和 "baz"
[bar, baz] = foo(10)
# "bar" 不包含任何闭包数据
print(bar.__closure__) # None
# "baz" 包含变量 "x"
print(baz.__closure__) # closure cells {'x': 10}
k = "y"
baz(20) # OK,20
bar(20) # OK,20
k = "x"
baz(20) # OK,10 - "x"
bar(20), # 错误,"x" 未定义
再次对比 ECMAScript,使用环境帧链,处理这个情况:
function foo(x) {
function bar(y) {
console.log(eval(k));
}
return bar;
}
// 创建 "bar"
var bar = foo(10);
var k = "y";
// 执行 bar
bar(20); // OK,20 - "y"
k = "x";
bar(20); // OK,10 - "x"
更多有关 Python 中闭包可以参考 这个有关 Python 的代码。
也就是说,主要区别在于,链式的环境帧模型(ECMAScript 使用)优化了函数创建的性能,不过在解析标志符时,可能要遍历整个作用域链(指定特定绑定被找到,或者触发 ReferenceError
)。
也就是说,单环境帧模型优化了执行过程(所有标志符都在最近的单个帧中进行查找,不必访问作用域链),不过需要更复杂的算法,以便在函数创建时解析内部函数以绝对哪些变量需要被保存。
不过需要注意,这个结论只针对 ECMA-262-5 规范。实际上,ES 引擎可以轻易优化 ECMAScript 的实现,只保存必要的变量。我们会在 3.2 节讨论 ECMAScript 的实现。
另外要注意,严格来说,组合的帧可能不是单个。意思是说,组合的帧被优化以保护多个父帧的绑定,不过环境中可能会包含一些额外的帧。Python 也是一样,执行时函数只有一个自己的帧、一个 __closure__
帧和一个全局帧。
Ruby 语言也采用了单个帧,捕获所有在闭包创建时存在的变量。下面例子中,Ruby 变量 x
被第二个闭包捕获,但第一个没有捕获:
# Ruby lambda 闭包示例
# 闭包 "foo",包含自由变量 "x"
foo = lambda {
print x
}
# 定义 "x"
x = 10
# 第二个闭包 "bar" 有相同的函数体
# 也引用了自由变量 "x"
bar = lambda {
print x
}
bar.call # OK,10
foo.call # 错误,"x" 未定义
上面提到,Ruby 保存所有存在的变量,不过这个例子使用了 eval
来对未使用变量进行求值(与 Python 相比,和 ES 相同):
k = "y"
foo = lambda { |x|
lambda { |y|
eval(k)
}
}
# 创建 "bar"
bar = foo.call(10)
print(bar.call(20)) # OK,20 - "y"
k = "x"
print(bar.call(20)) # OK, 10 - "x"
有些编程语言,例如 Lua(也采用单个环境帧)允许在函数运行时动态设置所需的环境。来看 Lua 的示例:
-- Lua 环境示例:
-- 全局 "x"
x = 10
-- 全局函数 "foo"
function foo(y)
-- 局部变量 "q"
local q = 40
-- 获取 "foo" 的环境
fooEnvironment = getfenv(foo)
-- {x = 10, globals...}
print(fooEnvironment) -- table
-- "x" 和 "y" 可以被获取,
-- 因为 "x" 在环境中,而
-- "y" 是局部变量(参数)
print(x + y) -- 30
-- 现在改变 "foo" 的环境
setfenv(foo, {
-- 引用 "print" 函数,
-- 给出另外的名称
printValue = print,
-- 重用 "x"
x = x,
-- 定义一个新的绑定 "z"
-- 值为 "y"
z = y
})
-- 使用新的绑定
printValue(x) -- OK,10
printValue(x + z) -- OK,30
-- 局部变量仍然可以访问
printValue(y, q) -- 20,40
-- 不过其他的名称
printValue(print) -- nil
print("test") -- 错误,"print" 名称是 nil,不能调用
end
foo(20)
现在我们完成了通用理论。下一节 3.2 将会关注 ECMAScript 的实现。我们会讨论诸如环境记录的结构(对应我们这里讨论的环境帧),以及它们的不同类型:声明环境记录和对象环境记录,还会看到该结构在 ES5 中包含执行上下文,并且对于不同类型的函数有不同的类型对应,就我们所知,是函数表达式和函数声明。
这一章我们讨论了:
环境的概念对应作用域的概念。
理论上有两种作用域:动态和静态。
ECMAScript 使用静态(词法)作用域。
不同, with
和 eval
可以认为是给静态作用域带来动态特性。
作用域、环境、激活绝对性、激活记录、调用栈帧、环境帧、环境记录和执行上下文的概念,是近似的,并且应用在讨论中。所以,技术上来讲,在 ECMAScript 中,它们是彼此的一部分。例如,环境记录时词法环境的一部分,词法环境又是执行上下文的一部分。不过,逻辑上来说,它们可以近似相等。这些说法很正常:“全局作用域”、“全局环境”、“全局上下文”,等等。
ECMAScript 使用链式的环境帧模型。在 ES3 中被称作作用域链。在 ES5 中环境帧被称作环境记录。
一个环境可以包含多个内部环境。
词法环境用于实现闭包和解决函数参数问题。
ECMAScript 中的所有函数都是一等的,都是闭包。
这一节,我们会讨论词法环境的细节,它是在一些编程语言中用于管理静态作用域的一种机制。为了确保能充分理解这一主题,我们会简要讨论下其对立面:动态作用域(并没有直接用于 ECMAScript)。我们会看到环境是如何管理代码中的词法嵌套结构,以及为闭包提供全面支持。
这一章专门讨论了ECMA-262-5 规范的新概念之一 — 属性特性及其处理机制 — 属性描述符。 当我们说“一个对象有一些属性”的时候,通常指的是属性名和属性值之间的关联关系。但是,正如在ES3系列文章中分析的那样,一个属性不仅仅是一个字符串名,它还包括一系列特性—比如我们在ES3系列文章中已经讨论过的{ReadOnly},{DontEnum}等。因此从这个观点来看,一个属性本身就是一个对象