Lexical environments: Common Theory

原文

ECMA-262-5 in detail. Chapter 3.1. Lexical environments: Common Theory.

简介

在这一章,我们将讨论词法环境的细节——一种被很多语言应用管理静态作用域的机制。为了能够更好地理解这个概念,我们也会讨论一些别的——动态作用域(ECMAScript中并没有直接使用)。我们将看到环境是如何管理嵌套的代码结构和闭包。ECMA-262-5规范介绍了词法环境,尽管这是一个和ECMAScript相独立的概念,被应用于很多函数。实际上,部分和这个话题相关的技术部分,我们已经在之前的ES3系列中讨论过了,例如变量和激活对象,作用域链。严格地来说,词法环境相比ES3中的概念只是更加理论化,更加抽象。但这是一个ES5的年代,我建议用这些新的定义来讨论和解释ECMAScript。尽管,更加普遍的概念,例如激活记录(activation record)(ES3中的激活对象)的调用栈(call-stack)(ES中执行环境栈),等等,已经在低级抽象的层面上讨论过了。这一章节致力于环境的一般理论,也会涉及程序语言理论(programming languages theory)的部分。我们将从不同的角度,用不同的语言实现,来理解为什么词法作用域是需要的,以及这些结构是如何被创建的。事实上,如果我们完全理解来作用域的一般理论,那些ES中的作用域问题也就消失了。

一般理论

ES中的概念(激活对象,作用域链,词法环境)都与作用域的概念相关。ES中提到的定义是作用域的一种本地实现,及相关术语。

作用域

作用域是用来在程序的不同部分中管理变量的可见行和可访问性。一些
封装的抽象概念(例如命名空间,模块)都和作用域相关,作用域被用来使系统更加模块化以及避免命名变量的冲突。函数有本地变量,代码块有本地变量,作用域封装了内在的数据,提升了抽象程度。作用域使我们在一个程序中使用相同的变量,但是代表不同的含义,拥有不同的值。从这个角度来说,作用域是个闭合的上下文,里面的变量都与值相关联。我们也可以说,作用域是某个变量它某个含义的逻辑边界。例如,全局变量,局部变量等,都反映了这个变量的声明周期。代码块和函数让我们拥有了一个主要的作用域的属性——嵌套其他作用域或者被嵌套。因此,我们可以看到并不是所有的实现都支持函数嵌套,同样不是所有的实现都提供块级作用域。让我们来考虑以下的C代码:

// global "x"
int x = 10;
 
void foo() {
   
  // local "x" of "foo" function
  int x = 20;
 
  if (true) {
    // local "x" of if-block
    int x = 30;
    printf("%d", x); // 30
  }
 
  printf("%d", x); // 20
 
}
 
foo();
 
printf("%d", x); // 10

它可以被下图表示

《Lexical environments: Common Theory》

ECMAScript在版本6之前并不支持块级作用域

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

这个块级作用域可以通过匿名自调用函数来实现

var x = 10;
 
if (true) {
  (function (x) {
    console.log(x); // 20
  })(20);
}
 
console.log(x); // 10

静态(词法)作用域

在静态作用域中,标识符指向最近的词法环境。单词“lexical”在这个场合下指的是程序书写的属性,词法上变量出现的源文字,变量被声明的地方。在那个作用域中,变量将会在运行时被解析。单词“static”意味着决定标识符作用域是在程序的词法分析(parsing)的过程中。这也就是说,在程序启动之前,我们通过阅读代码,就能判断在哪个作用域下,变量将被解析。举个例子

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在全局变量中被定义——意味着,运行时,它也将在全局对象中被解析。变量y有两个定义,我们说过,考虑拥有变量最近的词法作用域。变量自身所在的作用域拥有最高的优先级。因此,在bar函数中,变量y被解析为30。bar函数中局部变量y覆盖来同名的全局变量y。但是,同名变量y在foo函数中依然被解析为20,即使它在bar函数的内部被调用,而且在bar函数内部还有变量y。变量的解析和环境的调用是相互独立的(in this case bar is a caller of foo, and foo is a callee)。因为foo函数被定义的位置,最近的含有变量y的词法环境就是全局环境。如今,静态作用域已经被很多语言应用:C, Java, ECMAScript, Python, Ruby, Lua等等。

