阅读 392

《你不知道的JavaScript》--精读(九)

知识点

混合对象“类”

1.类理论

类/继承描述了一种代码的组织结构形式--一种在软件中对真实世界中问题领域的建模方法。

面向对象编程强调的是数据和操作数据的行为本质上是互相关联的(当然,不同的数据有不同的行为),因此好的设计就是把数据以及和它相关的行为打包(或者说封装)起来,这在正式的计算机科学中有时被称为数据结构。

举例来说,用来表示一个单词或者短语的一串字符通常被称为字符串。字符就是数据。但是你关心的往往不是数据是什么,而是可以对数据做什么,所以可以应用在这种数据上的行为(计算长度、添加数据、搜索等等)都被设计成String类的方法。

所有字符串都是String类的一个实例,也就是说它是一个包裹,包含字符数据和我们可以应用在数据上的函数。

我们来看一个常见的例子,“汽车”可以被看作“交通工具”的一种特例,后者是更广泛的类。

我们可以在软件中定义一个Vehicle类和一个Car类来对这种关系进行建模。

Vehicle的定义可能包含推进器(比如引擎)、载人能力等,这些都是Vehicle的行为。我们在Vehicle中定义的是(几乎)所有类型的交通工具(飞机、火车和汽车)都包含的东西。

在我们的软件中,对不同的交通工具重复定义“载人能力”是没有意义的。相反我们只在Vehicle中定义一次,定义Car时,只要声明它继承(或者扩展)了Vehicle的这个基础定义就行。Car的定义就是对通用Vehicle定义的特殊化。

虽然Vehicle和Car会定义相同的方法,但是实例中的数据可能是不同的,比如每辆车独一无二的VIN(车辆识别号码),等待。

这就是类、继承和实例化。

类的另一个核心概念是多态,这个概念是说父类的通用行为可以被子类用更特殊的行为重写。实际上,相对多态性允许我们从重写行为中引用基础行为。

类理论强烈建议父类和子类使用相同的方法名来表示特定的行为,从而让子类重写父类。我们之后会看到,在JavaScript代码中这样做会降低代码的可读性和健壮性。

1.1 “类”设计模式

1.2 JavaScript中的“类”

JavaScript属于哪一类呢?在相当长的一段时间里,JavaScript只有一些近似类的语法元素,(比如new和instanceof),不过在后来的ES6中新增了一些元素,比如class关键字。

这是不是意味着JavaScript中实际上有类呢?简单来说,不是。

由于类是一种设计模式,所以你可以用一些方法近似实现类的功能。为了满足对于类设计模式的最普遍需求,JavaScript提供了一些近似类的语法。

虽然有近似类的语法,但是JavaScript的机制似乎一直在阻止你使用类设计模式。在近似类的表象之下,JavaScript的机制其实和类完全不同。语法糖和JavaScript“类”库试图掩盖这个现实,但是你迟早会面对它:其他语言中的类和JavaScript中的“类”并不一样。

总结一下,在软件设计中类是一种可选的模式,你需要自己决定是否在JavaScript中使用它。由于许多开发者都非常喜欢面向类的软件设计,之后我们会介绍在JavaScript中实现类以及存在的一些问题。

2.类的机制

在许多面向类的语言中,“标准库”会提供Stack类,它是一种“栈”数据结构(支持压入、弹出,等等)。Stack类内部会有一些变量来存储数据,同时会提供一些公有的可访问的行为(“方法”),从而让你的代码可以和(隐藏的)数据进行交互(比如添加、删除数据)。

但是在这些语言中,你实际上并不是直接操作Stack(除非创建一个静态类成员引用),Stack类仅仅是一个抽象的表示,它描述了所有“栈”需要做的事,但是它本身并不是一个“栈”。你必须实例化Stack类然后才能对它进行操作。

2.1 建造

“类”和“实例”的概念来源于房屋建造。

建筑师会规划出一个建筑的所有特性:多宽、多高、多少个窗户以及窗户的位置,甚至连建造墙和房顶需要的材料都要计划好。在这个阶段他并不需要关心建筑会被建在哪,也不需要关心会建造多少个这样的建筑。

建筑师也不太关心建筑里的内容--家具、壁纸、吊扇等--他只关心需要用什么结构来容纳它们。

建筑蓝图只是建筑计划,它们并不是真正的建筑,我们还需要一个建筑工人来建造建筑。建筑工人会按照蓝图建造建筑。实际上,他会把规划好的特性从蓝图中复制到现实世界的建筑中。

完成后,建筑就成为了蓝图的物理实例,本质上就是对蓝图的复制。之后建筑工人就可以到下一个地方,把所有工作都重复一遍,再创建一份副本。

建筑和蓝图之间的关系是间接的。你可以通过蓝图了解建筑的结构,只观察建筑本身是无法获得这些信息的。但是如果你想打开一扇门,那就必须接触真实的建筑才行--蓝图只能表示门应该在哪,但并不是真正的门。

