首页
Javascript
Html
Css
Node.js
Electron
移动开发
小程序
工具类
服务端
浏览器相关
前端收藏
其他
关于
公司注册

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

2018年11月03日 发布 阅读(2458) 作者:Jerman

英文原文: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 语言的例子:

  1. // 全局 "x"
  2. int x = 10;
  3. void foo() {
  4. // "foo" 函数的局部 "x"
  5. int x = 20;
  6. if (true) {
  7. // if 语句块的局部 "x"
  8. int x = 30;
  9. printf("%d", x); // 30
  10. }
  11. printf("%d", x); // 20
  12. }
  13. foo();
  14. printf("%d", x); // 10

如下图所示:

图 1\. 嵌套作用域

图 1. 嵌套作用域

ECMAScript 在版本 6 (也叫 ES6 或 ES2015)之前并不支持块级作用域:

  1. var x = 10;
  2. if (true) {
  3. var x = 20;
  4. console.log(x); // 20
  5. }
  6. console.log(x); // 20

ES6 规定 let 关键字用于创建块级作用域变量:

  1. let x = 10;
  2. if (true) {
  3. let x = 20;
  4. console.log(x); // 20
  5. }
  6. console.log(x); // 10

之前的“块级作用”可以被实现为立即执行函数(IIFE):

  1. var x = 10;
  2. if (true) {
  3. (function (x) {
  4. console.log(x); // 20
  5. })(20);
  6. }
  7. console.log(x); // 10

另一个作用域的主要属性是变量求值的方法。尽管一个项目中的多个程序员可能会使用相同的变量名(例如,循环中的 i),我们要知道如何根据同样名称的标志符获取对应的正确的值。主要有两种方式,也对应两种作用域类型:静态和动态。让我们来搞清楚。

静态(词法)作用域

静态作用域中,标志符指向其最近的词法环境。“词法”一词对应程序文本的属性。例如,从词法上变量在源代码中出现的地方(也就是代码中的具体的位置),在这个作用域中,变量在运行时被关联到作用域上。“环境”这个词也暗示词法上包围着变量的定义。

“静态”指可以在程序的解析过程就决定作用域。这是指,如果我们(通过解析代码)在启动程序之前就能确定变量对应的作用域,那么我们是使用了静态作用域的方式。

来看一个例子:

  1. var x = 10;
  2. var y = 20;
  3. function foo() {
  4. console.log(x, y);
  5. }
  6. foo(); // 10, 20
  7. function bar() {
  8. var y = 30;
  9. console.log(x, y); // 10, 30
  10. foo(); // 10, 20
  11. }
  12. 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 的伪代码语法:

  1. // 伪代码 - 使用动态作用域
  2. y = 20;
  3. procedure foo()
  4. print(y)
  5. end
  6. // 在栈上的名称 "y"
  7. // 目前对于的值是 20
  8. // {y: [20]}
  9. foo() // 20, OK
  10. procedure bar()
  11. // 现在的栈上,有两个 "y" 的值:
  12. // {y: [20, 30]}
  13. // 取第一个值(从栈顶)
  14. y = 30
  15. // 所以:
  16. foo() // 30!不是 20
  17. end
  18. bar()

可以看到,调用者所在环境影响变量的求值。由于函数可以在很多不同的地方和不同状态下被调用,很难在解析阶段静态地判定执行的具体环境。这也是为什么这种类型的作用域是动态的。

也就是说,动态作用域下的变量在执行环境中求值,而不是静态作用域下那种在定义的环境中。

动态作用域的一个好处是,可以为系统不同状态应用相同代码。不过,这需要考虑到函数执行的所有可能状态。

显然,动态作用域下不可能为变量创建闭包。

今天,多数现代编程语言不使用动态作用域。不过,在有些语言中,特别是 Perl(或 Lisp 的一些方言),程序员可以选择如何定义变量,使用静态还是动态作用域。

看下 Perl 的例子。关键字 my 在词法上捕获了一个变量,而关键字 local 使得变量采用动态作用域:

  1. # Perl 示例:静态和动态作用域
  2. $a = 0;
  3. sub foo {
  4. return $a;
  5. }
  6. sub staticScope {
  7. my $a = 1; # 词法(静态)
  8. return foo();
  9. }
  10. print staticScope(); # 0 (来自保存的全局帧)
  11. $b = 0;
  12. sub bar {
  13. return $b;
  14. }
  15. sub dynamicScope {
  16. local $b = 1; # 动态
  17. return bar();
  18. }
  19. print dynamicScope(); # 1 (来自调用方的帧)
ECMAScript 中 with 和 eval 的动态作用域特性

我们说过,ECMAScript 也不使用全局作用域。不过,有的 ES 指令可以认为是给静态作用域带来了动态特性。所以,这些指令可以认为与动态作用域有关。不过再次注意,并非是采用动态作用域定义中的全局变量栈的方式,而是由于无法在解析阶段确定变量的值。这些指令是 witheval。它们为 ECMAScript 静态作用域带来的影响称为“运行时作用域扩大”。

看下面的例子:

  1. var x = 10;
  2. var o = {x: 30};
  3. var storage = {};
  4. (function foo(flag) {
  5. if (flag == 2) {
  6. eval("var x = 20;");
  7. }
  8. if (flag == 3) {
  9. storage = o;
  10. }
  11. with (storage) {
  12. // "x" 可以在全局作用域中求值 - 10,
  13. // 也可以在函数局部作用域中求值 - 20(通过"eval"函数),
  14. // 甚至在 "storage"对象中求值 - 30
  15. alert(x); // ? - "x" 的作用域在编译时决定
  16. }
  17. // 递归调用 3 次
  18. if (flag < 3) {
  19. foo(++flag);
  20. }
  21. })(1);

接下来我们会看到,静态作用域提高了效率,而 witheval 相比之下,可能会在实现层面降低词法环境存储和变量查找的性能。所以,with 语句被从 ES5 严格模式下 彻底删除。而 eval 函数在严格模式下 可能不会 在调用上下文中创建变量。也就是说,严格模式在 ES 下提供了完全词法作用域的环境。

本章后面,我们会只会讨论词法(静态)作用域以及其实现细节。不过在这之前,我们看简要了解下名称绑定,这在环境相关概念中会经常用的。

名称绑定

使用高度抽象的编程语言时,我们通常不会通过底层的地址来引用内存上的数据,而是通过给予合适的变量名(标志符),来映射相应的数据。

名称绑定就是标志符与对象的组合。

标志符可以是绑定的或未绑定的。如果标志符绑定到一个对象,那么它引用了这个对象。接下来通过使用标识符可以获得绑定到的对象。

绑定的概念涉及两个主要操作(常常在讨论传参数、赋值是传引用还是传值时让人困惑),分别是重绑定和修改。

重绑定

重绑定与标志符有关。这个操作将标志符从一个老对象解绑(如果之前已经绑定),然后绑定到另一个对象(也就是另一块存储区域)。通常(特别是在 ECMAScript 中)重绑定通过简单的赋值操作实现。

例如:

  1. // 将 "foo" 绑定到对象 {x: 10}
  2. var foo = {x: 10};
  3. console.log(foo.x); // 10
  4. // 绑定 "bar" 到相同的对象
  5. // 和标志符 "foo" 绑定对象相同
  6. var bar = foo;
  7. console.log(foo === bar); // true
  8. console.log(bar.x); // OK,也是 10
  9. // 重新绑定 "foo" 到新对象
  10. foo = {x: 20};
  11. console.log(foo.x); // 20
  12. // "bar" 仍旧指向老对象
  13. console.log(bar.x); // 10
  14. console.log(foo === bar); // false

重绑定常常与传引用赋值混淆。有人会觉得,通过向变量 foo 赋值新的对象,变量 bar 也应该指向新的对象。不过,我们看到,bar 仍旧指向老对象,而 foo 重绑定到了新的存储区域。下图显示了这两个动作:

图 2\. 重绑定

图 2. 重绑定

不要把绑定看作传引用,而是(以 C 的视角)传指针(或者传共享)操作。所以它也被称为是传值的特例,值为地址。赋值只是改变(重绑定)指针的值(地址),将一个变量赋值给另一个时,其实是拷贝了相同对象的地址给新的变量。这样两个标志符可以说是共享了一个对象,这也就是传共享的意思。

修改

和重绑定相比,修改操作也会影响对象的内容。