动态作用域

动态作用域并不在词法环境中解析变量,而是动态形成变量栈。每当碰到变量声明,就把变量放进栈中。变量的声明周期结束时,将变量从栈中弹出。来看一段伪代码。

// *pseudo* code - with dynamic scope
 
y = 20;
 
procedure foo()
  print(y)
end
 
 
// on the stack of the "y" name
// currently only one value 20
// {y: [20]}
 
foo() // 20, OK
 
procedure bar()
 
  // and now on the stack there
  // are two "y" values: {y: [20, 30]};
  // the first found (from the top) is taken
 
  y = 30
 
  // therefore:
  foo() // 30!, not 20
 
end
 
bar()

环境的调用影响了变量的解析。[译者注:不是重点不译了]

名称绑定

在高级语言中,我们不在操作地址,这个地址指向内存中的数据,我们直接使用变量名来指代那些数据。名称绑定是标识符和对象的关联。一个标识符可以绑定或解绑。如果标识符被绑定了个对象,那么它就指向这个对象。

重新绑定

// bind “foo” to {x: 10} object
var foo = {x: 10};

console.log(foo.x); // 10

// bind “bar” to the same object
// as “foo” identifier is bound

var bar = foo;

console.log(foo === bar); // true
console.log(bar.x); // OK, also 10

// and now rebind “foo”
// to the new object

foo = {x: 20};

console.log(foo.x); // 20

// and “bar” still points
// to the old object

console.log(bar.x); // 10
console.log(foo === bar); // false

《Lexical environments: Common Theory》

可变性

// bind an array to the "foo" identifier
var foo = [1, 2, 3];
 
// and here is a *mutation* of
// the array object contents
foo.push(4);
 
console.log(foo); // 1,2,3,4
 
// also mutations
foo[4] = 5;
foo[0] = 0;
 
console.log(foo); // 0,2,3,4,5

《Lexical environments: Common Theory》

环境

在这一部分,我们将提到词法作用域实现的技术。我们将操作更抽象的实体,讨论词法作用域,在以后的解释中,我们将用环境而不是作用域,因为ES5中也是这个术语,全局环境,函数的本地环境等等。正如我们提到的,环境说明了表达式中标识符的含义。ECMAScript用调用栈(call-stack)来管理函数的执行。来考虑一些通用的模型来保存变量。一些事情很有趣,有闭包的系统和没有闭包的系统。

激活记录模型

如果没有一等函数,或者不允许内部函数,最简单存储本地变量的方式就是调用栈本身。一个特殊的调用栈的数据结构叫做激活记录(activation record),被用来保存环境绑定。有时候也叫调用栈帧(call-stack frame)。每当函数被调用,一个激活记录(包含参数和本地变量)被压入栈中。因此,当函数调用其他函数,另一个栈帧被压入栈中。当上下文结束来,激活记录从栈中弹出,意味这所有本地变量被销毁。这个模型在c语言中被使用。
例如

void foo(int x) {
  int y = 20;
  bar(30);
}
 
void bar(x) {
  int z = 40;
}
 
foo(10);

调用栈会有如下变化

callStack = [];
 
// "foo" function activation
// record is pushed onto the stack
 
callStack.push({
  x: 10,
  y: 20
});
 
// "bar" function activation
// record is pushed onto the stack
 
callStack.push({
  x: 30,
  z: 40
});
 
// callStack at the moment of
// the "bar" activation
 
console.log(callStack); // [{x: 10, y: 20}, {x: 30, z: 40}]
 
// "bar" function ends
callStack.pop();
 
// "foo" function ends
callStack.pop();

当bar函数被调用时

《Lexical environments: Common Theory》

