设计模式是一套被反复使用、多数人知晓的、经过分类的、代码设计经验的总结。使用设计模式是为了重用代码、让代码更容易被他人理解、保证代码可靠性。

任何不可维护的代码,都是等待过时的代码。设计模式就是从长期开发中总结出来的,用以提高类的内聚性、降低类间的耦合性、提高代码的可扩展性和可维护性的方法。

职责单一原则

  • 核心
    每个类都应该有一个单一的功能,并且该功能应该由这个类完全封装起来。也就是高内聚。

  • 思想
    如果一个类承担的职责过多,就等于把这些职责耦合在一起,一个职责的变化可能会削弱或者抑制这个类完成其他职责的能力。这种耦合会导致脆弱的设计,当变化发生时,设计会遭受到意想不到的破坏。
    【Eg:游戏的界面组成与逻辑组成分离】

  • 简单的例子

type UserService interface{
    Login(username, password string)
    Register(email, username, password string)
    LogError(msg string)
    SendEmail(email string)
}

这段代码存在很大的问题,UserService 既要负责用户的注册和登录,还要负责日志的记录和邮件的发送,并且后者的行为明显区别于前者。这就相当于一个程序员既要编代码,中午还要给公司全体员工做午饭,并且公司的卫生也由他负责

  • 对代码进行改造
  1. UserService 用户服务
type UserService interface{
    Login(username, password string)
    Register(email, username, password string)
}
  1. LogService 日志服务
type LogService interface{
    LogError(msg string)
}
  1. EmailService 邮箱服务
type LogService interface{
    SendEmail(email string)
}
  • 职责单一原则的好处: 类的复杂度降低,并且,当想发邮件却不知道实现哪个类的时候,这种设计模式可以快速的帮我们定位到具体哪个类可以实现这个功能。

开闭原则

  • 核心
    一个软件实体应当对拓展开放,对修改关闭。即:软件实体应尽量在不修改原有代码的情况下进行拓展。

对Operation类的修改关闭,对Operation类的继承(拓展)开放。

  • 简单的例子
  1. Rectangle
type Rectangle struct {
    width int
    height int
}

func(r Rectangle) getWidth() int{
    return r.width
}

func(r Rectangle) getHeight() int{
    return r.height
}

  1. 面积计算器: AreaCalculator
type AreaCalculator struct {}

func (a AreaCalculator) area(shape Rectangle) int{
    return shape.getHeight() * shape.getWidth()
}

上面代码完全可以完成矩形面积的计算,但是,这时有一个新的需求,让计算圆形的面积. 可以这样更改 AreaCalculator 代码,来满足这个需求:

Circular:

type Circular struct {
    radius int
}

func(c Circular) getRadius() int{
    return c.radius
}

更改 AreaCalculator:

type AreaCalculator struct {}

func (a AreaCalculator) area(shape interface{}) int{
    switch shape.(type):
        case Circular:
            return math.Pow((shape.(Circular)).getRadius(), 2) * math.PI 
        case Rectangle:
            r := shape.(Rectangle)
            return r.getHeight() * r.getWidth()
        default: 
            panic("不支持的参数类型")
}

这么更改完成,完全没有问题。但是在真实的生产环境中,情况更为复杂,更改涉及的部分较多,那样就可能导致牵一发动全身。并且,以前编写的经过测试的一些功能需要重新测试,甚至导致某些功能不可用。

  • 进行开闭原则代码改进

shape:

type shape interface{
    area() int
}

Rectangle:

type Rectangle struct {
    width height int
}

func (r Rectangle) area() int{
    return r.width * r.height
}

这样,当需求变更,需要计算圆形面积的时候,我们只需创建一个圆形的结构体,并实现 Shape 接口即可:

type Circular struct {
    radius int
}

func (c Circular) area() int {
    return math.Pow(c.radius, 2) * math.PI  
}

这样计算三角形面积四边形面积... 的时候,我们只需让它们去实现 Shape 接口即可,无需修改源代码。

  • 好处
    一般编写完的代码,都是经过精心设计和测试过的,如果我们对其进行修改,就需要重新测试,这个测试可能涉及到所有依赖这个方法的类,如果大量修改源代码的话,那就是个可观的工程。
    所以,开闭原则可以在保证我们代码的质量的前提下,实现功能的扩展减少开发难度

里式替换原则

  • 核心
    在程序里,把父类都换成它的子类,程序的行为没有变化。
  • 思考
    里氏替换原则是开闭原则的基石,正是因为里斯替换原则才可以在不修改父类源码的情况下,实现功能的扩展。
    如果子类无法实现父类的全部功能,比如鸟类有个 fly() 方法,企鹅也是鸟类的一种,但是企鹅如果继承鸟类的话,就必须得实现 fly() 方法,这时就产生了一种"畸形",这种情况应该断开继承关系。如果强行继承的话,子类并无法完全替代父类,程序就有可能因此出现故障,比如一只企鹅在天上翱翔。

我喜欢动物(父类)------(替换为)------->我喜欢狗(子类) 【√】
我喜欢狗(子类) ------(替换为)------->我喜欢动物(父类) 【×】

依赖倒置原则

  • 核心
    抽象不应该依赖于细节,细节应当依赖于抽象。换言之,要针对接口编程,而非针对实现编程。

  • 思想
    抽象不应该依赖于细节,细节应当依赖于抽象。换言之,要针对接口编程,而不是针对实现编程。依赖倒转其实可以说是面向对象设计的标志,用哪种语言来编写程序不重要,如果编写时考虑的都是如何针对抽象编程而不是针对细节编程,即 程序中所有的依赖关系都是终止于抽象类或者接口,那就是面向对象的设计,反之就是面向过程化设计了。

  • 原则:

    1. 高层模块不应该依赖于低层模块。两个都应该依赖于抽象。
    2. 抽象不应该依赖细节。细节应该依赖于抽象。
  • 简单的例子
    InterCpu:

type InterCpu struct {}

func(i InterCpu) add(a, b int) int {
    return a + b
}

MainBoard:

type MainBoard struct {
    cpu InterCpu
}

func(m *MainBoard) setCpu(cpu InterCpu)  {
    m.cpu = cpu
}

当某一天,CPU 需要更换的时候,只能装配英特尔 CPU。
虽然这个例子比较简单,但是在实际的开发中,经常会被眼前的需求所蒙蔽,而不去思考拓展性,导致每次来个新需求,都要违背开闭原则。

  • 进行改进
    CPU:
type CPU interface {
    add(a, b int) int
}

InterCPU:

type InterCPU struct{}
func (_ interCPU) add(a, b int) int {
    return a + b
}

AMD CPU:

type AmdCPU struct{}
func (_ AmdCPU) add(a, b int) int {
    return a + b - b + b
}

MainBoard:

type MainBoard struct{
    cpu CPU
}
func(m MainBoard) setCPU(cpu CPU) {
    m.cpu = cpu
}
  • 好处
    依赖倒置原则的好处很明显,当需求变更的时候,我们可以很灵活的进行扩展,而不用破坏开闭原则。

接口隔离原则

  • 核心
    建立单一接口,不要建立庞大臃肿的接口,尽量细化接口,接口中的方法尽量少

  • 思考
    这个原则跟单一职责原则很像,都是为了精细化管理,将功能尽可能的细化。这样,当某个功能出现问题的时候,可以快速的定位问题,并且在最小范围内修复,功能的扩展也是一样的。

但是,接口也不能无限的小,那样会产生大量的接口,造成设计过于负责。

迪米特法则, 最少知道原则(Demeter Principle)

  • 核心
    一个软件实体应当尽可能少地与其他实体发生作用。(无熟人难办事). 降低类间的耦合性,如果两个类不必彼此通信,那么,这两个类就不要发生直接的作用。

在迪米特法则中,对于一个对象,其朋友包括如下几类:
(1)当前对象 this
(2)以参数形式传入到当前对象方法中的对象
(3)当前对象的成员对象
(4)若当前对象的成员你对象是一个集合,那么集合中的对象也都是朋友
(5)当前对象所创建的对象

  • 简单的例子
    Phone:
type Phone struct {}

func (p Phone) seeMovie(m Movie) {
    title := m.getTitle()
    totalTime := m.getTotalTime()
    // ... 
}

电影和我们的电话并没有直接的关系,这两个类的耦合度过高,也就是,电话类需要知道电影类的具体实现细节,这两个类之间没有必要进行直接的通信。

  • 进行改进
    MovieApp:
type MovieApp interface {
    seeMovie(Movie) 
}

Phone:

type Phone struct {
    m MovieApp
}

func (p *Phone) setMovieApp(m MovieApp) {
    p.m = m
}

func (p *Phone) seeMovie(m Movie) {
    p.m.seeMovie(movie)
}
  • 好处

低耦合、低耦合、低耦合...

合成复用原则

  • 核心
    尽量使用对象组合,而不是继承来达到复用的目的。

  • 思想
    合成复用原则就是在一个新的对象里通过关联关系(包括组合关系和聚合关系)来使用一些已有的对象,使之成为新对象的一部分;新对象通过委派调用已有对象的方法达到复用功能的目的。简言之:复用时要尽量使用组合/聚合关系(关联关系),少用继承。

在面向对象设计中,可以通过两种方法在不同的环境中复用已有的设计和实现,即通过组合/聚合关系或通过继承,但首先应该考虑使用组合/聚合,组合/聚合可以使系统更加灵活,降低类与类之间的耦合度,一个类的变化对其他类造成的影响相对较少;其次才考虑继承,在使用继承时,需要严格遵循里氏代换原则,有效使用继承会有助于对问题的理解,降低复杂度,而滥用继承反而会增加系统构建和维护的难度以及系统的复杂度,因此需要慎重使用继承复用

  • 简单的例子
    汽车分类管理程序:
    汽车按“动力源”划分可分为汽油汽车、电动汽车等;按“颜色”划分可分为白色汽车、黑色汽车和红色汽车等。如果同时考虑这两种分类,其组合就很多。图 1 所示是用继淨:关系实现的汽车分类的类图。

从图 1 可以看出用继承关系实现会产生很多子类,而且增加新的“动力源”或者增加新的“颜色”都要修改源代码,这违背了开闭原则,显然不可取。但如果改用组合关系实现就能很好地解决以上问题,其类图如图 2 所示。

这 7 种设计原则是软件设计模式必须尽量遵循的原则,各种原则要求的侧重点不同。其中,开闭原则是总纲,它告诉我们要对扩展开放,对修改关闭;里氏替换原则告诉我们不要破坏继承体系;依赖倒置原则告诉我们要面向接口编程;单一职责原则告诉我们实现类要职责单一;接口隔离原则告诉我们在设计接口的时候要精简单一;迪米特法则告诉我们要降低耦合度;合成复用原则告诉我们要优先使用组合或者聚合关系复用,少用继承关系复用。

设计模式之间的关系

参考

设计模式的六大原则
设计模式六大原则
合成复用原则