设计模式原则

152 阅读8分钟

单一职责原则

就一个类而言,应该仅有一个引起它变化的原因。
  单一职责原则(SRP)的职责被定义为“引起变化的原因”。如果我们有两个动机去改写一个方法,那么这个方法就具有两个职责。每个职责都是变化的一个轴线,如果一个方法承担了过多的职责,那么在需求的变迁过程中,需要改写这个方法的可能性就越大。
  因此,SRP原则体现为:一个对象(方法)只做一件事情。
  SRP原则在很多设计模式中都有着广泛的运用,例如代理模式、迭代器模式、单例模式和装饰器模式。

代理模式
  图片预加载:通过增加虚拟代理,把预加载图片的职责放到代理对象中,而本体仅仅负责往页面中添加img标签,这也是它最原始的职责。

// myImage 负责往页面中添加img标签
var myImage = (function(){
    var imgNode = document.createElement('img')
    document.body.appendChild(imgNode)
    return {
        setSrc: function(src){
            imgNode.src = src
        }
    }
})();

// proxyImage 负责预加载图片,并在预加载完成之后把请求交给本体 myImage
var proxyImage = (function(){
    var img = new Image
    img.onload = function(){
        myImage.setSrc(this.src)
    }
    return {
        setSrc: function(src){
            myImage.setSrc('file://C:  .../loading.gif')
            img.src = src
        }
    }
})();
proxyImage.setSrc('http://img.jpg')

  把添加img标签的功能和预加载图片的职责分开放到两个对象中,这两个对象各自都只有一个被修改的动机。在它们各自发生改变的时候,也不会影响另外的对象。

何时应该分离职责
  并不是所有的职责都应该一一分离。
  一方面,如果随着需求的变化,有两个职责总是同时变化,那就不必分离他们。比如在ajax请求的时候,创建xhr对象和发送xhr请求几乎总是在一起的,那么创建对象的职责和发送xhr请求的职责就没有必要分开。
  另一方面,职责的变化轴线仅当他们确定会发生变化时才具有意义,即使两个职责已经被耦合在一起,但它们还没有发生改变的征兆,那么也许没有必要主动分离它们,在代码需要重构的时候再进行分离也不迟。


单一职责原则的优缺点
  单一职责原则的优点是降低了单个类或者对象的复杂度,按照职责把对象分解成更小的粒度,这有助于代码的复用,也有利于进行单元测试。当一个职责需要变更的时候,不会影响到其他的职责。
  但单一职责原则也有一些缺点,最明显的是会增加编写代码的复杂度。当我们按照职责把对象分解成更小的粒度之后,实际上也增大了这些对象之间相互联系的难度。


最少知识原则

  最少知识原则(LKP)说的是一个软件实体应当尽可能少地与其他实体互相作用。这里的软件实体是一个广义的概念,不仅包括对象,还包括系统、类、模块、函数、变量等。

减少对象之间的联系
  单一职责原则指导我们把对象划分成较小的粒度,这可以提高对象的可复用性。但越来越多的对象之间可能会产生错综复杂的联系,如果修改了其中一个对象,很可能会影响到跟他相互引用的其他对象。对象和对象耦合在一起,有可能会降低他们的可复用性。
  最少知识原则要求我们在设计程序时,应当尽量减少对象之间的交互。如果两个对象之间不必彼此直接通信,那么这两个对象就不要发生直接的相互联系。常见的做法是引入一个第三者对象,来承担这些对象之间的通信作用。如果一些对象需要向另一些对象发起请求,可以通过第三者对象来转发这些请求。(例如中介者模式)

开放封闭原则

  软件实体(类、模块、函数)等应该是可以扩展的,但是不可修改。
  当需要改变一个程序的功能或者给这个程序增加新功能的时候,可以使用增加代码的方式,但是不允许改动程序的源代码。

用对象的多态性消除条件分支
  过多的条件分支语句是造成程序违反开放-封闭原则的一个常见原因。每当需要增加一个新的if语句时,都要被迫改动原函数。把if换成switch-case是没有用的,这是一种换汤不换药的做法。实际上,每当我们看到一大片的if或者switch-case语句时,第一时间就应该考虑,能否利用对象的多态性来重构他们。
  下面是一段不符合开放-封闭原则的代码。每当我们增加一种新的动物时,都需要改动makeSound函数的内部实现:

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){
    if(animal instanceof Duck){
        console.log('嘎嘎嘎')
    }else if(animal instanceof Chicken){
        console.log('咯咯咯')
    }else if(animal instanceof Chicken){
        console.log('汪汪汪')
    }
}

  利用多态的思想,我们把程序中不变的部分隔离出来(动物都会叫),然后把可变的部分封装起来(不同类型的动物发出不同的叫声),只需增加一段代码即可,而不用去改动原有的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('汪汪汪')
}
makeSound(new Dog())

找出变化的地方
  通过封装变化的方式,可以把系统中稳定不变的部分和容易变化的部分隔离开来。在系统的演变过程中,我们只需要替换那些容易变化的部分,如果这些部分是已经被封装好的,那么替换起来也相对容易。而变化部分之外就是稳定的部分。在系统的演变过程中,稳定的部分是不需要改变的。
  在上述的例子中,由于每种动物的叫声都不同,所以动物具体怎么叫是可变的,于是我们把动物具体怎么叫的逻辑从makeSound函数中分离出来。
  而动物都会叫这是不变的,makeSound函数里的实现逻辑只与动物都会叫有关,这样一来,makeSound就成了一个稳定和封闭的函数。

放置挂钩
  放置挂钩(hook)也是分离变化的一种方式。我们在程序有可能发生变化的地方放置一个挂钩,挂钩的返回结果决定了程序的下一步走向。这样一来,原本的代码执行路径上就出现了一个分岔路口,程序未来的执行方向被预埋下多种可能性。挂钩的返回结果由具体的子类决定。

使用回调函数
  在JavaScript中,函数可以作为参数传递给另外一个函数,这是高阶函数的意义之一。在这种情况下,我们通常会把这个函数称为回调函数。在JavaScript版本的设计模式中,策略模式和命令模式等都可以用回调函数轻松实现。
  回调函数是一种特殊的挂钩。我们可以把一部分易于变化的逻辑封装在回调函数里,然后把回调函数当做参数传入一个稳定和封闭的函数中。当回调函数被执行的时候,程序就可以因为回调函数的内部逻辑不同,而产生不同的结果。

开放-封闭原则的相对性
  实际上,让程序保持完全封闭是不容易做到的。就算技术上做得到,也需要花费太多的时间和精力。而且让程序符合开放-封闭原则的代价是引入更多的抽象层次,更多的抽象有可能会增大代码的复杂度。
  更何况,有一些代码是无论如何也不能完全封闭的,总会存在一些无法对其封闭的变化。作为程序员,我们可以做到的有下面两点:

  • 挑选出最容易发生变化的地方,然后构造抽象来封闭这些变化。
  • 在不可避免发生修改的时候,尽量修改那些相对容易修改的地方。拿一个开源库来说,修改它提供的配置文件,总比修改它的源代码来得简单。