很多相似的函数执行的逻辑方法在ECMAScript被使用。然后,有些很重要的不同。调用栈意味着ES中的执行环境栈,激活记录意味着ES3的激活对象。和C不同的是,ECMAScript不会从内存中移除激活对象如果有个闭包。当这个闭包是个内部函数,是用来外部函数中创建的变量,然后这个内部函数被返回到了外面。这就意味着激活对象不应该存在栈中,而是堆中(动态分配内存)。它会一直被保存,当闭包的引用使用激活对象中的变量。更重要的是,不仅是一个激活对象被保存,如果需要,所有的父级的激活对象。

var bar = (function foo() {
  var x = 10;
  var y = 20;
  return function bar() {
    return x + y;
  };
})();
 
bar(); // 30

《Lexical environments: Common Theory》

如果foo函数创建了一个闭包,即使foo执行结束了,它的帧不会从内存中移除,因为闭包中有它的引用。

环境帧模型

和c不同,ECMAScript含有内部函数和闭包。此外,所有的函数是一等公民。

一等函数

一等函数被当作普通的对象,可以被作为参数,可以作为返回值。一个简单的例子

// create a function expression
// dynamically at runtime and
// bind it to "foo" identifier
 
var foo = function () {
  console.log("foo");
};
 
// pass it to another function,
// which in turn is also created
// at runtime and called immediately
// right after the creation; result
// of this function is again bound
// to the "foo" identifier
 
foo = (function (funArg) {
 
  // activate the "foo" function
  funArg(); // "foo"
 
  // and return it back as a value
  return funArg;
 
})(foo);

函数参数和高阶函数

当一个函数被作为参数,称之为“funarg”– functional argument的缩写。将函数作为参数的函数称为高阶函数(higher-order function),和数学上的算子概念相似。

自由变量

自由变量是函数中使用的变量,既不是函数的参数,也不是函数的本地变量。换句话说,自由变量并不存在自身的环境中,而是周围的环境中。

// Global environment (GE)
 
var x = 10;
 
function foo(y) {
 
  // environment of "foo" function (E1)
 
  var z = 30;
 
  function bar(q) {
    // environment of "bar" function (E2)
    return x + y + z + q;
  }
 
  // return "bar" to the outside
  return bar;
 
}
 
var bar = foo(20);
 
bar(40); // 100

在这个例子中,我们有三个环境:GE,E1和E2,分别对应于全局对象,foo函数和bar函数。因此,对于bar函数来说,变量x,y,z是自由变量,它们既不是函数参数,也不是bar的本地变量。值得注意的是,foo函数并没有用到自由变量。但是变量x在内部的bar函数中被使用,另外在foo函数运行的过程中创建bar函数,尽管如此,还是保存了父环境的绑定,为了能够将x的绑定传递给内部嵌套的函数。正确的并期望出现100,当执行bar函数后,这意味着bar函数记住了foo函数激活时的环境,即使foo函数已经结束了。这就是与基于栈的激活记录模型的不同。当我们允许内部函数,同时希望静态词法作用域,同时将函数作为一等公民,我们应该保存函数需要的所有自由变量,当函数被创建的时候。

环境定义

最直接最简单的方法去实现这样的算法是保存完整的父环境,在这个父环境中,函数被创建。然后,在函数自己执行时,我们创建自己的环境,保存自己的本地变量和参数,然后设置我们的外部环境为之前保存的那个,为了能够在那找到自由变量。我们用术语环境指代单独绑定对象,或者所有的绑定对象依据嵌套的深度。在后面的情形中,我们将绑定对象称为环境帧。一个环境是一些列帧,每个帧是个记录绑定,将变量名和值关联起来。我们用抽象的概念记录,而没有具体说明它的实现结构,它可能是堆中的哈希表,栈内存,虚拟机注册(registers of the virtual machine)等。例如,例子中,环境E2有三个帧:自己的bar,foo的和全局的。环境E1有两个帧:foo自己的,和全局的。全局环境GE只有一个帧:全局。

《Lexical environments: Common Theory》

一个帧中任何变量至多只有一个绑定。每个帧都有个指针,指向围绕它的环境。全局帧的外部环境的链接是null。变量相对于环境的值是在包含该变量的绑定的环境中的第一帧中的变量的绑定给出的值(The value of a variable with respect to an environment is the value given by the binding of the variable in the first frame in the environment that contains a binding for that variable)(源自谷歌翻译)。

