|
似乎某些程序员的集合是不相交的,就好像JS程序员和玩编译原理和CPU指令的汇编程序员就几乎没有交叉。前些日子讨论的火热的“作用域链”问题,说白了就是寻址问题,不过,这个在C中十分简单的问题却被JS这个动态语言弄得很复杂。
正是因为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) } } |
|