浓缩解读《JavaScript设计模式与开发实践》①

f2deb48f8c5494ee6870cfaf2af5e0fe99257e32.jpg

面向对象的JavaScript

1.1 动态类型语言和鸭子类型

  • 按照数据类型,编程语言可以分为两大类:静态类型语言动态类型语言
  • 静态类型语言在编译时就已确定变量的类型,而动态类型语言要在程序运行时,等变量被赋值后,才确定数据类型。
  • 静态类型语言的优点:
    1. 可以帮助开发者在编译时检查类型错误;
    2. 在运行前明确了数据类型,编译器可以针对程序进行优化,提升性能;
  • 静态类型语言的缺点:强迫开发者依照契约编写程序,繁杂的类型声明会增加更多的代码,分散开发者的精力。
  • 动态类型语言的优点: 编写的代码量更少,利于阅读,让开发者更专注于业务逻辑。
  • 动态类型语言的缺点:
    1. 不区分数据类型的情况下,可能会让程序难以理解;
    2. 由于无法保证变量的数据类型,运行期间可能会发生于类型相关的错误;
  • JavaScript是典型的动态类型语言,在对变量赋值时不需要考虑它的类型。动态类型语言对变量类型的宽容给实际编码带来了很大的灵活性,这一切构建在鸭子类型(duck typing)的概念上。
  • 鸭子类型有这样一个故事:国王要组建一个100只鸭子组成的合唱团,找到99只鸭子了,还差一只。最后发现有一只非常特别的鸡,叫声跟鸭子一模一样,最后把这只鸡加入了合唱团。通俗的说法是指,“如果它走起来像鸭子,叫起来也是鸭子,那么它就是鸭子”。鸭子类型指导思想是说“应该关注对象的行为,而不是对象的本身。也就是说要关注HAS-A,而不是IS-A”。(注意单词has 和 is)
  • 鸭子类型的概念在动态类型语言的面相对象设计中至关重要,通过鸭子类型可以实现一个原则“面向接口编程,而不是面向实现编程”。例如,一个对象如果拥有push和po方法,它可以被当做栈来使用;一个对象如果有length属性,且可以通过下标对属性进行赠删改查,那这个对象就可以当做数组来使用。(如果A拥有某个对象的接口方法,那就可以认为A是对象的实例)
  • 由于JavaScript中“面向接口编程”的过程与主流的静态类型语言不一样,导致JavaScript在实现设计模式的过程也与主流的静态类型语言大相径庭。

1.2 多态

1.2.1 什么是多态?

  • polymorphism [ˌpɒlɪ'mɔ:fɪzəm](多态)一词源于希腊文,拆解开来是poly(复数)和morph(形态)两个单词的。它实际含义是指“同样一个操作,作用于不同的对象,可以产生不同的解释,并返回不同的执行结果”
  • 用多态来举例:主人有一只猫和一只狗,当主任向它们发出“叫”的命令时,不同的动物会以自己的方式来发出叫声,狗会汪汪叫,猫会喵喵叫。

//定义一个发声的方法,传入一个animal参数
var makeSound = function(animal){
//判断animal的实例是什么动物,就发出什么叫声
if(animal instanceof Gog){
console.info("汪汪汪");
}else if(animal instanceof Cat){
console.info("喵喵喵");
}
}
//定义两个动物
var Dog = function(){};
var Cat = function(){};
//调用方法
makeSound(new Dog());
makeSound(new Cat());

但示例1存在这样的问题:如果要增加一个动物(比如牛),则必须要改动makeSound函数了。要知道修改代码总是危险的,修改的地方越多,程序出错的可能性就越大。并且当动物种类越来越多,makeSound函数将变得非常巨大。
- 而多态背后的核心思想是`“将不变的事物和可能变化的事物分离开来”`。例子中,动物都会叫是不变的,但不同类型的动物具体怎么叫是可变的,我们可以将不变的部分隔离出来,把可变的部分封装起来,让程序符合开放-封闭原则。

//统一的makeSound函数调用入口
var markSound = function(animal){
//将具体怎么叫,封装成动物的方法
animal.sound();
};
//鸭子
var Duck = function(){ };
Duck.prototype.sound = function(){
console.info("嘎嘎嘎");
};
//小鸡
var Chicken = function(){ };
Chicken.prototype.sound = function(){
console.info("咯咯咯");
};
makeSound(new Duck()); //嘎嘎嘎
makeSound(new Chicken()); //咯咯咯


#### 1.2.2 多态引发的类型检查问题

- 谈到多态,类型检查是绕不开的话题,但JavaScript是一门不比进行类型检查的动态类型语言,不像静态类型语言。
- 以Java为例,代码编译时会进行严格的类型检查,不能给变量赋予不同类型的值。

String str; //定义一个String类型的变量
str = "abc"; //赋值成功
str = 123; //报错!

通过Java实现鸭子类型:

//声明一个鸭子类
public class Duck{
public void makeSound(){
System.out.println("嘎嘎嘎");
}
}
//声明一个小鸡类
public class Chicken{
public void makeSound(){
System.out.println("咯咯咯");
}
}
//呼叫动物类
public class AnimalSound{
public void makeSound(Duck duck){
duck.makeSound();
}
}
//测试类
public class Test{
public static void main(String args[]){
AnimalSound animalSound = new AnimalSound();
animalsound.makeSound(new Duck()); //输出:嘎嘎嘎
}
}

虽然顺利让鸭子发出叫声,但如果想让小鸡叫唤,发现几乎是不可能实现,因为Animal.makeSound()方法规定了只接受Duck类型的参数。
针对这种情况,静态类型的编程语言通常被设计成可以`向上转型`:当给一个类变量赋值时,既可以使用类本身,也可以使用这个类的超类。好比我们描述天上的一直麻雀时,既可以说“有一只麻雀在天上飞”,也可以说“有一只鸟在天上飞”。
- 通过继承实现多态的效果是最常用的手段。继承通常包含`实现继承`和`接口继承`,这里通过实现继承重新调整鸭子类型的代码。

//定义动物抽象类
public abstract class Animal{
abstract void makeSound(); //makeSound抽象方法
}
//小鸡实现类
public class Chicken extends Animal{
public void makeSound(){
System.out.println("咯咯咯");
}
}
//小鸭实现类
public class Duck extends Animal{
public void makeSound(){
System.out.println("嘎嘎嘎");
}
}
//呼叫动物类
public class AnimalSound{
public void makeSound(Animal animal){
animal.makeSound();
}
}
//测试类
public class Test{
public static void main(String args[]){
AnimalSound animalSound = new AnimalSound();
animalsound.makeSound(new Duck()); //输出:嘎嘎嘎
animalsound.makeSound(new Chicken()); //输出:咯咯咯
}
}


#### 1.2.3 JavaScript的多态
- 多态之所以要把“不变的事物”和“可能改变的事物”分离,是为了消除类型之间的耦合,Java通过向上转型来实现。而由于JavaScript的变量类型在运行期是可变的,意味着JavaScript对象的多态性是与生俱来的 。判断动物是否能发出叫声,不需要判断对象是某种类型的动物,只取决于它有没有makeSound方法,不存在任何程度的“类型耦合”。

#### 1.2.4 多态在面向对象程序设计中的作用
- 多态最根本的作用是消除条件分之语句,将过程化的条件分之语句转化为对象的多态性。
- Martin Fowler在《重构:改善既有代码的设计》书中以拍电影作为多态的比喻。
* 电影在拍摄时,当导演喊出“action”后,主演门开始讲台词,灯光师负责打灯,群众演员假装中枪倒地,道具师往镜头撒雪花。在等到导演的指令后,每个对象都知道自己应该做什么,这就是多态性。
* 如果不利用对象的多态性,而是用面向过程的方式上来编写代码,那么就相当于:每次电影开始拍摄后,导演要逐个走到每个人的面前,确认它们的职业分工(类型),然后再告诉他们要做什么。映射到程序当中,那么程序中将充斥着大量条件分之语句。
- 每个对象应该做什么,封装在成对象内部的一个方法,每个对象负责自己的行为。所以这些对象可以根据同一个指令,有条不紊地分别进行各自的工作,这正是面向对象的优点。

#### 1.2.5 多态与设计模式
- GoF的《设计模式》一书从面向对象设计的角度出发,通过对封装、继承、多态、组合等多种技术的反复使用,提炼出可重复使用的面向对象设计技巧。多态是当中的重中之重,绝大多数设计模式的实现都离不开多态性的思想。
- 比如命令模式,请求被封装在一些命令对象中,这使得命令的调用和命令的接受者可以完全解耦开来,当调用execute方法时,不同的命令做不同的事情,从而产生不同的执行结果。
- 在组合模式中,对组合对象和叶节点对象发出同一个指令时,它们会各自做自己应该做的事情,组合对象把消息继续传递给下面的叶节点对象,叶节点再对指令做出响应。
- 在策略模式中,Context并没有执行算法的能力,而是把职责委托给具体的策略对象。每个策略对象负责的算法被封装在各自对象的内部。当对这些策略对象发出计算的指令时,它们会个各自执行并响应不同的计算结果。

---

### 1.3 封装
#### 1.3.1 封装数据
- 很多编程语言是通过语法解析来实现封装数据的,比如Java提供private、public、protected等关键字来限定访问权限。
- 可JavaScript缺乏这些关键字的支持,只能依赖变量的作用域来实现public和private的封装特性。

var myObject = (function(){
var _name = 'sven'; //私有变量
return {
//公开方法
getName : function(){
return _name;
}
};
})();

- (ECMAScripte6标准,提供了let关键字来创建块级作用域)

#### 1.3.2 封装实现
- 很多人喜欢把封装理解成封装数据,这是一种狭义的定义。其实封装不仅是隐藏数据,还包括隐藏实现细节、设计细节以及隐藏对象的类型等。
- 封装实现细节指的是,使得对象内部的变化对于其他对象而言是不可见的,对象只对自己的行为负责。对象之间的耦合变松散,对象之间只通过暴露的API接口来通讯。这样一来,即便当我们需要修改对象时,可以任意修改它的内部实现,而由于对外接口没有变化,则不会影响程序的其他功能。
- 比如迭代器each()函数,不用关心它的内部是怎么实现的,只需要知道它的作用是遍历集合对象。及时each函数修改了内部源代码,主要调用方式没有变化,就不会对调用each()函数的代码造影响。

#### 1.3.3 封装类型
- 封装类型是类似Java等静态类型语言中一种重要的封装方式。一般通过抽象类和接口来进行。把对象的真正类型隐藏在抽象类或者接口背后,这样对于调用者来说,就看看呀只关心对象的行为,而不是对象的类型。
- 由于静态语言需要想方设法的隐藏对象的类型,也促使了比如工厂方法模式、组合模式等设计模式的诞生。
- JavaScript本身是一门类型模糊的语言。对于JavaScript的设计模式实现来说,不区分类型是一种失色,也可以说是一种解脱。

#### 1.3.4 封装变化
- 从设计模式的角度出发,封装的更高层面体现为封装变化。
- 《设计模式》提到“找到变化,并封装之”,《设计模式》一书中总共归纳总结了23中设计模式,这23种设计模式又可以从意图上区分为创建型模式、结构型模式和行为型模式。
- 拿创建型模式来说,具体创建什么对象是变化的,创建型模式的目的就是封装创建对象的变化。而结构型模式封装的是对象之间的组合关系。行为型模式封装的是对象的行为变化。通过封装变化,可以最大程度的保证程序的稳定性和可拓展性。

---

### 1.4 原型模式和JavaScript
- Brendan Eich设计JavaScript时,之所以选择原型的面向对象系统,是因为从一开始就没有打算在JavaScript中加入类的概念。
- 以类作为中心的面向对象编程语言当中,比如Java,类和对象的关系可以想象成铸模和铸件的关系,对象是从类中创建而来的。而在原型编程的思想当中,类不是必须的,对象是通过克隆另一个对象得到的。

#### 1.4.1 使用克隆的原型模式
- 原型模式是创建对象的一种模式。
- 相比起Java,创建一个对象要先指定它的类型,然后通过类来创建这个对象。原型模式不在关心对象的具体类型,而是找到一个对象,然后通过克隆来创建一个一模一样的对象,就好比游戏中的分身。
- 说到克隆,原型模式的实现关键在于编程语言是否提供clone方法,比如ECMAScript5提供的`Object.create()`方法。

#### 1.4.2 克隆是创建对象的手段
- 原型模式的真正目的并不是为了复制一个一模一样的对象,而是提供一种便捷的方式去创建某个类型的对象,而克隆只是创建对象的手段。
- 依赖倒置原则提醒开发者,编写像Java等静态型语言的程序时,创建对象要避免依赖具体的类型,比如`new XXX`创建对象的方式会使得类型之间的耦合度很高,代码很僵硬。需要通过工厂方法模式和抽象工厂模式解决此类问题,但这无可避免的,会增加很多额外的代码。
- 原型模式则提供了另外一种方式,通过克隆对象,不需要再关心对象的具体类型名称,所以也就不存在类型耦合的问题。
- JavaScript本身是一门基于原型的面型对象语言,它的面向对象系统通过原型模式来搭建,所以与其称为原型模式,不如称之为原型编程范例更为合适。

#### 1.4.3 原型模式的Io语言
- 事实上,使用原型模式来构建面向对象系统的编程语言,并非仅有JavaScript一家。还有比如Self语言、Smalltalk语言,以及另一个轻巧的Io语言。
- Io中同样没有类的概念每个对象都是基于另外一个对象的克隆。既然每个对象都是由其他对象克隆而来,那么Io语言本身应该至少要提供一个根对象,其他对象都发源于这个根对象才对,就好像美剧吸血鬼的始祖一样。对的,Io语言根对象是Object。
- 继续拿动物世界的例子讲解Io语言

//通过克隆Object根对象得到Animal对象,所以Obejct称为Animal的原型
Animal := Object clone;
//给Animal对象添加makeSound方法
Animal makeSound := method("animal makeSound" print);
//接下来以Animal作为原型,继续创建Dog对象
Dog := Animal clone;
//然后给Dog对象添加eat方法
Dog eat := method("dog eat" print);
//最后测试Animal对象和Dog对象的功能
Animal makeSound; //输出"animal makeSound"
Dog eat; //输出"dog eat"


#### 1.4.4 原型编程的特点
- 从Io语言的使用当中可看出,跟使用“类”的语言不同的是,原型编程语言最初只有一个根对象(Object),其他对象都是克隆自另一个对象。
- 在上一个例子当中,Object是Animal的原型,Animal的Dog的原型,它们串联起来形成了一条原型链。
- 原型链是很有用处的,当尝试调用Dog对象的某个方法,而它本身又没有时,那么Dog对象会把调用的请求委托给它的原型Animal对象。如果Animal对象也没有的话,请求会继续顺着原型链,被委托给Object对象。这样一来,便能得到继承的效果,看起了就像是Animal是Dog的父类,Object是Animal的父类。这个机制并不复杂却非常强大,JavaScript和Io语言一样,原型继承的本质就是基于原型链的委托机制。
- 最后我们观察发现,原型编程泛型包括以下的特点:
  * 所有的数据都是对象;
  * 不通过实例化创建对象,而是找到一个对象作为原型并克隆它;
  * 对象会记住个各自的原型;
  * 如果对象无法响应某个请求,会把这个请求委托给自己的原型;

#### 1.4.5 原型模式的JavaScript语言
- 接下来讲解JavaScript如何基于原型编程的规则来构建面向对象系统。
- __所有的数据都是对象__:
    * JavaScript在设计的时候模仿了Java,数据类型分为基本类型和对象类型。

    * 基本数据类型有boolean、number、string、null、undefined。
    * 按照JavaScript设计者的本意,除了undefined之外,其他都是对象。而为了实现这一目标,基本数据类型也可以通过“包装类”的方式变成对象类型来处理。

- __不通过实例化创建对象,而是找到一个对象作为原型并克隆它__:
    * 相比起Io语言,JavaScript中不需要关心克隆的细节,JavaScript引擎内部会处理。我们只需要显式的调用`var obj1 = new Object();`或者`var obj2 = {};`,JavaScript引擎就会从Object.prototype上克隆一个对象出来。
    * 演示用new运算符从构造器中得到一个对象
    ```
function Person(name){
        this.name = name;
};
Person.prototype.getName = function(){
        return this.name;
};
var person1 = new Person('William');
console.log(person1.name);   //输出”William“
console.log(person1.getName());   //输出”William“
console.log(Object.getPrototypeOf(person1) === Person.prototype);   //输出"true“
    ```  

    * 这里的Person不是类,而是构造函数。JavaScript的函数既可以作为普通函数被调用,也可以作为构造函数被调用。当使用new关键字调用函数时,此函数就是一个构造器。
    * 当你使用new操作符调用构造函数时,会经历以下步骤:
      1. 创建一个空对象,作为将要返回的实例对象;
      2. 将空对象的原型指向构造函数的prototype属性,也就是Keith构造函数的prototype属性;
      3. 将空对象赋值给构造函数内部的this关键字,也就是this关键字会指向实例对象;
      4. 开始执行构造函数内部的代码;
- __对象会记住个各自的原型__:
    * 要实现Io语言或者JavaScript语言中的原型链查找机制,每个对象至少应该先记住自己的原型对象。
    * 但就JavaScript真正的实现来说,其实说对象有原型并我准确,应该说对象的构造器有原型。对于“对象把请求委托给自己的原型”这句话,更好的说法应该是“对象把请求委托给它的构造器的原型”。
    ```
person1.constructor指向Person,然后Person.prototype指向原型对象;
或者
person1.[[Prototype]]指向Person.Person.prototype原型对象([[Prototype]]属性和书中所写的__proto__属性一致)
    ```
- __如果对象无法响应某个请求,会把这个请求委托给自己的原型__:
    * Io语言中每个对象都可以作为原型被克隆,Animal对象克隆自Object对象,Dog对象又克隆自Animal对象,形成了一条天然的原型链。但这样就只是单一的继承连,这样的面向对象系统显得非常受限。
    * 实际上JavaScript的对象最初都是由Object.prototype对象克隆而来,但不受限于Obejct.prototype,而是可以动态的指向其他对象。
    * 在原型链查找机制中,原型链并不是无限长的。当尝试访问对象的某个属性,请求会被委托给各自的原型对象,如果最终传递到Object.prototype对象也没有查找到。这次请求会就此打住,返回undefined。

#### 1.4.6 原型继承的未来
- 设计模式很多时候其实是在弥补语言的不足之处,就像Peter Norvig曾说,设计模式是对语言不足的补充,如果要使用设计模式,不如去找一门更好的语言。
- JavaScript中用`Object.create()`来完成原型继承,看起来更能体现原型模式的精髓。但效率却不高,比通过构造函数来创建对象要慢。而ECMAScript6带来了新的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());

推荐阅读更多精彩内容