一个类就是一张蓝图。为了获得真正可以交互的对象,我们必须按照类来建造(也可以说实例化)一个东西,这个东西通常被称为实例,有需要的话,我们可以直接在实例上调用方法并访问其所有公有数据属性。

这个对象就是类中描述的所有特性的一份副本。

你走进一栋建筑时,它的蓝图不太可能挂在墙上(尽管这个蓝图可能会保存在公共档案馆中)。类似地,你通常也不会是要一个实例对象来直接访问并操作它的类,不过只是可以判断出这个实例对象来自哪个类。

把类和实例对象之间的关系看作是直接关系而不是间接关系通常更有助于理解。类通过复制操作被实例化为对象形式。

2.2 构造函数

类实例是由一个特殊的类方法构造的,这个方法名通常和类名相同,被称为构造函数。这个方法的任务就是初始化实例需要的所有信息(状态)。

举例来说,思考下面这个关于类的伪代码(编造出来的语法):

class CoolGuy {
    specialTrick = nothing;
    CoolGuy(trick) {
        specialTrick = trick;
    }
    showOff() {
        output("Here's my trick: ",specialTrick);
    }
}
复制代码

我们可以调用类构造函数来生成一个CoolGuy实例:

Joe = new CoolGuy('jumping rope');
Joe.showOff(); // Here's my trick: specialTrick
复制代码

注意,CoolGuy类有一个CoolGuy()构造函数,执行new CoolGuy()时实际上调用的就是它。构造函数会返回一个对象(也就是类的一个实例),之后我们可以在这个对象上调用showOff()方法,来输出指定CoolGuy的特长。

显然,跳绳让乔成为了一个非常酷的家伙。

类构造函数属于类,而且通常和类同名。此外,构造函数大多需要用new来调,这样语言引擎才知道你想要构造一个新的类实例。

3.类的继承

在面向类的语言中,你可以先定义一个类,然后定义一个继承前者的类。

后者通常被称为“子类”,前者通常被称为“父类”。

定义好一个子类之后,相对于父类来说它就是一个独立并且完全不同的类。子类会包含父类行为的原始副本,但是也可以重写所有继承的行为甚至定义新行为。

接下来,讲解一个稍有不同的例子:不同类型的交通工具。

首先回顾一下本章前面部分提出的Vehicle和Car类。思考下面关于继承的伪代码:

class Vehicle {
    engines = 1
    ignition() {
        output('Turning on my engine.')
    }
    drive() {
        ignition()
        output('Steering and moving forward!')
    }
}

class Car inherits Vehicle {
    wheels = 4
    dirve() {
        inherited:drive()
        output('Rolling on all ', wheels, "wheels!")
    }
}

class SpeedBoat inherits Vehicle {
    engines = 2
    ignition() {
        output('Turning on my ', engines, "engines.")
    }
    pilot() {
        inherited:drive()
        output('Speeding through the water with ease!')
    }
}
复制代码

我们通过定义Vehicle类来假设一种发动机,一种点火方式,一种驾驶方法。但是你不可能制造一个通用的“交通工具”,因为这个类只是一个抽象的概念。

接下来,我们定义了两类具体的交通工具:Car和SpeedBoat。它们都从Vehicle继承了通用的特性并根据自身类别修改了某些特性。汽车需要四个轮子,快艇需要两个发动机,因此它必须启动两个发动机的点火装置。

3.1 多态

Car重写了继承自父类drive()方法,但是之后Car调用了inherited:drive()方法,这表明Car可以引用继承来的原始drive()方法。快艇的pilot()方法同样引用了原始的drive()方法。

这个技术被称为多态或者虚拟多态。在本例中,更恰当的说法是相对多态。

多态是一个非常广泛的话题,我们现在所说的“相对”只是多态的一个方面:任何方法都可以引用继承层次中高层的方法(无论高层的方法名和当前方法名是否相同)。之所以说“相对”是因为我们并不会定义想要访问的绝对继承层次(或者说类),而是使用相对引用“查找上一层”。

在许多语言中可以使用super来代替本例中的inherited:,它的含义是“超类”(superclass),表示当前类的父类/祖先类。

多态的另一个方面是,在继承链的不同层次中一个方法名可以被多次定义,当调用方法时会自动选择合适的定义。

在子类(而不是它们创建的实例对象!)中也可以相对引用它继承的父类,这种相对引用通常被称为super。

多态并不表示子类和父类有关联,子类得到的只是父类的一份副本。类的继承其实就是复制。

3.2 多重继承

有些面向类的语言允许你继承多个“父类”。多重继承意味着所有父类的定义都会被复制到子类中。

相比之下,JavaScript要简单得多:它本身并不提供“多重继承”功能。许多人认为这是件好事,因为使用多重继承的代价太高。然而这无法阻挡开发者们的热情,他们会尝试各种各样的方法来实现多重继承,我们马上就会看到。

