编程语言按照数据类型大体可以分为两类,一类是静态语言,另一类是动态语言,在javascript中,当我们对一个变量赋值时,并不需要考虑它的类型,显然javascript是典型的动态类型语言。
由于无需进行类型检测,我们可以尝试调用任何对象的任意方法,而无需考虑它原本是否被设计为拥有该方法。这一切都建立在鸭子类型的概念上,鸭子类型的通俗说法是:“如果它走起来像鸭子,叫起来是鸭子,那么它就是鸭子。”
通过一个小例子深入理解一下:
从前在JavaScript王国里,有一个国王,他觉得世界上最美妙的声音就是鸭子的叫声,于是国王召集大臣,要组建1000只鸭子的合唱团,大臣们找遍全国,只找到了999只,最后它们发现了一只叫声像鸭子的鸡,于是这只鸡成为了第1000只合唱团成员。
这个故事里的国王,很明显是只注重鸭子的叫声而非是不是真的鸭子,鸭子类型指导我们只关注对象的行为,而不是关注对象的本身,也就是关注HAS-A,而不是IS-A。
用代码模拟一下这个故事:
var duck = {
duckSinging:function(){
console.log("嘎嘎嘎")
}
}
var chicken = {
duckSinging:function(){
console.log("嘎嘎嘎")
}
}
var choir = [];
var joinChoir = function(animal){
if(animal && typeof animal.duckSinging == 'function'){
choir.push(animal);
console.log('恭喜加入合唱团');
console.log('合唱团已有成员数量:'+choir.length)
}
}
joinChoir(duck);//恭喜加入合唱团/合唱团已有成员数量:1
joinChoir(chicken);//恭喜加入合唱团/合唱团已有成员数量:2
我们可以看到,对于加入合唱团的动物,大臣们不检查它们的类型,只要会鸭子叫,也就是拥有duckSinging的方法,就能顺利加入。
在动态类型语言的面向对象设计中,我们利用鸭子类型的思想,就能轻松地在动态类型语言中实现一个原则:“面向接口编程,而不是面向实现编程”。
多态的实际含义是:同一操作作用于不同的对象上面,可以产生不同的解释和不同的执行结果。换句话说,给不同的对象发送同一个消息的时候,这些对象会根据这个消息分别给出不同的反馈。
还是一个小例子理解一下:
主人家里养了一只鸭子和一只鸡,当主人向它们发出“叫”的命令,小鸭子会叫“嘎嘎嘎”,小鸡会叫“咯咯咯”,它们同样是动物,都会发出叫声,根据主人的指令,它们就会各自发出不同的叫声。
根据上边的小故事,写一段“多态”的代码:
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());//咯咯咯
这段代码确实能体现多态性,当我们分别向鸭和鸡发出“叫”的指令时,它们会各自作出不同的反应。但是这样的“多态性”是无法令人满意的,如果后来又增加了一只狗,显然我们要修改makeSound,那么这个函数可能会变成一个巨大的函数。
多态背后的思想是“做什么”和“谁去做以及怎样去做”分离开来,也就是将“不变的事物”与“可能改变的事物”分离开来。把不变的部分隔离出来,把可变的部分封装起来,这给予了我们扩展程序的能力,程序看起来是可生长的,也是符合开放-封闭原则的,增加代码就能完成同样的功能,这显然优雅和安全得多。
根据上面的思想,首先我们找出不变的部分,那就是所有的动物都会发出叫声,把它隔离开来。再把可变的部分各自封装起来,也就是动物各自叫发出声音,把它们封装起来。修改一下代码:
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());
如果我们后续需要加一只狗,这个时候只需要追加一些代码就可以了,而不需要再修改makeSound里的代码。如下所示:
var Dog = function(){}
Dog.prototype.sound = function(){
console.log("汪汪汪")
}
多态最根本的作用就是通过把过程化的条件分支语句转化为对象的多态性,从而消除这些条件分支语句。
再看一个例子,与上边动物叫声的例子很相似。假设我们要编写一个地图应用,我们选择了百度地图,代码如下:
var baiduMap = {
show:function(){
console.log("开始渲染百度地图");
}
}
var renderMap = function(){
baiduMap.show()
}
renderMap();//开始渲染百度地图
后来我们又要改成使用高德地图,让renderMap两种地图都支持,代码如下:
var baiduMap = {
show:function(){
console.log("开始渲染百度地图");
}
}
var gaodeMap = {
show:function(){
console.log("开始渲染高德地图");
}
}
var renderMap = function(type){
if(type === 'baidu'){
baiduMap.show()
}else if(type === 'gaode'){
gaodeMap.show()
}
}
renderMap("gaode");//开始渲染高德地图
renderMap("baidu");//开始渲染百度地图
很明显,这段代码的弹性是很脆弱的,这个例子不变的地方是渲染某个地图,把不变的部分抽象出来:
var renderMap = function(map){
if(map.show instanceof Function){
map.show()
}
}
renderMap(baiduMap);//开始渲染高德地图
renderMap(gaodeMap);//开始渲染百度地图
这样再增加地图只需要追加一段代码,而不用改变renderMap的内部函数。如下:
var tengxunMap = {
show:function(){
console.log("开始渲染腾讯地图");
}
}
renderMap(tengxunMap);//开始渲染腾讯地图
注:这个例子中,我们只假设了每个地图API提供展示地图的方法名都是show,但是在实际情况中不会这么顺利,这个时候可以借助适配器模式来解决问题。
封装的目的是将信息隐藏,接下来我们将讨论封装数据、封装实现、封装类型、封装变化。
1.3.1封装数据
javascript依赖变量的作用域来实现封装特性,除了let之外,一般我们通过函数来创建作用域:
var myObject = (function(){
var __name = 'sven';
return {
getName:function(){
return __name;
}
}
})()
console.log(myObject.getName())//sven
console.log(myObject.__name)//undefined
1.3.2封装实现
封装的目的是将信息隐藏,不仅仅是隐藏数据,还包括隐藏实现细节、设计细节以及隐藏对象的类型等。
封装实现细节的例子非常多,拿迭代器来说明,迭代器的作用是在不暴露一个聚合对象内部表示的前提下,提供一种方式来顺序访问这个聚合对象。我们编写了一个each函数,它的作用就是遍历一个聚合对象,使用这个each函数,我们不关心它内部是怎样实现的,只要它提供的功能正确便可以。即使each函数修改了内部源码,只要对外的接口或者调用方式没有变化,用户就不用关心它内部实现的改变。
1.3.3封装类型
封装类型是静态类型语言中一种重要的封装方式,一般而言,封装类型是通过抽象类和接口来进行的。把对象的真正类型隐藏在抽象类或者接口之后,相比对象的类型,客户更关心对象的行为。
在javascript中,并没有对抽象类和接口的支持。javascript本身也是一门类型模糊的语言。在封装类型方面,javascript没有能力,也没有必要做得更多。对于javascript实现来说,不区分类型是一种失色,也是一种解脱。
1.3.4封装变化
从设计模式的角度出发,封装在更重要的层面体现为封装变化。
拿创建型模式来说,要创建一个对象,是一种抽象行为,而具体创建什么对象则是可以变化的,创建型模式的目的就是封装创建对象的变化。而结构型模式封装的是对象之间的组合关系。行为型模式封装的是对象的行为变化。
通过封装变化的方式,把系统中稳定不变的部分和容易变化的部分隔离开来,在系统的演变过程中,我们只需要替换那些容易变化的部分,如果这些部分是已经封装好的,替换起来也相对容易。这可以最大程度的保证程序的稳定性和可扩展性。
在以类为中心的面向对象编程语言中,类和对象的关系可以想象成铸模和铸件的关系,对象总是从类中创建而来。而在原型编程思想中,类并不是必需的,对象未必需要从类中创建而来,一个对象是通过克隆另外一个对象所得到的。
原型模式不单是一种设计模式,也被称为一种编程范型。
从设计模式的角度讲,原型模式是用于创建对象的一种模式。假设我们在编写一个飞机大战的网页游戏。某种飞机拥有分身技能,当它使用分身技能的时候,要在页面创建一些和它一样的飞机。如果不使用原型模式,那么在创建分身之前,无疑必须先保存飞机的当前信息,比如血量、炮弹等级、防御等级等信息,然后将这些信息设置到新创建飞机上面。这样才能得到一模一样的新飞机。
如果使用原型模式,我们只需要调用负责克隆的方法,就能完成同样的功能。
原型模式的实现关键,是语言是否提供了clone方法ES5中提供了Object.create方法用来 克隆。代码如下:
var Plane = function(){
this.blood = 100;
this.attckLevel = 1;
this.defenseLevel = 1;
};
var plane = new Plane();
plane.blood = 500;
plane.attckLevel = 10;
plane.defenseLevel = 7;
var clonePlane = Object.create(plane);
console.log(clonePlane.blood);
console.log(clonePlane.attckLevel);
console.log(clonePlane.defenseLevel);
// 如果浏览器不支持Object.create,可以使用
Object.create = Object.create || function(obj){
var F = function(){
F.prototype = obj;
return new F();
}
}
原型模式提供了另外一种创建对象的方式,通过克隆对象,我们就不需要在乎对象的具体类型名字。就好像一个小孩子想要礼物,他不知道飞机或者汽车怎么说,但是他可以指着商店橱窗里的飞机模型说“我要这个”。
javascript中遵循的原型编程的基本规则
1. 所有的数据都是对象
我们不能说JavaScript中所有的数据都是对象,但是可以说绝大部分数据与都是对象。而且这些对象追根溯源都来源于一个根对象。
javascript的根对象是Object.prototype对象。这是一个空对象,我们在JavaScript中遇到的每个对象,实际上都是从Object.prototype中克隆而来的,Object.prototype就是它们的原型,比如:
var obj1 = new Object();
var obj2 = {};
//Object.getPrototypeOf查看对象原型
console.log(Object.getPrototypeOf(obj1) === Object.prototype)//true
console.log(Object.getPrototypeOf(obj2) === Object.prototype)//true
2. 要得到一个对象,不是通过实例化类,而是找到一个对象作为原型并克隆它。
像上述代码中,我们调用 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
这里的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);//取得外部传入的构造器,此例是Person
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
3.对象会记住它的原型。
其实就JavaScript真正实现来说,其实并不能说对象有原型,而只能说对象的构造器有原型。
JavaScript给对象提供了一个名为__proto__的隐藏属性,某个对象的__proto__属性默认会指向它的构造器的原型对象,即{Constructor}.prototype。以下代码可以验证:
var a = new Object();
console.log(a.__proto__===Object.prototype)//true
4. 如果对象无法响应某个请求,它会把这个请求委托给它自己的原型。
这条规则是原型继承的精髓所在,之前已经说过,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)
这段代码执行的时候,引擎做了什么事情。
当我们期望得到一个“类”继承自另外一个“类的效果时,可以采用下面代码来模拟:
var obj = {name:'sven'};
var A = function(){};
A.prototype = obj;
var B = function(){}
B.prototype = new A();
var b = new B();
console.log(b.name)
再看这段代码执行的时候,引擎做了什么事情: