多态
多态的实际含义是:同一操作作用于不同的对象上面,可以产生不同的解释和不同的执行结果。换句话说,给不同的对象发送同一个消息的时候,这些对象会根据这个消息分别给出不同的反馈。
从字面上来理解多态不太容易,下面我们来举例说明一下
主人家里养了两只动物,分别是一只鸭和一只鸡,当主人向它们发出“叫”的命令 时,鸭会“嘎嘎嘎”地叫,而鸡会“咯咯咯”地叫。这两只动物都会以自己的方式来发出叫声。它们同样“都是动物,并且可以发出叫声”,但根据主人的指令,它们会各自发出不同的叫声。
var makeSound = function(animal) {
if (animal instanceof Duck) {
console.log("嘎嘎嘎");
} else if (animal instanceof Chicken) {
console.log("咯咯咯");
}
};
var Duck = function() {};
var Chicken = function() {};
makeSound(new Duck()); // 嘎嘎嘎
makeSound(new Chicken()); // 咯咯咯
多态背后的思想是将“做什么”和“谁去做以及怎样去做”分离开来,也就是将“不变的事物”与 “可能改变的事物”分离开来。在这个故事中,动物都会叫,这是不变的,但是不同类型的动物具体怎么叫是可变的。把不变的部分隔离出来,把可变的部分封装起来,这给予了我们扩展程序的能力,程序看起来是可生长的,也是符合开放—封闭原则的,相对于修改代码来说,仅仅增加代码就能完成同样的功能,这显然优雅和安全得多。
var makeSound = function(animal) {
animal.sound();
};
// 然后把可变的部分各自封装起来,我们刚才谈到的多态性实际上指的是对象的多态性
var Duck = function() {};
Duck.prototype.sound = function() {
console.log("嘎嘎嘎");
};
var Chicken = function() {};
Chicken.prototype.sound = function() {
console.log("咯咯咯");
};
makeSound(new Duck()); // 嘎嘎嘎
makeSound(new Chicken()); // 咯咯咯
封装
封装的目的是将信息隐藏。一般而言,我们讨论的封装是封装数据和封装实现。这一节将讨论更广义的封装,不仅包括封装数据和封装实现,还包括封装类型和封装变化。
封装数据
在许多语言的对象系统中,封装数据是由语法解析来实现的,这些语言也许提供了 private、
public、protected 等关键字来提供不同的访问权限。
但 JavaScript 并没有提供对这些关键字的支持,我们只能依赖变量的作用域来实现封装特性, 而且只能模拟出 public 和 private 这两种封装性。
var myObject = (function() {
var name = "sven"; // 私有(private)变量
return {
getName: function() {
// 公开(public)方法
return name;
}
};
})();
console.log(myObject.getName()); // 输 出 :sven
console.log( myObject. name ) // 输出:undefined
封装实现
封装的目的是将信息隐藏,封装应该被视为“任何形式的封装”,也就是说,封装不仅仅是隐藏数据,还包括隐藏实现细节、设计细节以及隐藏对象的类型等。
从封装实现细节来讲,封装使得对象内部的变化对其他对象而言是透明的,也就是不可见的。对象对它自己的行为负责。其他对象或者用户都不关心它的内部实现。封装使得对象之间的耦合变松散,对象之间只通过暴露的 API 接口来通信。当我们修改一个对象时,可以随意地修改它的内部实现,只要对外的接口没有变化,就不会影响到程序的其他功能。
封装实现细节的例子非常之多。拿迭代器来说明,迭代器的作用是在不暴露一个聚合对象的内部表示的前提下,提供一种方式来顺序访问这个聚合对象。我们编写了一个 each 函数,它的作用就是遍历一个聚合对象,使用这个 each 函数的人不用关心它的内部是怎样实现的,只要它提供的功能正确便可以。即使 each 函数修改了内部源代码,只要对外的接口或者调用方式没有变化,用户就不用关心它内部实现的改变
封装类型
封装类型是静态类型语言中一种重要的封装方式。一般而言,封装类型是通过抽象类和接口来进行的①。把对象的真正类型隐藏在抽象类或者接口之后,相比对象的类型,客户更关心对象的行为。在许多静态语言的设计模式中,想方设法地去隐藏对象的类型,也是促使这些模式诞生的原因之一。比如工厂方法模式、组合模式等。
当然在 JavaScript 中,并没有对抽象类和接口的支持。JavaScript 本身也是一门类型模糊的语言。在封装类型方面,JavaScript 没有能力,也没有必要做得更多。对于 JavaScript 的设计模式实现来说,不区分类型是一种失色,也可以说是一种解脱。
封装变化
通过封装变化的方式,把系统中稳定不变的部分和容易变化的部分隔离开来,在系统的演变过程中,我们只需要替换那些容易变化的部分,如果这些部分是已经封装好的,替换起来也相对容易。这可以最大程度地保证程序的稳定性和可扩展性。
原型模式和基于原型继承的 JavaScript 对象系统
在以类为中心的面向对象编程语言中,类和对象的关系可以想象成铸模和铸件的关系,对象总是从类中创建而来。而在原型编程的思想中,类并不是必需的,对象未必需要从类中创建而来, 一个对象是通过克隆另外一个对象所得到的。
使用克隆的原型模式
从设计模式的角度讲,原型模式是用于创建对象的一种模式,如果我们想要创建一个对象, 一种方法是先指定它的类型,然后通过类来创建这个对象。原型模式选择了另外一种方式,我们不再关心对象的具体类型,而是找到一个对象,然后通过克隆来创建一个一模一样的对象。
既然原型模式是通过克隆来创建对象的,那么很自然地会想到,如果需要一个跟某个对象一模一样的对象,就可以使用原型模式。
如果使用原型模式,我们只需要调用负责克隆的方法,便能完成同样的功能。
原型模式的实现关键,是语言本身是否提供了clone 方法。ECMAScript 5 提供了Object.create
方法,可以用来克隆对象。
var Plane = function() {
this.blood = 100;
this.attackLevel = 1;
this.defenseLevel = 1;
};
var plane = new Plane();
plane.blood = 500;
plane.attackLevel = 10;
plane.defenseLevel = 7;
var clonePlane = Object.create(plane);
console.log(clonePlane); // 输出:Object {blood: 500, attackLevel: 10, defenseLevel: 7}
在不支持 Object.create 方法的浏览器中,则可以使用以下代码:
Object.create =
Object.create ||
function(obj) {
var F = function() {};
F.prototype = obj;
return new F();
};
克隆是创建对象的手段
通过原型模式来克隆出一个一模一样的对象。但原型模式的真正目的并非在于需要得到一个一模一样的对象,而是提供了一种便捷的方式去创建某个类型的对象,克隆只是创建这个对象的过程和手段
原型模式提供了另外一种创建对象的方式,通过克隆对象,我们就不用再关心对象的具体类型名字。这就像一个仙女要送给三岁小女孩生日礼物,虽然小女孩可能还不知道飞机或者船怎么说,但她可以指着商店橱柜里的飞机模型说“我要这个”。
当然在 JavaScript 这种类型模糊的语言中,创建对象非常容易,也不存在类型耦合的问题。从设计模式的角度来讲,原型模式的意义并不算大 。但 JavaScript 本身是一门基于原型的面向对象语言,它的对象系统就是使用原型模式来搭建的,在这里称之为原型编程范型也许更合适。
JavaScript中的原型继承
在原型继承方面,JavaScript 的实现原理和 Io 语言非常相似, JavaScript 也同样遵守这些原型编程的基本规则
- 所有的数据都是对象。
- 要得到一个对象,不是通过实例化类,而是找到一个对象作为原型并克隆它。
- 对象会记住它的原型。
- 如果对象无法响应某个请求,它会把这个请求委托给它自己的原型。
所有的数据都是对象
JavaScript 在设计的时候,模仿 Java 引入了两套类型机制:基本类型和对象类型。
基本类型包括 undefined
、number
、boolean
、string
、function
、object
。从现在看来,这并不是一个好的想法。
按照 JavaScript 设计者的本意,除了 undefined
之外,一切都应是对象。
为了实现这一目标,number
、boolean
、string
这几种基本类型数据也可以通过“包装类”的方式变成对象类型数据来处理。
我们不能说在 JavaScript 中所有的数据都是对象,但可以说绝大部分数据都是对象。那么相信在 JavaScript 中也一定会有一个根对象存在,这些对象追根溯源都来源于这个根对象。
事实上,JavaScript 中的根对象是 Object.prototype 对象。Object.prototype 对象是一个空的对象。我们在 JavaScript 遇到的每个对象,实际上都是从 Object.prototype 对象克隆而来的,
Object.prototype 对象就是它们的原型。比如下面的 obj1 对象和 obj2 对象:
var obj1 = new Object(); var obj2 = {};
可以利用 ECMAScript 5 提供的 Object.getPrototypeOf
来查看这两个对象的原型:
console.log( Object.getPrototypeOf( obj1 ) === Object.prototype ); // 输出:true
console.log( Object.getPrototypeOf( obj2 ) === Object.prototype ); // 输出:true
要得到一个对象,不是通过实例化类,而是找到一个对象作为原型并克隆它
但在 JavaScript 语言里,我们并不需要关心克隆的细节,因为这是引擎内部负责实现的。我们所需要做的只是显式地调用 var obj1 = new Object()
或者 var obj2 = {}
。此时,引擎内部会从
Object.prototype 上面克隆一个对象出来,我们最终得到的就是这个对象。
再来看看如何用 new 运算符从构造器中得到一个对象,下面的代码我们再熟悉不过了:
function Person(name) {
this.name = name;
}
Person.prototype.getName = function() {
return this.name;
};
var a = new Person("sven");
console.log(a.name); // 输出:sven
console.log(a.getName()); // 输出:sven
console.log(Object.getPrototypeOf(a) === Person.prototype); // 输出:true
在 JavaScript 中没有类的概念。但刚才不是明明调用了 new Person()吗?
在这里 Person 并不是类,而是函数构造器,JavaScript 的函数既可以作为普通函数被调用, 也可以作为构造器被调用。当使用 new 运算符来调用函数时,此时的函数就是一个构造器。 用
new 运算符来创建对象的过程,实际上也只是先克隆 Object.prototype 对象,再进行一些其他额外操作的过程。
在 Chrome 和 Firefox 等向外暴露了对象 proto 属性的浏览器下,我们可以通过下面这段代码来理解 new 运算的过程:
function Person(name) {
this.name = name;
}
Person.prototype.getName = function() {
return this.name;
};
var objectFactory = function() {
var obj = new Object(), // 从 Object.prototype 上克隆一个空的对象
Constructor = [].shift.call(arguments); // 构造器
obj.proto = Constructor.prototype; // 指向函数
var ret = Constructor.apply(obj, arguments); // 借用外部传入的构造器给 obj 设置属性
return typeof ret === "object" ? ret : obj; // 确保构造器总是会返回一个对象
};
var a = objectFactory(Person, "sven");
console.log(a.name); // 输 出 :sven
console.log( a.getName() ); // 输出:sven
console.log(Object.getPrototypeOf(a) === Person.prototype); // 输出:true
我们看到,分别调用下面两句代码产生了一样的结果:
var a = objectFactory( A, 'sven' );
var a = new A( 'sven' );
对象会记住它的原型
就 JavaScript 的真正实现来说,其实并不能说对象有原型,而只能说对象的构造器有原型。对于“对象把请求委托给它自己的原型”这句话,更好 的说法是对象把请求委托给它的构造器的原型。那么对象如何把请求顺利地转交给它的构造器的原型呢?
JavaScript 给对象提供了一个名为 proto 的隐藏属性,某个对象的 proto 属性默认会指向它的构造器的原型对象,即{Constructor}.prototype。在一些浏览器中, proto 被公开出来, 我们可以在 Chrome 或者 Firefox 上用这段代码来验证:
var a = new Object();
console.log ( a.proto === Object.prototype ); // 输出:true
实际的对象有proto,构造器有prototype
实际上, proto 就是对象跟“对象构造器的原型”联系起来的纽带。正因为对象要通过
proto 属性来记住它的构造器的原型,所以我们用上一节的 objectFactory 函数来模拟用 new
创建对象时, 需要手动给 obj 对象设置正确的 proto 指向。
obj. proto = Constructor.prototype;
通过这句代码,我们让 obj. proto 指向 Person.prototype,而不是原来的 Object.prototype。
如果对象无法响应某个请求,它会把这个请求委托给它的构造器的原型
这条规则即是原型继承的精髓所在,在 JavaScript 中,每个对象都是从 Object.prototype
对象克隆而来的,如果是这样的话, 我们只能得到单一的继承关系,即每个对象都继承自 Object.prototype 对象,这样的对象系统显然是非常受限的。
实际上,虽然 JavaScript 的对象最初都是由 Object.prototype
对象克隆而来的,但对象构造器的原型并不仅限于 Object.prototype
上,而是可以动态指向其他对象。这样一来,当对象 a 需要借用对象 b 的能力时,可以有选择性地把对象 a 的构造器的原型指向对象 b,从而达到继承的效果。下面的代码是我们最常用的原型继承方式:
var obj = { name: "sven" };
var A = function() {};
A.prototype = obj;
var a = new A();
console.log(a.name); // 输出:sven
我们来看看执行这段代码的时候,引擎做了哪些事情。
首先,尝试遍历对象 a 中的所有属性,但没有找到 name 这个属性。
查找 name 属性的这个请求被委托给对象 a 的构造器的原型,它被 a. proto 记录着并且指向 A.prototype,而 A.prototype 被设置为对象 obj。
在对象 obj 中找到了 name 属性,并返回它的值。
当我们期望得到一个“类”继承自另外一个“类”的效果时,往往会用下面的代码来模拟实现:
var A = function() {};
A.prototype = { name: "sven" };
var B = function() {};
B.prototype = new A();
var b = new B();
console.log(b.name); // 输出:sven
再看这段代码执行的时候,引擎做了什么事情。
1.首先,尝试遍历对象 b 中的所有属性,但没有找到 name 这个属性。
2.查找 name 属性的请求被委托给对象 b 的构造器的原型,它被 b. proto 记录着并且指向
B.prototype,而 B.prototype 被设置为一个通过 new A()创建出来的对象。
3.在该对象中依然没有找到 name 属性,于是请求被继续委托给这个对象构造器的原型
A.prototype。
4.在 A.prototype 中找到了 name 属性,并返回它的值。
和把 B.prototype 直接指向一个字面量对象相比,通过 B.prototype = new A()形成的原型链比之前多了一层。但二者之间没有本质上的区别,都是将对象构造器的原型指向另外一个对象,继承总是发生在对象和对象之间。
最后还要留意一点,原型链并不是无限长的。现在我们尝试访问对象 a
的 address
属性。而对象 b 和它构造器的原型上都没有 address
属性,那么这个请求会被最终传递到哪里呢?
实际上,当请求达到 A.prototype
,并且在 A.prototype
中也没有找到 address
属性的时候, 请求会被传递给 A.prototype
的构造器原型 Object.prototype
,显然 Object.prototype
中也没有
address 属性,但 Object.prototype
的原型是 null
,说明这时候原型链的后面已经没有别的节点了。所以该次请求就到此打住,a.address
返回 undefined
。
a.address // 输出:undefined
原型继承的未来
作为Web 前端开发者,相信 JavaScript 在未来很长一段时间内都是唯一的选择。虽然我们没有办法换一门语言,但语言本身也在发展,说不定哪天某个模式在 JavaScript 中就已经是天然的存在,不再需要拐弯抹角来实现。比如 Object.create
就是原型模式的天然实现。使用 Object.create
来完成原型继承看起来更能体现原型模式的精髓。
目前大多数主流浏览器都提供了 Object.create
方法。
但美中不足是在当前的 JavaScript 引擎下,通过 Object.create
来创建对象的效率并不高,通常比通过构造函数创建对象要慢。此外还有一些值得注意的地方,比如通过设置构造器的
prototype
来实现原型继承的时候,除了根对象 Object.prototype 本身之外,任何对象都会有一个原型。而通过 Object.create( null )
可以创建出没有原型的对象。
另外,ECMAScript 6 带来了新的 Class 语法。这让 JavaScript 看起来像是一门基于类的语言, 但其背后仍是通过原型机制来创建对象。
通过 Class 创建对象的一段简单示例代码如下所示:
class Animal {
constructor(name) {
this.name = name;
}
getName() {
return this.name;
}
}
class Dog extends Animal {
constructor(name) {
super(name);
}
speak() {
return "woof";
}
}
var dog = new Dog("Scamp");
console.log(dog.getName() + " says " + dog.speak());