目录
设计模式的原则
0 概念
设计原则指的是 程序员在编程时应当遵守的原则,也是各种设计模式的基础。设计模式在设计时,也得遵守这些原则。
1 单一职责原则
1.1 基本介绍
单一职责原则(Single Responsibility Principle, SRP)是面向对象设计中的一个重要原则,它指出 一个类应该仅有一个引起它变化的原因。换句话说,一个类应该负责一组 相对独立 且 内聚 的职责,并且当这个类需要修改时,应该 只有一个原因 促使这个修改。
1.2 优点
- 降低类的 复杂性:类只负责一组职责,而不负责其他相关度不大的职责。
- 提高系统的 可维护性、可读性 和 可测试性:由于所有方法的职责是 内聚 的,所以阅读类中的代码变得很简单,从而易于对代码做更改 (即 维护),并且也方便对这一组方法进行测试。
- 降低变更引起的 风险:若不遵守单一职责原则,出于不小心,不同职责的方法可能相互调用,从而更改一个职责时 (假如修改类中的 属性 信息) 可能会影响其他职责 (假如其他职责也使用类被修改的 属性 信息)。但如果遵守单一职责原则,其他职责的方法只能调用本类的方法,从而不会出现这个情况。
1.3 举例
1.3.1 反例
public class Employee {
private String name;
private double salary;
public Employee(String name, double salary) {
this.name = name;
this.salary = salary;
}
// 打印员工信息
public void printInfo() {
// 内部可能有更复杂的操作
System.out.println("[Employee] name: " + name + ", salary: " + salary);
}
// 计算员工总工资
public double calculateTotalSalary() {
// 内部可能有更复杂的操作
return salary * 0.05 + salary; // 假设奖金是薪水的 5%
}
}
以上程序没有将 大于员工信息 和 计算员工总工资 这两个不相干的职责分开,而是将其放到同一个类中,这就增加了这个类的复杂性,不建议 这样做。但是,如果有多个 极其简单 的方法,还是可以将其放到同一个类中的。
1.3.2 正例
将 大于员工信息 和 计算员工总工资 这两个职责放到不同的类中,这就遵守了 单一职责原则,如下所示:
// Employee 类,仅负责员工信息的存储和打印
public class Employee {
private String name;
private double salary;
public Employee(String name, double salary) {
this.name = name;
this.salary = salary;
}
// 打印员工信息
public void printInfo() {
// 内部可能有更复杂的操作
System.out.println("[Employee] name: " + name + ", salary: " + salary);
}
}
// TotalSalaryCalculator 类,负责计算员工总工资
public class TotalSalaryCalculator {
// 计算员工总工资
public double calculateTotalSalary(double salary) {
// 内部可能有更复杂的操作
return salary * 0.05 + salary; // 假设奖金是薪水的 5%
}
}
2 接口隔离原则
2.1 基本介绍
接口隔离原则(Interface Segregation Principle, ISP)是面向对象设计的基本原则之一,它的核心思想是:使用 多个 专门 的接口,比使用 单一 的 总 接口(总接口包含多个方法)要好。这样客户端仅需要知道它们感兴趣的方法。换句话说,一个类对另一个类的依赖应该建立在最小的接口上。
2.2 优点
- 降低系统的 耦合性:通过使用多个专门接口,将模块间的依赖关系最小化,从而降低了系统的耦合性。
- 提高 复用性:更专一的接口复用性更好,能被更多类实现。如果接口定义的方法很多,则很臃肿。
- 提高系统的 可维护性:接口变更时,对其他系统的影响降低了,从而易于维护系统 (维护系统实际上就是给系统添加新功能)。
2.3 举例
2.3.1 反例
public interface Animal {
void eat();
void sleep();
void fly();
void swim();
}
public class Bird implements Animal {
@Override
public void eat() {
System.out.println("小鸟在吃饭");
}
@Override
public void sleep() {
System.out.println("小鸟在睡觉");
}
@Override
public void fly() {
System.out.println("小鸟在飞翔");
}
@Override
public void swim() {
System.out.println("小鸟不会游泳");
}
}
以上代码中定义了 Animal
接口和 Bird
类,显然 Animal
接口中定义的方法太多了,甚至定义了一些 部分子类无法实现的方法 fly(), swim()
,所以这个设计很失败。
2.3.2 正例
以下代码将 Animal
接口拆分成多个接口,将 fly(), swim()
方法分别放到 Flyable, Swimmable
接口中,将 eat(), sleep()
方法放到 LivingBeing
接口中。Bird
类只需要实现 LivingBeing, Flyable
接口即可。
public interface LivingBeing {
void eat();
void sleep();
}
public interface Flyable {
void fly();
}
public interface Swimmable {
void swim();
}
public class Bird implements LivingBeing, Flyable {
@Override
public void eat() {
System.out.println("小鸟在吃饭");
}
@Override
public void sleep() {
System.out.println("小鸟在睡觉");
}
@Override
public void fly() {
System.out.println("小鸟在飞翔");
}
}
3 依赖倒置原则
3.1 基本介绍
依赖倒置原则(Dependency Inversion Principle, DIP)是面向对象设计中的一个重要原则,它强调了以下两个关键点:
- 高层模块不应该依赖低层模块,两者都应该依赖其抽象:这意味着,在设计系统时,我们应该尽量通过 接口或抽象类 来定义模块之间的依赖关系,而不是直接依赖于具体的实现类。
- 抽象不应该依赖细节,细节应该依赖抽象:这一点进一步强调了 抽象层与具体实现层之间的依赖关系应该是反向的。即抽象层 定义 了一套规范或接口,而具体实现层则根据这些规范来 实现 相应的功能。
3.2 优点
提高了系统的 可维护性 (也叫做 扩展性):当具体实现发生变化时,高层模块不需要进行修改,从而使扩展实现变得比较简单。
3.3 举例
在这个例子中,Application
类(高层模块)不依赖于任何具体的日志记录器实现(低层模块),而是依赖于Logger
接口(抽象)。这样一来,当需要添加新的日志记录方式时,我们只需要实现 Logger
接口即可,而无需修改 Application
类 或 其他使用 Logger
接口的类。
public interface Logger {
void log(String message);
}
public class ConsoleLogger implements Logger { // 将日志打印到控制台上
@Override
public void log(String message) {
System.out.println("Console: " + message);
}
}
public class Application { // 使用 Logger 接口的类
private Logger logger;
public Application(Logger logger) { // 构造 Application 时,如果传递不同的 Logger 实现类
this.logger = logger; // 则 doSomething() 的逻辑也会变化
}
public void doSomething() {
// 执行一些操作
logger.log("执行一些操作");
}
}
3.4 依赖倒置的传递方式
遵守依赖倒置原则,意味着在使用实现类时,不能直接使用实现类,而是使用接口。例如对于以上例子,可以通过以下三种方式将 接口 Logger
实现类的实例 传递到 Application
类的 doSomething()
方法中。
- 通过 接口 传递。
public class Application { // 使用 Logger 接口的类
public void doSomething(Logger logger) { // 传递不同的 Logger 实现类
// 执行一些操作
logger.log("执行一些操作"); // doSomething() 的逻辑也会变化
}
}
- 通过 构造器 传递,本例中使用的就是这种传递方式。
- 通过 setter() 传递,不过得记住:在使用接口之前一定要给接口传递实现类的实例。
public class Application { // 使用 Logger 接口的类
private Logger logger;
public void setLogger(Logger logger) { // 设置 Logger 时,如果传递不同的 Logger 实现类
this.logger = logger; // 则 doSomething() 的逻辑也会变化
} // 注意:在调用 doSomething() 方法之前一定要先调用 setLogger() 方法设置 Logger 的实现类
public void doSomething() {
// 执行一些操作
logger.log("执行一些操作");
}
}
总结一下,后两种方式很像 Spring 中在 xml 文件中配置生成 Bean 的两种方式。在使用 Spring/SpringBoot 时,我们会经常在变量上写 @Resource
或 @Autowired
,这就遵守了依赖倒置原则。
4 里氏替换原则
4.1 基本介绍
里氏替换原则(Liskov Substitution Principle, LSP)是面向对象设计的基本原则之一,由芭芭拉·里氏(Barbara Liskov)在1988年的“数据抽象和层次”一文中提出。该原则的核心思想是:子类对象能够替换掉父类对象被使用的地方,并且程序的行为没有变化。这要求子类必须能够 完整地 继承父类的行为,并且保证父类引用在指向子类对象时,不会破坏原有的功能。
换句话说,就是 子类不能无缘无故重写父类的方法。但如果父类要求子类重写某个方法 (例如使用 abstract
修饰这个方法),则子类需要重写这个方法。
4.2 优点
- 保证了系统的 稳定性:如果子类将父类中 求两数之和 的方法重写为 求两数之差,则会出现大问题。
- 保证了系统的 可维护性:如果子类不会随意重写父类的方法,那么在出现问题时不需要一个一个地检查子类,只需要检查父类中定义的方法。
4.3 对于继承的思考
- 继承包含这样一层含义:父类中已实现的方法,作用是 设定规范和契约。虽然父类不强制要求所有子类必须遵循这些契约,但是如果子类 任意 修改这些已实现的方法,就会对整个继承体系造成 破坏。
- 继承在给程序设计带来便利的同时,也带来了 弊端:
- 使用继承会给程序带来 侵入性,增加对象间的耦合性。
- 如果一个类被其他多个类所继承,则当这个类需要修改时,必须考虑到 所有 子类。父类修改后,所有涉及到子类的功能都有可能产生故障。
5 开闭原则
5.1 基本介绍
开闭原则(Open-Closed Principle, OCP)是面向对象设计中的一个重要原则,由Bertrand Meyer在其著作《Object-Oriented Software Construction》中提出。这个原则的核心思想是:软件实体(类、模块、函数等)应该对扩展开放,对修改关闭。也就是说,使用方 在 不需要 或 尽量少 修改代码 的情况下,就能够 通过 扩展 提供方 (即 在提供方中添加新代码) 来添加新的功能。
5.2 优点
- 提高软件的 可复用性:一个类不需要或尽量少修改原有代码,那么随着时间的流逝,它的使用次数就会越来越多的。
- 提高软件的 可维护性:如果不遵守开闭原则,那么添加新功能时就可能修改原有代码,从而还要检查原有代码是否能够正常运行。但如果遵守了开闭原则,则只会对代码做很小程度上的修改,会省去一部分时间。
5.3 举例
本例可以复用 依赖倒置原则 的例子,使用到了其中的 Logger
接口和 ConsoleLogger
类,摈弃了 Application
类,新的 LoggerManager
类用来管理 Logger
接口的子类。
public class LoggerManager { // 本类是使用方,提供方是 Logger 接口及其所有子类
private Logger logger;
public LoggerManager(Logger logger) {
this.logger = logger;
}
public void logMessage(String message) {
logger.log(message);
}
public static void main(String[] args) {
// 构造器的参数可以换成 Logger接口的 其他的实现类,并且 不需要修改原有的代码
LoggerManager loggerManager = new LoggerManager(new ConsoleLogger());
loggerManager.logMessage("Hello, Open-Closed Principle!");
}
}
对于以上的 LoggerManager
类,如果想要把日志存储到文件中 (添加一个新功能),则可以新增一个实现 Logger
接口的实现类,它的 log()
方法可以将日志存储到文件中 (扩展提供方),然后修改 main()
中构造器的参数 (尽量少修改使用方的代码)。这样的设计就遵守了开闭原则。
6 迪米特法则
6.1 基本介绍
迪米特法则(Law of Demeter),也称为 最少知识原则,是一种面向对象设计的原则,用于 减少软件模块之间的通信复杂度和依赖关系。它要求 一个软件实体应当尽可能少地与其他实体发生相互作用。具体来说,一个对象应该对其他对象有尽可能少的了解。
6.2 优点
- 降低模块间的 耦合性:一个对象应该对其他对象有尽可能少的了解,这就意味着要将不属于本对象的代码全都放到其他对象中,也就是说其他对象处理了自己的所有逻辑,从而降低了 其他对象 与 使用其他对象的对象 之间的耦合性。
- 提高系统的 模块独立性,使得系统更容易维护和扩展:对象应该处理自己的所有逻辑,从而对象是独立的、对其他对象依赖较少的。在扩展功能时,不需要重复造轮子了,直接将对象的处理逻辑 (即 对象定义的方法) 拿过来使用,系统就变得更易维护和拓展了。
6.3 举例
假设我们有一个系统,包括三个类:School
(学校)、Teacher
(教师)和Student
(学生)。我们的目标是减少 School
类与 Student
类的直接交互,通过 Teacher
类作为中介来实现。
6.3.1 反例
public class Student {
private String name;
public Student(String name) {
this.name = name;
}
public void attendClass() {
System.out.println(name + " is attending class.");
}
}
public class School {
private List<Student> students;
public School() {
students = new ArrayList<>();
}
public void addStudent(Student student) {
students.add(student);
}
// 违反迪米特法则:School 直接操作 Student
public void startClasses() {
for (Student student : students) {
student.attendClass();
}
}
}
6.3.2 正例
在改进的实现中,School
类不再直接与 Student
类交互,而是通过 Teacher
类来管理 Student
实例的上课行为。
public class Teacher {
private List<Student> students;
public Teacher() {
students = new ArrayList<>();
}
public void addStudent(Student student) {
students.add(student);
}
public void startClasses() {
for (Student student : students) {
student.attendClass();
}
}
}
public class School {
private List<Teacher> teachers;
public School() {
teachers = new ArrayList<>();
}
public void addTeacher(Teacher teacher) {
teachers.add(teacher);
}
// 遵循迪米特法则:School 不直接与 Student 交互
public void startClasses() {
for (Teacher teacher : teachers) {
teacher.startClasses();
}
}
}
在这个改进的例子中,School
类不再直接管理 Student
实例的上课行为,而是通过 Teacher
类来管理。这样一来,School
类对 Student
类的了解就减少了,遵守了迪米特法则。同时,这样的设计也更容易扩展,如果将来需要改变上课的方式,我们只需要修改 Teacher
类即可,而不需要修改 School
类。
实际生活也是这样的,总不可能让学校管理所有学生吧,这样会导致学校极其繁忙。一般情况下,学校管理老师,老师管理学生。
7 合成复用原则
7.1 基本介绍
合成复用原则(Composition/Aggregate Reuse Principle, C/ARP),又称为 组合/聚合复用原则,是面向对象设计中的一个重要原则。其核心思想是 尽量使用对象关联(包括组合关系和聚合关系)的方式,而不是通过 继承 来达到复用的目的。通过这种方式,新对象可以通过 关联 已有的对象,并利用这些对象的方法来实现功能的复用,从而增加系统的灵活性和可维护性。
7.2 优点
- 提高系统的 灵活性:通过 关联 关系,可以在运行时动态地改变对象的行为,而 继承 关系则是在编译时就确定了对象的行为。
- 降低类之间的 耦合性:组合关系允许对象之间保持松散的耦合,一个类的变化对其他类的影响相对较小。
- 支持 开闭原则:通过向系统添加新的对象类型来扩展系统的功能,而无需修改现有的类代码。
7.3 要求
合成复用原则要求我们尽量不使用继承,而是通过以下两种方式来解决问题:
- 聚合:将别的类的对象作为本类的一个属性,并且可以通过方法 (例如
构造器
或setter()
) 进行赋值。 - 组合:将别的类的对象作为本类的一个属性,并且无法通过方法进行赋值,一开始就被赋值了。
虽然没有推荐使用 依赖 关系,但 依赖 关系也很重要,只要使用到别的类或其实例,就属于 依赖,具体有以下三种形式:
- 包括上面提到的 聚合 与 组合,它们两个统称为 关联。
- 还包括 实现(实现类 实现 接口) 和 泛化(子类 继承 父类)。不过尽量不要使用 泛化。
- 此外,只要方法中使用到其他类的实例,无论是作为 形式参数 还是 局部变量,都属于 依赖。
7.4 举例
假设我们有一个场景,需要设计一个关于“交通工具”的系统。在这个系统中,我们有两种交通工具:汽车(Car)和飞机(Plane),它们都有移动(move)的能力。另外,我们还需要一个特殊的交通工具——混合动力车(HybridCar),它既有汽车的特点,又有一些独特的功能(比如使用电力驱动)。
如果我们采用继承的方式来实现,可能会让系统变得复杂且难以维护。下面,我们遵循合成复用原则,通过 聚合 的方式来设计这个系统。
首先,定义一个接口 Movable
,代表可移动的能力:
public interface Movable {
void move();
}
然后,定义 Car
和 Plane
类,它们都实现了 Movable
接口:
public class Car implements Movable {
@Override
public void move() {
System.out.println("Car is moving.");
}
}
public class Plane implements Movable {
@Override
public void move() {
System.out.println("Plane is flying.");
}
}
接下来,定义 HybridCar
类,它 聚合 了 Car
和其他可能需要的组件(比如电池):
public class HybridCar {
private Car car;
// 可能还有其他组件,比如电池等
public HybridCar(Car car) {
this.car = car;
}
public void move() {
// 假设 HybridCar 使用电力和汽油混合驱动
System.out.println("HybridCar is starting electric motor...");
car.move(); // 调用 Car 的 move 方法
System.out.println("HybridCar is also using gasoline engine...");
}
}
在上面的例子中,HybridCar
类并没有继承自 Car
类,而是通过 聚合 的方式 持有 一个 Car
类型的对象。这样,HybridCar
既可以复用 Car
的功能,又保持了自身的独立性,可以根据需要添加更多的功能或组件,而不需要修改 Car
类。
总结
至此,七大设计原则算是讲完了,这些原则主要是为了趋近以下三个目标:
- 代码更具复用性。
- 针对接口或抽象类编程。
- 模块之间最好到达松耦合的状态。