欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

由Javascript的继承引发的:抽象类、接口、多态,甚至是类型转换!

程序员文章站 2022-04-18 13:36:34
...

缘起

工作中需要用到Javascript,关注了Javascript中继承复用的问题,翻阅了网上的各种关于Javascript继承的帖子,感觉大都思考略浅,并没有做过多说明,简单粗暴的告诉你实现Javascript继承有1.2.3.4.5几种方式,名字都叫啥,然后贴几行代码就算是玩了。 
不管你们懂没懂,反正我着实没懂。 
随后研读了《Javascript高级程序设计》的部分章节,对Javascript的继承机制略有体会。思考之后,遂而分享并且阐述了如何实现抽象类、接口、多态甚至是类型转换的思路。

JS继承,那就先说“继承”

凡是玩过1、2种面向对象的语言的人大都不难归纳出继承所有的几个特性: 
1. 子类继承父类中的属性和方法 
2. 子类、父类的实例对象拥有两份副本,改了其中之一,另一个实例对象的属性并不会随之改变 
3. 子类可覆盖父类的方法或属性 
4. 子类和父类的实例对象通过“[对象] instanceof [子类/父类]”判定的结果应该为true 
5. 子类和父类的实例对象的constructor指针应该分别指向子类和父类的构造函数

构造一个类

说到构造一个Javascript的类,网上的说法五花八门。 
1. 有说JS中根本没有类,用模拟实现的。对,但是也不对。 
Javascript中的确没有class关键字,但是这并不带表我们封装不出一个“类”一样的东西来。只不过在Javascript中不叫这个名字而已。遂而有人会反驳,在ECMAScript 6标准中要加入class关键字了,这不是明显表示javascript现在不存在“类”么?对于这样的抬杠,只能“呵呵”了。 
2. 也有人说JS可以这么构造一个类:

1 var Person = function(name, age) {
2     this.name = name;
3     this.age = age;
4 };
5 var p = new Person("小王", 10);

注1:此为代码1,后面可能作为引用。 
注2: var Person = function(){}; 等同于 function Persson(){},前一种定义函数的方式没有名字,故而在var的后面跟上其名字,而后面function定义直接就跟了名字Person了。不过事实上我更喜欢后一种,因为可以少写一个var和分号。但是如果在局部作用域要定义一个临时类,我还是喜欢前一种,这是一种变量的方式。在局部作用域我更喜欢定义变量而不是函数或者类等结构性的东西,C语言后遗症,呵呵。 
注3:其实这种构建类的方式可以说成是通过构造器(constructor)来构造一个类。 
3. 也有人说,应该用Prototype来构造一个类,简要代码如下:

1 var Person = function (){};
2 Person.prototype.name = "小王";
3 Person.prototype.age = 10;
4 var p = new Person();

注4:这种构造方式,我们可以暂且称之为“用prototype”的方式来构造。 
注5:此为代码2,后面可能作引用。 
类的构建方式虽然五花八门,但是大抵都是以上两种或者其组合的变种。可是我们什么时候用构造函数来构建?什么时候用prototype?什么时候两者结合使用呢?要明白这个,我们先来看看new关键字。

new,你到底干了什么事儿?

new关键字在绝大多数面向对象的语言中都扮演者举足轻重的位置,javascript中也不例外。*上有一篇帖子关于new关键字的玄机,我觉得说的很好:Javascript中的new关键字背后到底做了什么 
翻译如下,为了懒得移步的童鞋,PC端的童鞋可以直接点过去。

  1. 创建一个新的简单的Object类型的的对象; 
  2. 把Object的内部的[[prototype]]属性设置为构造函数prototype属性。这个[[prototype]]属性在Object内部是无法访问到的,而构造函数的prototype是可以访问到的; 
  3. 执行构造函数,如果构造函数中用到了this关键字,那就把这个this替换为刚刚创建的那个object对象。 

注6:其实某个对象的[[prototype]]属性在很多宿主环境中已经可以访问到,例如Chrome和IE10都可以,用_proto_就可以访问到,如果下面出现了_proto_字样,那就代表一个对象的内部prototype。 
上面说了一大通,又是构造器,又是prototype,不知所云。下面依次解释。

prototype

prototype属性在构造函数中可以访问到,在对象中需要通过prototype访问到。它到底是什么?prototype中定义了一个类所共享的属性和方法。这就意味着:一旦prototype中的某个属性的值变了,那么所有这个类的实例的该属性的值都变了。请看代码:

01 function Person() {
02 }
03 Person.prototype.name = "小明";
04 var p1 = new Person();
05 console.log(Person.prototype);
06 console.log(p1.__proto__);
07 var p2 = new Person();
08 console.log(p1.name + "\t" + p2.name);
09 Person.prototype.name = "小王";
10 console.log(p1.name + "\t" + p2.name);

注7:此为代码3。 输出结果如下: 
由Javascript的继承引发的:抽象类、接口、多态,甚至是类型转换!
            
    
    博客分类: 前端开发 Javascript面向对象继承prototype类型转换 
通过这个代码3的实验,我们可以得出以下结论: 
1. prototype属性其实就是一个实例对象,其内容为:Person {name: "小明"} 
2. 通过构造函数可以访问到prototype属性,通过对象的_proto_也可以访问到prototype属性。 
3. prototype原型指向的内容是所有对象共享的,只要prototype对象的某个属性或者方法变了,那么所有的通过这个类new出来的实例对象的该属性和方法都变了。

this和构造函数

看完了上面的new关键字做的第3步,我们不难得出,其实利用constructor的方式来构造类本质:先new一个临时实例对象,将this关键字替换为临时实例对象关键字,然后使用[对象].[属性]=xxx的方式来构建一个对象,再将其返回。 
可是这样带来一个问题就是:方法不被共享。 
请看代码4实验:

01 function Person() {
02     this.name = "小明";
03     this.showName = function() {
04         console.log(this.name)
05     };
06 }
07 var p1 = new Person();
08 var p2 = new Person();
09 p1.showName();
10 p2.showName();
11 p1.showName = function() {
12     console.log("我不是小明,我是小王");
13 }
14 p1.showName();
15 p2.showName();

注8:以上为代码4。 
其运行结果为: 
由Javascript的继承引发的:抽象类、接口、多态,甚至是类型转换!
            
    
    博客分类: 前端开发 Javascript面向对象继承prototype类型转换 
我们知道,类的同一个方法,应该尽量保持共享,因为他们属于同一个类,那么这一个方法应该相同,所以应该保持共享,不然会浪费内存。 
我们的Person类中含有方法showName,虽然p1和p2实例属于两个实例对象,但是其showName却指向了不同的内存块! 
这可怎么办? 
对,请出我们的prototype,它可以实现属性和方法的共享。请看代码5实验:

01 function Person() {
02     this.name = "小明";
03 }
04 Person.prototype.showName = function() {
05     console.log(this.name);
06 }
07 var p1 = new Person();
08 var p2 = new Person();
09 p1.showName();
10 p2.showName();
11 Person.prototype.showName = function() {
12     console.log("我的名字是" this.name);
13 }
14 p1.showName();
15 p2.showName();

注9:以上为代码5 。 运行结果如下: 
由Javascript的继承引发的:抽象类、接口、多态,甚至是类型转换!
            
    
    博客分类: 前端开发 Javascript面向对象继承prototype类型转换 
这样我们非常完美地完成了一个类的构建,他满足: 
1. 属性非共享 
2. 方法共享(其实对于需要共享的属性,我们也可以用prototype来设置) 
但是!大家在使用prototype来设置共享方法的时候千万不要把构造函数的整个prototype都改写了。这样导致的结果就是:constructor不明。 
请看代码6实验。

01 function Person1() {
02 }
03 // prototype 没有完全被改写
04 Person1.prototype.showName = function() {
05     console.log(this.name);
06 };
07 var p1 = new Person1();
08 console.log(p1 instanceof Person1);
09 console.log(p1.constructor);
10 function Person2() {
11 }
12 // prototype 完全被改写
13 Person2.prototype = {
14     showName : function() {
15         console.log(this.name);
16     }
17 };
18 var p2 = new Person2();
19 console.log(p2 instanceof Person2);
20 console.log(p2.constructor);

注10:以上为代码6 。 运行结果如下: 
由Javascript的继承引发的:抽象类、接口、多态,甚至是类型转换!
            
    
    博客分类: 前端开发 Javascript面向对象继承prototype类型转换 
通过以上代码6的实验,我们可以看出:重写整个prototype会将对象的constructor指针直接指向了Object,从而导致了constructor不明的问题。 
如何解决呢?我们可以通过显示指定其constructor为Person即可。 
请看代码7:

01 function Person2() {
02 }
03 // prototype 完全被改写
04 Person2.prototype = {
05     constructor : Person2, // 显示指定其constructor
06     showName : function() {
07         console.log(this.name);
08     }
09 };
10 var p2 = new Person2();
11 console.log(p2 instanceof Person2);
12 console.log(p2.constructor);

注11:以上为代码7 。 运行结果如下: 由Javascript的继承引发的:抽象类、接口、多态,甚至是类型转换!
            
    
    博客分类: 前端开发 Javascript面向对象继承prototype类型转换

对象、constructor和prototype三者之间的关系

上面说了那么多,我想大家都有点被constructor、prototype、对象搞得云里雾里的,其实我刚开始也是这样。下面我总结叙述一下这三者之间的关系,相信看了之后就会逐渐明白的: 
1. 构造函数有个prototype属性,这个prototype属性指向一个实例对象,这个对象的所有的属性和方法为所有该构造函数实例化的类所共享! 
2. 对象的创建是通过constructor构造函数来创建的,每当new一次就调用一次构造函数,构造函数内部执行的机制是:new一个临时Object实例空对象,然后把this关键字提换成这个临时对象,然后依次设置这个临时对象的各个属性和方法,最后返回这个临时实例对象。 
3. 被实例化的对象本身有个_proto_指针,指向创建该对象的构造函数的的prototype对象。 
如果你还是云里雾里的,没有关系,我们来看下Javascript的Object架构,看完这个你肯定就会明白的一清二楚了。

Javascript的Object架构

由Javascript的继承引发的:抽象类、接口、多态,甚至是类型转换!
            
    
    博客分类: 前端开发 Javascript面向对象继承prototype类型转换 
解释如下: 
1. var f1 = new Foo();创建了一个Foo对象; 
2. f1对象有个内部__proto__属性,指向了一个prototype的实例对象Foo.prototype; 
3. Foo.prototype有个constructor属性,指向了Foo构造函数,这个属性的值标明了,这个f1对象的类型,也即f1 instanceof Foo的结果为true; 
4. 构造函数Foo有个prototype属性,指向了prototype实例对象,这个prototype属性是通过Foo可以直接访问到的Foo.prototype; 
5. 剩下的解释,大家能看就看懂,看不懂我后续再出文章解释吧。与本篇关系不是太大了。

Javascript对象的属性查找方式

我们访问一个Javascript对象的属性(含“方法”)的时候,查找过程到底是什么样的呢? 
先找先找对象属性,对象的属性中没有,那就找对象的prototype共享属性 
请看代码8:

1 var p = {
2     name : "小明"
3 };
4 //对象中能查找到name
5 console.log(p.name);
6 //对象中找不到myName,查找其prototype属性,由于p是Object类型的对象,故而查找Object的prototype是否有myName
7 console.log(p.myName);
8 Object.prototype.myName="我的名字是小明";
9 console.log(p.myName);

注12:以上为代码8 。 
结果如下: 
由Javascript的继承引发的:抽象类、接口、多态,甚至是类型转换!
            
    
    博客分类: 前端开发 Javascript面向对象继承prototype类型转换 
此处不难理解,不多做解释。

按照“继承”理念来实现JS继承

在我们懂了prototype、constructor、对象、new之后,我们可以真正按照“继承”的理念来实现javascript的继承了。

原型链