看下面的例子:

  1. // 将一个数组绑定到标志符 "foo"
  2. var foo = [1, 2, 3];
  3. // 这是对数组对象的修改
  4. foo.push(4);
  5. console.log(foo); // 1,2,3,4
  6. // 继续修改
  7. foo[4] = 5;
  8. foo[0] = 0;
  9. console.log(foo); // 0,2,3,4,5

代码执行如下图所示:

图 3\. 修改

图 3. 修改

可以在 第 8 章 求值策略 了解更多有关绑定和求值策略(传引用、传值、传共享)的信息。

现在我们可以讨论有关环境的细节了,来看下它们是如何构建的。

环境

这一节我们会提到有关词法作用域实现有关的技术。由于我们在高抽象层面操作并讨论词法作用域,后面我们主要使用术语环境而非作用域,因为这正是在 ES5 中使用的术语。例如,函数的全局环境、局部环境等等。

我们提到过,环境决定了表达式中标志符(符号)的含义。实际上,离开特定的环境信息,讨论类似 x + 1 这样的表达式的值毫无意义,因为环境提供了符号 x 的含义(甚至是符号 +,如果把它视为一个简单的加法函数的语法糖的话)。

ECMAScript 通过使用调用栈模型来管理函数的执行,这里称之为 执行上下文栈。我们来看一些基本的存储变量(绑定)的模型。有闭包和没闭包的系统。

激活记录模型

如果我们没有一等函数(例如,函数可以作为数据使用,后面会讨论到)或者压根不考虑内部函数,最简单的存储局部变量的方式是通过调用栈。

调用栈上的特殊数据结构 激活记录(activation record) 被用于存储环境中的绑定。有时候也被称为调用栈帧。

每次函数被激活时,它的激活记录(包括参数和局部变量)被压入调用栈。这样,当函数调用其他函数时(或者递归调用自身),栈上会被压入另一个栈帧。上下文完成时,激活记录被从栈上移除(出栈),此时所有局部变量被销毁。这种模型在诸如 C 语言中使用。

例如:

  1. void foo(int x) {
  2. int y = 20;
  3. bar(30);
  4. }
  5. void bar(x) {
  6. int z = 40;
  7. }
  8. foo(10);

然后调用栈进行如下修改:

  1. callStack = [];
  2. // "foo" 函数激活记录入栈
  3. callStack.push({
  4. x: 10,
  5. y: 20
  6. });
  7. // "bar" 函数激活记录入栈
  8. callStack.push({
  9. x: 30,
  10. z: 40
  11. });
  12. // "bar" 函数激活时的调用栈
  13. console.log(callStack); // [{x: 10, y: 20}, {x: 30, z: 40}]
  14. // "bar" 函数执行结束
  15. callStack.pop();
  16. // "foo" 函数执行结束
  17. callStack.pop();

下图中我们看到两条激活记录被入栈,也就是函数 bar 激活时的状态:

图 4\. 有激活记录的调用栈

图 4. 有激活记录的调用栈

ECMAScript 中也使用了相同的函数执行过程。不过,有些非常重要的不同。

首先,我们知道,调用栈表示执行上下文栈,激活记录对应(ES3)激活对象。

主要的区别是,与 C 不同,如果存在闭包,ECMAScript 不会从存储中移除激活对象。最重要的情况是,如果闭包是使用了外部函数的变量的内部函数,并且该内部函数被返回到了外部。

这意味激活对象不能存储在栈上,而是要放在堆(动态分配内存,有时候这样的编程语言被称为基于堆的语言,相比于基于栈的语言)上。并且它会被存储到不再有闭包使用激活对象上的变量为止。进一步讲,不仅激活对象被保存,如果有必要(在多层嵌套的情况下)所有父级激活对象都会被保存。

  1. var bar = (function foo() {
  2. var x = 10;
  3. var y = 20;
  4. return function bar() {
  5. return x + y;
  6. };
  7. })();
  8. bar(); // 30

下图显示了基于堆的激活记录的抽象表示。可以看到如果 foo 函数创建了一个闭包,那么执行结束后它的帧也不会从内存中移除,因为它在闭包中仍被引用。

图 5\. 基于堆的调用帧

图 5. 基于堆的调用帧

理论上对于这些激活对象使用的术语叫作环境帧(类比调用栈帧)。我们使用这个术语来强调实现上的不同,环境帧在没有闭包引用时继续存在。我们也用这个术语来强调高抽象概念(例如,相比关注底层的栈和地址结构,我们称之为环境),以及其实现机制,这已经是派生的问题了。

