1
2
3
4
5
单一职责原则(Single Responsibility Principle, SRP)
开放 - 封闭原则(Open-Closed Principle, OCP)
里氏替换原则(Liskov Substitution Principle, LSP)
接口隔离原则(Interface Segregation Principle, ISP)
依赖倒置原则(Dependency Inversion Principle, DIP)

S、单一职责原则

单一职责原则的描述是一个 class 应该只做一件事,一个 class 应该只有一个变化的原因

更技术的描述该原则:应该只有一个软件定义的潜在改变(数据库逻辑、日志逻辑等)能够影响 class 的定义。

这意味着如果 class 是一个数据容器,比如 Book class 或者 Student class,考虑到这个实体有一些字段,应该只有我们更改了数据定义时才能够修改这些字段。

遵守单一职责原则很重要。首先,可能很多不同的团队可能修改同一个项目,可能因为不同的原因修改同一个 class,会导致冲突。

其次,单一职责更容易版本管理,比如,有一个持久化 class 处理数据库操作,我们在 GitHub 看到某个文件上有一处修改。如果遵循 SRP 原则,根据文件就能判断这是关于存储或者数据库相关的提交。

另一个例子是合并冲突,当不同的团队修改同一个文件时,如果遵循 SRP原则,冲突很少会发生,因为文件只有一个变化的原因,即使出现冲突也会很容易解决。

在本节我们会看一些违背单一职责原则的常见错误。然后会探讨修复他们的方法。

我们会以一个简单的书店发票程序代码作为例子。让我们从定义一个使用发票的图书 class 开始。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Book {
String name;
String authorName;
int year;
int price;
String isbn;

public Book(String name, String authorName, int year, int price, String isbn) {
this.name = name;
this.authorName = authorName;
this.year = year;
this.price = price;
this.isbn = isbn;
}
}

这是一个有一些字段的 book class。没什么新奇的。之所以没有把字段设置为私有的是因为想专注于逻辑而不是 getter 和 setter。

现在让我们来创建一个 invoice class,包含创建发票和计算总额的业务逻辑。目前为止,假设书店只卖书,不卖别的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class Invoice {

private Book book;
private int quantity;
private double discountRate;
private double taxRate;
private double total;

public Invoice(Book book, int quantity, double discountRate, double taxRate) {
this.book = book;
this.quantity = quantity;
this.discountRate = discountRate;
this.taxRate = taxRate;
this.total = this.calculateTotal();
}

public double calculateTotal() {
double price = ((book.price - book.price * discountRate) * this.quantity);

double priceWithTaxes = price * (1 + taxRate);

return priceWithTaxes;
}

public void printInvoice() {
System.out.println(quantity + "x " + book.name + " " + book.price + "$");
System.out.println("Discount Rate: " + discountRate);
System.out.println("Tax Rate: " + taxRate);
System.out.println("Total: " + total);
}

public void saveToFile(String filename) {
// Creates a file with given name and writes the invoice
}

}

这是 invoice class。它包含一些发票相关的字段以及三个方法。

  • calculateTotal 方法,计算总价格
  • printInvoice 方法,打印发票信息到控制台
  • saveToFile 方法,负责将发票写到一个文件里

在读下一段之前停下来想一想,这样的 class 设计有什么问题。

那么问题出在哪呢? 我们的 class 在多个地方都违背了单一职责原则。

第一处是 printInvoice 方法,因为里面包含了打印逻辑。SRP 描述 class 应该只有一个变化的原因,这个变化原因应该是 class 里的发票计算。

在这个架构里,如果我们想要改变打印格式,我们需要修改这个 class。我们不能把打印逻辑和业务逻辑混合在一个class 里。

在 class 里面还有一个方法违背了 SRP: saveToFile 方法。这也是一个很常见的错误,把持久化逻辑和业务逻辑混合在了一起。

这不单单是写入文件 - 也可能是存库,发起 API 调用或者其他与持久化相关的操作。

你可能会问,怎样修复这个打印函数呢?

可以为打印和持久化逻辑创造一个新 class,因此就无需因为这些原因修改 invoice class 了。

创建两个 class, InvoicePrinterInvoicePersistence ,并移入相应方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class InvoicePrinter {
private Invoice invoice;

public InvoicePrinter(Invoice invoice) {
this.invoice = invoice;
}

public void print() {
System.out.println(invoice.quantity + "x " + invoice.book.name + " " + invoice.book.price + " $");
System.out.println("Discount Rate: " + invoice.discountRate);
System.out.println("Tax Rate: " + invoice.taxRate);
System.out.println("Total: " + invoice.total + " $");
}
}
public class InvoicePersistence {
Invoice invoice;

public InvoicePersistence(Invoice invoice) {
this.invoice = invoice;
}

public void saveToFile(String filename) {
// Creates a file with given name and writes the invoice
}
}

现在 class 结构遵从了单一职责原则,每个 class 为我们应用的一个部分负责。棒!

O、开闭原则

开闭原则要求“class 应该对扩展开放、对修改关闭”。