试想一下,如果构造函数的prototype对象的_proto_指针(每个实例对象都有一个proto指针)指向的是另一个prototype对象(我们称之为prototype对象2)的话,而prototype对象2的constructor指向的是构建prototype对象2的构造函数。那么依次往复,就构成了原型链。 
上面的话有点绕口,大家多多体会。 
我结合上面的Javascript对象的架构继续给大家说说: 
1. 大家可以看到Foo.prototype对象的_proto_指向了Object.prototype对象,而这个Object.prototype的constructor属性指向的是Object构造函数。这里就是一个简单的原型链。 
2. 所有的类都有原型链,最终指向Object。 
大家或许已经怀疑,听说Javascript的所有的对象都是继承自Object对象,那么Javascript继承是不是就这个原型连搞的鬼呢? 
是,但是不完全是。 
原型链只能继承共享的属性和方法,对于非共享的属性和方法,我们需要通过显示调用父类构造函数来实现 
查找对象的属性的修正: 
1. 查找对象是否含有该属性; 
2. 如果没有改属性,则查找其prototype是否含有该属性; 
3. 如果还是没有,则向上查找原型链的上一级,查找其prototype的_proto_所指向的prototype是否含有该属性,直到查找Object。 
所以很简单,我们想要实现Javascript的继承已经呼之欲出了: 
1. 继承prototype中定义的属性和方法; 
2. 继承构造函数中定义的属性和方法; 
3. 修改子类的prototype对象的constructor指针,使得constructor的判别正确。

继承构造函数中定义的属性和方法

我们通过call或者apply方法即可实现父类构造函数调用,然后把当前对象this和参数传递给父类,这样就可以实现继承构造函数中定义的属性和方法了。请看代码9:

01 function Person(name, age) {
02     this.name = name;
03     this.age = age;
04 }
05 Person.prototype.showName = function() {
06     console.log(this.name);
07 };
08 function Male(name, age) {
09     // 调用Person构造函数,把this和参数传递给Person
10     Person.apply(this, arguments);
11     this.sex = "男";
12 }
13 var m = new Male("小明", 20);
14 console.log(m);
15 console.log(m instanceof Male);
16 console.log(m instanceof Person);
17 console.log(m instanceof Object);

执行结果如下: 
由Javascript的继承引发的:抽象类、接口、多态,甚至是类型转换!
            
    
    博客分类: 前端开发 Javascript面向对象继承prototype类型转换 
1. 大家可以看到,m就是一个很简单的对象,只有name,age,sex三个属性,不含有showName方法,因为这个是在Person.prototype中继承过来的。 
2. m instanceof Person结果为false, 显然m.\__proto\__.constructor指向的是Male构造函数,而非Person。 3. 可是m instanceof Object的结果却为true,那是因为m的原型链的上一级为Object类型,故而instance of Object的结果为true。

继承prototype中定义的属性和方法,并且与继承构造函数结合起来

如何继承prototype中定义的属性和方法呢? 
直接把父类的prototype给子类的prototype不就行了。 
的确,这样是能够实现方法共享,可是一旦子类的prototype的某个方法被重写了,那么父类也会搁着变动,怎么办? 
new一个父类!赋值给子类的prototype。 
请看代码10:

01 function Person(name, age) {
02     this.name = name;
03     this.age = age;
04 }
05 Person.prototype.showName = function() {
06     console.log(this.name);
07 };
08 function Male(name, age) {
09     // 调用Person构造函数,把this和参数传递给Person
10     Person.apply(this, arguments);
11     this.sex = "男";
12 }
13 // 继承prototype
14 Male.prototype = new Person();
15 var m = new Male("小明", 20);
16 console.log(m);
17 console.log(m instanceof Male);
18 console.log(m instanceof Person);
19 console.log(m instanceof Object);

结果如下: 
由Javascript的继承引发的:抽象类、接口、多态,甚至是类型转换!
            
    
    博客分类: 前端开发 Javascript面向对象继承prototype类型转换 