环境帧模型

如上所述,在 ECMAScript 中,相比 C,我们有内部函数和闭包。并且,所有函数在 ES 中都是一等的。让我们会想这种函数的定义,以及在函数式编程中的其他定义。我们会看到这些术语与词法环境模型有紧密的关系。

我们也会明白,闭包的问题(或者函数参数问题,后面会提到)就是词法环境的问题。这也是为什么我们在这一节中主要在说函数式编程的基本概念。

一等函数

一等函数可以作为数据,例如,在词法上的运行时被创建,作为参数传递,或者从另一个函数中作为值返回。

简单的例子:

  1. // 在运行时,动态创建一个函数表达式
  2. // 绑定到标志符 "foo"
  3. var foo = function () {
  4. console.log("foo");
  5. };
  6. // 将其传递给另一个函数,也是在运行时创建并
  7. // 在创建后立即被调用,然后传入的函数并绑定
  8. // 到标志符 "foo"
  9. foo = (function (funArg) {
  10. // 激活 "foo" 函数
  11. funArg(); // "foo"
  12. // 作为值返回
  13. return funArg;
  14. })(foo);

一等函数可以进一步细分。

函数参数和高级函数

当一个函数作为参数传递是,被称作“函数参数”(funarg),缩写自 functional argument。

接收函数参数的函数,被称作高阶函数(HOF),或者,像数学那样,称为算子。

返回另一个函数的函数,被称作函数值函数(值为函数的函数)。

有了这些概念,我们来看下所谓的“函数参数”问题。我们马上会看到,这个问题的解决方案正是闭包和词法环境。

在上面例子中,函数 foo 是一个函数参数,被传递给了一个匿名的高阶函数(接收函数参数 foo,作为参数 funArg)。这个匿名函数返回一个函数值,同时也是 foo 函数自身。而这些都被这个一等函数定义所包括。

自由变量

另一个与一等函数相关并且我们需要回顾的概念是:自由变量。

自由变量是有函数使用,但既不是参数也不是函数局部变量的变量。

换句话说,自由变量是没有在自身环境中,而是可能在外部环境中的变量。注意,自由变量同样支持绑定(例如,在某个外部环境中找到)和解绑定。后一种情况在 ECMAScript 中会触发 ReferenceError

来看这个例子:

  1. // 全局环境那(GE)
  2. var x = 10;
  3. function foo(y) {
  4. // 函数 "foo" 的环境(E1)
  5. var z = 30;
  6. function bar(q) {
  7. // 函数 "bar" 的环境(E2)
  8. return x + y + z + q;
  9. }
  10. // 返回 "bar" 到外部
  11. return bar;
  12. }
  13. var bar = foo(20);
  14. 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)。

  1. var x = 10;
  2. (function foo(y) {
  3. // z使用自由绑定的变量 "x"
  4. console.log(x);
  5. // 自身绑定的变量 "y"
  6. console.log(y); // 20
  7. // 自由未绑定的变量 "z"
  8. console.log(z); // ReferenceError: "z" is not defined
  9. })(20);

例如,回到作用域的概念,这些环境帧的序列(或者换个视角,环境组成链表)构成了我们称之为作用域链的东西。并不意外,ES3 就定义了这个术语:作用域链。

注意,一个环境可能是多个内部环境的包围环境:

  1. // 全局环境(GE)
  2. var x = 10;
  3. function foo() {
  4. // "foo" 环境(E1)
  5. var x = 20;
  6. var y = 30;
  7. console.log(x + y);
  8. }
  9. function bar() {
  10. // "bar" 环境(E2)
  11. var z = 40;
  12. console.log(x + z);
  13. }

伪代码:

  1. // 全局
  2. GE = {
  3. x: 10,
  4. outer: null
  5. };
  6. // foo
  7. E1 = {
  8. x: 20,
  9. y: 30,
  10. outer: GE
  11. };
  12. // bar
  13. E2 = {
  14. z: 40,
  15. outer: GE
  16. };

下图说明了这些关系:

图 7\. 一般的父环境帧

图 7. 一般的父环境帧

也就是说,环境 E1 中的绑定 x 覆盖了全局帧中的同名绑定。

函数创建和执行规则

综上我们有了关于创建和使用(调用)函数的一般规则:

函数相对于给定的环境创建。返回的函数对象由包含的代码和到函数创建的环境的指针组成。

代码:

  1. // 全局 "x"
  2. var x = 10;
  3. // 函数 "foo" 相对于全局环境创建
  4. function foo(y) {
  5. var z = 30;
  6. console.log(x + y + z);
  7. }

对应伪代码:

  1. // 创建函数 "foo"
  2. foo = functionObject {
  3. code: "console.log(x + y + z);"
  4. environment: {x: 10, outer: null}
  5. };

如下图所示:

图 8\. 一个函数

图 8. 一个函数

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

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

看应用:

  1. // 函数 "foo" 以参数 20 调用
  2. foo(20);

对应如下伪代码:

  1. // 用形参和局部变量创建新的帧
  2. fooFrame = {
  3. y: 20,
  4. z: 30,
  5. outer: foo.environment
  6. };
  7. // 执行函数 "foo" 的代码
  8. execute(foo.code, fooFrame); // 60

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

图 9\. 函数调用

图 9. 函数调用

结论的第一点直接引出了闭包的定义。

闭包

闭包由函数代码和函数创建时所在的环境组成。

上面提到,闭包是作为函数参数问题的解决方案引入的。让我们回想一下,以便加深理解。

函数参数问题

函数参数问题可以分为两个与作用域、环境、闭包有关的子问题。

首要的函数参数问题,是将内部函数返回到外部的复杂性,例如,如果函数使用了其创建时的父环境的自由变量,如果实现返回函数?

  1. (function (x) {
  2. return function (y) {
  3. return x + y;
  4. };
  5. })(10)(20); // 30

我们知道,在堆上保存包含帧的词法作用域,是回答的关键。在栈上保存绑定的策略(C 中使用)不再合适。再说一遍,这里保存了代码块和环境,也就是闭包。

接下来的函数参数问题,是关于将函数作为参数传给另一个函数时,其使用的自由变量的变量名如何解析。在哪个作用域中对这些自由变量进行求值,是函数定义的作用域,还是函数执行时的作用域?

  1. var x = 10;
  2. (function (funArg) {
  3. var x = 20;
  4. funArg(); // 10,而不是 20
  5. })(function () { // 创建并传递一个函数
  6. console.log(x);
  7. });

也就是说,这个问题与本章开始时讨论的静态(词法)还是动态作用域的选择有关。我们知道,词法(静态)作用域就是答案。我们要保存完整的词法变量以避免歧义。并且,这里保存的词法变量和函数代码,就是我们称作的闭包。

所以我们最终的答案是什么?一等函数、闭包和词法环境是紧密相关的。而词法环境正是用于实现闭包和静态作用域的技术。

这里我们提到,ECMAScript 正是使用了环境帧的模型。不过,要结合我们将在其他节讨论的 ES 术语。

有关闭包的细节可以参考 ES3 系统文章的 第 6 章 闭包

为全面理解,我们也介绍下再其他语言中的其他环境实现。

组合的环境帧模型

记住,为了深入理解一些具体的技术(例如,ECMAScript),我们需要首先理解通用理论的机制,以及其他语言如何实现这些技术。我们会看到这些一般的机制是如何在许多相似的语言中显而易见的。不过,不同语言的实现也会有区别。这一节我们关注像 Python、Ruby 和 Lua 这样的语言的环境。

保存所有自由变量的另一种方式是创建一个大的环境帧来包含所有,不过是从不同包围环境中收集的必要的自由变量。

显然,如果一些变量对于内部函数不需要,那就不必保存它们。看这个例子:

  1. // 全局环境
  2. var x = 10;
  3. var y = 20;
  4. function foo(z) {
  5. // 函数 "foo" 的环境
  6. var q = 40;
  7. function bar() {
  8. // 函数 "bar" 的环境
  9. return x + z;
  10. }
  11. return bar;
  12. }
  13. // 创建 "bar"
  14. var bar = foo(30);
  15. // 创建 "bar"
  16. bar();

可以看到没有函数使用全局变量 y。所以,不必在 foo 的闭包或 bar 的闭包中包含它。

全局变量 x 没有在 foo 中使用,但是我们之前提到过,需要将其保存到 foo 的闭包,因为内部的函数 bar 在其创建环境中获得 x 的信息(也就是函数 foo 的环境)。

