'类'模式

August 23, 2018 by Sylvenas

oop

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

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

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

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

我们可以在软件中定义一个Vehicle类和一个Car类来对这种关系进行建模。 Vehicle的定义可能包含推进器(比如引擎)、载人能力等等,这些都是Vehicle的行为。我们在Vehicle中定义的是(几乎)所有类型的交通工具(飞机、火车和汽车)都包含的东西。 在我们的软件中,对不同的交通工具重复定义“载人能力”是没有意义的。相反,我们只在Vehicle中定义一次,定义Car时,只要声明它继承(或者扩展)了Vehicle的这个基础定义就行。Car的定义就是对通用Vehicle定义的特殊化。 虽然VehicleCar会定义相同的方法,但是实例中的数据可能是不同的,比如每辆车独一无二的 VIN(Vehicle Identification Number,车辆识别号码),等等。

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

类理论强烈建议父类和子类使用相同的方法名来表示特定的行为,从而让子类重写父类。

总结一下经典的面向对象编程的思维导图: oop

类与实例

举例来说类和实例的关系,服装设计师会设计好每个服装的模型,包括领口多款,袖子多长,等等模版;设计师并不关心他设计的模版衣服会在哪里生产,会生产多少件。

而生产厂家拿到了衣服的模版之后,就会让工人来批量生产衣服。

那么生产完成之后的具体的每一件衣服。也就是模版的一份物理实例,本质上就是对模版的复制。

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

构造函数

类实例是由一个特殊的类方法构造的,这个方法名通常和类名相同,被称为构造函数。这 个方法的任务就是初始化实例需要的所有信息(状态)。 举例来说,思考下面这个关于类的伪代码(编造出来的语法):

class CoolGuy {
  specialTrick = nothing
  CoolGuy( trick ) {
    specialTrick = trick
}
  showOff() {
    output( "Here's my trick: ", specialTrick )
  }
}

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

Joe = new CoolGuy( "jumping rope" )
Joe.showOff() // 这是我的绝技:跳绳

注意,CoolGuy类有一个CoolGuy()构造函数,执行new它。构造函数会返回一个对象(也就是类的一个实例),之后我们可以在这个对象上调用showOff()方法,来输出指定 CoolGuy的特长。 类构造函数属于类,而且通常和类同名。此外,构造函数大多需要用new来调,这样语言引擎才知道你想要构造一个新的类实例。

类的继承

在面向类的语言中,你可以先定义一个类,然后定义一个继承前者的类。 后者通常被称为“子类”,前者通常被称为“父类”。这些术语显然是类比父母和孩子,不过在意思上稍有扩展,你很快就会看到。 对于父母的亲生孩子来说,父母的基因特性会被复制给孩子。显然,在大多数生物的繁殖系统中,双亲都会贡献等量的基因给孩子。但是在编程语言中,我们假设只有一个父类。 一旦孩子出生,他们就变成了单独的个体。虽然孩子会从父母继承许多特性,但是他是一个独一无二的存在。如果孩子的头发是红色,父母的头发未必是红的,也不会随之变红,二者之间没有直接的联系。

同理,定义好一个子类之后,相对于父类来说它就是一个独立并且完全不同的类。子类会包含父类行为的原始副本,但是也可以重写所有继承的行为甚至定义新行为。 非常重要的一点是,我们讨论的父类和子类并不是实例。父类和子类的比喻容易造成一些误解,实际上我们应当把父类和子类称为父类 DNA 和子类 DNA。我们需要根据这些 DNA 来创建(或者说实例化)一个人,然后才能和他进行沟通。

好了,我们先抛开现实中的父母和孩子,来看一个稍有不同的例子:不同类型的交通工具。这是一个非常典型(并且经常被抱怨)的讲解继承的例子。

class Vehicle {
  engines = 1
  ignition() {
    output("Turning on my engine.");
  }
  drive() {
    ignition();
    output("Steering and moving forward!")
  }
}
class Car inherits Vehicle {
  wheels = 4
  drive() {
    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类来假设一种发动机,一种点火方式,一种驾驶方法。但是你不可 能制造一个通用的“交通工具”,因为这个类只是一个抽象的概念。 接下来我们定义了两类具体的交通工具:CarSpeedBoat。它们都从Vehicle继承了通用的特性并根据自身类别修改了某些特性。汽车需要四个轮子,快艇需要两个发动机,因此 它必须启动两个发动机的点火装置。

多态

Car重写了继承自父类的drive()方法,但是之后Car调用了inherited:drive()方法, 这表明Car可以引用继承来的原始drive()方法。快艇的pilot()方法同样引用了原始drive()方法。 这个技术被称为多态或者虚拟多态。在本例中,更恰当的说法是相对多态。 多态是一个非常广泛的话题,我们现在所说的“相对”只是多态的一个方面:任何方法都 可以引用继承层次中高层的方法(无论高层的方法名和当前方法名是否相同)。之所以说 “相对”是因为我们并不会定义想要访问的绝对继承层次(或者说类),而是使用相对引用 “查找上一层”。 在许多语言中可以使用super来代替本例中的 inherited:,它的含义是“超类” (superclass),表示当前类的父类/祖先类。 多态的另一个方面是,在继承链的不同层次中一个方法名可以被多次定义,当调用方法时 会自动选择合适的定义。 在之前的代码中就有两个这样的例子:drive() 被定义在VehicleCar中,ignition()被定义在VehicleSpeedBoat中。

maxin

上面是描述的经典的面向对象编程中的基本的概念以及特征。

我个人认为更应该叫面向类编程,而JavaScript才是真正的面向对象编程,JavaScript中只有对象而没有类的概念,并且可以不通过类就能直接创建对象。

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

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

显式混入

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

// 非常简单的 mixin(..) 例子 :
function mixin(sourceObj, targetObj) {
  for (var key in sourceObj) {
    // 只会在不存在的情况下复制
    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中定义的同名属性,实现了“子类”对“父类”属性的重写(参见 mixin(..) 例子中 的 if 语句)。

显式多态

我们来分析一下这条语句:Vehicle.drive.call(this)。这就是我所说的显式多态。还记得吗,在之前的伪代码中对应的语句是inherited:drive(),我们称之为相对多态。 JavaScript(在 ES6之前;参见附录A)并没有相对多态的机制。所以,由于CarVehicle中都有drive()函数,为了指明调用对象,我们必须使用绝对(而不是相对)引 用。我们通过名称显式指定Vehicle对象并调用它的drive()函数。 但是如果直接执行Vehicle.drive(),函数调用中的this会被绑定到Vehicle对象而不是Car对象,这并不是我们想要的。因此,我们会使用.call(this)来确保 drive() 在Car对象的上下文中执行。

复制操作完成后,Car就和Vehicle分离了,向Car中添加属性不会影响Vehicle,反之亦然。

这里跳过了一些小细节,实际上,在复制完成之后两者之间仍然有一些巧妙 的方法可以“影响”到对方,例如引用同一个对象(比如一个数组)。

由于两个对象引用的是同一个函数,因此这种复制(或者说混入)实际上并不能完全模拟面向类的语言中的复制。 JavaScript中的函数无法(用标准、可靠的方法)真正地复制,所以你只能复制对共享函数对象的引用(函数就是对象,只不过是可调用的对象)。如果你修改了共享的函数对象(比如 ignition()),比如添加了一个属性,那VehicleCar都会受到影响。 显式混入是JavaScript中一个很棒的机制,不过它的功能也没有看起来那么强大。虽然它可以把一个对象的属性复制到另一个对象中,但是这其实并不能带来太多的好处,无非就是少几条定义语句,而且还会带来我们刚才提到的函数对象引用问题。

在支持相对多态的面向类的语言中,CarVehicle之间的联系只在类定义的开头被创建,从而只需要在这一个地方维护两个类的联系。

但是在JavaScript中(由于屏蔽)使用显式伪多态会在所有需要使用(伪)多态引用的地 方创建一个函数关联,这会极大地增加维护成本。此外,由于显式伪多态可以模拟多重继承,所以它会进一步增加代码的复杂度和维护难度。 使用伪多态通常会导致代码变得更加复杂、难以阅读并且难以维护,因此应当尽量避免使 用显式伪多态,因为这样做往往得不偿失。

solution

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

类意味着复制。

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

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

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

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

此外,显式混入实际上无法完全模拟类的复制行为,因为对象(和函数!别忘了函数也 是对象)只能复制引用,无法复制被引用的对象或者函数本身。忽视这一点会导致许多问题。 总地来说,在JavaScript中模拟类是得不偿失的,虽然能解决当前的问题,但是可能会埋下更多的隐患。