本文先對es6發布之前javascript各種繼承實作方式進行深入的分析比較,然後再介紹es6中對類別繼承的支援以及優缺點討論。最後介紹了javascript物件導向程式設計中很少被涉及的“多態”,並提供了“運算子重載”的想法。本文假設你已經知道或了解了js中原型、原型鏈的概念。
es6之前,javascript本質上不能算是一門物件導向的程式語言,因為它對於封裝、繼承、多型這些物件導向語言的特點並沒有在語言層面上提供原生的支持。但是,它引入了原型(prototype)的概念,可以讓我們以另一種方式模仿類,並透過原型鏈的方式實現了父類子類之間共享屬性的繼承以及身份確認機制。其實,物件導向的概念本質上來講不是指某種語言特性,而是一種設計思想。如果你深諳物件導向的程式設計思想,也就是使用c這種過程導向的語言也能寫出物件導向的程式碼(典型的代表就是windows NT 核心實作),而javascript亦是如此!正是由於javascript本身對物件導向程式設計沒有一個語言上的支援標準,所以才有了五花八門、令人眼花撩亂的「類別繼承」的程式碼。所幸,es6增加了class、extends、static等關鍵字用來在語言層面支援物件導向,但是,還是有些保守!我們先列舉出es6之前常見的幾種繼承方案,然後再來一探es6的類別繼承機制,最後再討論下javascript多型。
簡而言之,就是直接將父類別的一個實例賦給子類別的原型。如下範例:
function Person(name){ this.name=name; this.className="person" } Person.prototype.getClassName=function(){ console.log(this.className) } function Man(){ } Man.prototype=new Person();//1 //Man.prototype=new Person("Davin");//2 var man=new Man; >man.getClassName() >"person" >man instanceof Person >true
如程式碼中1處所示,這種方法是直接new 了一個父類別的實例,然後賦給子類別的原型。這樣也就相當於直接將父類別原型中的方法屬性以及掛在this上的各種方法屬性全賦給了子類別的原型,簡單粗暴!我們再來看看man,它是Man的一個實例,因為man本身沒有getClassName方法,那麼就會去原型鏈上去找,找到的是person的getClassName。在這種繼承方式下,所有的子類別實例會共用一個父類別物件的實例,這種方案最大問題就是 子類別無法透過父類別建立私有屬性 。例如每個Person都有一個名字,我們在初始化每個Man的時候要指定一個不同名字,然後子類將這個名字傳給父類,對每個man來說,保存在對應person中的name應該是不同的,但是這種方式根本做不到。所以,這種繼承方式,實戰中基本上不用!
function Person(name){ this.name=name; this.className="person" } Person.prototype.getName=function(){ console.log(this.name) } function Man(name){ Person.apply(this,arguments) } var man1=new Man("Davin"); var man2=new Man("Jack"); >man1.name >"Davin" >man2.name >"Jack" >man1.getName() //1 报错 >man1 instanceof Person >true
這裡在子類別的在建構函式裡用子類別實例的this去呼叫父類別的建構函數,從而達到繼承父類屬性的效果。這樣一來,每new一個子類別的實例,建構函式執行完後,都會有自己的一份資源(name)。但是這種辦法只能繼承父類別建構子中宣告的實例屬性,並沒有繼承父類別原型的屬性和方法,所以就找不到getName方法,所以1處會報錯。為了同時繼承父類別原型,從而誕生了組合繼承的方式:
function Person(name){ this.name=name||"default name"; //1 this.className="person" } Person.prototype.getName=function(){ console.log(this.name) } function Man(name){ Person.apply(this,arguments) } //继承原型 Man.prototype = new Person(); var man1=new Man("Davin"); > man1.name >"Davin" > man1.getName() >"Davin"
這個例子很簡單,這樣不僅會繼承建構函式中的屬性,也會複製父類原型鏈中的屬性。但是,有個問題, Man.prototype = new Person(); 這句執行後,Man的原型如下:
> Man.prototype > {name: "default name", className: "person"}
也就是說Man的原型中已經有了一個name屬性,而之後創建man1時傳給建構的函數的name則是透過this重新定義了一個name屬性,相當於只是覆寫了原型的name屬性(原型中的name依然還在),這樣很不優雅。
這是目前es5中主流的繼承方式,有些人取了一個吊炸天的名字「寄生組合繼承」。首先說明一下,兩者是一回事。分離組合繼承的名字是我起的,一來感覺不裝逼會好點,二來,更確切。綜上所述,其實我們可以將繼承分為兩個步驟:建構子屬性繼承和建立子類別和父類別原型的連結。所謂的分離就是分兩步驟;組合是指同時繼承子類別建構子和原型中的屬性。
function Person(name){ this.name=name; //1 this.className="person" } Person.prototype.getName=function(){ console.log(this.name) } function Man(name){ Person.apply(this,arguments) } //注意此处 Man.prototype = Object.create(Person.prototype); var man1=new Man("Davin"); > man1.name >"Davin" > man1.getName() >"Davin"
這裡用到了 Object.creat(obj) 方法,該方法會對傳入的obj物件進行淺拷貝。和上面組合繼承的主要區別是:將父類別的 原型 複製給了子類別原型。這種做法很清楚:
建構子中繼承父類別屬性/方法,並初始化父類別。
子類別原型和父類別原型建立聯繫。
还有一个问题,就是constructor属性,我们来看一下:
> Person.prototype.constructor < Person(name){ this.name=name; //1 this.className="person" } > Man.prototype.constructor < Person(name){ this.name=name; //1 this.className="person" }
constructor是类的构造函数,我们发现,Person和Man实例的constructor指向都是Person,当然,这并不会改变instanceof的结果,但是对于需要用到construcor的场景,就会有问题。所以一般我们会加上这么一句:
Man.prototype.constructor = Man
综合来看,es5下,这种方式是首选,也是实际上最流行的。
行文至此,es5下的主要继承方式就介绍完了,在介绍es6继承之前,我们再往深的看,下面是独家干货,我们来看一下Neat.js中的一段简化源码(关于Neat.js,这里是传送门Neat.js官网,待会再安利):
//下面为Neat源码的简化 ------------------------- function Neat(){ Array.call(this) } Neat.prototype=Object.create(Array.prototype) Neat.prototype.constructor=Neat ------------------------- //测试代码 var neat=new Neat; >neat.push(1,2,3,4) >neat.length //1 >neat[4]=5 >neat.length//2 >neat.concat([6,7,8])//3
现在提问,上面分割线包起来的代码块干了件什么事?
对,就是定义了一个继承自数组的Neat对象!下面再来看一下下面的测试代码,先猜猜1、2、3处执行的结果分别是什么?期望的结果应该是:
4 5 1,2,3,4,5,6,7,8
而实际上却是:
4 4 [[1,2,3,4],6,7,8]
呐尼!这不科学啊 !why ?
我曾在阮一峰的一篇文章中看到的解释如下:
因为子类无法获得原生构造函数的内部属性,通过 Array.apply() 或者分配给原型对象都不行。原生构造函数会忽略 apply 方法传入的 this ,也就是说,原生构造函数的 this 无法绑定,导致拿不到内部属性。ES5是先新建子类的实例对象 this ,再将父类的属性添加到子类上,由于父类的内部属性无法获取,导致无法继承原生的构造函数。比如,Array构造函数有一个内部属性 [[DefineOwnProperty]] ,用来定义新属性时,更新 length 属性,这个内部属性无法在子类获取,导致子类的 length 属性行为不正常。
然而,事实并非如此!确切来说,并不是原生构造函数会忽略掉 apply 方法传入的this而导致属性无法绑定。要不然1处也不会输出4了。还有,neat依然可以正常调用push等方法,但继承之后原型上的方法有些也是有问题的,如neat.concat。其实可以看出,我们通过 Array.call(this) 也是有用的,比如length属性可用。但是,为什么会出问?根据症状,可以肯定的是最终的this肯定有问题,但具体是什么问题呢?难道是我们漏了什么地方导致有遗漏的属性没有正常初始化?或者就是浏览器初始化数组的过程比较特殊,和自定义对象不一样?首先我们看第一种可能,唯一漏掉的可能就是数组的静态方法(上面的所有继承方式都不会继承父类静态方法)。我们可以测试一下:
for(var i in Array){ console.log(i,"xx") }
然而并没有一行输出,也就是说Array并没有静态方法。当然,这种方法只可以遍历可枚举的属性,如果存在不可枚举的属性呢?其实即使有,在浏览器看来也应该是数组私有的,浏览器不希望你去操作!所以第一种情况pass。那么只可能是第二种情况了,而事实,直到es6出来后,才找到了答案:
ES6允许继承原生构造函数定义子类,因为ES6是先新建父类的实例对象this,然后再用子类的构造函数修饰this,使得父类的 所有行为 都可以继承。
请注意我加粗的文字。“所有”,这个词很微妙,不是“没有”,那么言外之意就是说es5是部分了。根据我之前的测试(在es5下),下标操作和concat在chrome下是有问题的,而大多数函数都是正常的,当然,不同浏览器可能不一样,这应该也是jQuery每次操作后的结果集以一个新的扩展后的数组的形式返回而不是本身继承数组(然后再直接返回this的)的主要原因,毕竟jQuery要兼容各种浏览器。而Neat.js面临的问题并没有这么复杂,只需把有坑的地方绕过去就行。言归正传,在es5中,像数组一样的,浏览器不让我们愉快与之玩耍的对象还有:
Boolean() Number() String() Array() Date() Function() RegExp() Error() Object()
es6引入了class、extends、super、static(部分为ES2016标准)
class Person{ //static sCount=0 //1 constructor(name){ this.name=name; this.sCount++; } //实例方法 //2 getName(){ console.log(this.name) } static sTest(){ console.log("static method test") } } class Man extends Person{ constructor(name){ super(name)//3 this.sex="male" } } var man=new Man("Davin") man.getName() //man.sTest() Man.sTest()//4 输出结果: Davin static method test
ES6明确规定,Class内部只有静态方法,没有静态属性,所以1处是有问题的,ES7有一个静态属性的 提案 ,目前Babel转码器支持。熟悉java的可能对上面的代码感觉很亲切,几乎是自解释的。我们大概解释一下,按照代码中标号对应:
constructor为构造函数,一个类有一个,相当于es5中构造函数标准化,负责一些初始化工作,如果没有定义,js vm会定义一个空的默认的构造函数。
实例方法,es6中可以不加"function"关键字, class内定义的所有函数都会置于该类的原型当中 ,所以,class本身只是一个语法糖。
构造函数中通过super()调用父类构造函数,如果有super方法,需要时构造函数中第一个执行的语句,this关键字在调用super之后才可用。
静态方法,在类定义的外部只能通过类名调用,内部可以通过this调用,并且静态函数是会被继承的。如示例中:sTest是在Person中定义的静函数,可以通过 Man.sTest() 直接调用。
大多数浏览器的ES5实现之中,每一个对象都有 proto 属性,指向对应的构造函数的prototype属性。Class作为构造函数的语法糖,同时有prototype属性和 proto 属性,因此同时存在两条继承链。
(1)子类的 proto 属性,表示构造函数的继承,总是指向父类。
(2)子类 prototype 属性的 proto 属性,表示方法的继承,总是指向父类的 prototype 属性。
class A { } class B extends A { } B.proto === A // true B.prototype.proto === A.prototype // true
上面代码中,子类 B 的 proto 属性指向父类 A ,子类 B 的 prototype 属性的 proto 属性指向父类 A 的 prototype 属性。
这样的结果是因为,类的继承是按照下面的模式实现的:
class A { } class B { } // B的实例继承A的实例 Object.setPrototypeOf(B.prototype, A.prototype); // B继承A的静态属性 Object.setPrototypeOf(B, A);
Object.setPrototypeOf的简单实现如下:
Object.setPrototypeOf = function (obj, proto) { obj.proto = proto; return obj; }
因此,就得到了上面的结果。
Object.setPrototypeOf(B.prototype, A.prototype); // 等同于 B.prototype.proto = A.prototype; Object.setPrototypeOf(B, A); // 等同于 B.proto = A;
这两条继承链,可以这样理解:作为一个对象,子类( B )的原型( proto 属性)是父类( A );作为一个构造函数,子类( B )的原型( prototype 属性)是父类的实例。
Object.create(A.prototype); // 等同于 B.prototype.proto = A.prototype;
不支持静态属性(除函数)。
class中不能定义私有变量和函数。class中定义的所有函数都会被放倒原型当中,都会被子类继承,而属性都会作为实例属性挂到this上。如果子类想定义一个私有的方法或定义一个private 变量,便不能直接在class花括号内定义,这真的很不方便!
总结一下,和es5相比,es6在语言层面上提供了面向对象的部分支持,虽然大多数时候只是一个语法糖,但使用起来更方便,语意化更强、更直观,同时也给javascript继承提供一个标准的方式。还有很重要的一点就是-es6支持原生对象继承。
更多es6类继承资料请移步:MDN Classess 。
多态(Polymorphism)按字面的意思就是“多种状态”。在面向对象语言中,接口的多种不同的实现方式即为多态。这是标准定义,在c++中实现多态的方式有虚函数、抽象类、模板,在java中更粗暴,所有函数都是“虚”的,子类都可以重写,当然java中没有虚函数的概念,我们暂且把相同签名的、子类和父类可以有不同实现的函数称之为虚函数,虚函数和模版(java中的范型)是支持多态的主要方式,因为javascript中没有模版,所以下面我们只讨论虚函数,下面先看一个例子:
function Person(name,age){ this.name=name this.age=age } Person.prototype.toString=function(){ return "I am a Person, my name is "+ this.name } function Man(name,age){ Person.apply(this,arguments) } Man.prototype = Object.create(Person.prototype); Man.prototype.toString=function(){ return "I am a Man, my name is"+this.name; } var person=new Person("Neo",19) var man1=new Man("Davin",18) var man2=new Man("Jack",19) > person+"" > "I am a Person, my name is Neo" > man1+"" > "I am a Man, my name isDavin" > man1<man2 //期望比较年龄大小 1 > false
上面例子中,我们分别在子类和父类实现了toString方法,其实,在js中上述代码原理很简单,对于同名函数,子类会覆父类的,这种特性其实就是虚函数,只不过js中不区分参数个数,也不区分参数类型,只看函数名称,如果名称相同就会覆盖。现在我们来看注释1,我们期望直接用比较运算符比较两个man的大小(按年龄),怎么实现?在c++中有运算符重载,但java和js中都没有,所幸的是,js可以用一种变通的方法来实现:
function Person(name,age){ this.name=name this.age=age } Person.prototype.valueOf=function(){ return this.age } function Man(name,age){ Person.apply(this,arguments) } Man.prototype = Object.create(Person.prototype); var person=new Person("Neo",19) var man1=new Man("Davin",18) var man2=new Man("Jack",19) var man3=new Man("Joe",19) >man1<19//1 >true >person==19//2 >true >man1<man2//3 >true >man2==man3 //4 注意 >true >person==man2//5 >false
其中1、2、3、5在所有js vm下结果都是确定的。但是4并不一定!javascript规定,对于比较运算符,如果一个值是对象,另一个值是数字时,会先尝试调用valueOf,如果valueOf未指定,就会调用toString;如果是字符串时,则先尝试调用toString,如果没指定,则尝试valueOf,如果两者都没指定,将抛出一个类型错误异常。如果比较的两个值都是对象时,则比较的时对象的引用地址,所以若是对象,只有自身===自身,其它情况都是false。现在我们回过头来看看示例代码,前三个都是标准的行为。而第四点取决于浏览器的实现,如果严格按照标准,这应该算是chrome的一个bug ,但是,我们的代码使用时双等号,并非严格相等判断,所以浏览器的相等规则也会放宽。值得一提的是5,虽然person和man2 age都是19,但是结果却是false。 总结一下,chrome对相同类的实例比较策略是先会尝试转化,然后再比较大小,而对非同类实例的比较,则会直接返回false,不会做任何转化。 所以我的建议是:如果数字和类实例比较,永远是安全的,可以放心玩,如果是同类实例之间,可以进行 非等 比较,这个结果是可以保证的,不要进行相等比较,结果是不能保证的,一般相等比较,变通的做法是:
var equal= !(ob1<ob2||ob1>ob2) //不小于也不大于,就是等于,前提是比较操作符两边的对象要实现valueOf或toString
当然类似toString、valueOf的还有toJson方法,但它和重载没有什么关系,故不冗述。
让对象支持数学运算符本质上和让对象支持比较运算符原理类似,底层也都是通过valueOf、toString来转化实现。 但是通过这种覆盖原始方法模拟的运算符重载有个比较大局限就是:返回值只能是数字!而c++中的运算符重载的结果可以是一个对象 。试想一下,如果我们现在要实现一个复数类的加法,复数包括实部与虚部,加法要同时应用到两个部分,而相加的结果(返回值)仍然是一个复数对象,这种情况下,javascript也就无能为力了。
【相关推荐】
1. 特别推荐:“php程序员工具箱”V0.1版本下载
2. 免费js在线视频教程
3. php.cn独孤九贱(3)-JavaScript视频教程
以上是總結Javascript的繼承和多態性的詳細內容。更多資訊請關注PHP中文網其他相關文章!