函数 foo 中的变量 q 有和全局变量 y 类似的情况,没有被使用,所以所以不需要在 bar 的闭包中保存。变量 z 则被保存在 bar 中。

这样,我们有了一个保存了所有需要的自由变量的函数 bar 的单个环境帧。

  1. bar = closure {
  2. code: <...>,
  3. environment: {
  4. x: 10,
  5. z: 30,
  6. }
  7. }

类似的模型用于 Python 编程语言。由一个保存的环境帧的函数被简单称作 __closure__(反映了词法环境的本质)。全局变量不包含在这个帧中,因为它们始终可以在全局帧中被找到。未被使用的变量也没有包含在 __closure__ 中。来看这个例子:

  1. # Python 环境示例
  2. # 全局 "x"
  3. x = 10
  4. # 全局函数 "foo"
  5. def foo(y):
  6. # 局部 "z"
  7. z = 40
  8. # 局部函数 "bar"
  9. def bar():
  10. return x + y
  11. return bar
  12. # 创建 "bar"
  13. bar = foo(20)
  14. # 执行 "bar"
  15. bar() # 30
  16. # 函数 "bar" 保存的环境;
  17. # 存储在其 __closure__ 属性中;
  18. #
  19. # 只包含 {"y": 20};
  20. # "x" 不在 __closure__ 中,因为它
  21. # 始终可以在全局被找到;
  22. # "z" 也没有被保存,因为没有用到
  23. barEnvironment = bar.__closure__
  24. print(barEnvironment) # 闭包单元组成的元组
  25. internalY = barEnvironment[0].cell_contents
  26. print(internalY) # 20, "y"

注意,即使在使用 eval 的情况下也不会保存未使用的变量,如果不能确定一个变量是否会在上下文被使用。在下面例子中,内部的 baz 函数捕获了自由变量 x,而函数 bar 没有:

  1. def foo(x):
  2. def bar(y):
  3. print(eval(k))
  4. def baz(y):
  5. z = x
  6. print(eval(k))
  7. return [bar, baz]
  8. # 创建函数 "bar" 和 "baz"
  9. [bar, baz] = foo(10)
  10. # "bar" 不包含任何闭包数据
  11. print(bar.__closure__) # None
  12. # "baz" 包含变量 "x"
  13. print(baz.__closure__) # closure cells {'x': 10}
  14. k = "y"
  15. baz(20) # OK,20
  16. bar(20) # OK,20
  17. k = "x"
  18. baz(20) # OK,10 - "x"
  19. bar(20), # 错误,"x" 未定义

再次对比 ECMAScript,使用环境帧链,处理这个情况:

  1. function foo(x) {
  2. function bar(y) {
  3. console.log(eval(k));
  4. }
  5. return bar;
  6. }
  7. // 创建 "bar"
  8. var bar = foo(10);
  9. var k = "y";
  10. // 执行 bar
  11. bar(20); // OK,20 - "y"
  12. k = "x";
  13. bar(20); // OK,10 - "x"

更多有关 Python 中闭包可以参考 这个有关 Python 的代码

也就是说,主要区别在于,链式的环境帧模型(ECMAScript 使用)优化了函数创建的性能,不过在解析标志符时,可能要遍历整个作用域链(指定特定绑定被找到,或者触发 ReferenceError)。

也就是说,单环境帧模型优化了执行过程(所有标志符都在最近的单个帧中进行查找,不必访问作用域链),不过需要更复杂的算法,以便在函数创建时解析内部函数以绝对哪些变量需要被保存。

不过需要注意,这个结论只针对 ECMA-262-5 规范。实际上,ES 引擎可以轻易优化 ECMAScript 的实现,只保存必要的变量。我们会在 3.2 节讨论 ECMAScript 的实现。

另外要注意,严格来说,组合的帧可能不是单个。意思是说,组合的帧被优化以保护多个父帧的绑定,不过环境中可能会包含一些额外的帧。Python 也是一样,执行时函数只有一个自己的帧、一个 __closure__ 帧和一个全局帧。

Ruby 语言也采用了单个帧,捕获所有在闭包创建时存在的变量。下面例子中,Ruby 变量 x 被第二个闭包捕获,但第一个没有捕获:

  1. # Ruby lambda 闭包示例
  2. # 闭包 "foo",包含自由变量 "x"
  3. foo = lambda {
  4. print x
  5. }
  6. # 定义 "x"
  7. x = 10
  8. # 第二个闭包 "bar" 有相同的函数体
  9. # 也引用了自由变量 "x"
  10. bar = lambda {
  11. print x
  12. }
  13. bar.call # OK,10
  14. foo.call # 错误,"x" 未定义

上面提到,Ruby 保存所有存在的变量,不过这个例子使用了 eval 来对未使用变量进行求值(与 Python 相比,和 ES 相同):

  1. k = "y"
  2. foo = lambda { |x|
  3. lambda { |y|
  4. eval(k)
  5. }
  6. }
  7. # 创建 "bar"
  8. bar = foo.call(10)
  9. print(bar.call(20)) # OK,20 - "y"
  10. k = "x"
  11. print(bar.call(20)) # OK, 10 - "x"

有些编程语言,例如 Lua(也采用单个环境帧)允许在函数运行时动态设置所需的环境。来看 Lua 的示例:

  1. -- Lua 环境示例:
  2. -- 全局 "x"
  3. x = 10
  4. -- 全局函数 "foo"
  5. function foo(y)
  6. -- 局部变量 "q"
  7. local q = 40
  8. -- 获取 "foo" 的环境
  9. fooEnvironment = getfenv(foo)
  10. -- {x = 10, globals...}
  11. print(fooEnvironment) -- table
  12. -- "x" "y" 可以被获取,
  13. -- 因为 "x" 在环境中,而
  14. -- "y" 是局部变量(参数)
  15. print(x + y) -- 30
  16. -- 现在改变 "foo" 的环境
  17. setfenv(foo, {
  18. -- 引用 "print" 函数,
  19. -- 给出另外的名称
  20. printValue = print,
  21. -- 重用 "x"
  22. x = x,
  23. -- 定义一个新的绑定 "z"
  24. -- 值为 "y"
  25. z = y
  26. })
  27. -- 使用新的绑定
  28. printValue(x) -- OK10
  29. printValue(x + z) -- OK30
  30. -- 局部变量仍然可以访问
  31. printValue(y, q) -- 2040
  32. -- 不过其他的名称
  33. printValue(print) -- nil
  34. print("test") -- 错误,"print" 名称是 nil,不能调用
  35. end
  36. foo(20)

结论

现在我们完成了通用理论。下一节 3.2 将会关注 ECMAScript 的实现。我们会讨论诸如环境记录的结构(对应我们这里讨论的环境帧),以及它们的不同类型:声明环境记录和对象环境记录,还会看到该结构在 ES5 中包含执行上下文,并且对于不同类型的函数有不同的类型对应,就我们所知,是函数表达式和函数声明。

这一章我们讨论了:

  • 环境的概念对应作用域的概念。

  • 理论上有两种作用域:动态和静态。

  • ECMAScript 使用静态(词法)作用域。

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

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

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

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

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

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


译文原文:https://www.zcfy.cc/article/ecma-262-5-in-detail-chapter-3-1-lexical-environments-common-theory-ds-laboratory

版权声明:本站文章除特别声明外,均采用署名-非商业性使用-禁止演绎 4.0 国际 许可协议,如需转载,请注明出处
  • ECMA-262-5 详解 - 3.1 词法环境:通用理论 – ds.laboratory

    这一节,我们会讨论词法环境的细节,它是在一些编程语言中用于管理静态作用域的一种机制。为了确保能充分理解这一主题,我们会简要讨论下其对立面:动态作用域(并没有直接用于 ECMAScript)。我们会看到环境是如何管理代码中的词法嵌套结构,以及为闭包提供全面支持。

    发布:2018-11-03 阅读(2458)

  • ECMA-262-5详述 第一章. 属性和属性描述符

    这一章专门讨论了ECMA-262-5 规范的新概念之一 — 属性特性及其处理机制 — 属性描述符。 当我们说“一个对象有一些属性”的时候,通常指的是属性名和属性值之间的关联关系。但是,正如在ES3系列文章中分析的那样,一个属性不仅仅是一个字符串名,它还包括一系列特性—比如我们在ES3系列文章中已经讨论过的{ReadOnly},{DontEnum}等。因此从这个观点来看,一个属性本身就是一个对象

    发布:2018-10-26 阅读(1760)