2013年/10月/30日
JavaScript中的三个链条
JS是一个动态和弱类型语言,这两项特征造就了它的与众不同,用它写出的代码可以怪异得完全看不懂,也可以正经得跟Java一样,本文分析JS语言中的三个链条,个人认为理解了这三个链条基本上可以掌握这个语言的80%。
第一链:原型对象
第二链:构造函数
第三链:作用域
前两个链条我们要在JS的面向对象中分析,而第三个链条我们在函数的执行原理中分析,注意JS中只有对象的概念,而对象又有一个原型的概念,原型其实也是一个对象,有人会说函数不是对象吧?错,函数也是一个对象,JS真是一个一切皆对象的 语言,对象就是一个key,value结构体,key是对象,value还是对象,所以对象本身就是一个对象树状图,JS中没有hashcode的概念,因为它的key全部必须为字符串对象,如果把一个非字符串对象作为key,那么执行引擎会自动调用对象的toString方法。
那么对象怎么来?有些是浏览器或者引擎内置的,比如window,Object,一般情况下的对象有两种方式产生,一种通过直接量,比如var o = {},另一种通过new关键字加上一个函数对象,比如var o = new Object(),这个new类似Java语言的实例化对象,但是JS中没有类。每个对象都有一个原型对象和它关联, 内部机制是在每个对象内部有一个_proto_属性指向自己的原型,这个属性是只读的,不过IE的js引擎访问不到。 一个新生对象是如何关联到自己的原型的呢?就在new的时候,new的过程是,引擎先创建了一个非常干净的对象,其实这个时候我觉得不应该叫做对象,叫它内存块更合适,然后让这个干净对象作为this去调用new后面的那个函数,函数中可以用this访问这个干净对象并对它进行初始化,最后引擎把函数对象上的原型对象介绍给那个干净对象,这样干净对象就和原型 关联起来了。
所以一般对象有一个_proto_属性,而函数对象除了有_proto_属性还有一个prototype属性,这个属性用来new对象的时候介绍给新对象的。
代码演示
借用标准面向对象的说法,我们把和new一起进行造对象的那个函数对象叫做构造函数,js中所有函数在定义的时候都会被自动的初始化一个原型,构造函数做两件事情,一是初始化,二是介绍自己prototype属性引用的对象给新对象。由此我们得到了第一条链,原型链,就拿上面的代码来说p的原型指向Plane的原型,Plane的原型本身也是 一个对象,它由Object函数构造出来,那么它就指向Object函数的原型,Object函数的原型是链条的顶端,Object的原型的_proto_是空的,也就表明链条终结于此,现在链条上只有两个原型对象,等会我们来实现继承的时候会加入更多的原型。那么有人会问,Object函数所关联的那个原型是哪个函数构造的,这个我不知道,它的_proto_为null,只能说明没有函数构造它,它是上帝创造的。 维护了链条的作用是用来做属性查找,对象的属性访问如果对象自身没有就沿着链条搜索直到object.prototype。
JS的每一个对象都有一个constructor属性,这个属性指向构造这个对象的构造函数,按照我们的例子var p = new Plane(2,3),p.constructor指向Plane这个构造函数,那么constructor是谁的属性?是p的吗?不是,它是属于p.__proto__的 因为p.__proto__是某个函数关联的对象,很自然,这个对象又用constructor指回到这个函数,也就是说,引擎在给函数创建prototype对象的时候设置了一个constructor属性指向了这个函数,由于函数也是对象,所以也可以写出p.constructor.constructor这样的代码,它的值是Function函数,下面的代码都为true:
那么Function.constructor是哪个函数?通过测试我们发现它的值是Function,Function是个对象,它的构造函数居然是它自己,那么Function.constructor.constructor.constructor == Function的值也是true,我的个神啊! 由此,我们又得到了第二条链,构造函数链,p.constructor指向Plane,Plane.prototype.proto .constructor指向Object,只不过这个链条似乎是嫁接在原型上的,迂回上升。在IE中,我们不能访问__proto__属性,现在 我们可以通过o.constructor.prototype直接访问到一个对象的原型了,找来一张图,大家看看,图中的Foo是一个自定函数,类似本文的Plane。
搞清楚了JS的原型和构造器链条,我们就可以来研究如何在JS中实现继承层次了,在Java这种语言中,我们用extends不断的把类连起来就可以了,但是在JS中必须要照顾好这两个链条,不然继承就可能出问题。我们正常写一个构造函数会自然的得到一个原型链继承层次,它只有两层, 第一个原型是构造函数的prototype,第二个原型是是Object的prototype,如果我们要实现大于两层的层次就必须手动建立原型链条。继承要注意两个地方,一是如何复用代码,二是如何调用超类的构造函数,伪代码:
extends函数中我们必须要完成原型链和构造函数链的装配,原型也是对象,所以实现sub.prototype.proto = sup.prototype的方法就是子类的原型由超类实例化,但是这带来了一个副作用,超类构造函数中的那些属性也写到了子类的原型上,这是我们不需要的。 所以这种方法不行,那么还有什么办法既保证原型链条的建立有保证原型的干净吗? 由于函数的prototype是可以被赋值的,所以我们可以通过用一个干净函数来间接实现,如下代码:
这样就实现了原型链的组装,但是构造函数链是乱的,sub.prototype.constructor指向了F函数,所以要做一个修正sub.prototype.constructor = sub,因为子类的原型被替换了,所以之前原型上的数据丢了,于是我们还需要把之前的数据还原回来,到了这里原型链和构造函数链才完美的组装好了,现在问题只有一个,子类构造函数如何调用超类构造函数, 在Java这样的语言中,只要用super关键字就可以了,层级关系由虚拟机维护的,js中我们需要自己来实现某种机制让子类的函数内部能够感知到超类,可以做一个小手脚,在extend函数里面把超类函数放到子类的原型上,比如sub.prototype.superClass = sup,那么上面的代码 Space中就可以this.superClass(x,y)了,但是又存在一个问题,原型链条的查找是由下到上,下的属性可覆盖上的属性,所以当继承层次大于三级的时候代码就会在由下到上的第二层发生无限递归,因为this.super只能访问到第二层,所以不能在原型上来做文章了,由于函数也是对象 那么我们就直接在函数对象上做关联呢?sub.superClass = sup,似乎可行,让我们写出完整代码:
现在我们的extend函数可以实现两个构造函数继承,可是我们在做面向对象编程的时候经常子类重写父类的方法,不过即便重写了有时候也要调用一下父类的,在当前这个extend函数中只有一种机制访问到超类构造函数,而访问超类方法只能通过显式的访问原型sub.superClass.prototype,如果我们用sub.superClass = sup.prototype建立父子关系这里就不用指定prototype, 这样做在调用超类构造函数的时候必须用superClass.constructor。
以上所有分析都是围绕构造函数链和原型链的,我们现在来分析第三个链条,作用域链,在c,java等语言中,我们可以非常容易的从代码块或者花括号判断处一个变量的作用域,这种语言因为少了一项特性导致作用域管理非常简单,拿就是函数当做数据来传递的 能力,js或者其他函数式编程语言里面,函数是可以当作数据来传递的,而c,java是不行的,c语言中的函数能够访问的数据要么是局部变量,要么是函数参数,java语言根本没有独立的函数,函数叫做方法绑定在了类上,所以它能够访问的变量要么是局部变量,要么是参数,要么是类的实例变量, 这种僵化和不灵活性带了简单,js中函数是独立的,叫做第一类值,函数本身是一串可运行的代码,函数可以像变量一样到处传递,那么它所访问的数据就是自由的,在哪里执行访问哪里的数据,所以函数如果不运行就是函数,一旦运行我们叫它闭包,闭包就是代码 和一个提供数据环境的组合体。
那么这和作用域链有什么关系呢?js执行中的函数必须要有一个环境,默认就是全局对象,浏览器中就是window,我说的作用域链的顶端就是全局对象,顶端向下有一个作用域是解释器在定义函数的时候保存的,我们叫它词法定界,程序还没运行,解释器通过词法分析简历了一 作用域链,这在函数内部又定义函数,内层函数访问外层函数变量的时候体现特别明显,比如在我推导Y combinator的时候的如下代码 :
内层函数在被定义的时候就引用了外层的that,如果内层函数被返回,返回的函数又被hold住那么它将永远引用着原来外层函数的那个that参数,这种机制形成了另一种代码和数据的封装,我们甚至也可以叫它对象。 在词法定界作用域的下端还有一个作用域,那就是调用对象call object,这个对象放置了函数运行时的函数参数,局部变量,这就可以充分解释js没有块级作用域,函数内的所有变量都扁平化的铺在了调用对象上。作用域链和原型链一样也是从小到上进行查找 下会隐藏上的属性,注意在函数内部的this只是一个关键字,它不是调用对象的属性,js中每一个函数都一个apply和call方法,这两个方法的第一个参数表示this可引用的值,如果一个函数直接用括号表达式执行,那么它的this是全局对象。
除非我们使用闭包,一般情况下我们可以不用太关心这个链条。就像原型链条可以扩展一样,这个链条我们也可以手动扩展,只要用js的with关键字即可,比如我们在一个函数中要频繁的用this来访问属性,那么就可以用with 把this这个对象放在链条的最底端: