ECMA-262-5 详解 - 3.1 词法环境:通用理论 – ds.laboratory

原文出处 ECMA-262-5 in detail. Chapter 3.1. Lexical environments: Common Theory. – ds.laboratory
  • 介绍

  • 通用理论

    1. 作用域

      1. 静态(词法)作用域

      2. 动态作用域

        1. ECMAScript 中 with 和 eval 的动态作用域特性
    2. 命名绑定

      1. 重绑定

      2. 突变

    3. 环境

      1. 激活记录模型

      2. 环境帧模型

        1. 一等函数

        2. 函数参数和高级函数

        3. 自由变量

        4. 环境定义

        5. 函数创建和执行规则

        6. 闭包

        7. 函数参数问题

      3. 组合的环境帧模型

  • 结论

  • 附录

介绍

这一节,我们会讨论词法环境的细节,它是在一些编程语言中用于管理静态作用域的一种机制。为了确保能充分理解这一主题,我们会简要讨论下其对立面:动态作用域(并没有直接用于 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\. 嵌套作用域

图 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。标识符的求值独立于调用者所在环境(例子中,barfoo 的调用者,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 中 with 和 eval 的动态作用域特性

我们说过,ECMAScript 也不使用全局作用域。不过,有的 ES 指令可以认为是给静态作用域带来了动态特性。所以,这些指令可以认为与动态作用域有关。不过再次注意,并非是采用动态作用域定义中的全局变量栈的方式,而是由于无法在解析阶段确定变量的值。这些指令是 witheval。它们为 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);

接下来我们会看到,静态作用域提高了效率,而 witheval 相比之下,可能会在实现层面降低词法环境存储和变量查找的性能。所以,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\. 重绑定

图 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\. 修改

图 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\. 有激活记录的调用栈

图 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\. 基于堆的调用帧

图 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

这个例子中有三个环境:GEE1E2,对应全局对象、函数 foo 和函数 bar

然后,对于函数 barxyz 是自由变量,既不是形式参数,也不是局部变量。

注意,函数 foo 没有使用自由变量。不过,由于变量 x 在函数 bar 中使用,并且函数 bar 是在函数 foo 执行时创建,后者需要保存其父环境的绑定,使得能够向其内部嵌套函数传递绑定 x(在 bar 中)。

函数 bar 执行后正确返回的 100 表明,函数 bar 的确记住了函数 foo 执行时的环境(也就是函数 bar 创建的环境),即使 foo 上下文已经结束。再次重申,这是与 C 使用的基于栈的激活记录模型的区别。

显然,如果允许内部函数并且想要静态(词法)作用域,同时让这些函数是一等的,我们需要在函数创建时保存函数使用的所有自有变量。

环境定义

最直接和简单的实现这样的机制的方式,是在创建时保存完整的父环境。然后,在自身执行时(例子中是函数 bar 执行时),创建自身的环境,填充局部变量和参数,并配置外部环境,从而在那里查找自由变量。

可以将环境一词既用在单个绑定对象,也可以用在到当前嵌套层级的所有绑定对象列表上。后一种情况下,我们可以将绑定对象称为环境的帧。观点如下:

环境是一系列的帧。每一帧都是一条绑定记录(可能为空),将变量名与其对应的值关联起来。

注意,由于这是一个一般性的定义,我们使用了抽象的记录的概念,而没有指定具体的实现结构,可以是堆上的哈希表,或者栈内存,甚至是虚拟机上的寄存器,等等。

例如,示例中环境 E2 有三个帧:自己的 barfooglobal。环境 E1 包含两个帧:foo(自身)和 global 帧。全局环境 GE 只包含一个 global 帧。

图 6\. 带有帧的环境

图 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\. 一般的父环境帧

图 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\. 一个函数

图 8. 一个函数

注意,函数引用其环境,而环境中的一个绑定,也就是函数,引用回函数对象。

函数通过一组参数被调用,构造新的帧,绑定函数形参到调用的实参,创建当前帧的局部变量的绑定,然后在新的环境中执行函数体。新的帧关联到函数创建时包围的环境。

看应用:

// 函数 "foo" 以参数 20 调用

foo(20);

对应如下伪代码:

// 用形参和局部变量创建新的帧

fooFrame = {
  y: 20,
  z: 30,
  outer: foo.environment
};

// 执行函数 "foo" 的代码

execute(foo.code, fooFrame); // 60

下图展示了函数通过环境进行调用的过程的:

图 9\. 函数调用

图 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 使用静态(词法)作用域。

  • 不同, witheval 可以认为是给静态作用域带来动态特性。

  • 作用域、环境、激活绝对性、激活记录、调用栈帧、环境帧、环境记录和执行上下文的概念,是近似的,并且应用在讨论中。所以,技术上来讲,在 ECMAScript 中,它们是彼此的一部分。例如,环境记录时词法环境的一部分,词法环境又是执行上下文的一部分。不过,逻辑上来说,它们可以近似相等。这些说法很正常:“全局作用域”、“全局环境”、“全局上下文”,等等。

  • ECMAScript 使用链式的环境帧模型。在 ES3 中被称作作用域链。在 ES5 中环境帧被称作环境记录。

  • 一个环境可以包含多个内部环境。

  • 词法环境用于实现闭包和解决函数参数问题。

  • ECMAScript 中的所有函数都是一等的,都是闭包。

如果你有疑问、补充或更正,欢迎在评论里进行讨论。

附录

计算机程序的结构和翻译(SICP):

ECMA-262-5 详细内容:

其他官员通用理论的文献: