1 | 单一职责原则(Single Responsibility Principle, SRP) |
S、单一职责原则
单一职责原则的描述是一个 class 应该只做一件事,一个 class 应该只有一个变化的原因。
更技术的描述该原则:应该只有一个软件定义的潜在改变(数据库逻辑、日志逻辑等)能够影响 class 的定义。
这意味着如果 class 是一个数据容器,比如 Book class 或者 Student class,考虑到这个实体有一些字段,应该只有我们更改了数据定义时才能够修改这些字段。
遵守单一职责原则很重要。首先,可能很多不同的团队可能修改同一个项目,可能因为不同的原因修改同一个 class,会导致冲突。
其次,单一职责更容易版本管理,比如,有一个持久化 class 处理数据库操作,我们在 GitHub 看到某个文件上有一处修改。如果遵循 SRP 原则,根据文件就能判断这是关于存储或者数据库相关的提交。
另一个例子是合并冲突,当不同的团队修改同一个文件时,如果遵循 SRP原则,冲突很少会发生,因为文件只有一个变化的原因,即使出现冲突也会很容易解决。
在本节我们会看一些违背单一职责原则的常见错误。然后会探讨修复他们的方法。
我们会以一个简单的书店发票程序代码作为例子。让我们从定义一个使用发票的图书 class 开始。
1 | class Book { |
这是一个有一些字段的 book class。没什么新奇的。之所以没有把字段设置为私有的是因为想专注于逻辑而不是 getter 和 setter。
现在让我们来创建一个 invoice class,包含创建发票和计算总额的业务逻辑。目前为止,假设书店只卖书,不卖别的。
1 | public class Invoice { |
这是 invoice class。它包含一些发票相关的字段以及三个方法。
- calculateTotal 方法,计算总价格
- printInvoice 方法,打印发票信息到控制台
- saveToFile 方法,负责将发票写到一个文件里
在读下一段之前停下来想一想,这样的 class 设计有什么问题。
那么问题出在哪呢? 我们的 class 在多个地方都违背了单一职责原则。
第一处是 printInvoice 方法,因为里面包含了打印逻辑。SRP 描述 class 应该只有一个变化的原因,这个变化原因应该是 class 里的发票计算。
在这个架构里,如果我们想要改变打印格式,我们需要修改这个 class。我们不能把打印逻辑和业务逻辑混合在一个class 里。
在 class 里面还有一个方法违背了 SRP: saveToFile 方法。这也是一个很常见的错误,把持久化逻辑和业务逻辑混合在了一起。
这不单单是写入文件 - 也可能是存库,发起 API 调用或者其他与持久化相关的操作。
你可能会问,怎样修复这个打印函数呢?
可以为打印和持久化逻辑创造一个新 class,因此就无需因为这些原因修改 invoice class 了。
创建两个 class, InvoicePrinter 和 InvoicePersistence ,并移入相应方法。
1 | public class InvoicePrinter { |
现在 class 结构遵从了单一职责原则,每个 class 为我们应用的一个部分负责。棒!
O、开闭原则
开闭原则要求“class 应该对扩展开放、对修改关闭”。
修改意味着修改存在 class 的代码,扩展意味着添加新的功能。
这个原则想要表达的是:我们应该能在不动 class 已经存在代码的前提下添加新的功能。这是因为当我们修改存在的代码时,我们就面临着创建潜在 bug 的风险。因此,如果可能,应该避免碰通过测试的(大部分时候)可靠的生产环境的代码。
你可能会好奇,怎样不动 class 还能添加新功能,接口和抽象类可以做到。
现在基本概念已经介绍完了,让我们给发票应用应用一下这个原则。
假如老板来了,提了一个需求,他们想把发票存入数据库以方便查找。我一想,行啊,小菜一碟,五分钟搞定。
创建数据库,连接,给 InvoicePersistence 添加保存方法:
1 | public class InvoicePersistence { |
很不幸,作为书店的懒家伙,并没有把 class 设计的易于未来扩展。为了添加这一特性,需要修改 InvoicePersistence class。
如果 class 设计遵循开闭原则,我们就不需要修改这个 class 了。
因此,作为书店里聪明的懒家伙,我们发现了设计问题并决定重构代码以符合开闭原则。
1 | interface InvoicePersistence { |
我们把 InvoicePersistence 改成了接口类型并添加了 save 方法。每个持久化 class 都实现这个 save 方法。
1 | public class DatabasePersistence implements InvoicePersistence { |
现在持久化逻辑更易于扩展了,如果老板要求我们添加另一个数据库,有了两种不同类型的数据库如 MySQL和 MongoDB ,可以更快搞定了。
你可能会想,我们只需创建多个 class 给每个都添加一个 save 方法而无需接口。
来看一下如果我们不用接口来扩展 app,创建多个持久化 class 如 InvoicePersistence, BookPersistence ,还需要创建了一个 PersistenceManager class 来管理所有的持久化 class:
1 | public class PersistenceManager { |
有了多态,我们可以把任何实现了 InvoicePersistence 接口的 class 作为入参。这就是接口的灵活性。
L、里氏替换原则
里氏替换原则描述的是子类应该能替换为它的基类。
意思是,给定 class B 是 class A 的子类,在预期传入 class A 的对象的任何方法传入 class B 的对象,方法都不应该有异常。
这是一个预期的行为,因为继承假定子类继承了父类的一切。子类可以扩展行为但不会收窄。
因此,当 class 违背这一原则时,会导致一些难于发现的讨厌的 bug。
里氏替换原则容易理解但是很难在代码里发现。看一个例子:
1 | class Rectangle { |
有一个简单的 Rectangle class,以及一个 getArea 方法返回矩形的面积。
现在准备创建另一个 Squares class。众所周知,正方形只不过是宽和高相等的特殊的矩形。
1 | class Square extends Rectangle { |
我们的 Square class 继承自 Rectangle class。在构造器里设置宽和高相等,我们不希望任何客户端(在他们的代码里使用我们的 class)违背了正方形的特性将宽高改成不相等。
因此我们重载了 setter 使宽和高任何一个改变时都会同时改变宽高。这样一来,我们就违背了里氏替换原则。
让我们先编写一个 test 来测试 getArea 函数。
1 | class Test { |
团队的测试人员提出测试函数 getAreaTest ,然后告诉你正方形对象的 getArea 函数不能通过测试。
在第一个测试中,我们创建了一个宽为 2 高为 3 的矩形,然后调用 getAreaTest,预期输出为 20,但是当传入一个正方形时出错了。这是因为调用测试里的 setHeight 函数会同时设置 width,导致输出结果不符预期。
I、接口隔离原则
隔离意味着保持独立,接口隔离原则是关于接口的独立。
该原则描述了很多客户端特定的接口优于一个多用途接口。客户端不应该强制实现他们不需要的函数。
这是一个简单的原则,很好理解和实践,直接看例子。
1 | public interface ParkingLot { |
我们定义了一个非常简单的停车场。这是按小时付费的停车场,不考虑免费的情况。
1 | public class FreeParking implements ParkingLot { |
停车场接口组合了两个事情:停车相关逻辑(停车、取车、获取车位信息)以及支付相关逻辑。
但是这太具体了。即使是 FreeParking class 也要必须实现不相关的支付相关的方法。让我们隔离接口。
现在停车场更干净了。有了新的 model,可以更进一步把 PaidParkingLot 分割一下以支持更多的支付类型。
现在我们的 model 更灵活、可扩展,客户端无需实现任何不相关的逻辑,因为只在停车场接口实现了停车相关的函数。
D、依赖倒置原则
依赖倒置原则描述的是我们的 class 应该依赖接口和抽象类而不是具体的类和函数。
它的定义是:
- 高层模块不应该依赖于低层模块。两者都应该依赖于抽象。
- 抽象不应该依赖于细节。细节应该依赖于抽象。
换句话说,设计中应当依赖于接口或抽象类,而不是依赖于具体实现。这种设计有助于减少模块之间的耦合,增加系统的灵活性和可维护性。
依赖倒置原则的核心思想是面向抽象编程而不是面向具体编程。通过依赖抽象层次(如接口、抽象类),可以让高层模块不依赖具体的实现细节,从而使得系统更具扩展性和灵活性。
反例:
1 | class MySQLDatabase { |
依赖倒置VS依赖注入
依赖倒置VS依赖注入
1、理解依赖注入
依赖注入(Dependency Injection, DI)是一种设计模式,旨在将对象的依赖关系从对象内部转移到外部管理,从而降低类之间的耦合度,提高代码的可维护性和可测试性。
2、依赖注入方式
依赖注入(Dependency Injection,DI)是一种通过将依赖关系从高层模块解耦的方式,常用于实现依赖倒置原则。
- 构造函数注入:通过在类的构造函数中声明依赖参数,将依赖关系通过构造函数传递给类的实例。最常见的依赖注入方式。
- Setter方法注入:通过提供一组setter方法,允许外部代码设置依赖对象。这种方式相对于构造函数注入更灵活,可以在对象创建后随时更改依赖。
- 接口注入:通过在类中定义一个接口,该接口包含用于注入依赖的方法。类实现该接口,并通过接口方法接收依赖对象。这种方式相对较少使用,因为它引入了更多的接口和方法。
- 属性注入:通过在类中声明依赖对象的属性,并提供相应的setter方法,将依赖对象注入到属性中。可能导致类的实例在没有依赖对象的情况下被创建。
3、两者有何区别
依赖倒置原则(Dependency Inversion Principle,DIP)和依赖注入(Dependency Injection,DI)是面向对象设计中两个相关但不同的概念。
- 依赖倒置原则是一种设计原则,它指导我们在设计软件时应该依赖于抽象而不是具体实现。高层模块不应该直接依赖于低层模块,而是通过抽象接口或基类来进行依赖。
- 依赖注入是一种实现依赖倒置原则的具体技术,它通过将依赖关系从高层模块解耦,将依赖对象注入到类的实例中。依赖注入有多种方式,如构造函数注入、setter方法注入、属性注入等。它的目的是通过外部注入依赖对象,而不是在类内部创建或获取依赖对象。
依赖注入是实现依赖倒置原则的一种常见方式,但并不是唯一的方式。依赖倒置原则还可以通过工厂模式、策略模式等其他设计模式来实现。