您的位置:首页 > 其它

ECMA-262-5 词法环境:通用理论(四)--- 环境

2017-12-05 18:07 253 查看

环境

   这一章我们将介绍词法作用域的技术实现。同时,在进一步介绍涉及到的一些高度抽象的实体和讲解词法作用域的时候,本文将开始使用环境的概念而不是作用域。”环境”是在ES5时期被引入规范中,与它一起的还包括全局环境和函数的局部环境等。

   之前提过的,环境(老版的作用域)的作用是使一个位于表达式中的标识符具有意义。举个例子,对于表达式“x+1”,如果没有任何这个表示式的环境信息,那么就无法知道符号x所代表的含义,进而对这个表达式的求值也就失去了意义。(甚至在某些情况下,这个例子还需要“+”在环境中所代表的含义)

   ECMAScript 通过调用栈的模式来管理函数的执行,又叫执行上下文(execution context stack)。下面将介绍一些储存变量的一般模式,同时,还有个有趣的地方,那就是拥有闭包的程序和没有闭包的实现的差异。

活动记录模型(Activation record model)

   回到没有一类函数(比如函数可以被作为参数传递)或者还不允许内敛函数的时候,储存局部变量最简单的方式就是通过调用栈本身,从定义角度来说,调用栈是一个方法列表,按调用顺序保存所有在运行期被调用的方法。。

   调用栈中有一个特殊的数据结构叫做活动记录,它被用来储存环境绑定(environment bindings)的信息(包括参数、局部变量和返回地址),也是编译器用来实现过程/函数调用的一种数据结构。它的另一个名字是栈帧。

   每次函数被激活,它的活动记录就会被push到调用栈。若在函数内部调用另一个函数(或者递归的调用它自己),那另一个函数代表的栈帧也会被push到调用栈中。当函数返回时,这些活动记录就会从调用栈中被pop移除,同时函数的所有局部变量也被销毁。这种模式也是诸如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被激活的时候,两个活动记录push进调用栈的情况。



   ECMAScript中也采用了相似的逻辑实现,用于管理函数执行。但和上面所说的还有有一些很重要的不同点。

   首先对于上面提到的概念:调用栈可以认为是执行上下文(栈),活动记录就是激活对象(ES3中)。

  从技术角度来看,二者的本质是一样的。最主要的区别在于:当存在闭包时,ECMAScript在函数结束后不会将活动对象从内存中移除,而C实现则会。一个最常见的例子就是,使用自由变量的内部函数,被父函数作为返回值返回,从而形成了闭包。

  这意味着在ECMAScript中,活动记录不应该被存储在栈上(因为栈数据机构的特性),而应该通过动态内存分配的方式存入堆中。而那些活动记录会一直在堆中,等待那些来自闭包中的引用。此外,不仅仅只会储存一个父级的活动对象,在需要的情况下,对于那些嵌套的内联结构,所有的父级活动对象都要被储存起来。

  我们来看一例子:

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

bar(); // 30


  下面这张图从抽象层面展示了基于堆的活动记录是如何表示的。在函数foo中创建了闭包,当它返回时,它的栈帧并未从内存中移除,这就是因为闭包中还存留着对它的引用。



   对此定义了一个专门的术语—环境帧(environment frames),用来描述上面这种对活动对象的处理行为(类比与栈帧)。这个术语主要是用来区别它和栈帧的两种不同实现,当存在闭包的时候是否会将活动对象从内存中移除。同时,使用这个术语也让我们无须去关心底层的栈和地址结构,我们把这些都统称为环境。下面,就将介绍环境帧和环境是如何实现的。

环境栈模型

  上面提到过,与C相比,ECMAScript中拥有闭包和内联函数。此外,ECMAScript中的所有函数都是第一类函数。

  下面先来回顾下一类函数以及一些关于函数式编程的定义。我们将会发现,这些概念与词法环境模型有着很多的相似的地方。同时,我们也将清楚的介绍,闭包是解决的是函数式参数(funarg problem)存在的问题,而闭包是通过词法环境实现的。这也是为什么我们会花费这些篇幅来介绍函数相关的概念。

