查看原文
其他

你是否听说过JavaScript的环境模型?

点击上方“IT平头哥联盟”,选择“置顶或者星标

一起进步~

环境模型(Environment Model) 这一个概念,它用于解释Scheme的函数计算规则。由@佳木授权分享。

正文从这开始~~

《SICP》提到了 环境模型(Environment Model) 这一个概念,它用于解释Scheme的函数计算规则。同样,它也适用于JavaScript的函数计算规则。

环境是什么

节选《SICP》 3.2 The Environment Model of Evaluation

环境在计算过程必不可少,因为它决定了计算表达式的上下文。 可以这样认为,表达式本身在程序语言里毫无意义,表达式的意义取决于它计算时所在的环境。就算是(+ 1 1)这一条极其简单的表达式,也需要在符号+表示加法的上下文里才能进行计算。

JavaScript的解释器就充当着环境的角色。在该环境下,表达式1 + 1的计算结果为2,表达式Date()调用一个函数并返回当前的时间,表达式() => 1定义了一个返回1的函数……总之,对程序而言,环境就是在计算过程为符号提供实际意义的东西。

环境模型

变量环境

环境模型中的环境具体指的是变量环境。函数在计算时会根据 环境(environment) 决定变量的值,从而决定它的计算结果。

环境的创建和作用

函数在调用时会先创建一个环境,然后在该环境中计算函数的内容。

