设计模式中的设计原则

面向对象程序设计

  • 抽象
  • 封装
  • 继承
  • 多态

什么是设计模式

设计模式(Design Pattern)是前辈们对代码开发经验的总结,是解决特定问题的一系列套路。它不是语法规定,而是一套用来提高代码可复用性、可维护性、可读性、稳健性以及安全性的解决方案。

1995 年,GoF(Gang of Four,四人组/四人帮)合作出版了《设计模式:可复用面向对象软件的基础》一书,共收录了 23 种设计模式,从此树立了软件设计模式领域的里程碑,人称「GoF设计模式」。

为什么学习设计模式

  • 设计模式是经过实践验证的解决方案,能指导你如何使用面向对象的设计原则来解决各种问题。

  • 设计模式是高效沟通的通用语言。你只需说“这里用单例就可以了”,所有人都会理解这条建议背后的想法。只要知晓模式及其名称,你就无需解释什么是单例。

优秀设计的特征

  • 代码复用

    减少开发成本的最常用的方式之一。

  • 扩展性

    理解更深入之后重构代码;程序经常需要变化适应新需求。

设计原则

封装变化的内容

找到程序中的变化内容并将其与不变的内容区分开,将变更的影响最小化。

方法层面的封装

例如,有一个获取订单总价的方法:

1
2
3
4
5
6
7
8
9
10
11
12
method getOrderTotal(order)
total = 0
foreach item in order.lineItems
total += item.price * item.quantity

if (order.country == "US")
// 美国增值税
total += total * 0.07
else if (order.country == "EU")
// 欧洲增值税
total += total * 0.20
return total

修改前:税率计算代码和方法的其他代码混杂在一起。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
method getOrderTotal(order)
total = 0
foreach item in order.lineItems
total += item.price * item.quantity

total += total * getTaxRate(order.country)

return total

method getTaxRate(country)
if (country == "US")
// 美国增值税
return 0.07
else if (country == "EU")
// 欧洲增值税
return 0.20
else
return 0

修改后:可通过调用指定方法获取税率。

类层面的封装

如果方法中的职责越来越多,还有其他变量引入,则可以将这些东西抽取到一个新类中。

修改前:在 订单 Order 类中计算税金。

修改后:对订单类隐藏税金计算。

面向接口开发,而不是面向实现

面向接口进行开发,而不是面向实现;依赖于抽象类型,而不是具体类。

例如,Company 类中有一个开发软件方法:

修改前:所有类都紧密耦合。

归纳雇员的方法并抽取通用接口:

优化:多态机制能帮助我们简化代码,但 Company 类的其他部分仍然依赖于具体的雇员类。

再抽象一个该方法来获取雇员:

修改后: Company 类的主要方法独立于具体的雇员类。雇员对象将在具体公司子类中创建。(工厂方法模式)

组合优于继承

继承可能是类之间最明显、最简单的代码复用方式。

继承存在的问题:

  • 子类不能减少父类的接口。(必须实现父类所有抽象方法)
  • 重写方法时需要保证与父类版本兼容。(子类对象可能传递给父类作为参数的方法)
  • 继承打破了父类的封装。(子类可以访问父类内部详细内容)
  • 子类与父类紧密耦合。(父类修改可能破坏子类功能)
  • 通过继承复用代码可能导致平行继承体系的产生。(多维度继承)

例如,有一个创建车的程序,需要创建卡车和小汽车,有电车和油车之分,还有自动驾驶和手动驾驶之分:

继承:在多个维度上扩展一个类(汽车类型 × 引擎类型 × 驾驶类型)可能会导致子类组合的数量爆炸。

使用组合,将汽车行为委派给别的对象:

组合:将不同“维度”的功能抽取到各自的类层次结构中。(策略模式)

SOLID 原则

Robert C. Martin《敏捷软件开发:原则、模式与实践》中首次提出。SOLID 是让软件设计更易于理解、更加灵活和更易于维护的五个原则的简称。

单一职责原则(Single Responsibility Principle)

修改类的原因只能有一个。该原则的目的是减少复杂度,尽量让每个类只负责软件中的一个功能,并将该功能完全封装(隐藏)在该类中。

例如,有一个雇员类:

修改前:类中包含多个不同的行为。

