实现继承


要说为何基于原型的 JavaScript 中,始终有人追求基于类的模拟,主要的原因之一,大概就是在实现继承时,基于原型的方式,是许多开发者难以掌握,或者实现上复杂、难以阅读的地方,因而寄望在基于类的模拟下,在继承这方面能够有更直觉、更加简化、更容易掌握的方式。

ES6 提供了定义(模拟)类时的标准化方式,而在继承这方面,可以使用extends来实现。例如在〈模拟类的封装与继承〉中看到的继承模拟:

function Circle(x, y, r) {
    this.x = x;
    this.y = y;
    this.r = r;
}

Circle.PI = 3.14159; // 相当于Java类的静态方法

Circle.prototype.area = function() {
    return this.r * this.r * Circle.PI;
};

Circle.prototype.toString = function() {
    var text = [];
    for(var p in this) {
        if(typeof this[p] != 'function') {
            text.push(p + ':' + this[p]);
        }
    }
    return '[' + text.join() + ']';
};

function Cylinder(x, y, r, h) {
    Circle.call(this, x, y, r); // 调用父构造函数
    this.h = h;
}

// 原型继承
Cylinder.prototype = new Circle();

// 设定原型对象之constructor为目前构造函数
Cylinder.prototype.constructor = Cylinder;

// 以下在 new 时会再构造,不需要留在原型对象上
delete Cylinder.prototype.x;
delete Cylinder.prototype.y;
delete Cylinder.prototype.r;

// 共用的对象方法设定在 prototype 上
Cylinder.prototype.volumn = function() {
    return this.area() * this.h;
};

在 ES6 中可以写成:

class Circle {
    constructor(x, y, r) {
        this.x = x;
        this.y = y;
        this.r = r;     
    }

    area() {
        return this.r * this.r * Circle.PI();
    }

    toString() {
        let text = [];
        for(let p in this) {
            if(typeof this[p] != 'function') {
                text.push(p + ':' + this[p]);
            }
        }
        return '[' + text.join() + ']';     
    }

    static PI() {
        return 3.14159;
    }
}

class Cylinder extends Circle {
    constructor(x, y, r, h) {
        super(x, y, r); // 调用父构造函数
        this.h = h;
    }

    volumn() {
        return this.area() * this.h;
    }
}

let cylinder = new Cylinder(0, 0, 10, 5);
console.log(cylinder.area());
console.log(cylinder.toString());
console.log(cylinder.volumn());
console.log(Cylinder.PI());

如果熟悉基于类的继承,对上面的程序同样无需做太多的解释,而这边也看到了super在构造函数中,可以用来调用父构造函数,这是过去基于原型模拟类继承时,难以做到的功能,而子类也可以查找到父类中的static方法。

如果定义了子类构造函数,除非子类构造函数最后return了一个与this无关的对象,否则一定要明确地使用super来调用父类构造函数,不然new时会引发错误:

> class A {}
undefined
> class B extends A {
...     constructor() {}
... }
undefined
> new B();
ReferenceError: Must call super constructor in derived class before accessing 't
his' or returning from derived constructor
    at new B (repl:2:16)
    at repl:1:1
    at ContextifyScript.Script.runInThisContext (vm.js:50:33)
    at REPLServer.defaultEval (repl.js:240:29)
    at bound (domain.js:301:14)
    at REPLServer.runBound [as eval] (domain.js:314:12)
    at REPLServer.onLine (repl.js:441:10)
    at emitOne (events.js:121:20)
    at REPLServer.emit (events.js:211:7)
    at REPLServer.Interface._onLine (readline.js:282:10)
>

在子类构造函数中试图使用this之前,也一定要先使用super调用父类构造函数,就类风格来说,可以想成父类构造初始化化必须先完成,再执行子类初始化化;如果没有子类没有定义构造函数,自动加入的构造函数中会调用父类构造函数。

super也可以用在方法之中,这可用来指定调用父类中定义的方法,例如:

> class A {
...     toString() {
.....       return 'A';
.....   }
... }
undefined
> class B extends A {
...     toString() {
.....         return super.toString() + 'B'
.....   }
... }
undefined
> let b = new B();
undefined
> b.toString();
'AB'
>

而在 ES6 中要继承内置的类型变得简单多了:

> class MyArray extends Array {}
undefined
> let myArray = new MyArray();
undefined
> myArray[0] = 1;
1
> myArray[1] = 10;
10
> myArray.length;
2
> myArray instanceof Array;
true
>

若父类与子类中有同名的静态方法,可以使用super来指定调用父类的静态方法:

> class A {
...     static show() {
.....         console.log('A show');
.....    }
... }
undefined
> class B extends A {
...     static show() {
.....         super.show();
.....         console.log('B show');
.....   }
... }
undefined
> B.show();
A show
B show
undefined
>

如果你来自基于类的某个面向对象语言,知道这些大概就蛮足够了,当然,JavaScript 终究是个基于原型的面向对象语言,以上的继承语法,很大成份是语法蜜糖,也大致上可以对照至基于原型的写法,你反过来透过原型对象的设定与操作,也可以影响既定的类定义。

只不过,既然决定使用基于类来简化程序的编写,非绝对必要的话,不建议混合基于原型的操作,那只会使得程序变得复杂,如果已经使用基于类的语法,又经常大量地操作原型对象,那么建议还是放弃基于类的语法,直接畅快地使用基于原型就好了。

当然,如果对原型够了解,是可以来玩玩一些试验。

首先是super,它是个语法糖,不是个内置变量,在不同的环境或操作中,代表着不同的意义。

在构造函数调用的话,基本上代表着调用父类构造函数,而在子类构造函数中,要用super调用父类构造函数,在这之后才能访问this,这是因为 ES6 中的super()主要是为了创造this参考的对象(更具体地说,就是最顶层父类构造函数return的对象),然后再从父至子逐层执行初始化流程,这点跟基于原型时实现继承的方式就有差异了,基于原型时实现继承时,会先在子构造函数中创造出this参考的对象,然后再调用父初始化流程。

如果子类构造函数没有return任何对象,那么隐含地return this,这就表示如果子类构造函数中没有returnthis无关的对象时,一定要调用super,不然就会发生错误。

透过super获取某个特性的话,比较容易理解的方式是,可以将super视为父类的prototype

> class A {}
undefined
> A.prototype.foo = 10;
10
> class B extends A {
...     show() {
.....         console.log(super.foo);
.....   }
... }
undefined
> new B().show();
10
undefined
>

然而,如果试图透过super来设定特性时,这时代表的是在父类构造函数返回的对象上设定特性,然而在子类中,父类构造函数返回的对象就会是this参考的对象,因此这个时候的super就等同于this

> class A {}
undefined
> A.prototype.foo = 10;
10
> class B extends A {
...     show() {
.....         console.log(super.foo);
.....         super.foo = 100;        // 相当于 this.foo = 100;
.....         console.log(super.foo); // 还是取 A.prototype.foo
.....         console.log(this.foo);
.....    }
... }
undefined
> new B().show();
10
10
100
undefined
>

如果用在static方法中,那么super代表着父类:

> class A {
...     static show() {
.....         console.log('A show');
.....   }
... }
undefined
> class B extends A {
...     static show() {
.....         console.log(super.name);
.....   }
... }
undefined
> B.show();
A
undefined
>

这就可以来探讨一个有趣的问题,当我写class A {}时,它是继承哪个类呢?ES6 中,只要是可以new的对象,就可以作为extends的对象,在其他面向对象程序语言中,你可能会想是是不是相当于class A extends Object {}?这看你从哪个角度来看,单就类语法的继承语义与执行结果看来,class A {}时没有继承任何类,而是作为一个基类:

> class A {
...     static show() {
.....         console.log(super.name);
.....    }
... }
undefined
> class B extends Object {
...     static show() {
.....         console.log(super.name);
.....   }
... }
undefined
> A.show();

undefined
> B.show();
Object
undefined
>