function add10(value) { //1
var increment = 10; //2
return value + increment; //3} //4
add10(2); //5

表达式add10(2)(>5)的计算过程:

  • 创建环境$add10。(>5)

  • 给环境$add10中的变量value赋值2。(>5)

  • 进入环境$add10。

  • 在环境$add10中,给变量increment赋值10。(>2)

  • 在环境$add10中,获得变量value的值2。(>3)

  • 在环境$add10中,获得变量increment的值10。(>3)

  • 计算表达式2 + 10得到12。(>3)

  • 返回12。(>3)

  • 离开环境$add10。

值得一提的是,形参也是变量,它在形参列表里定义,在函数调用时获得初始值。

变量绑定

环境使用变量绑定来存放变量的值, 绑定(binding) 与函数中的变量一一对应。

约束变量和自由变量

在函数中定义一个变量,变量的意义取决于函数的内容,它的作用范围也被约束在函数之中,此时的变量被称为 约束变量(bound variable) 。

在函数中使用一个没有定义的变量,它的作用范围不受函数的约束,此时的变量被称为 自由变量(free variable) 。

function main() { //1
var x = 10; //2
var addX = function (value) { //3
var increment = x; //4
return value + increment; //5
}; //6

var value = 2; //7
addX(value); //8} //9
main(); //10

var关键字可以定义变量:

  • 在函数main中,变量x(>2、4),addX(>3、8),value(>7、8)皆为约束变量。

  • 在函数addX中,变量value(>3、5),increment(>4、5)是约束变量,变量x(>4)是自由变量。

绑定与变量

在函数的计算过程中,变量定义会使当前的环境加入对应的绑定。

上文中表达式main()(>10)的计算过程产生了2个环境,$main和$addX:

  • 环境$main拥有3个绑定,x,addX,*value。

  • 环境$addX拥有2个绑定,value,increment。

可见,绑定存放的是约束变量的值,约束变量的值可以直接从当前环境获取。

而自由变量的值需要从其他环境获取,该环境是自由变量定义时所在的环境,拥有自由变量的绑定。

上文中表达式addX(value)(>8)的计算过程:

  • 获得环境$main中绑定*addX的值addX函数。(>8)

  • 获得环境$main中绑定*value的值2。(>8)

  • 修改环境$addX中绑定*value的值为2。(>8)

  • 获得环境$main中绑定*x的的值10。(>4)

  • 修改环境$addX中绑定*increment的值为10。(>4)

  • 获得环境$addX中绑定*value的值2。(>5)

  • 获得环境$addX中绑定*increment的值10。(>5)

计算function表达式或lambda表达式会得到一个函数,这种情况一般被称为函数定义。方便起见,本文将值是变量的函数称为函数。

就这样,函数在计算时只要找到对应的绑定,就能确定一个变量的值。

环境引用

环境不仅保存了变量绑定,还会保存一个 环境引用(environment pointer) ,环境引用指向其他的变量环境。通过环境引用,自由变量可以从其他环境寻找自己对应的绑定。

环境引用的来源

函数在定义时会把当前环境的引用记录下来。在调用函数后,新的环境会得到函数中的环境引用并将此保存。
也就是说,一个函数在计算时的环境,拥有函数在定义时的环境的引用。

var getCounter = function (start) { //1
return function () { //2
return start++; //3
}; //4}; //5var counter = getCounter(0); //6
counter(); //7

表达式getCounter(0)(>6)和counter()(>7)分别创建了两个环境:

  • 环境$getCounter拥有全局环境的引用。

  • 环境$counter拥有环境$getCounter的引用。

一些看似不在函数中定义的函数,其定义时也身处环境中,该环境被称为全局环境。函数getCounter就保存了全局环境的引用。

环境引用与绑定

函数在计算过程中定义函数,如同代码文本结构那样一层包裹一层,里层的函数定义是外层函数中的一条表达式,里层函数创建的环境通过引用连接外层函数创建的环境。

因此,一个变量在当前环境找不到对应的绑定时,可以通过引用一层层回溯到它定义时所在的环境,从而找到该绑定。自由变量便是通过这种方法找到自己对应的绑定。

上文中表达式counter()(>7)的计算过程:

  • 使用变量counter。(>7)

  • 在当前环境(全局环境)找到变量绑定*counter,它的值是一个函数 。

  • 调用函数counter会创建环境$counter。(>7)

  • 环境$counter从函数counter得到环境$getCounter的引用。

  • 进入环境$counter。

  • 使用变量start。(>3)

  • 在环境$counter找不到绑定*start。

  • 环境$counter通过引用定位到环境$getCounter。

  • 在环境$getCounter中找到绑定*start。

  • 返回绑定*start的值0作为函数的计算结果。(>3)

  • 令绑定*start的值自增1,从0变为1。(>3)

  • 离开环境$counter。

每次计算表达式counter(),绑定*start的值都会自增1,并依次返回0,1,2,3……

总结

函数在定义时会保存当前 环境 的 引用 。

一旦函数被调用,就会创建一个新的环境,新的环境拥有函数定义时环境的引用。

函数中的变量定义表达式会给新环境加入 绑定 。

函数使用变量就是访问环境中对应的绑定。

如果变量在当前环境找不到对应的绑定,就会通过引用一层层回溯到它定义时所在环境,从而找到它的绑定。

而这种访问其他变量环境的机制,通常被人称为 闭包 。

模拟环境模型

下文将讲述如何用js模拟环境模型。在这个模拟环境模型中,不需要用到js的变量定义语法也能使用闭包。

代码实现

模拟环境模型不是编写函数的解释器,只是将环境变为可操作的实体,用来解释函数中的变量。

首先确定模拟环境的使用方式。为了能在函数中使用环境,环境将作为参数传入被调用的函数:

function $func($){
//$是$func调用时创建的环境。};

class Environment

函数通过环境使用变量,环境应有getVariable和setVariable方法。

变量在使用前要有定义,环境应有defineVariable方法。

此外,函数在定义时会保存当前环境的引用,环境应有defineFunction方法。

因此,代表环境的class是这样的:

class Environment {
//变量定义
defineVariable(name) {
}
//变量取值
getVariable(name) {
}
//变量赋值
setVariable(name, value) {
}
//函数定义
defineFunction($func) {
}}

bindingContainer member

环境可以看作是变量(绑定)的容器,应有一个bindingContainer成员用来存放变量。

考虑到前端js的全局变量可以在window对象上找到,bindingContainer使用Object类型的对象的话,可以与window[name]同样的形式bindingContainer[name]来访问变量。

因此,变量定义、取值、赋值可以表达为:

this.bindingContainer[name] = null; //定义
value = this.bindingContainer[name]; //取值this.bindingContainer[name] = value; //赋值

defineVariable method

Environment的defineVariable方法实现很直接,为当前环境加入绑定:

defineVariable(name) {
this.bindingContainer[name] = null;}

environmentPointer member

在当前环境使用的变量,绑定有可能在别的环境中,应有一个代表环境引用的成员environmentPointer。
且environmentPointer是Environment类型。

findBindingContainer method

取值和赋值都需要找到变量的绑定,应有一个共同的方法findBindingContainer用来查找绑定。
为了方便赋值进行,方法返回的是绑定的容器。

变量在当前环境找不到绑定时,会通过引用向上一层环境查找。这是递归的,因此findBindingContainer的表达为:

findBindingContainer(variable_name) {
//判断当前环境是否存在绑定。
if (this.bindingContainer.hasOwnProperty(variable_name)) {
//找到了绑定,返回绑定的容器。
return this.bindingContainer;
} else {
//在该环境中找不到绑定。
//判断引用是否达到了尽头。
if (this.environmentPointer === Environment.End) {
//环境引用走到了尽头,抛出异常。
throw '不存在对应的绑定。';
} else {
//通过环境引用在上一层环境中查找绑定。
return this.environmentPointer.findBindingContainer(variable_name);
}
}}

Object类型的对象自带hasOwnProperty方法判断自己是否拥有某个成员。

Environment.End member

显然,通过引用一直向上遍历环境是有尽头的,在这里规定环境的尽头Environment.End为null:

Environment.End = null;

getVariable method

有了findBindingContainer方法,便能轻易写出getVariable方法:

getVariable(name) {
var binding_container = this.findBindingContainer(name);
var value = binding_container[name];
return value;}

setVariable method

同上,setVariable方法的表达为:

setVariable(name, value) {
var binding_container = this.findBindingContainer(name);
binding_container[name] = value;}

defineFunction method

模拟环境模型不具备定义函数的功能,defineFunction只需令已定义的函数保存当前环境的引用。

js函数无法直接保存引用和创建模拟环境,因此需要一个用来代理函数的对象,假设defineFunction的表达为:

defineFunction(proxy) {
proxy.saveEnvironmentPointer(this);
var func = proxy.getCall();
return func;}

class $Function

代理函数的对象使用saveEnvironmentPointer方法保存环境引用,使用getCall方法返回实际被调用的函数。

被代理的函数,就是使用模拟环境的函数$func,显然$func不能被直接调用。它需要:

  • 创建新的环境。

  • 在新环境中加入可能的实际参数。

  • 以新环境为参数。

因此,代理函数的对象应具有call方法,以此满足$func被调用的需求。

综上所述,代理函数的对象类型是这样的:

class $Function {
saveEnvironmentPointer(environmentPointer) {
}
getCall() {
}
call(...args) {
}}

$Function还应有这样三个成员:

  • 使用模拟环境的函数$func。

  • 描述函数$func的参数列表parameterList。

  • $func定义时所在环境的引用environmentPointer。

值得一提的是,函数$func只有一个表示环境的参数$,无法表达普通函数的参数列表。因此需要parameterList来描述它的参数列表,用一个字符串数组便能表达。

saveEnvironmentPointer method

saveEnvironmentPointer方法的实现很直接:

saveEnvironmentPointer(environmentPointer) {
this.environmentPointer = environmentPointer;}

getCall method

getCall方法实际返回的是call方法:

getCall() {
return this.call.bind(this);}

由于call方法的实现用到其他成员,call在返回时需要绑定this。

call method

如上文所述,call方法作为实际被调用的函数,它会:

  • 创建新的环境。

  • 在新环境中加入可能的实际参数。

  • 以新环境为参数调用$func函数。


call(...args) {
//创建新的环境,并传入上一层环境的引用。
var new_environment = new Environment(this.environmentPointer);
//根据形参列表初始化新环境的绑定。
for (var [i, name] of this.parameterList.entries()) {
new_environment.bindingContainer[name] = args[i];
}
//将新环境作为参数传入使用模拟环境的函数并调用之。
var result = this.$func(new_environment);
return result;}

Environment constructor

至此,补充一下Environment的构造方法,上一层环境引用在构造新环境时传入:

constructor(pointer) {
this.environmentPointer = pointer;
this.bindingContainer = {};}

$Function constructor

$Function在构造时只需要从外部传入$func和parameterList:

constructor($func, parameterList = []) {
this.$func = $func;
this.parameterList = parameterList;}

参数列表默认为空数组。

Environment.Global member

在使用模拟环境之前补充全局环境的定义:

//全局环境中的环境引用只能是Environment.End了。Environment.Global = new Environment(Environment.End);//前端js通过window可以访问全局变量,因此window作为全局环境的容器。Environment.Global.bindingContainer = window;

使用方式

获得模拟环境模型代码的整合。

例1

原代码:

var add = function (a, b) {
return a + b;};
add(1, 2); //3

使用模拟环境的代码:

Environment.Global.defineVariable('add');Environment.Global.setVariable(
'add',
Environment.Global.defineFunction(new $Function(
function ($) {
//return a + b;
return $.getVariable('a') + $.getVariable('b');
},
['a', 'b'])));
add(1, 2); //3

例2

原代码:

var getCounter = function (start) {
return function () {
var result = start;
start += 1;
return result;
};};var counter = getCounter(0);
counter(); //0
counter(); //1
counter(); //2

使用模拟环境的代码:

Environment.Global.defineVariable('getCounter');Environment.Global.setVariable(
'getCounter',
Environment.Global.defineFunction(new $Function(
function ($) {
return $.defineFunction(new $Function(function ($) {
$.defineVariable('result');
//result = start;
$.setVariable('result', $.getVariable('start'));
//start += 1;
$.setVariable('start', $.getVariable('start') + 1);
//return result;
return $.getVariable('result');
}));
},
['start'])
));Environment.Global.defineVariable('counter');//counter = getCounter(0);Environment.Global.setVariable('counter', getCounter(0));
counter(); //0
counter(); //1
counter(); //2

其他

Environment.js的主要意义是让人熟悉环境模型的概念,作为代码没有太多的使用价值。

这里有一个展示环境模型细节的版本。它通过console打印每一阶段的内容。这是它的demo。

Environment.detail.js在使用上与Environment.js有微小的差异,$Function的构造函数多了一个用作函数名的参数。

验证环境模型

以chrome为观察平台,通过console.dir方法可以展示一个对象的状态。

特别的,console.dir一个函数可以查看它的环境信息。

作用域

执行以下代码:

var foo = function () {};
console.dir(foo);

在Console展开foo可见:

▼ƒ foo()...
▼[[Scopes]]: Scopes[1]
▶0: Global {postMessage: ƒ, blur: ƒ, focus: ƒ, close: ƒ, parent: Window, …}

实际上, 作用域(scope) 就是环境的实现,从console.dir看到的[[Scopes]]属性便包含了环境的信息。

Global是作用域的类型之一,代表的是全局作用域。全局作用域全局环境,可见函数foo保存了全局环境的引用。

作用域链

执行以下代码:

var f1 = function () {
var s1 = 0;
var f2 = function () {
return s1;
};
console.dir(f2);};
f1();

在Console展开f2可见:

▼ƒ f2()...
▼[[Scopes]]: Scopes[2]
▼0: Closure (f1)
s1: 0
▶1: Global {postMessage: ƒ, blur: ƒ, focus: ƒ, close: ƒ, parent: Window, …}

[[Scopes]]是一个数组,它表示的是作用域链。环境也是一个链表,从环境模型的角度看待,这是把环境引用的关系转化为数组,数组前面的环境保存后面环境的引用。

函数f2保存了环境$f1的引用,环境$1保存了全局环境的引用。这种信息同样可以从[[Scopes]]获得。
Closure也是作用域的类型之一,还能从作用域“Closure (f1)”得知环境$f1包含了绑定*s1。

消失的变量

上文中,作用域“Closure (f1)”只包含了s1。f2也是变量,根据环境模型,它理应包含两个变量的状态,s1和f2。

实际上,这是环境模型的实践被js优化过所造成的结果。

解释器在执行代码之前会对代码进行分析。从分析中,可以知道一些变量除了定义它的函数,不在别的函数内出现,或者说不被别的函数使用。这意味着,这些变量可以在函数返回时从当前环境中移除,而不影响到后续代码的运行。

甚至,如果设置一种专门用来被别的环境引用的环境,那么只有那些被其他函数所用到的变量,才会加入到这种环境中。那些不被别的函数使用的变量,就能进一步地,在函数不需要它们时提前被释放。

js就是如此,作用域只会捕捉那些被其他函数使用的变量。

上文,函数f1中只有变量s1被其他函数使用,因此作用域“Closure (f1)”只捕获了变量s1。 下面是作用域捕获f2的例子。

var f1 = function () {
var s1 = 0;
var f2 = function () {
return f2;
};
console.dir(f2);};
f1();

▼ƒ f2()...
▼[[Scopes]]: Scopes[2]
▼0: Closure (f1)
f2: ƒ ()
▶1: Global {postMessage: ƒ, blur: ƒ, focus: ƒ, close: ƒ, parent: Window, …}

这次函数f1中只有变量f2被其他函数使用,因此作用域“Closure (f1)”只捕获了变量f2。

消失的作用域

进一步地,如果一个函数没有定义变量,亦或是它的变量都不被其他函数所用,那么它创建的环境就没有被引用的必要,取而代之的是它本身保存的环境引用。

同样的,js会移除不必要的作用域。

执行以下代码:

var f1 = function () {
var s1 = 0;
var f2 = function () {
};
console.dir(f2);};
f1();

▼ƒ f2()...
▼[[Scopes]]: Scopes[1]
▶0: Global {postMessage: ƒ, blur: ƒ, focus: ƒ, close: ƒ, parent: Window, …}

可见,f1函数调用时创建的环境$1、作用域“Closure (f1)”被移除了。

另一个移除作用域的例子:

var f1=function(){
var f2=function(){
var f3=function(){
var f4=function(){
return f2;
};
console.dir(f4);
};
f3();
};
f2();};
f1();

▼ƒ f4()...
▼[[Scopes]]: Scopes[2]
▼0: Closure (f1)
f2: ƒ ()
▶1: Global {postMessage: ƒ, blur: ƒ, focus: ƒ, close: ƒ, parent: Window, …}

再看看使用eval的例子:

var f1=function(){
var f2=function(){
var f3=function(){
var f4=function(){
return f2;
};
console.dir(f4);
};
f3();
};
f2();};
f1();

▼ƒ f4()...
▼[[Scopes]]: Scopes[4]
▶0: Closure (f3) {f4: ƒ, arguments: Arguments(0)}
▶1: Closure (f2) {f3: ƒ, arguments: Arguments(0)}
▶2: Closure (f1) {f2: ƒ, arguments: Arguments(0)}
▶3: Global {postMessage: ƒ, blur: ƒ, focus: ƒ, close: ƒ, parent: Window, …}

js的eval函数可以执行动态代码,解释器无法通过代码分析它未来的执行内容,只能让函数保留所有相关环境的引用。

关于本文
作者:@佳木
原文:https://zhuanlan.zhihu.com/p/58864841


往期回顾:

彻底终结 Javascript 背后的隐式类型转换

localStorage也能像cookie设置一个过期时间?

我知道你不想跳槽,但你应该多去面试~

浅谈理解JS中的几种循环方式~

用不了多久 Web Component,就能取代你的前端框架吗?

What!听说你还不会移动端真机DEBUG?

那天,她无意间瞟了眼程序员的桌面。。。

史上最污技术解读,我竟然秒懂了!

What's New in JavaScript

关于图片防盗链的来龙去脉~


都看到这里了,给个“在看”再走~

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存