一类函数

   第一类函数是指函数可以像普通数据一样赋值给其他变量或者结构,可以在运行时被动态的创建,可以作为参数传递,也可以作为返回值从其他函数中返回。

  一个简单的例子:

// 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”,funarg这个词是functional

argument的缩写。

反过来,一个使用funarg的函数被称为高阶函数-higher-order

function (HOF),或者偏数理的叫法,称为操作符函数。

若一个函数用函数作为返回值,

它被称为带函数值的函数,或者值函数( function-valued function)。

  了解了上面的几个概念后,下面将介绍一个和函数式参数相关的问题,而关于这个问题的解决方法就是闭包和词法环境。

   最后,在上面这个例子中,foo函数就是一个传入匿名高阶函数的函数式参数,并且这个匿名函数将函数foo作为返回值。这些函数都能在一类函数的定义中找到对应的类别。

自由变量

  还有一个与一类函数息息相关的概念—自由变量,这将在这一部分介绍它。

  先给出定义:

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

  换句说话,自由变量就是本函数中使用,但声明不在当前函数的局部环境中,而是可能在其他环境(一般指函数的上层环境)中。

// 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函数。

根据自由变量的定义,变量x,y,z都是函数bar的自由变量,因为它们既不是函数参数,也是函数内局部变量。

   注意,虽然函数foo本身没有使用自由变量,但函数bar内部使用了,并且函数bar是在函数foo的执行期间被创建的。因此,函数foo会保存上层环境的绑定信息,为了让下层的内嵌函数正常访问到关于x的信息(这里是bar)。

   最后,在执行bar(40)的时候得到了我们想要的值100,这意味着bar能够通过某种方式记住函数foo的活动记录,即使此时的foo已经结束了。再最后强调一次,这也是ECMAScript与C这种完全基于栈的活动记录模型的不同之处。

   很显然,对于所有内部的嵌套函数,当它们不光是一类函数,同时也要遵循词法作用域的实现。那么我们就必须把这些函数访问的所有自由变量在(上层)函数创建的时候全部储存起来。

环境定义

  最简单直接实现这个算法的方法,就是把(内部函数的)父函数的完整环境信息保存下来。之后,当内部函数激活(这里面的bar函数),它首先去创建自己的环境,包含函数参数和它的内部变量,再去建立一个外部环境的属性,将刚才保存起来的(父函数环境或全局环境)赋值给这个属性。通过这 种方式可以在需要的时候找到自由变量。

  对于环境而言,它不仅能绑定一个单独的对象(激活对象,亦活动记录),也能绑定在嵌套结构上所有的对象。在下面会将这些绑定在环境上的对象为环境帧( frames of the environment)。从这个角度看:

环境是一组帧序列( sequence of frames),每一帧都是一条绑定的活动记录(可以为空),里面记录了变量的名字和它绑定的值。

  在上面用活动记录这种抽象的概念给出了环境的通用定义,在定义内容中并没有给出具体的实现结构,它可以一个堆中的哈希表,或者一个栈存储器,甚至是虚拟机的寄存器。

  以上面的代码为例,环境E2有三个(环境)帧,自身的bar,foo以及全局的global。环境E1包含两帧:foo(自身的)以及全局帧global。全局环境GE只包含一帧,也就是它自己,全局帧。



  一个最简单的帧中可能至多包含一个变量的绑定。每一个帧中都会有一个指针,指向其外部的环境(enclosing environment.)。对于全局帧来说,它指向的外部环境是null。如果有两个相同名字的变量存在于不同的环境帧中,那么会从当前帧从内到外向上查询,处于下面的会优先被找到。如果在整个帧序列中没有找到变量的绑定,就意味着这个变量没有绑定在环境中(访问会出现ReferenceError)。

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);


   再回到作用域的概念,环境的帧序列(从另外一个角度看,也可以叫做链式列表)在某些时候也被叫做一条作用域的链。而在ES3中,的确用这种方式称呼它,也就是很多人熟悉的作用域链。

注意,一个环境可以同时被几个内部环境当作它们的外层环境。看下面的例子。

// 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
};


  或者我们可以通过一张图表示:



  此外,可以看到,在环境E1对x的绑定会覆盖全局帧中的x。

