设计模式-1-六大设计原则
1. 设计原则介绍
在大学时期,笔者就系统地学习了各种设计模式,也一直希望能够在编码时使用起来,写出简洁优雅的代码。
然而,在实际工作中总是无法将实际问题和设计模式联系起来,从而实际很少用得上。
如果说设计模式是对于一个常见问题总结出来的优秀模板,那么设计原则就是对于优秀代码准则的高度抽象。
掌握了设计原则,我们不仅能更好地理解各个设计模式为什么这么做,还可以指导我们在一些新的场景下总结出新的设计模式。
2. 设计原则定义
2.1. 单一职责原则
单一职责原则要求一个模块应该有且仅有一个职责,否则模块需要被拆分。
单一职责原则(Single Responsibility Principle,
SRP)中的职责也可以简单理解为模块的功能。
原则指出,一个模块应该聚焦在一个功能领域,避免承担多个职责。
2.1.1. 示例
classDiagram
class People {
+ String role
+ void goSchool()
}
1 |
|
People类同时包含了学习和上班的逻辑,这里就是承担了多个职责。因此可以拆分如下:
classDiagram
direction LR
class People {
+ String role
}
class Student {
+ void doStudy()
}
class Teacher {
+ void doWork()
}
People <|-- Student
People <|-- Teacher
2.1.2. 解决的问题
多职责实现有以下三点问题:
- 模块修改频繁。职责通常是我们去修改代码实现的原因,过多的职责会使得我们频繁修改整个模块。
- 复杂度上升。模块中单个职责的实现,会使得模块的复杂度上升。多个职责混合在一个模块中会使得模块的复杂度指数上升。
- 可维护性下降。调用方在引用模块时,会引入无关的功能实现。模块升级时,调用方就需要去额外关注一些无关的修改,增加了维护成本。
2.1.3. 原则分析
单一职责的核心思想是控制模块的细粒度。这样将有以下优点:
- 显著降低模块复杂度。一个模块负责一项职责,不仅会降低模块内复杂度,也会降低模块间调用的复杂度。
- 提升可维护性。降低了单个功能的修改对其他功能的影响。调用方也降低了模块的升级频率。
- 提升可读性。单一的职责使得模块的命名可以更加准确,从而见名知意。
单一职责原则是简单的,然而实际运用时非常考验开发人员的分析和抽象能力。
开发人员需要理解功能模块的核心功能,并将不同职责拆分出来,封装为独立的新模块中。与此同时,模块功能的细粒度也是需要结合项目实际情况把控的,过细地拆分往往伴随着设计和开发的工作量上升。
2.2. 接口隔离原则
接口隔离原则要求客户端不应该依赖于无关的接口,接口应该尽量小而专注。
接口隔离原则(Interface Segregation Principle,
ISP)强调的是建立模块之间依赖的接口应该最小化,做到精简专一。
2.2.1. 示例
classDiagram
direction LR
class GoSchool {
<<interface>>
void doStudy()
void doWork()
}
class People {
+ String role
+ void doStudy()
+ void doWork()
}
People ..|> GoSchool
GoSchool 接口中包含了两个方法,根据角色不同,People 其实只需要实现一个方法,所以另一个方法是无用的。将接口可以拆分如下:
classDiagram
direction LR
class DoStudy {
<<interface>>
void doStudy()
}
class DoWork {
<<interface>>
void doWork()
}
class People {
+ String role
}
class Student {
+ void doStudy()
}
class Teacher {
+ void doWork()
}
People <|-- Student
People <|-- Teacher
Student ..|> DoStudy
Teacher ..|> DoWork
2.2.2. 解决的问题
大而全的接口通常伴随着如下问题:
- 实现代码冗余:当一个接口包含多个方法时,任何实现该接口的类都必须实现所有这些方法,即使它们并不需要。
- 降低灵活性:接口的变化可能会影响所有实现该接口的类,导致频繁的修改和回归测试,增加了维护成本。
- 增加耦合度:实现类被迫依赖于大型接口中的所有方法,导致类之间的耦合度上升,降低了系统的灵活性和可扩展性。
- 可读性差:大型接口往往包含多个不相关的方法,使得实现类的可读性降低,增加了理解和使用的难度。
2.2.3. 原则分析
接口隔离原则的核心思想是将接口设计得更加精简和专注,这样将带来以下优点:
- 降低耦合度:小而专用的接口使得实现类只需关注其需要的方法,减少了对其他不相关功能的依赖。
- 提高灵活性:接口的变化只影响相关的实现类,其他类不会受到影响,从而降低了修改的风险。
- 提升可维护性:接口依赖关系更加纯粹,对于功能的划分也更加清晰。
- 增强可读性:小接口使得每个接口的功能更加明确,增强了代码的可读性和可理解性。
- 支持扩展:新的功能可以通过新增小接口实现,而不是修改已有的大接口。
2.3. 开闭原则
开闭原则要求模块实现对于扩展开放且对于修改封闭
开放封闭原则(Open Closed Principle, OCP)具体拆分为两部分:
- 对扩展开放,即对于新需求可以对已有模块进行横向扩展,以适应新情况。
- 对修改封闭,即已完成的模块具有独立稳定的功能实现,不需要继续任何代码修改。
2.3.1. 示例
classDiagram
direction LR
class Computer {
+ Keyboard keyboard
}
namespace USB {
class Keyboard {
+ typingWithUsb() void
}
}
namespace Bluetooth {
class KeyBoard {
+ typingWithBluetooth() void
}
}
Computer ..> Keyboard
Computer ..> KeyBoard
Computer 依赖于 Keyboard,但是 Keyboard 早期大部分是使用 USB
连接的,后期变更为了使用 Bluetooth连接。此时就需要修改 KeyBoard 已有的
USB 实现。
为实现开闭原则,可以进行如下修改:
classDiagram
direction LR
class Computer {
+ Keyboard keyboard
}
class Keyboard {
<<abstract>>
+ typing() void*
}
Computer ..> Keyboard
class KeyboardUsb {
+ typing() void
}
class KeyBoardBluetooth {
+ typing() void
}
Keyboard <|-- KeyboardUsb
Keyboard <|-- KeyBoardBluetooth
2.3.2. 解决的问题
“这个世界是频繁变化的”、“需求并非一成不变”。
实际的软件开发工作中,需求总是变化的,可软件功能实现是相对静态的。每当需求变化,原有的软件功能就无法满足,因此时常需要修改代码实现。然而频繁对已有代码实现进行修改,对于开发者而言是地狱般的体验,并且这往往导致模块质量的下降和复杂度的上升。
2.3.3. 原则分析
开闭原则的核心思想是依赖抽象、封装变化。
通过让模块依赖于稳定的抽象,而不是具体的实现,将相对稳定的核心功能逻辑固化。通过新增抽象的不同实现,实现模块的功能扩展,满足新需求。
开闭原则可以带来如下好处:
- 显著提升扩展性:通过新增新的抽象实现即可扩展模块的功能。
- 提升可维护性:扩展功能不需要修改已有代码,不同扩展功能之间代码独立。
- 降低复杂度:每个抽象实现仅需要关注自身的功能实现。
开闭原则实现首要需要考虑的是提取出相对稳定的抽象。这就要求开发者区分出代码中不变和变化的部分,并提取出其中变化部分的稳定特征,从而设计出抽象约束。这一点是主要的难点:如果抽象程度不足,那么每个实现中都包含大量重复的实现;如果抽象程度过深,则较难使得抽象相对稳定,无法适应后续变化。
2.4. 里氏替换原则
里氏替换原则要求所有引用基类的地方都能使用子类替换
里氏替换原则(Liskov Substitution Principle,
LSP)是实现开闭原则的方式之一。原则仅允许子类对于基类进行扩展,具体而言:
- 子类可以实现基类的抽象方法,尽量不重写基类的非抽象方法。
- 子类重写基类的非抽象方法时,应该保障原有功能逻辑,然后进行扩展。
2.4.1. 示例
classDiagram
direction LR
class Bird {
+fly() void
}
class Penguin {
+fly() void
+swim() void
}
Bird <|-- Penguin
企鹅属于鸟类,但是企鹅却不会飞。因此如果 Penguin 继承
Bird,就需要修改 fly() 方法。这就违反了里氏替换原则。
classDiagram
direction LR
class Bird {
<<abstract>>
+action()*
}
class FlyingBird {
+action() fly
}
class Penguin {
+action() swim
}
FlyingBird --|> Bird
Bird <|--Penguin
Bird 类有一个抽象方法 action(),FlyingBird 类实现了这个方法来飞,而 Penguin 类也实现了这个方法来游泳。
2.4.2. 解决的问题
如果子类无法覆盖基类的功能,这会至少带来一下问题:
- 增加多态出错概率:子类与基类方法功能会产生不一致。这种不一致是隐性的,在多态运用时,非常容易产生预期之外的结果。
- 降低整体可复用性:子类无法复用基类原本的方法。
2.4.3. 原则分析
里氏替换原则的核心思想是严格的继承复用。若不满足该原则,则说明模块之间的关系的最优解不是继承,因为继承的重点在于子类复用基类。
在不满足里氏替换原则时,有以下调整思路:
- 两个模块可能存在一个更具泛性的基类。
- 模块关系可以用组合或聚合来代替继承关系。
2.5. 依赖倒置原则
依赖倒置原则要求模块依赖于抽象而不是具体实现
依赖倒置原则(Dependence Inversion Principle,
DIP)也是实现开闭原则的方式之一。原则要求:
- 高层模块不要应该依赖于低层模块,两者都应该依赖于抽象。
- 抽象不应该依赖于具体实现,而具体实现应该依赖于抽象。
2.5.1. 示例
classDiagram
direction LR
class Application {
+saveData(String) void
}
class Database {
+connect() boolean
+save(String) void
}
Application ..> Database
在这个类图中,Application 类直接使用 Database
类,违反了依赖倒置原则,因为 Application 依赖于一个具体的实现。
为了遵循依赖倒置原则,我们可以引入一个抽象层,比如 IDataRepository
接口,让 Application 类依赖于这个抽象,而不是具体的 Database 类。
classDiagram
direction LR
class Application {
+saveData(String) void
}
class IDataRepository {
<<interface>>
+save(String) void
}
class Database {
+connect() boolean
+save(String) void
}
Application ..> IDataRepository
IDataRepository <|.. Database
2.5.2. 解决的问题
在软件设计中,模块间的相互依赖通常是让整个系统设计僵化、脆弱和难以复用的罪魁祸首。
这种僵化体现在任意一处的修改会牵连出一些列依赖模块的级联式的修改,当这种现象发生时通常伴随而来的就是工作量评估失效,这使得后期开发者举步维艰。
模块依赖通常也会使得软件更加脆弱,当我们修改了某个模块并通过了单元测试,后期其他一个与本次修改无关的模块却出现了错误。这种脆弱性会极大降低开发的效率和质量,开发者通常在修复一个bug后又出现多个bug。
难以复用的影响将体现在方案设计时,当我们希望去复用某个模块时,发现该模块强依赖了一些我们不需要的模块,那么复用模块的工作量足以让设计人员知难而退,因为隔离重构的代价很可能已经超过重新实现一个新模块的代价了。
2.5.3. 原则分析
依赖倒置原则的核心思想是加入抽象的中间层,来约束和复用依赖关系。模块间的依赖关系是可能变化的,而模块间的直接依赖固定了依赖关系,从而使得模块强耦合。通过避免直接依赖,替换为依赖相对稳定的抽象,使得低层实现模块可以任意替换,降低了模块间的耦合性。
值得注意的是,在软件开发初期,我们很难预估模块间关系是否在未来会发生变化。盲目采用依赖倒置可能导致系统内模块关系反而更加复杂且实现工作量更大。
2.6. 迪米特原则(最少知识原则)
迪米特法则要求一个模块只与直接的朋友交流,尽量减少与陌生人的交互。
迪米特法则(Law of Demeter,
LoD)强调模块之间的交互应该是最小化的,鼓励模块之间的松耦合。
2.6.1. 示例
classDiagram
direction LR
class Student {
+ getLibraryOpenTime() String
}
class School {
- Library library
+ getLibrary() Library
}
class Library {
+ getOpenTime() string
}
Student ..> School
School ..> Library
Student ..> Library
在这个类图中,Student 类直接调用了 School 类的 getLibrary()
方法,然后通过返回的 Library 对象调用 getOpenTime() 方法。这里 Student
直接与 Library 交互,违反了迪米特原则。
为减少类之间的耦合,可以修改如下:
classDiagram
direction LR
class Student {
+ getLibraryOpenTime() String
}
class School {
- Library library
+ getLibraryOpenTime() String
}
class Library {
+ getOpenTime() string
}
Student ..> School
School ..> Library
Student 类不再直接调用
Library 类的方法。相反,它调用 School 类的 getLibraryOpenTime()
方法,这个方法内部可以调用 Library 类的 getOpenTime()
方法。这样,Student 类只与它的直接朋友类 School
交互,遵循了迪米特原则。
2.6.2. 解决的问题
如果一个模块与许多其他模块有过多的直接依赖,会导致以下问题:
- 高耦合度:模块之间的紧密耦合使得系统的灵活性降低,修改一个模块可能会影响到多个其他模块。
- 可维护性差:当模块之间的依赖关系复杂时,理解和维护代码变得更加困难,增加了出错的可能性。
- 重用性低:高度耦合的模块往往难以在其他项目中复用,因为它们依赖于许多特定的实现。
2.6.3. 原则分析
迪米特法则的核心思想是最小化依赖关系。遵循这一原则可以带来以下优点:
- 降低耦合度:通过限制模块之间的交互,减少了依赖关系,使得模块更加独立。
- 提高可维护性:模块之间的关系更加清晰,修改某个模块时不必担心影响到其他模块。
- 增强可重用性:松耦合的模块可以更容易地在不同的上下文中重用。