大家可以看到m对象不仅有name, age , sex三个属性,而且通过其原型链可以找到showName方法。 
如果大家仔细观察,会发现多出了两个undefined值的name和age! 
为什么?! 
究其原因,因为在执行Male.prototype = new Person()的时候,这两个属性就在内存中分配了值了。而且改写了Male的整个prototype,导致Male对象的constructor也跟着变化了,这也不好。 
这并不是我们想要的!我们只是单纯的想要继承prototype,而不想要其他的属性。 
怎么办? 
借用一个空的构造函数,借壳继承prototype,并且显示设置constructor 
代码如下:

01 function Person(name, age) {
02     this.name = name;
03     this.age = age;
04 }
05 Person.prototype.showName = function() {
06     console.log(this.name);
07 };
08 function Male(name, age) {
09     // 调用Person构造函数,继承构造函数的属性,把this和参数传递给Person
10     Person.apply(this, arguments);
11     this.sex = "男";
12 }
13 // 借用一个空的构造函数
14 function F() { }
15 F.prototype = Person.prototype;
16 // 继承prototype
17 Male.prototype = new F();
18 // 显示指定constructor
19 Male.prototype.constructor = Male;
20 var m = new Male("小明", 20);
21 console.log(m);
22 m.showName();
23 console.log(m.constructor == Male);
24 console.log(m instanceof Person);
25 console.log(m instanceof Male);
26 console.log(m instanceof F);

执行结果: 
由Javascript的继承引发的:抽象类、接口、多态,甚至是类型转换!
            
    
    博客分类: 前端开发 Javascript面向对象继承prototype类型转换 
我们可喜的将m的constructor正本清源!而且instanceof类型判断都没有错误(instanceof本质上是通过原型链找的,只要有一个原型满足了那结果就为true)。

继承prototype的封装&测试

上述继承prototype的代码很是丑陋,让我们封装起来吧。并且测试了一下代码:

01 // 继承prototype & 设定subType的constructor为子类,不跟着prototype变化而变化
02 function inheritPrototype(subType, superType) {
03     // 以下三行可以写成一个新的函数来完成
04     function F() {
05     }
06     // 把F的prototype指向父类的prototype,修改整个prototype而不是部分prototype
07     F.prototype = superType.prototype;
08     // new F()完成两件事情,1. 执行F构造函数,为空;2. 执行F的prototype的内存分配,这里就是父类,也就是Person的getAge方法
09     // 所以这里是继承了父类的getAge()方法,赋值给了proto
10     var proto = new F();
11     // proto的构造函数显示指定为子类(由于上面重写了F的prototype,故而构造函数也变化了)
12     proto.constructor = subType;
13     // 实现真正意义上的prototype的继承,并且constructor为子类
14     subType.prototype = proto;
15 }
16 function Person(name, age) {
17     this.name = name;
18     this.age = age;
19     this.getName = function() {
20         return this.name;
21     };
22 }
23 Person.prototype.getAge = function() {
24     return this.age;
25 };
26 function Male(name, age) {
27     Person.apply(this, [name, age]); // 借用构造函数继承属性
28     this.sex = "男";
29     this.getSex = function() {
30         return this.sex;
31     };
32 }
33 inheritPrototype(Male, Person);
34 // 方法覆盖
35 Male.prototype.getAge = function() {
36     return this.age + 1;
37 };
38 var p = new Person("好女人", 30);
39 var m = new Male("好男人", 30);
40 console.log(p);
41 console.log(m);
42 console.log(p.getAge());
43 console.log(m.getAge());

运行结果为: 
由Javascript的继承引发的:抽象类、接口、多态,甚至是类型转换!
            
    
    博客分类: 前端开发 Javascript面向对象继承prototype类型转换 
