设计模式概念
背景
设计模式最初并不是出现在软件设计中,而是被用于建筑领域的设计中。
1977年美国著名建筑大师、加利福尼亚大学伯克利分校环境结构中心主任克里斯托夫·亚历山大(Christopher Alexander)在他的著作《建筑模式语言:城镇、建筑、构造》中描述了一些常见的建筑设计问题,并提出了253种关于对城镇、邻里、住宅、花园和房间等进行设计的基本模式。
1990年软件工程界开始研讨设计模式的话题,后来召开了多次关于设计模式的研讨会。
直到1995年,艾瑞克·伽马(ErichGamma)、理査德·海尔姆(Richard Helm)、拉尔夫·约翰森(Ralph Johnson)、约翰·威利斯迪斯(John Vlissides)等 4 位作者合作出版了《设计模式:可复用面向对象软件的基础》一书,在此书中收录了23个设计模式,这是设计模式领域里程碑的事件,导致了软件设计模式的突破。
这4位作者在软件开发领域里也以他们的四人组(Gang of Four,GoF)著称。
概念
软件设计模式(Software Design Pattern),又称设计模式,是一套被反复使用、多数人知晓的、经过分类编目的、代码设计经验的总结。
它描述了在软件设计过程中的一些不断重复发生的问题,以及该问题的解决方案。
也就是说,它是解决特定问题的一系列套路,是前辈们的代码设计经验的总结,具有一定的普遍性,可以反复使用。
优点
设计模式的本质是面向对象设计原则的实际运用,是对类的封装性、继承性和多态性以及类的关联关系和组合关系的充分理解。
正确使用设计模式具有以下优点:
- 可以提高程序员的思维能力、编程能力和设计能力。
- 使程序设计更加标准化、代码编制更加工程化,使软件开发效率大大提高,从而缩短软件的开发周期。
- 使设计的代码可重用性高、可读性强、可靠性高、灵活性好、可维护性强。
分类
- 创建型模式:用于描述怎样创建对象,它的主要特点是将对象的创建与使用分离。GoF(四人组)书中提供了单例、原型、工厂方法、抽象工厂、建造者等
5种创建型模式。 - 结构型模式:用于描述如何将类或对象按某种布局组成更大的结构,GoF(四人组)书中提供了代理、适配器、桥接、装饰、外观、享元、组合等
7种结构型模式。 - 行为型模式:用于描述类或对象之间怎样相互协作共同完成单个对象无法单独完成的任务,以及怎样分配职责。GoF(四人组)书中提供了模板方法、策略、命令、职责链、状态、观察者、中介者、迭代器、访问者、备忘录、解释器等
11种行为型模式。
统一建模语言
统一建模语言(Unified Modeling Language,UML)是用来设计软件的可视化建模语言。
它的特点是简单、统一、图形化、能表达软件设计中的动态与静态信息。
UML 从目标系统的不同角度出发,定义了用例图、类图、对象图、状态图、活动图、时序图、协作图、构件图、部署图等9种图。
类图概述
类图(Class Diagram)是显示了模型的静态结构,特别是模型中存在的类、类的内部结构以及它们与其他类的关系等。
类图不显示暂时性的信息,类图是面向对象建模的主要组成部分。
类图作用
- 在软件工程中,类图是一种静态的结构图,描述了系统的类的集合,类的属性和类之间的关系,简化了人们对系统的理解。
- 类图是系统分析和设计阶段的重要产物,是系统编码和测试的重要模型。
类图表示
在 UML 类图中,类使用包含类名、字段(field) 和方法(method) 且带有分割线的矩形来表示。
下图表示一个Employee类,它包含name,age,address三个字段,以及work()方法。
classDiagram
class Employee {
- int age
- String name
- String address
+ void work()
}
字段、方法名称修饰符表示其可见性:
+:public-:private#:protected~:package、internal$:static
字段的完整表示方式为:可见性 类型 名称 : [ = 缺省值]
字段的完整表示方式为:可见性 [ : 返回值类型 ] 名称(参数列表)
注:
- 中括号中的内容表示是可选的。
- 也有将类型放在变量名前面,返回值类型放在方法名前面。
- 部分 JS 绘制的 UML 类图
:不严格。
方法特有修饰符:
*:abstract
类的关系
类与类之间的关系,可分为以下几种:
classDiagram direction TB classA --|> classB : Inheritance classC --* classD : Composition classE --o classF : Aggregation classG --> classH : Association classI -- classJ : Link(Solid) classK ..> classL : Dependency classM ..|> classN : Realization classO .. classP : Link(Dashed)
本文档使用 mermaidjs class diagram 表示所有的 UML 图,文档见:https://mermaid.js.org/syntax/classDiagram.html
软件设计原则
在软件开发中,为了提高软件系统的可维护性和可复用性,增加软件的可扩展性和灵活性,软件开发人员要尽量根据6条原则来开发程序,从而提高软件开发效率、节约软件开发成本和维护成本。
开闭原则
对扩展开放,对修改关闭。在程序需要进行拓展的时候,不能去修改原有的代码,实现一个热插拔的效果。简言之,是为了使程序的扩展性好,易于维护和升级。
想要达到这样的效果,需要使用接口和抽象类。
因为抽象灵活性好,适应性广,只要抽象的合理,可以基本保持软件架构的稳定。而软件中易变的细节可以从抽象派生来的实现类来进行扩展,当软件需要发生变化时,只需要根据需求重新派生一个实现类来扩展就可以了。
以搜狗输入法的皮肤为例介绍开闭原则的应用。
需求:搜狗输入法的皮肤设计。
分析:
- 搜狗输入法的皮肤是输入法背景图片、窗口颜色和声音等元素的组合。
- 用户可以根据自己的喜爱更换自己的输入法的皮肤,也可以从网上下载新的皮肤。
- 这些皮肤有共同的特点,可以为其定义一个抽象类(
AbstractSkin),而每个具体的皮肤(DefaultSkin和Win11StyleSkin)是其子类。 - 用户窗体可以根据需要选择或者增加新的主题,而不需要修改原代码,所以它是满足开闭原则的。
classDiagram
class DefaultSkin {
~ void display()
}
class SoGouInput {
- AbstractSkin skin
+ AbstractSkin getSkin()
+ void setSkin(AbstractSkin)
}
class Win11StyleSkin {
~ void display()
}
class AbstractSkin {
<<抽象类>>
~ void display()*
}
AbstractSkin <|-- DefaultSkin
AbstractSkin -- SoGouInput
AbstractSkin <|-- Win11StyleSkin
里氏替换原则
里氏代换原则:任何基类可以出现的地方,子类一定可以出现。
通俗理解:子类可以扩展父类的功能,但不能改变父类原有的功能;换言之,子类继承父类时,除添加新的方法完成新增功能外,尽量不要重写父类的方法。
如果通过重写父类的方法来完成新的功能,这样写起来虽然简单,但是整个继承体系的可复用性会比较差,特别是运用多态比较频繁时,程序运行出错的概率会非常大。
里氏替换原则中经典的一个例子:正方形不是长方形。
在数学领域里,正方形毫无疑问是长方形,它是一个长宽相等的长方形。
所以,开发的一个与几何图形相关的软件系统,就可以顺理成章的让正方形继承自长方形。
classDiagram
direction LR
class Square {
+ void setWidth(double)
+ void setLength(double)
}
class Rectangle {
- double width
- double length
+ double getWidth()
+ void setWidth(double)
+ double getLength()
+ void setLength(double)
+ void printLengthAndWidth()
}
class App {
- List~Rectangle~ panels
+ void addPanel(Rectangle)
+ void resize()
+ void main(String[])$
}
Rectangle <|-- Square
Rectangle -- App
代码如下:
长方形类(Rectangle):
1 | public class Rectangle { |
正方形类(Square):由于正方形的长和宽相同,所以在方法setLength和setWidth中,对长度和宽度都需要赋相同值。
1 | public class Square extends Rectangle { |
应用类(App):软件的启动程序,它在启动时基于Rectangle类绘制界面 UI,并有一个基于Rectangel类的自动调整大小适应整体界面的方法。
1 | public class App { |
运行上述代码会发现:
- 若把一个长方形(
panel1)作为参数传入resize方法,就会看到长方形宽度逐渐增长的效果,当宽度大于长度,代码就会停止,这种行为的结果符合预期。 - 若把一个正方形(
panel2)作为参数传入resize方法后,就会看到正方形的宽度和长度都在不断增长,代码会一直运行下去,直至系统产生溢出错误。所以,普通的长方形是适合这段代码的,正方形不适合。 - 在
resize方法中,Rectangle类型的参数不能被Square类型的参数所代替,如果进行了替换就得不到预期结果。因此,Square类和Rectangle类之间的继承关系违反了里氏代换原则,它们之间的继承关系不成立,正方形不是长方形。
改进:抽象一个四边形接口Quadrilateral,让Rectangle类和Square类实现Quadrilateral接口。
classDiagram
class Square {
- double side
+ double getSide()
+ void setSide(double)
+ double getWidth()
+ double getLength()
}
class Rectangle {
- double width
- double length
+ double getWidth()
+ double getLength()
+ void setWidth(double)
+ void setLength(double)
}
class Quadrilateral {
<<接口>>
~ double getWidth()
~ double getLength()
}
Quadrilateral <|.. Square
Quadrilateral <|.. Rectangle
依赖倒置原则
高层模块不应该依赖低层模块,两者都应该依赖其抽象;抽象不应该依赖细节,细节应该依赖抽象。
简单的说就是要求对抽象进行编程,不要对实现进行编程,这样就降低了客户与实现模块间的耦合。
案例:组装电脑。
需求:现要组装一台电脑,需要配件 CPU,硬盘,内存条;只有这些配置都有了,计算机才能正常的运行。CPU 有很多种选择,如 Intel、AMD 等,硬盘可以选择希捷、西数等,内存条可以选择金士顿、海盗船等。
错误实践:
classDiagram
class HaseeComputer {
- IntelCPU cpu
- KingstonMemory memory
- SeagateHardDisk hardDisk
+ IntelCPU getCpu()
+ void setCpu(IntelCPU)
+ KingstonMemory getMemory()
+ void setMemory(KingstonMemory)
+ SeagateHardDisk getHardDisk()
+ void setHardDisk(SeagateHardDisk)
+ void run()
}
class CPU {
<<接口>>
~ void run()
}
class HardDisk {
<<接口>>
~ String load()
~ void save(String)
}
class Memory {
<<接口>>
~ String load()
~ void save(String)
}
class SeagateHardDisk {
+ String load()
+ void save(String)
}
class KingstonMemory {
+ String load()
+ void save(String)
}
class IntelCPU {
+ void run()
}
IntelCPU -- HaseeComputer
KingstonMemory -- HaseeComputer
SeagateHardDisk -- HaseeComputer
HardDisk <|.. SeagateHardDisk
Memory <|.. KingstonMemory
CPU <|.. IntelCPU
上面代码经组装好了一台电脑,但电脑的 CPU 只能是 Intel ,内存条只能是 Kingston ,硬盘只能是 Seagate,对用户极其不友好,用户有了机箱肯定是想按照自己的喜好,选择自己喜欢的配件。
正确实践:
classDiagram
class CPU {
<<接口>>
~ void run()
}
class HardDisk {
<<接口>>
~ String load()
~ void save(String)
}
class Memory {
<<接口>>
~ String load()
~ void save(String)
}
class SeagateHardDisk {
+ String load()
+ void save(String)
}
class Computer {
- CPU cpu
- Memory memory
- HardDisk hardDisk
+ CPU getCpu()
+ void setCpu(CPU)
+ Memory getMemory()
+ void setMemory(Memory)
+ HardDisk getHardDisk()
+ void setHardDisk(HardDisk)
+ void run()
}
class KingstonMemory {
+ String load()
+ void save(String)
}
class IntelCPU {
+ void run()
}
HardDisk <|.. SeagateHardDisk
CPU -- Computer
Memory -- Computer
HardDisk -- Computer
Memory <|.. KingstonMemory
CPU <|.. IntelCPU
让Computer类依赖抽象(各个配件的接口),而不是依赖于各个组件具体的实现类。
接口隔离原则
客户端不应该依赖它不需要的接口。一个类对另一个类的依赖应该建立在最小的接口上。
案例:安全门。
需求:打造一个品牌安全门,该品牌安全门具有防火、防水、防盗的功能;可以将防火,防水,防盗功能提取成一个接口,形成一套规范。
错误实践:
classDiagram
class SafetyDoor {
<<接口>>
~ void antiTheft()
~ void fireproof()
~ void waterproof()
}
class DaMiSafetyDoor {
+ void antiTheft()
+ void fireproof()
+ void waterproof()
}
SafetyDoor <|.. DaMiSafetyDoor
代码如下:
1 | public class DaMiSafetyDoor implements SafetyDoor { |
上面的设计存在的问题:若要打造另一种低端品牌的安全门,只能防火,防水,不能防盗,上面的设计无法满足需求;很显然若实现SafetyDoor接口就违背了接口隔离原则。
正确实践:
classDiagram
class Waterproof {
<<接口>>
~ void waterproof()
}
class Fireproof {
<<接口>>
~ void fireproof()
}
class CheapSafetyDoor {
+ void fireproof()
+ void waterproof()
}
class AntiTheft {
<<接口>>
~ void antiTheft()
}
Fireproof <|.. CheapSafetyDoor
Waterproof <|.. CheapSafetyDoor
核心代码:
1 | public class CheapSafetyDoor implements Fireproof, Waterproof { |
迪米特法则
迪米特法则(Law of Demeter,简称 LOD)又叫作最少知识原则(The Least Knowledge Principle)。
只和你的直接朋友交谈,不跟陌生人说话(Talk only to your immediate friends and not to strangers)。
其含义是:如果两个软件实体无须直接通信,那么就不应当发生直接的相互调用,可以通过第三方转发该调用。其目的是降低类之间的耦合度,提高模块的相对独立性。
迪米特法则中的朋友是指:当前对象本身、当前对象的成员对象、当前对象所创建的对象、当前对象的方法参数等,这些对象同当前对象存在关联、聚合或组合关系,可以直接访问这些对象的方法。
案例:明星与经纪人的关系;明星由于全身心投入艺术,所以许多日常事务由经纪人负责处理,如和粉丝的见面会,和媒体公司的业务洽淡等。这里的经纪人是明星的朋友,而粉丝和媒体公司是陌生人,所以适合使用迪米特法则。
类图如下:
classDiagram
class Star {
- String name
+ Star Star(String)
+ String getName()
}
class Agent {
- Star star
- List~Fan~ fans
- Company company
+ void setStar(Star)
+ void setFans(List~Fan~)
+ void setCompany(Company)
+ void meeting()
+ void business()
}
class Company {
- String name
+ Company Company(String)
+ String getName()
}
class Fan {
- String name
+ Fan Fan(String)
+ String getName()
}
Star -- Agent
Fan -- Agent
Company -- Agent
合成复用原则
尽量先使用组合或者聚合等关联关系来实现,其次才考虑使用继承关系来实现。
通常类的复用分为继承复用和合成复用两种。
继承复用虽然有简单和易实现的优点,但它也存在以下缺点:
- 继承复用破坏了类的封装性:因为继承会将父类的实现细节暴露给子类,父类对子类是透明的,所以这种复用又称为白箱复用。
- 子类与父类的耦合度高:父类的实现的任何改变都会导致子类的实现发生变化,这不利于类的扩展与维护。
- 限制了复用的灵活性:从父类继承而来的实现是静态的,在编译时已经定义,所以在运行时不可能发生变化。
采用组合或聚合复用时,可以将已有对象纳入新对象中,使之成为新对象的一部分,新对象可以调用已有对象的功能,它有以下优点:
- 维持了类的封装性:因为成分对象的内部细节是新对象看不见的,所以这种复用又称为黑箱复用。
- 对象间的耦合度低:可以在类的成员位置声明抽象。
- 复用的灵活性高:这种复用可以在运行时动态进行,新对象可以动态地引用与成分对象类型相同的对象。
案例:汽车分类管理程序。
汽车按动力源划分可分为汽油汽车、电动汽车等;按颜色划分可分为白色汽车、黑色汽车和红色汽车等。如果同时考虑这两种分类,其组合就很多。
类图如下:
classDiagram
class GasolineCar {
+ void move()
}
class ElectricCar {
+ void move()
}
class RedGasolineCar {
+ void move()
}
class WhiteElectricCar {
+ void move()
}
class RedElectricCar {
+ void move()
}
class WhiteGasolineCar {
+ void move()
}
class Car {
+ void move()
}
Car <|-- GasolineCar
Car <|-- ElectricCar
GasolineCar <|-- RedGasolineCar
ElectricCar <|-- WhiteElectricCar
ElectricCar <|-- RedElectricCar
GasolineCar <|-- WhiteGasolineCar
从上面类图可以看到使用继承复用产生了很多子类,如果现在又有新的动力源或者新的颜色的话,就需要再定义新的类。
将其改造为聚合复用,类图如下:
classDiagram
class Red {
}
class White {
}
class ElectricCar {
+ void move()
}
class GasolineCar {
+ void move()
}
class Car {
- Color color
+ void move()
}
class Color {
<<接口>>
}
Color <|.. Red
Color <|.. White
Car <|-- ElectricCar
Car <|-- GasolineCar
Color -- Car