4.混入

在继承或者实例化时,JavaScript的对象机制并不会自动执行复制行为。简单来说,JavaScript中只有对象,并不存在可以实例化的“类”。一个对象并不会被复制到其他对象,它们会被关联起来。

由于在其他语言中类表现出来的都是复制行为,因此JavaScript开发者也想出了一个方法来模拟类的复制行为,这个方法就是混入。接下来,我们会看到两种类型的混入:显式和隐式。

4.1 显式混入

首先我们来回顾一下之前提到的Vehicle和Car。由于JavaScript不会自动实现Vehicle到Car的复制行为,所以我们需要手动实现复制功能。这个功能在许多库和框架中被称为extend(...),但是为了方便理解我们称之为mixin(...)。

// 非常简单的mixin(...)例子:
Function mixin(sourceObj, targetObj) {
    for(var key in sourceObj) {
        // 只会在key不存在的情况下复制
        if(!key in targetObj) {
            targetObj[key] = sourceObj[key]
        }
    }
    return targetObj;
}

var Vehicle = {
    engines: 1,
    ignition: function() {
        console.log("Turning on my engine.");
    },
    drive: function() {
        this.ignition();
        console.log("Steering and moving forward!");
    }
}

var Car = mixin(Vehicle,{
    wheels: 4,
    drive: function() {
        Vehicle.drive.call(this);
        console.log("Rolling on all " + this.wheels + "wheels!");
    }
})
复制代码

注意:我们处理的已经不再是类了,因为在JavaScript中不存在类,Vehicle和Car都是对象,供我们分别进行复制和粘贴。

现在Car中就有了一份Vehicle属性和函数的副本了。从技术角度来说,函数实际上没有被复制,复制的是函数的引用。所以,Car中的属性ignition只是从Vehicle中复制过来的对应ignition()函数的引用。相反,属性engines就是直接从Vehicle中复制了值1。

Car中已经有了drive属性(函数),所以这个属性引用并没有被mixin重写,从而保留了Car中定义的同名属性,实现了“子类”对“父类”属性的重写。

4.2 隐式混入

思考下面的代码:

var Something = {
    cool: function() {
        this.greeting = "Hello World!";
        this.count = this.count ? this.count + 1 : 1;
    }
}

Something.cool();
Something.greeting; // "Hello World!"
Something.count; // 1

var Another = {
    cool: function() {
        // 隐式把Something混入Another
        Something.cool.call(this);
    }
}

Another.cool();
Another.greeting; // "Hello World"
Another.count; // 1(count不是共享状态)
复制代码

通过构造函数调用或者方法调用中使用Something.cool.call(this),我们实际上“借用”了函数Something.cool()并在Another的上下文中调用了它。最终的结果是Something.cool()中的赋值操作都会应用在Another对象上而不是Something对象上。

因此,我们把Something的行为“混入”到了Another中。

虽然这类技术利用了this的重新绑定功能,但是Something.cool.call(this)仍然无法变成相对(而且更灵活的)引用,所以使用时千万要小心。通常来说,尽量避免使用这样的结构,以保证代码的整洁和可维护性。

总结

类是一种设计模式。许多语言提供了对于面向类软件设计的原生语法。JavaScript也有类似的语法,但是和其他语言中的类完全不同。

类意味着复制。

传统的类被实例化时,它的行为会被复制到实例中。类被继承时,行为也会被复制到子类中。

多态(在继承链的不同层次名称相同但是功能不同的函数)看起来似乎是从子类引用父类,但是本质上引用的其实是复制的结果。

JavaScript并不会(像类那样)自动创建对象的副本。

混入模式(无论显式还是隐式)可以用来模拟类的复制行为,但是通常会产生丑陋并且脆弱的语法,比如显式伪多态(OtherObj.methodName.call(this,...)),这会让代码更加难懂并且难以维护。

此外,显式混入实际上无法完全模拟类的复制行为,因为对象(和函数!别忘了函数也是对象)只能复制引用,无法复制被引用的对象或者函数本身。忽视这一点会导致许多问题。

总地来说,在JavaScript中模拟类是得不偿失的,虽然能解决当前的问题,但是可能会埋下更多的隐患。

巴拉巴拉

最近懒癌犯了,感觉又好久没有更新,一开始写这个的初衷也是希望积少成多,量变引起质变,可是总因为各种各样的原因耽搁了,我慢慢的开始发现,很多时候难以坚持,其实是因为本来的作息被打断,我自己是这样的原因,比如说,我一般九点开始写这个,如果我下班晚了,就任性的不写了,如果我下班早了,很可能也不写了,因为躺着躺着就睡着了,还是希望自己能养成习惯吧,多看书,多写代码。

关注下面的标签,发现更多相似文章
评论