函数创建和执行的规则

  在这一部分将介绍创建和调用函数的一般规则:

  函数是创建在一个给定的环境中(这个环境是确定的,存在的)。函数的创建会得到一个函数对象,里面包含两个属性,分别是代表函数体的code和一个指向函数创建时所在环境的指针。

  看下面这段代码:

// 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}
};


  再看下面这张图,记录了函数对象的内容



  注意,函数中保留对创建它的环境的引用,而这个环境也会有一个指向这个函数的反应引用。

  在传入参数调用函数的时候,会产生新的一帧(即活动记录或AO),上面会包含函数的局部变量和传入的参数。然后在这个新产生帧的上下文中执行函数的代码。新产生的帧中也会包含函数的外层环境。

  调用上面的函数:

// 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


  再看下面这张图:



  下面我们将开始介绍闭包

闭包

  先来看看闭包定义:

闭包是由函数代码和函数创建时所在环境的结合。

  在上文中提到过,闭包最初发明是为了解决函数式参数- “funarg problem” 的问题,下面来看它的具体细节。

函数式参数问题

  函数式参数问题,即funarg问题,分为两类,并且都与作用域,环境和闭包有着直接的关系。

  首先我们看第一类,一个内部函数作为返回值从它的父函数被返回。当这个内部函数使用了来自创建它的父函数的自由变量,在父函数运行结束后(父函数的环境被销毁),这个返回的内部函数该如何继续访问那些父函数中的自由变量。

  看下面例子:
(function (x) {
return function (y) {
return x + y;
};
})(10)(20); // 30


  这一点我们已经知道了,在词法作用域中,会将函数的的上层环境帧存在堆中。这就是这个问题的答案。而这种策略对于那些使用栈来储存绑定信息的c语言,就无法做到。

  第二类就是一个使用自由变量的函数被作为参数传递给另外一个函数时,这个参数函数使用的自由变量应该去哪查找,是这个函数定义时所在的作用域还是执行这个函数所在的作用域。

  看这个例子:

var x = 10;
function foo() {
alert(x); // 自由变量 "x" == 10
}
(function (funArg) {

var x = 20;
funArg(); // 10, not 20

})(foo);


  第二类问题和我们在这一章最开始的关于选择词法作用域和动态作用域讨论有关。而在这里,我们已经知道了ECMAScript选择词法作用域,会去函数定义所在的作用域。

  上面两个问题的答案,都是通过同一种方式,在函数创建时就将上层环境(环境帧的集合)和函数的代码保存起来,这种策略实现就是闭包。

  通过伪代码来表示如下:

// foo的闭包
fooClosure = {
code: foo // 函数的代码
lexicalEnvironment: {x: 10} // 查询自由变量的上下文环境
};


  这也是上面提过的,在函数创建规则中时得到的函数对象。

  据函数创建的规则,我们看到 在ECMAScript中,所有的函数都是闭包,因为它们都是在创建的时候就保存了上层环境 (不管这个函数后续是否会执行 ):

  那么在最后,可以看到一类函数,闭包和词法环境的概念都是紧密关联的。也知道了词法环境是一种实现闭包和静态作用域的具体技术。

  接下来讲了ECMAScript 所实现的环境帧,而具体的技术会在随后内容中。

  关于闭包的具体解释可以去了解ES3版本关于闭包的介绍。

  为了更好的理解上面所说的,下面继续介绍下其他语言中关于环境的实现。

组合环境帧模型

  记住一件事,想要深入的了解某些具体的概念(以ECMAScript为例),首先需要弄清楚它的理论实现机制,然后去看看其他语言是如何实现的。我们会发现,这些通用机制(如环境)同样也出现在许多语言中。当然,不同语言的具体实现会有稍许不同。这一部分将介绍例如Python,Ruby和Lua的关于环境的实现。

  一个保存自由变量的方法就是创建一个大的环境帧,里面包含来自所有外层环境的并且用到的自由变量。

显然,这种方法并不会保存那些内部函数没有用到的变量。看下面这个例子:

// 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中有访问。上面提到过,这种情况下x也需要保存在foo的闭包中,同时保存的还有x定义所在的环境。

  对于foo的局部变量q,和全局变量y的情况一样,没有任何内部函数需要它,所以不会保存在bar的闭包中。而变量z很显然会保存在bar的闭包中。

  因此,我们创建了一个简单的环境帧,里面包含函数bar所需要的全部自由变量:

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


  在Python 中就采取了相似的模型。每个函数会将一个环境帧保存到closure属性上。全局变量不会包含在其中,因为所有全局变量都能在一个全局帧中找到。没有用到变量也不会保存到closure上。下面看个例子:

// Python environments example

// global "x"
x = 10

// global "foo" function
def foo(y):

# local "z"
z = 40

# local "bar" function
def bar():
return x + y

return bar

// create "bar"
bar = foo(20)

// execute "bar"
bar() # 30

// Saved environment of the "bar" function;
// it's stored in the __closure__ property;

// It contains only {"y": 20};
// "x" is not in the __closure__ since it's
// always can be found in the global scope;
// "z" is not saved either since it's not used

barEnvironment = bar.__closure__
print(barEnvironment) # tuple of closure cells

internalY = barEnvironment[0].cell_contents
print(internalY) # 20, "y"


  注意,Python不会保存在eval语法中出现变量,因为Python只会维护单独一个环境帧。下面这个例子中,内部函数baz保存了变量x,而bar则没有:

def foo(x):

def bar(y):
print(eval(k))

def baz(y):
z = x
print(eval(k))

return [bar, baz]

// create "bar" and "baz" functions
[bar, baz] = foo(10)

// "bar" does not closure anything
print(bar.__closure__) # None

// "baz" closures "x" variable
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), # error, "x" is not defined


  ECMAScript 则不同,它会有一个链式的环境帧模型,会为eval创建新的一帧来管理这种情况,同样还有with这种语法。

function foo(x) {
function bar(y) {
console.log(eval(k));
}
return bar;
}

// create "bar"
var bar = foo(10);

var k = "y";

// execute bar
bar(20); // OK, 20 - "y"

k = "x";

bar(20); // OK, 10 - "x"


  此外,关于Python闭包的详细解释请看this Python code-article.

  与Python这种保存单一环境帧的模型不同。ECMAScript使用的这种链式环境帧模型在函数创建时生成,对于标识符的解析贯穿整个环境帧链,一层层向上查找链上所有的环境帧(直到找到或者找不到抛出ReferenceError )。

  此外,上面关于ECMAScript 的结论是基于ECMA-262-5 规范而言。在实际上,ES引擎可以优化存储,比如只保存那些需要的自由变量。这部分会在下一章介绍。

总结

  这一部分我们介绍了环境和相关的通用理论。下一部分会具体讨论ECMAScript的实现。将会介绍环境记录项(就是上面讲的环境帧),以及它的两种不同类型:声明式环境记录项和对象式环境记录项,可以看到哪种环境记录项会拥有ES5中的执行上下文,了解对于不同函数:函数声明和函数表达式它们的区别。

这一部分主要内容:

通过作用于概念来介绍环境的概念 两种作用域类型:动态和静态 ECMAScript使用静态(词法)作用域

eval和with会动态的加入到静态作用域前端 作用域,环境,活动对象,活动记录,调用栈帧,环境帧,环境记录以及执行上下文,这都是一些同义的概念介绍。对于ECMAScript来说,它们中的部分,比如一个环境记录项是词法环境的一部分,而词法环境又是执行上下文的一部分。同样,很多概念在逻辑上来说是几乎完全相等的,如称呼全局作用域,全局环境,全局上下文都是指同一个概念。

ECMAScript使用链式环境帧模型,在ES3中被称为作用域链。在ES5中环境帧被叫做环境记录项。

一个环境可以被多个内部环境当做它们的外层环境。 词法环境被用来实现闭包,解决了函数式参数的问题 ECMAScript中所有函数都是一类函数

延伸阅读:

Structure and Interpretation of Computer Programs (SICP):

3.2 The Environment Model of Evaluation

其他有用的理论:

Scope

Name binding

Call stack

Activation record

Funarg problem

Free variable

引用

英文原版链接 Lexical environments: Common Theory.
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息