然而,就原型链继承的语义来看,是继承自Object没错:

> new A().__proto__.__proto__ === Object.prototype;
true
> new B().__proto__.__proto__ === Object.prototype;
true
>

然而,就A__proto__来看,A只是一个普通函数,就像没有 ES6 的class语法前,利用function来定义构造函数那样:

> A.__proto__ === Function.prototype;
true
>

当使用extends指定继承某个可以new的对象时,__proto__会是extends的对象:

> B.__proto__ === Object;
true
> class C extends B {}
undefined
> C.__proto__ === B;
true
>

如果想要判断class定义下的继承关系,可以透过类的__proto__,如果一路向上,最后一定会是Function.prototype,也就是最后一定会是个普通函数,例如,就算是class B extends Object {}B.__proto__会是Object,而Object.__proto__Function.prototype,因为原生的Object本来就是个普通函数。

照这来看,ES6 的类本身,没有定义一个顶层的基类,任何没有extendsclass定义,都会是个基类,行为上就像是个普通函数,毕竟 JavaScript 本来就是个基于原型的语言。

一个特殊的情况是extends null

> class A {}
undefined
> A.__proto__ === Function.prototype;
true
> A.prototype.__proto__ === Object.prototype;
true
> class B extends null {}
undefined
> B.__proto__ === Function.prototype;
true
> B.prototype.__proto__ === undefined;
true
> new B();
TypeError: Super constructor null of B is not a constructor
    at new B (repl:1:1)
    at repl:1:1
    at ContextifyScript.Script.runInThisContext (vm.js:50:33)
    at REPLServer.defaultEval (repl.js:240:29)
    at bound (domain.js:301:14)
    at REPLServer.runBound [as eval] (domain.js:314:12)
    at REPLServer.onLine (repl.js:441:10)
    at emitOne (events.js:121:20)
    at REPLServer.emit (events.js:211:7)
    at REPLServer.Interface._onLine (readline.js:282:10)
>

就语义上来说,extends null是真正没有继承任何类(或者说也不是个类了),最后也不会有普通函数的行为(虽然B.__proto__是参考至Function.prototype),而且原型链最后中断了,B.prototype.__proto__会是undefined,这意谓着B.prototype.toString也会返回undefined(就像null实际上也不能做什么),这样的类也没办法拿来构造(也不能当普通函数调用),除非明确定义constructor(),并在最后return一个与与目前类无关的对象。

> class C extends null {
...     constructor() {}
... }
undefined
> new C();
ReferenceError: Must call super constructor in derived class before accessing 't
his' or returning from derived constructor
    at new C (repl:2:16)
    at repl:1:1
    at ContextifyScript.Script.runInThisContext (vm.js:50:33)
    at REPLServer.defaultEval (repl.js:240:29)
    at bound (domain.js:301:14)
    at REPLServer.runBound [as eval] (domain.js:314:12)
    at REPLServer.onLine (repl.js:441:10)
    at emitOne (events.js:121:20)
    at REPLServer.emit (events.js:211:7)
    at REPLServer.Interface._onLine (readline.js:282:10)
> class D extends null {
...     constructor() {
.....       return {}
.....   }
... }
undefined
> new D();
{}
>

一个extends null的类可以做什么呢?我想得到的是定义一个Null extends null {},用来真正代表null类型吧!只是对于动态定型的 JavaScript 来说,这样的意义并不大,只能说是特别为null做出的边角案例(Corner case)考量。

可以玩的原型探讨还有很多,而且会让你迷失方向…别忘了,ES6 基于类的语法终究只是模拟,试图从原型链等机制上,来理解 ES6 基于类的语法是不明智的,因为很大的成份是语法糖,而且看来在这过程中原型链也被改写过,如果你打算使用 ES6 基于类的语法来实现面向对象,以便约束基于原型时的过度弹性,那就用基于类的想法来看待。

如果用了基于类的语法,却又老是在那边逐磨基于原型时的原理机制,那么建议就放弃 ES6 的类语法吧!


展开阅读全文