修改意味着修改存在 class 的代码,扩展意味着添加新的功能。

这个原则想要表达的是:我们应该能在不动 class 已经存在代码的前提下添加新的功能。这是因为当我们修改存在的代码时,我们就面临着创建潜在 bug 的风险。因此,如果可能,应该避免碰通过测试的(大部分时候)可靠的生产环境的代码。

你可能会好奇,怎样不动 class 还能添加新功能,接口和抽象类可以做到。

现在基本概念已经介绍完了,让我们给发票应用应用一下这个原则。

假如老板来了,提了一个需求,他们想把发票存入数据库以方便查找。我一想,行啊,小菜一碟,五分钟搞定。

创建数据库,连接,给 InvoicePersistence 添加保存方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class InvoicePersistence {
Invoice invoice;

public InvoicePersistence(Invoice invoice) {
this.invoice = invoice;
}

public void saveToFile(String filename) {
// Creates a file with given name and writes the invoice
}

public void saveToDatabase() {
// Saves the invoice to database
}
}

很不幸,作为书店的懒家伙,并没有把 class 设计的易于未来扩展。为了添加这一特性,需要修改 InvoicePersistence class。

如果 class 设计遵循开闭原则,我们就不需要修改这个 class 了。

因此,作为书店里聪明的懒家伙,我们发现了设计问题并决定重构代码以符合开闭原则。

1
2
3
4
interface InvoicePersistence {

public void save(Invoice invoice);
}

我们把 InvoicePersistence 改成了接口类型并添加了 save 方法。每个持久化 class 都实现这个 save 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class DatabasePersistence implements InvoicePersistence {

@Override
public void save(Invoice invoice) {
// Save to DB
}
}
public class FilePersistence implements InvoicePersistence {

@Override
public void save(Invoice invoice) {
// Save to file
}
}

现在持久化逻辑更易于扩展了,如果老板要求我们添加另一个数据库,有了两种不同类型的数据库如 MySQL和 MongoDB ,可以更快搞定了。

你可能会想,我们只需创建多个 class 给每个都添加一个 save 方法而无需接口。

来看一下如果我们不用接口来扩展 app,创建多个持久化 class 如 InvoicePersistenceBookPersistence ,还需要创建了一个 PersistenceManager class 来管理所有的持久化 class:

1
2
3
4
5
6
7
8
9
10
public class PersistenceManager {
InvoicePersistence invoicePersistence;
BookPersistence bookPersistence;

public PersistenceManager(InvoicePersistence invoicePersistence,
BookPersistence bookPersistence) {
this.invoicePersistence = invoicePersistence;
this.bookPersistence = bookPersistence;
}
}

有了多态,我们可以把任何实现了 InvoicePersistence 接口的 class 作为入参。这就是接口的灵活性。

L、里氏替换原则

里氏替换原则描述的是子类应该能替换为它的基类。

意思是,给定 class B 是 class A 的子类,在预期传入 class A 的对象的任何方法传入 class B 的对象,方法都不应该有异常。

这是一个预期的行为,因为继承假定子类继承了父类的一切。子类可以扩展行为但不会收窄。

因此,当 class 违背这一原则时,会导致一些难于发现的讨厌的 bug。

里氏替换原则容易理解但是很难在代码里发现。看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class Rectangle {
protected int width, height;

public Rectangle() {
}

public Rectangle(int width, int height) {
this.width = width;
this.height = height;
}

public int getWidth() {
return width;
}

public void setWidth(int width) {
this.width = width;
}

public int getHeight() {
return height;
}

public void setHeight(int height) {
this.height = height;
}

public int getArea() {
return width * height;
}
}

有一个简单的 Rectangle class,以及一个 getArea 方法返回矩形的面积。

现在准备创建另一个 Squares class。众所周知,正方形只不过是宽和高相等的特殊的矩形。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Square extends Rectangle {
public Square() {}

public Square(int size) {
width = height = size;
}

@Override
public void setWidth(int width) {
super.setWidth(width);
super.setHeight(width);
}

@Override
public void setHeight(int height) {
super.setHeight(height);
super.setWidth(height);
}
}

我们的 Square class 继承自 Rectangle class。在构造器里设置宽和高相等,我们不希望任何客户端(在他们的代码里使用我们的 class)违背了正方形的特性将宽高改成不相等。

因此我们重载了 setter 使宽和高任何一个改变时都会同时改变宽高。这样一来,我们就违背了里氏替换原则。

让我们先编写一个 test 来测试 getArea 函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Test {

static void getAreaTest(Rectangle r) {
int width = r.getWidth();
r.setHeight(10);
System.out.println("Expected area of " + (width * 10) + ", got " + r.getArea());
}

public static void main(String[] args) {
Rectangle rc = new Rectangle(2, 3);
getAreaTest(rc);

Rectangle sq = new Square();
sq.setWidth(5);
getAreaTest(sq);
}
}

团队的测试人员提出测试函数 getAreaTest ,然后告诉你正方形对象的 getArea 函数不能通过测试。

