细品javascript 寻址,闭包,对象模型和相关问题

正是因为JS是动态语言,所以JS的寻址是现场寻址,而非像C一样,编译后确定。此外,JS引入了this指针,这是一个很麻烦的东西,因为它“隐式”作为一个参数传到函数里面。我们先看“作用域链”话题中的例子:
var testvar = 'window属性';
var o1 = {testvar:'1', fun:function(){alert('o1: '+this.testvar);}};
var o2 = {testvar:'2', fun:function(){alert('o2: '+this.testvar);}};
o1.fun(); // '1'
o2.fun(); // '2'
o1.fun.call(o2); //'2'三次alert结果并不相同,很有趣不是么?其实,所有的有趣、诡异的概念最后都可以归结到一个问题上,那就是寻址。
简单变量的寻址
JS是静态还是动态作用域?
告诉你一个很不幸的消息,JS是静态作用域的,或者说,变量寻址比perl之类的动态作用域语言要复杂得多。下面的代码是程序设计语言原理上面的例子:
01| function big(){
02| var x = 1;
03| eval('f1 = function(){echo(x)}');
04| function f2(){var x = 2;f1()};
05| f2();
06| };
07| big();
输出的是1,和pascal、ada如出一辙,虽然f1是用eval动态定义的。另外一个例子同样来自程序设计语言原理:
function big2(){
var x = 1;
function f2(){echo(x)}; //用x的值产生一个输出
function f3(){var x = 3;f4(f2)};
function f4(f){var x = 4;f()};
f3();
}
big2();//输出1:深绑定;输出4:浅绑定;输出3:特别绑定
输出的还是1,说明JS不仅是静态作用域,还是深绑定,这下事情出大了……
ARI的概念
为了解释函数(尤其是允许函数嵌套的语言中,比如Ada)运行时复杂的寻址问题,《程序设计语言原理》一书中定义了“ARI”:它是堆栈上一些记录,包括:
函数地址
局部变量
返回地址
动态链接
静态链接
这里,动态链接永远指向某个函数的调用者(如b执行时调用a,则a的ARI中,动态链接指向b);静态链接则描述了a定义时的父元素,因为函数的组织是有根树,所以所有的静态链接汇总后一定会指向宿主(如window),我们可以看例子(注释后为输出):
var x = 'x in host';
function a(){echo(x)};
function b(){var x = 'x inside b';echo(x)};
function c(){var x = 'x inside c';a()};
function d(){
var x = 'x inside d,a closure-made function';
return function(){echo(x)}};
a();// x in host
b();// x inside b
c();// x in host
d()();// x inside d,a closure-made function在第一句调用时,我们可以视作“堆栈”上有下面的内容(左边为栈顶):
[a的ARI] → [宿主]A的静态链直直的戳向宿主,因为a中没有定义x,解释器寻找x的时候,就沿着静态链在宿主中找到了x;对b的调用,因为b的局部变量里记录了x,所以最后echo的是b里面的x:'x inside b';
现在,c的状况有趣多了,调用c时,可以这样写出堆栈信息:
动态链:[a]→[c]→[宿主]
静态链:[c]→[宿主];[a]→[宿主]
因为对x的寻址在调用a后才进行,所以,静态链接还是直直的戳向宿主,自然x还是'x in host'咯!
d的状况就更加有趣了,d创建了一个函数作为返回值,而它紧接着就被调用了~因为d的返回值是在d的生命周期内创建的,所以d返回值的静态链接戳向d,所以调用的时候,输出d中的x:'x inside d,a closure-made function'。
静态链接的创建时机
月影和amingoo说过,“闭包”是函数的“调用时引用”,《程序设计语言原理》上面干脆直接叫ARI,不过有些不同的是,《程序设计语言原理》里面的ARI保存在堆栈中,而且函数的生命周期一旦结束,ARI就跟着销毁;而JS的闭包却不是这样,闭包被销毁,当且仅当没有指向它和它的成员的引用(或者说,任何代码都无法找到它)。我们可以简单地认为函数ARI就是一个对象,只不过披上了函数的“衣服”而已。
《程序设计语言原理》描述的静态链是调用时创建的,不过,静态链的关系却是在代码编译的时候就确定了。比如,下面的代码:
PROCEDURE a;
PROCEDURE b;
END
PEOCEDURE c;
END
END
中,b和c的静态链戳向a。如果调用b,而b中某个变量又不在b的局部变量中时,编译器就生成一段代码,它希望沿着静态链向上搜堆栈,直到搜到变量或者RTE。
和ada之类的编译型语言不同的是,JS是全解释性语言,而且函数可以动态创建,这就出现了“静态链维护”的难题。好在,JS的函数不能直接修改,它就像erl里面的符号一样,更改等于重定义。所以,静态链也就只需要在每次定义的时候更新一下。无论定义的方式是function(){}还是eval赋值,函数创建后,静态链就固定了。
我们回到big的例子,当解释器运行到“function big(){......}”时,它在内存中创建了一个函数实例,并连接静态链接到宿主。但是,在最后一行调用的时候,解释器在内存中画出一块区域,作为ARI。我们不妨成为ARI[big]。执行指针移动到第2行。
执行到第3行时,解释器创建了“f1”实例,保存在ARI[big]中,连接静态链到ARI[big]。下一行。解释器创建“f2”实例,连接静态链。接着,到了第5行,调用f2,创建ARI[f1];f2调用f1,创建ARI[f1];f1要输出x,就需要对x寻址。
简单变量的寻址
我们继续,现在要对x寻址,但x并不出现在f1的局部变量中,于是,解释器必须要沿着堆栈向上搜索去找x,从输出看,解释器并不是沿着“堆栈”一层一层找,而是有跳跃的,因为此时“堆栈”为:
|f1 | ←线程指针
|f2 | x = 2
|big | x = 1
|HOST|
如果解释器真的沿着堆栈一层一层找的话,输出的就是2了。这就触及到Js变量寻址的本质:沿着静态链上搜。
继续上面的问题,执行指针沿着f1的静态链上搜,找到big,恰好big里面有x=1,于是输出1,万事大吉。
那么,静态链是否会接成环,造成寻址“死循环”呢?大可不用担心,因为还记得函数是相互嵌套的么?换言之,函数组成的是有根树,所有的静态链指针最后一定能汇总到宿主,因此,担心“指针成环”是很荒谬的。(反而动态作用域语言寻址容易造成死循环。)
现在,我们可以总结一下简单变量寻址的方法:解释器现在当前函数的局部变量中寻找变量名,如果没有找到,就沿着静态链上溯,直到找到或者上溯到宿主仍然没有找到变量为止。
ARI的生命
现在来正视一下ARI,ARI记录了函数执行时的局部变量(包括参数)、this指针、动态链和最重要的——函数实例的地址。我们可以假想一下,ARI有下面的结构:
ARI :: {
variables :: *variableTable, //变量表
dynamicLink :: *ARI, //动态链接
instance :: *funtioninst //函数实例
}
variables包括所有局部变量、参数和this指针;dynamicLink指向ARI被它的调用者;instance指向函数实例。在函数实例中,有:
functioninst :: {
source :: *jsOperations, //函数指令
staticLink :: *ARI, //静态链接
......
}
当函数被调用时,实际上执行了如下的“形式代码”:
*ARI p;
p = new ARI();
p->dynamicLink = thread.currentARI;
p->instance = 被调用的函数
p->variables.insert(参数表,this引用)
thread.transfer(p->instance->operations[0])
看见了么?创建ARI,向变量表压入参数和this,之后转移线程指针到函数实例的第一个指令。
函数创建的时候呢?在函数指令赋值之后,还要:
newFunction->staticLink = thread.currentARI;
现在问题清楚了,我们在函数定义时创建了静态链接,它直接戳向线程的当前ARI。这样就可以解释几乎所有的简单变量寻址问题了。比如,下面的代码:
function test(){
for(i=0;i<5;i++){
(function(t){ //这个匿名函数姑且叫做f
setTimeout(function(){echo(''+t)},1000) //这里的匿名函数叫做g
})(i)
}
}
test()
这段代码的效果是延迟1秒后按照0 1 2 3 4的顺序输出。我们着重看setTimeout作用的那个函数,在它创建时,静态链接指向匿名函数f,f的(某个ARI的)变量表中含有i(参数视作局部变量),所以,setTimeout到时时,匿名函数g搜索变量t,它在匿名函数f的ARI里面找到了。于是,按照创建时的顺序逐个输出0 1 2 3 4。
公用匿名函数f的函数实例的ARI一共有5个(还记得函数每调用一次,ARI创建一次么?),相应的,g也“创建”了5次。在第一个setTimeout到时之前,堆栈中相当于有下面的记录(我把g分开写成5个):
+test的ARI [循环结束时i=5]
| f的ARI;t=0 ←——————g0的静态链接
| f的aRI ;t=1 ←——————g1的静态链接
| f的aRI ;t=2 ←——————g2的静态链接
| f的aRI ;t=3 ←——————g3的静态链接
| f的aRI ;t=4 ←——————g4的静态链接
\------
而,g0调用的时候,“堆栈”是下面的样子:
+test的ARI [循环结束时i=5]
| f的ARI ;t=0 ←——————g0的静态链接
| f的ARI ;t=1 ←——————g1的静态链接
| f的ARI ;t=2 ←——————g2的静态链接
| f的ARI ;t=3 ←——————g3的静态链接
| f的ARI ;t=4 ←——————g4的静态链接
\------
+g0的ARI
| 这里要对t寻址,于是……t=0
\------
g0的ARI可能并不在f系列的ARI中,可以视作直接放在宿主里面;但寻址所关心的静态链接却仍然戳向各个f的ARI,自然不会出错咯~因为setTimeout是顺序压入等待队列的,所以最后按照0 1 2 3 4的顺序依次输出。
函数重定义时会修改静态链接吗?
现在看下一个问题:函数定义的时候会建立静态链接,那么,函数重定义的时候会建立另一个静态链接么?先看例子:
var x = "x in host";
f = function(){echo(x)};
f();
function big(){
var x = 'x in big';
f();
f = function(){echo (x)};
f()
}
big()
输出:
x in host
x in host
x in big
这个例子也许还比较好理解,big运行的时候重定义了宿主中的f,“新”f的静态链接指向big,所以最后一行输出'x in big'。
但是,下面的例子就有趣多了:
var x = "x in host";
f = function(){echo(x)};
f();
function big(){
var x = 'x in big';
f();
var f1 = f;
f1();
f = f;
f()
}
big()
输出:
x in host
x in host
x in host
x in host
不是说重定义就会修改静态链接么?但是,这里两个赋值只是赋值,只修改了f1和f的指针(还记得JS的函数是引用类型了么?),f真正的实例中,静态链接没有改变!。所以,四个输出实际上都是宿主中的x。
结构(对象)中的成分(属性)寻址问题
请基督教(java)派和摩门教(csh)派的人原谅我用这个奇怪的称呼,不过JS的对象太像Hash表了,我们考虑这个寻址问题:
a.b编译型语言会生成找到a后向后偏移一段距离找b的代码,但,JS是全动态语言,对象的成员可以随意增减,还有原型的问题,让JS对象成员的寻址显得十分有趣。
对象就是哈希表
除开几个特殊的方法(和原型成员)之外,对象简直和哈希表没有区别,因为方法和属性都可以存储在“哈希表”的“格子”里面。月版在他的《JS王者归来》里面就实现了一个HashTable类。
对象本身的属性寻址
“本身的”属性说的是hasOwnProperty为真的那些属性。从实现的角度看,就是对象自己的“哈希表”里面拥有的成员。比如:
function Point(x,y){
this.x = x;
this.y = y;
}
var a = new Point(1,2);
echo("a.x:"+a.x)
Point构造器创建了“Point”对象a,并且设置了x和y属性;于是,a的成员表里面,就有:
| x | ---> 1
| y | ---> 2
搜索a.x时,解释器先找到a,然后在a的成员表里面搜索x,得到1。
从构造器给对象设置方法不是好策略,因为它会造成两个同类的对象方法不等:
function Point(x,y){
this.x = x;
this.y = y;
this.abs = function(){return Math.sqrt(this.x*this.x+this.y*this.y)}
}
var a = new Point(1,2);
var b = new Point(1,2);
echo("a.abs == b.abs ? "+(a.abs==b.abs));
echo("a.abs === b.abs ? "+(a.abs===b.abs));
两个输出都是false,因为第四行中,对象的abs成员(方法)每次都创建了一个,于是,a.abs和b.abs实际上指向两个完全不同的函数实例。因此,两个看来相等的方法实际上不等。
扯上原型的寻址问题
原型是函数(类)的属性,它指向某个对象(不是类)。“原型”思想可以类比“照猫画虎”:类“虎”和类“猫”没有那个继承那个的关系,只有“虎”像“猫”的关系。原型着眼于相似性,在js中,代码估计可以写作:
Tiger.prototype = new Cat()函数的原型也可以只是空白对象:
SomeClass.prototype = {}我们回到寻址上来,假设用.来获取某个属性,它偏偏是原型里面的属性怎么办?现象是:它的确取到了,但是,这是怎么取到的?如果对象本身的属性和原型属性重名怎么办?还好,对象本身的属性优先。
把方法定义在原型里面是很好的设计策略。假如我们改一下上面的例子:
function Point(x,y){
this.x = x;
this.y = y;
}
Point.prototype.abs = function(){return Math.sqrt(this.x*this.x+this.y*this,y)}
var a = new Point(1,2);
var b = new Point(1,2);
echo("a.abs == b.abs ? "+(a.abs==b.abs));
echo("a.abs === b.abs ? "+(a.abs===b.abs));
这下,输出终于相等了,究其原因,因为a.abs和b.abs指向的是Point类原型的成员abs,所以输出相等。不过,我们不能直接访问Point.prototype.abs,测试的时候直接出错。更正:经过重新测试,“Point.prototype.abs不能访问”的问题是我采用的JSCOnsole的问题。回复是对的,感谢您的指正!
原型链可以很长很长,甚至可以绕成环。考虑下面的代码:
A = function(x){this.x = x};
B = function(x){this.y = x};
A.prototype = new B(1);
B.prototype = new A(1);
var a = new A(2);
echo(a.x+' , '+a.y);
var b = new B(2);
echo(b.x+' , '+b.y);
这描述的关系大概就是“我就像你,你也像我”。原型指针对指造成了下面的输出:
2 , 1
1 , 2
搜索a.y的时候,沿着原型链找到了“a.prototype”,输出1;b.x也是一样的原理。现在,我们要输出“a.z”这个没有注册的属性:
echo(tyoeof a.z)我们很诧异,这里并没有死循环,看来解释器有一个机制来处理原型链成环的问题。同时,原型要么结成树,要么就成单环,不会有多环结构,这是很简单的图论。
this:函数中的潜规则
方法(函数)调用中最令人烦恼的潜规则就是this问题。从道理上讲,this是一个指针,戳向调用者(某个对象)。但假如this永远指向调用者的话,世界就太美好了。但这个可恶的指针时不时的“踢你的狗”。可能修改的情况包括call、apply、异步调用和“window.eval”。
我更愿意把this当做一个参数,就像lua里面的self一样。lua的self可以显式传递,也可以用冒号来调用:
a:f(x,y,z) === a.f(a,x,y,z)JS中“素”的方法调用也是这个样子:
a.f(x,y,z) === a.f.call(a,x,y,z)f.call才是真正“干净”的调用形式,这就如同lua中干净的调用一般。很多人都说lua是js的清晰版,lua简化了js的很多东西,曝光了js许多的潜规则,着实不假。
修正“this”的原理
《王者归来》上面提到的“用闭包修正this”,先看代码:
button1.onclick = (
function(e){return function(){button_click.apply(e,arguments)}}
)(button1)别小看了这一行代码,其实它创建了一个ARI,将button1绑定于此,然后返回一个函数,函数强制以e为调用者(主语)调用button_click,所以,传到button_click里的this就是e,也就是button1咯!事件绑定结束后,环境大概是下面的样子:
button1.onclick = _F_; //给返回的匿名函数设置一个名字
_F_.staticLink = _ARI_; //创建之后就调用的匿名函数的ARI
_ARI_[e] = button1 //匿名ARI参数表里面的e,同时也是_F_寻找的那个e
于是,我们单击button,就会调用_F_,_F_发起了一个调用者是e的button_click函数,根据我们前面的分析,e等于button1,所以我们得到了一个保险的“指定调用者”方法。或许我们还可以继续发挥这个思路,做成通用接口:
bindFunction = function(f,e){ //我们是好人,不改原型,不改……
return function(){
f.apply(e,arguments)
}
}

您可能感兴趣的文章:

内容版权声明:除非注明,否则皆为本站原创文章。

转载注明出处:https://www.heiqu.com/wjwfpd.html