你是否听说过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
往期回顾:
localStorage也能像cookie设置一个过期时间?
浅谈理解JS中的几种循环方式~
用不了多久 Web Component,就能取代你的前端框架吗?
关于图片防盗链的来龙去脉~
都看到这里了,给个“在看”再走~