JavaScript笔记:混合对象“类”

类理论

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

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

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

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

“类”设计模式

你可能从来没把类作为设计模式来看待,讨论得最多的是面向对象设计模式,比如迭代器模式、观察者模式、工厂模式、单例模式,等等。从这个角度来说,我们似乎是在(低级)面向对象类的基础上实现了所有(高级)设计模式,似乎面向对象是优秀代码的基础。

如果你之前接受过正规的编程教育的话,可能听说过过程化编程,这种代码只包含过程(函数)调用,没有高层的抽象。或许老师还教过你最好使用类把过程化风格的“意大利面代码”转换成结构清晰、组织良好的代码。

当然,如果你有函数式编程(比如 Monad)的经验就会知道类也是非常常用的一种设计模 式。但是对于其他人来说,这可能是第一次知道类并不是必须的编程基础,而是一种可选的代码抽象。

有些语言(比如 Java)并不会给你选择的机会,类并不是可选的——万物皆是类。其他语言(比如 C/C++ 或者 PHP)会提供过程化和面向类这两种语法,开发者可以选择其中一种风格或者混用两种风格。

JavaScript中的“类”

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

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

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

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

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

类的机制

建造

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

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

构造函数

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

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

类的继承

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

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

多态

多态是一个非常广泛的话题,我们现在所说的“相对”只是多态的一个方面:任何方法都可以引用继承层次中高层的方法。

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

方法定义的多态性取决于你是在哪个类的实例中引用它。

子类应当可以通过相对多态引用(或者说 super)来访问父类中的行为。
需要注意,子类得到的仅仅是继承自父类行为的一份副本。子类对继承到的一个方法进行“重写”,不会影响父类中的方法,这两个方法互不影响,因此才能使用相对多态引用访问父类中的方法。

多重继承

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

从表面上来,对于类来说这似乎是一个非常有用的功能,可以把许多功能组合在一起。然而,这个机制同时也会带来很多复杂的问题。

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

混入

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

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

显式混入

由于 JavaScript 不会自动实现复制行为,所以我们需要手动实现复制功能。

// 非常简单的 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 之前)并没有相对多态的机制。所以,由于 Car 和 Vehicle 中都有 drive() 函数,为了指明调用对象,我们必须使用绝对(而不是相对)引用。我们通过名称显式指定 Vehicle 对象并调用它的 drive() 函数。

但是如果直接执行 Vehicle.drive(),函数调用中的 this 会被绑定到 Vehicle 对象而不是 Car 对象,这并不是我们想要的。因此,我们会使用 .call(this)来确保 drive() 在 Car 对象的上下文中执行。

使用伪多态通常会导致代码变得更加复杂、难以阅读并且难以维护,因此应当尽量避免使用显式伪多态,因为这样做往往得不偿失。

混合复制:

回顾一下之前提到的 mixin(..) 函数。

现在我们来分析一下 mixin(..) 的工作原理。它会遍历 sourceObj(本例中是 Vehicle)的属性,如果在 targetObj(本例中是 Car)没有这个属性就会进行复制。由于我们是在目标对象初始化之后才进行复制,因此一定要小心不要覆盖目标对象的原有属性。

如果我们是先进行复制然后对 Car 进行特殊化的话,就可以跳过存在性检查。不过这种方法并不好用并且效率更低,所以不如第一种方法常用。

// 另一种混入函数,可能有重写风险 
function mixin( sourceObj, targetObj ) {
    for (var key in sourceObj) { 
        targetObj[key] = sourceObj[key];
    }
    return targetObj; 
}
var Vehicle = { // ...
};
// 首先创建一个空对象并把 Vehicle 的内容复制进去
var Car = mixin( Vehicle, { } );
// 然后把新内容复制到 Car 中 
mixin( {
    wheels: 4,
    drive: function() { 
        // ...
    }
}, Car );

由于两个对象引用的是同一个函数,因此这种复制(或者说混入)实际上并不能完全模拟面向类的语言中的复制。

JavaScript 中的函数无法(用标准、可靠的方法)真正地复制,所以你只能复制对共享函数对象的引用。如果你修改了共享的函数对象(比如 ignition()),比如添加了一个属性,那 Vehicle 和 Car 都会受到影响。

显式混入是 JavaScript 中一个很棒的机制,不过它的功能也没有看起来那么强大。虽然它可以把一个对象的属性复制到另一个对象中,但是这其实并不能带来太多的好处,无非就是少几条定义语句,而且还会带来我们刚才提到的函数对象引用问题。

寄生继承:

//“传统的 JavaScript 类”Vehicle 
function Vehicle() {
    this.engines = 1; 
}
Vehicle.prototype.ignition = function() { 
    console.log( "Turning on my engine." );
};
Vehicle.prototype.drive = function() {
    this.ignition();
    console.log( "Steering and moving forward!" );
};

//“寄生类”Car 
function Car() {
    // 首先,car 是一个 Vehicle 
    var car = new Vehicle();
    // 接着我们对 car 进行定制 
    car.wheels = 4;
    // 保存到 Vehicle::drive() 的特殊引用 
    var vehDrive = car.drive;
    // 重写 Vehicle::drive() 
    car.drive = function() {
        vehDrive.call( this );
        console.log("Rolling on all " + this.wheels + " wheels!");
    }
    return car; 
}
var myCar = new Car();
myCar.drive();

隐式混入

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)