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


    原贴地址:http://infinte.yo2.cn/archives/633839

    似乎某些程序员的集合是不相交的,就好像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”:它是堆栈上一些记录,包括:

    1. 函数地址
    2. 局部变量
    3. 返回地址
    4. 动态链接
    5. 静态链接

    这里,动态链接永远指向某个函数的调用者(如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 34的顺序输出。我们着重看setTimeout作用的那个函数,在它创建时,静态链接指向匿名函数f,f的(某个ARI的)变量表中含有i(参数视作局部变量),所以,setTimeout到时时,匿名函数g搜索变量t,它在匿名函数f的ARI里面找到了。于是,按照创建时的顺序逐个输出0 1 2 34。

    公用匿名函数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)
    }
    }
  • 相关阅读:
    捋一下Redis
    docker 的简单操作
    opencv的级联分类器(mac)
    python日常
    pip安装显示 is not a supported wheel on this platform.
    字节流与字符流的区别详解
    请求转发和重定向的区别及应用场景分析
    Eclipse的快捷键使用总结
    Eclipse给方法或者类添加自动注释
    IntelliJ IDEA 连接数据库 详细过程
  • 原文地址:https://www.cnblogs.com/myssh/p/1876585.html
Copyright © 2020-2023  润新知