至此,我们已经完成了真正意义上的javascript继承! 
让我们再来回头验证一下,TTD嘛~呵呵 
1. 子类继承父类中的属性和方法。Check! 
2. 子类、父类的实例对象拥有两份副本,改了其中之一,另一个实例对象的属性并不会随之改变。Check!通过constructor继承属性,由于采用了new,故而每个实例对象的属性肯定是有不同的副本。 
3. 子类可覆盖父类的方法或属性。Check!由于方法的继承是采用继承prototype来实现的,借F的prototype来继承,所以所有被继承的方法都在new F()的一刹那存在了F中,而F是一个空构造函数,故而没有多余的属性,只有被继承的方法。我们再将这个F实例对象指向子类构造函数的prototype对象,即可实现方法继承。从而在改写子类的prototype中的方法并不会影响到父类的prototype中的方法,从而实现方法重写! 
4. 子类和父类的实例对象通过“[对象] instanceof [子类/父类]”判定的结果应该为true。Check!原型链没有断掉。子类的_proto_指向F,F的_proto_指向父类。 5. 子类和父类的实例对象的constructor指针应该分别指向子类和父类的构造函数。Check!我们在写的过程中显示制定了constructor,所以constructor指针的指向也不会错。

总结

我们是通过: 
1. 继承父类的构造函数来实现属性继承; 
2. 借中间函数F,继承父类的prototype来实现方法继承&方法覆盖; 
3. 显示指定constructor防止prototype改写带来的问题。 
至此,较为漂亮的完成了Javascript的继承! 
通过此思路,想要实现抽象类,接口等面向对象的概念应该也不是难事吧。呵呵。 
抽象类:父类构造函数中只有方法定义,则该父类即为抽象父类。 
接口:父类构造函数中方法定义为空。 
多态:父类中调用一个未实现的函数,在子类中实现即可。 
类型转换:把中间层F断掉,重新指定实例对象的_proto_指向的prototype对象,那么F中继承的方法将不复存在,故而调用方法就是直接调用被指向的prototype对象的方法了。关于类型转换的代码如下:

01 // 继承prototype & 设定subType的constructor为子类,不跟着prototype变化而变化
02 function inheritPrototype(subType, superType) {
03     // 以下三行可以写成一个新的函数来完成
04     function F() {
05     }
06     // 把F的prototype指向父类的prototype,修改整个prototype而不是部分prototype
07     F.prototype = superType.prototype;
08     // new F()完成两件事情,1. 执行F构造函数,为空;2. 执行F的prototype的内存分配,这里就是父类,也就是Person的getAge方法
09     // 所以这里是继承了父类的getAge()方法,赋值给了proto
10     var proto = new F();
11     // proto的构造函数显示指定为子类(由于上面重写了F的prototype,故而构造函数也变化了)
12     proto.constructor = subType;
13     // 实现真正意义上的prototype的继承,并且constructor为子类
14     subType.prototype = proto;
15 }
16 function Person(name, age) {
17     this.name = name;
18     this.age = age;
19     this.getName = function() {
20         return this.name;
21     };
22 }
23 Person.prototype.getAge = function() {
24     return this.age;
25 };
26 function Male(name, age) {
27     Person.apply(this, [name, age]); // 借用构造函数继承属性
28     this.sex = "男";
29     this.getSex = function() {
30         return this.sex;
31     };
32 }
33 inheritPrototype(Male, Person);
34 // 方法覆盖
35 Male.prototype.getAge = function() {
36     return this.age + 1;
37 };
38 var p = new Person("好女人", 30);
39 var m = new Male("好男人", 30);
40 console.log(p);
41 console.log(m);
42 // 将m转换为Person类型从而调用Person类的方法
43 m.__proto__ = Person.prototype;
44 console.log(p.constructor == Person);
45 console.log(m.constructor == Male);
46 console.log(m instanceof Male);
47 console.log(m instanceof Person);
48 console.log(p.getAge());
49 console.log(m.getAge());
50 // 将m转换为Male类型从而调用Male类的方法
51 m.__proto__ = Male.prototype;
52 console.log(p.constructor == Person);
53 console.log(m.constructor == Male);
54 console.log(m instanceof Male);
55 console.log(m instanceof Person);
56 console.log(p.getAge());
57 console.log(m.getAge());

运行结果: 
由Javascript的继承引发的:抽象类、接口、多态,甚至是类型转换!
            
    
    博客分类: 前端开发 Javascript面向对象继承prototype类型转换 
大家可以看到类型转换之后,调getAge()方法的不同了吧。

【文献引用】 
1.《Professional Javascript for Web Developers》 3rd. Edition 第六章