var x = 10;
 
(function foo(y) {
   
  // use of free-bound "x" variable
  console.log(x);
 
  // own-bound "y" variable
  console.log(y); // 20
   
  // and free-unbound variable "z"
  console.log(z); // ReferenceError: "z" is not defined
 
})(20);

一系列的环境帧形成了我们所称的作用域链。一个环境可能会包裹多个内部环境。

// Global environment (GE)
 
var x = 10;
 
function foo() {
 
  // "foo" environment (E1)
 
  var x = 20;
  var y = 30;
 
  console.log(x + y);
 
}
 
function bar() {
   
  // "bar" environment (E2)
 
  var z = 40;
 
  console.log(x + z);
}

伪代码

// global
GE = {
  x: 10,
  outer: null
};
 
// foo
E1 = {
  x: 20,
  y: 30,
  outer: GE
};
 
// bar
E2 = {
  z: 40,
  outer: GE
};

《Lexical environments: Common Theory》

变量x相对于环境E1的绑定遮蔽了同名变量在全局环境中的绑定。

函数创建和应用法则

一个函数是相对于给定的环境创建的。这导致函数对象是由函数本身的代码(函数体)和指向创建函数本身的环境的指针构成。

// global "x"
var x = 10;
 
// function "foo" is created relatively
// to the global environment
 
function foo(y) {
  var z = 30;
  console.log(x + y + z);
}

相当于伪代码

// create "foo" function
 
foo = functionObject {
  code: "console.log(x + y + z);"
  environment: {x: 10, outer: null}
};

《Lexical environments: Common Theory》

注意,函数指向它的环境,其中一个环境与函数自身相绑定。一个函数被调用,一系列的参数构成了新的帧,在这个帧中绑定了本地变量,然后在创建的新的环境中执行函数体。

// function "foo" is applied
// to the argument 20
 
foo(20);

与之对应的伪代码

// create a new frame with formal 
// parameters and local variables
 
fooFrame = {
  y: 20,
  z: 30,
  outer: foo.environment
};
 
// and evaluate the code
// of the "foo" function 
 
execute(foo.code, fooFrame); // 60

《Lexical environments: Common Theory》

闭包

闭包是由函数代码和创建函数的环境构成的。闭包被发明用来解决函数参数的问题。

函数参数问题

当返回一个函数到外面,这个函数使用了创建它的父环境中的自由变量怎么办?

(function (x) {
  return function (y) {
    return x + y;
  };
})(10)(20); // 30

正如我们所知,词法作用域在堆中保存封闭的帧。这是问题的关键。像c一样用栈来保存绑定是不可行的。被保存的代码块和环境就是个闭包。当我们把函数作为参数传递到其他函数中,这个函数参数中的自由变量是如何被解析的,是在函数定义的作用域中,还是函数执行的作用域中?

var x = 10;
 
(function (funArg) {
 
  var x = 20;
  funArg(); // 10, not 20
 
})(function () { // create and pass a funarg
  console.log(x);
});

回答这个问题的关键就是词法作用域。

组合环境帧模型

很明显,如果一些变量不被内部函数需要,就没有必要保存它们。

// global environment
 
var x = 10;
var y = 20;
 
function foo(z) {
 
  // environment of "foo" function
  var q = 40;
 
  function bar() {
    // environment of "bar" function
    return x + z;
  }
 
  return bar;
 
}
 
// creation of "bar"
var bar = foo(30);
 
// applying of "bar"
bar();

没有函数使用变量y,因此,我们不需要在foo和bar的闭包中保存它。全局变量x没有在函数foo中使用,但是我们仍应该保存它,因为更深的内部函数bar需要它。

bar = closure {
  code: <...>,
  environment: {
    x: 10,
    z: 30,
  }
}

[译者注:后面是python等其他语言闭包的例子,不译了]

    原文作者:nbb3210
    原文地址: https://segmentfault.com/a/1190000011918048
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