在第一个测试中,我们创建了一个宽为 2 高为 3 的矩形,然后调用 getAreaTest,预期输出为 20,但是当传入一个正方形时出错了。这是因为调用测试里的 setHeight 函数会同时设置 width,导致输出结果不符预期。

I、接口隔离原则

隔离意味着保持独立,接口隔离原则是关于接口的独立。

该原则描述了很多客户端特定的接口优于一个多用途接口。客户端不应该强制实现他们不需要的函数。

这是一个简单的原则,很好理解和实践,直接看例子。

1
2
3
4
5
6
7
8
9
10
11
12
public interface ParkingLot {

void parkCar(); // Decrease empty spot count by 1
void unparkCar(); // Increase empty spots by 1
void getCapacity(); // Returns car capacity
double calculateFee(Car car); // Returns the price based on number of hours
void doPayment(Car car);
}

class Car {

}

我们定义了一个非常简单的停车场。这是按小时付费的停车场,不考虑免费的情况。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class FreeParking implements ParkingLot {

@Override
public void parkCar() {

}

@Override
public void unparkCar() {

}

@Override
public void getCapacity() {

}

@Override
public double calculateFee(Car car) {
return 0;
}

@Override
public void doPayment(Car car) {
throw new Exception("Parking lot is free");
}
}

停车场接口组合了两个事情:停车相关逻辑(停车、取车、获取车位信息)以及支付相关逻辑。

但是这太具体了。即使是 FreeParking class 也要必须实现不相关的支付相关的方法。让我们隔离接口。

现在停车场更干净了。有了新的 model,可以更进一步把 PaidParkingLot 分割一下以支持更多的支付类型。

现在我们的 model 更灵活、可扩展,客户端无需实现任何不相关的逻辑,因为只在停车场接口实现了停车相关的函数。

D、依赖倒置原则

依赖倒置原则描述的是我们的 class 应该依赖接口和抽象类而不是具体的类和函数

它的定义是:

  1. 高层模块不应该依赖于低层模块。两者都应该依赖于抽象。
  2. 抽象不应该依赖于细节。细节应该依赖于抽象。

换句话说,设计中应当依赖于接口或抽象类,而不是依赖于具体实现。这种设计有助于减少模块之间的耦合,增加系统的灵活性和可维护性。

依赖倒置原则的核心思想是面向抽象编程而不是面向具体编程。通过依赖抽象层次(如接口、抽象类),可以让高层模块不依赖具体的实现细节,从而使得系统更具扩展性和灵活性。

反例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class MySQLDatabase {

public void saveUser(String username) {

// 保存用户到MySQL数据库
System.out.println("Saving " + username + " to MySQL database.");
}
}

class UserService {

private MySQLDatabase database = new MySQLDatabase();

public void addUser(String username) {

database.saveUser(username);
}
}
依赖倒置VS依赖注入

依赖倒置VS依赖注入

1、理解依赖注入

‌依赖注入(Dependency Injection, DI)是一种设计模式,旨在将对象的依赖关系从对象内部转移到外部管理,从而降低类之间的耦合度,提高代码的可维护性和可测试性‌。‌

2、依赖注入方式

依赖注入(Dependency Injection,DI)是一种通过将依赖关系从高层模块解耦的方式,常用于实现依赖倒置原则。

  1. 构造函数注入:通过在类的构造函数中声明依赖参数,将依赖关系通过构造函数传递给类的实例。最常见的依赖注入方式。
  2. Setter方法注入:通过提供一组setter方法,允许外部代码设置依赖对象。这种方式相对于构造函数注入更灵活,可以在对象创建后随时更改依赖。
  3. 接口注入:通过在类中定义一个接口,该接口包含用于注入依赖的方法。类实现该接口,并通过接口方法接收依赖对象。这种方式相对较少使用,因为它引入了更多的接口和方法。
  4. 属性注入:通过在类中声明依赖对象的属性,并提供相应的setter方法,将依赖对象注入到属性中。可能导致类的实例在没有依赖对象的情况下被创建。

3、两者有何区别

依赖倒置原则(Dependency Inversion Principle,DIP)和依赖注入(Dependency Injection,DI)是面向对象设计中两个相关但不同的概念。

  1. 依赖倒置原则是一种设计原则,它指导我们在设计软件时应该依赖于抽象而不是具体实现。高层模块不应该直接依赖于低层模块,而是通过抽象接口或基类来进行依赖。
  2. 依赖注入是一种实现依赖倒置原则的具体技术,它通过将依赖关系从高层模块解耦,将依赖对象注入到类的实例中。依赖注入有多种方式,如构造函数注入、setter方法注入、属性注入等。它的目的是通过外部注入依赖对象,而不是在类内部创建或获取依赖对象。

依赖注入是实现依赖倒置原则的一种常见方式,但并不是唯一的方式。依赖倒置原则还可以通过工厂模式、策略模式等其他设计模式来实现。


本站由 卡卡龙 使用 Stellar 1.29.1主题创建

本站访问量 次. 本文阅读量 次.