将与打印时间表报告相关的行为移动到一个单独的类中,这样其他与报告相关的内容也可以移到这个类中。

修改后:额外行为有了它们自己的类。

开闭原则(Open/closed Principle)

对于扩展,类应该是“开放”的;对于修改,类则应是“封闭”的。该原则主要理念是实现新功能时保持已有代码不变。

例如,有一个计算运输费用的 Order 类,该类中所有运输方法都以硬编码的方式实现。如果你需要添加一个新的运输方式,那就必须承担可能对 Order 类造成破坏的风险来对其进行修改。

修改前:在程序中添加新的运输方式时,你必须对 Order 类进行修改。

使用策略模式进行修改,将运输方法抽取出来:

修改后:添加新的运输方式不需要修改已有的类,并且满足单一职责原则。

里氏替换原则(Liskov Substitution Principle)

当你扩展一个类时, 要能在不修改客户端(使用这个类的地方)代码的情况下将子类的对象作为父类对象进行传递。

该原则对子类的具体要求:

  • 子类方法的参数类型必须与其父类的参数类型相匹配或更加抽象。

    父类有方法 feed(Cat c),子类重写 feed(Animal c) ✔️​ feed(BlackCat c)

  • 子类方法的返回值类型必须与父类方法的返回值类型或是其子类相匹配。

    父类有方法 Cat buyCat(),子类重写 BlackCat buyCat() ✔️​ Animal buyCat()

  • 子类中的方法不应抛出基础方法预期之外的异常类型。

  • 子类不应该加强其前置条件。

    父类方法参数为 int,子类方法参数为 正数

  • 子类不能削弱其后置条件。

    父类方法使用数据库连接并关闭,子类方法不关闭连接❌

  • 父类的不变量必须保留。

  • 子类不能修改超类中私有成员变量的值。

例如,有一个文档类:

修改前:只读文件中的保存行为没有任何意义,因此子类试图在重写后的方法中重置父类行为来解决这个问题。

只读文件子类(ReadOnlyDocument)中的 save 方法会在被调用时抛出一个异常,而父类方法则没有这个限制。如果在保存前没有检查文档类型,客户端代码将会出错。

重新设计类层次结构,子类应该扩展超类的行为:

修改后:当把只读文档类作为层次结构中的基类后,这个问题得到了解决。只读文档变成了层次结构中的基类。可写文件现在变成了子类,对基类进行扩展并添加了保存行为。

接口隔离原则(Interface Segregation Principle)

客户端不应被强迫依赖于其不使用的方法。尽量缩小接口的范围,使得客户端的类不必实现其不需要的行为。

例如,有一程序库可以和云服务供应商整合:

修改前:不是所有客户端能满足复杂接口的要求。

将接口拆分为多个部分:

修改后:一个复杂的接口被拆分为一组颗粒度更小的接口。

不要进一步划分已经非常具体的接口。创建的接口越多,代码就越复杂。

依赖倒置原则(Dependency Inversion Principle)

高层次的类不应该依赖于低层次的类。两者都应该依赖于抽象接口。抽象接口不应依赖于具体实现。具体实现应该依赖于抽象接口。

通常在设计软件时,有不同层次的类:

  • 低层次的类实现基础操作(例如磁盘操作、传输网络数据和连接数据库等)。
  • 高层次的类包含复杂业务逻辑以指导低层次的类执行特定操作。

有时会先设计低层次的类, 然后才会开发高层次的类。 当低层次的东西还没有实现或不确定,就无法确定高层次类能实现哪些功能。

依赖倒置原则建议改变这种依赖方式。

例如,高层次的预算报告类使用低层次的数据库类来读取和保存其数据。低层次类中的任何改变(例如当数据库服务器发布新版本时)都可能会影响到高层次的类,但高层次的类不应关注数据存储的细节。

修改前:高层次的类依赖于低层次的类。

创建一个描述读写操作的高层接口, 并让报告类使用该接口代替低层次的类。然后可以修改或扩展低层次的原始类来实现业务逻辑声明的读写接口。

修改后:低层次的类依赖于高层次的抽象,原始的依赖关系被倒置。

有原则是件好事,但是应用这些原则可能会使程序架构变得过于复杂,要从实用的角度来考量,不能盲目遵守这些原则。