SOLID 设计原则:构建稳定灵活的系统
为了构建稳定灵活的软件,我们需要牢记软件设计原则。拥有无错误的代码至关重要。然而,精心设计的软件架构也同样重要。
SOLID 是最著名的软件设计原则之一。它可以帮助您避免常见的陷阱,并从更高的层次思考应用程序的架构。
什么是 SOLID 设计原则?
SOLID 设计原则包含五项软件设计原则,助您编写高效的面向对象代码。了解抽象、封装、继承和多态等面向对象编程原则固然重要,但如何在日常工作中运用它们呢?SOLID 设计原则近年来如此受欢迎,是因为它们能够以直观的方式解答这个问题。
SOLID 名称是一个助记缩写,其中每个字母代表一个软件设计原则,如下所示:
- 单一职责原则
- O 代表开放/封闭原则
- L 代表里氏替换原则
- I 代表接口隔离原则
- D 代表依赖倒置原则
这五项原则相互交织,程序员们也广泛运用它们。SOLID 原则能够构建更灵活、更稳定的软件架构,使其更易于维护和扩展,并且更不容易崩溃。
单一职责原则
单一职责原则是第一个 SOLID 设计原则,以字母“S”表示,由Robert C Martin定义。该原则指出,在一个设计良好的应用程序中,每个类(微服务、代码模块)应该只有一个单一职责。职责的含义是只有一个更改原因。
当一个类处理多个职责时,对功能的任何更改都可能影响其他功能。如果您的应用程序规模较小,这种情况已经够糟糕了,但当您处理复杂的企业级软件时,情况就可能变成一场噩梦。通过确保每个模块只封装一项职责,您可以节省大量的测试时间,并创建更易于维护的架构。
单一职责原则示例
让我们看一个例子。我将使用 Java,但你也可以将 SOLID 设计原则应用于任何其他 OOP 语言。
假设我们正在为一家书店编写一个 Java 应用程序。我们创建一个Book
类,让用户获取和设置每本书的书名和作者,并在库存中搜索这本书。
class Book {
String title;
String author;
String getTitle() {
return title;
}
void setTitle(String title) {
this.title = title;
}
String getAuthor() {
return author;
}
void setAuthor(String author) {
this.author = author;
}
void searchBook() {...}
}
然而,上述代码违反了单一职责原则,因为该类Book
有两个职责。首先,它设置与书籍相关的数据(title
和author
)。其次,它在库存中搜索书籍。setter 方法会更改Book
对象,当我们想在库存中搜索同一本书时,这可能会引起问题。
要应用单一职责原则,我们需要将这两个职责解耦。在重构后的代码中,该类Book
将只负责获取和设置Book
对象的数据。
class Book {
String title;
String author;
String getTitle() {
return title;
}
void setTitle(String title) {
this.title = title;
}
String getAuthor() {
return author;
}
void setAuthor(String author) {
this.author = author;
}
}
然后,我们创建另一个名为的类InventoryView
,用于检查库存。我们将searchBook()
方法移到这里,并Book
在构造函数中引用该类。
class InventoryView {
Book book;
InventoryView(Book book) {
this.book = book;
}
void searchBook() {...}
}
在下面的 UML 图上,您可以看到我们按照单一职责原则重构代码后架构的变化。我们将最初Book
具有两个职责的类拆分为两个类,每个类都有各自的单一职责。
开放/封闭原则
开放/封闭原则是SOLID 五项软件设计原则中的“O”。Bertrand Meyer在其著作《面向对象软件构造》中提出了这一术语。开放/封闭原则指出,类、模块、微服务和其他代码单元应该对扩展开放,但对修改关闭。
因此,您应该能够使用 OOP 特性(例如通过子类和接口继承)来扩展现有代码。但是,切勿修改已存在的类、接口和其他代码单元(尤其是在生产环境中使用它们),因为这可能会导致意外行为。通过扩展代码而不是修改代码来添加新功能,可以最大限度地降低失败的风险。此外,您也不必对现有功能进行单元测试。
开放/封闭原则示例
我们继续以书店为例。现在,书店想在圣诞节前以折扣价出售烹饪书。我们已经遵循了单一职责原则,因此我们创建了两个独立的类:一个CookbookDiscount
用于保存折扣详情,另一个DiscountManager
用于将折扣信息应用到价格中。
class CookbookDiscount {
String getCookbookDiscount() {
String discount = "30% between Dec 1 and 24.";
return discount;
}
}
class DiscountManager {
void processCookbookDiscount(CookbookDiscount discount) {...}
}
这段代码运行良好,直到商店管理层通知我们,他们的食谱折扣销售非常成功,他们想扩展它。现在,他们想在每本传记的主人公生日当天,给每本传记提供 50% 的折扣。为了添加这个新功能,我们创建一个新BiographyDiscount
类:
class BiographyDiscount {
String getBiographyDiscount() {
String discount = "50% on the subject's birthday.";
return discount;
}
}
为了处理新类型的折扣,我们DiscountManager
还需要向类中添加新功能:
class DiscountManager {
void processCookbookDiscount(CookbookDiscount discount) {...}
void processBiographyDiscount(BiographyDiscount discount) {...}
}
然而,当我们修改现有功能时,我们违反了开闭原则。虽然上述代码运行正常,但它可能会给应用程序带来新的漏洞。我们不知道新增的功能将如何与依赖于该类的其他代码部分交互DiscountManager
。在实际应用中,这意味着我们需要重新测试和部署整个应用程序。
但是,我们也可以选择通过添加一个代表所有折扣类型的抽象层来重构代码。因此,让我们创建一个名为 的新接口,BookDiscount
并让CookbookDiscount
和BiographyDiscount
类实现它。
public interface BookDiscount {
String getBookDiscount();
}
class CookbookDiscount implements BookDiscount {
@Override
public String getBookDiscount() {
String discount = "30% between Dec 1 and 24.";
return discount;
}
}
class BiographyDiscount implements BookDiscount {
@Override
public String getBookDiscount() {
String discount = "50% on the subject's birthday.";
return discount;
}
}
现在,DiscountManager
可以引用接口BookDiscount
而不是具体的类。processBookDiscount()
调用方法时,我们可以将CookbookDiscount
和BiographyDiscount
作为参数传递,因为它们都是接口的实现BookDiscount
。
class DiscountManager {
void processBookDiscount(BookDiscount discount) {...}
}
重构后的代码遵循开放/封闭原则,因为我们可以CookbookDiscount
在不修改现有代码库的情况下添加新类。这也意味着,将来我们可以扩展应用,添加其他折扣类型(例如CrimebookDiscount
)。
下面的 UML 图展示了示例代码重构前后的样子。在左侧,您可以看到DiscountManager
依赖于CookbookDiscount
和BiographyDiscount
类。在右侧,所有三个类都依赖于BookDiscount
抽象层(DiscountManager
引用它,而CookbookDiscount
和BiographyDiscount
实现它)。
里氏替换原则
里氏替换原则是SOLID 的第三条原则,用字母“L”表示。芭芭拉·里氏替换原则 ( Barbara Liskov)于 1987 年在大会主题演讲“数据抽象”中提出了这一原则。里氏替换原则的原始表述略显复杂,它断言:
“在计算机程序中,如果 S 是 T 的子类型,则类型 T 的对象可以用类型 S 的对象替换(即,类型 S 的对象可以替代类型 T 的对象),而不会改变该程序的任何期望属性(正确性、执行的任务等)。”
通俗地说,它规定超类的对象应该可以被其子类的对象替换,而不会在应用程序中引发问题。因此,子类永远不应该改变其父类的特征(例如参数列表和返回类型)。您可以通过注意正确的继承层次结构来实现里氏替换原则。
里氏替换原则示例
现在,书店要求我们在应用程序中添加新的送货功能。因此,我们创建一个BookDelivery
类,用于告知客户他们可以在哪些地点取书:
class BookDelivery {
String titles;
int userID;
void getDeliveryLocations() {...}
}
然而,这家商店也出售一些高档精装书,他们只想送货到高街商店。因此,我们创建一个新的HardcoverDelivery
子类,扩展BookDelivery
并重写该getDeliveryLocations()
方法,并使其具有自己的功能:
class HardcoverDelivery extends BookDelivery {
@Override
void getDeliveryLocations() {...}
}
后来,商店要求我们创建有声读物的配送功能。现在,我们BookDelivery
用一个AudiobookDelivery
子类扩展了现有的类。但是,当我们想要重写该getDeliveryLocations()
方法时,我们意识到有声读物无法配送到实体店。
class AudiobookDelivery extends BookDelivery {
@Override
void getDeliveryLocations() {/* can't be implemented */}
}
然而,我们可以修改该方法的某些特性getDeliveryLocations()
,但这会违反里氏替换原则。修改之后,我们无法在不破坏应用程序的情况BookDelivery
下用子类替换超类。AudiobookDelivery
为了解决这个问题,我们需要修复继承层次结构。我们引入一个额外的层,以便更好地区分图书递送类型。新增的OfflineDelivery
和OnlineDelivery
类将超类拆分开来BookDelivery
。我们还将getDeliveryLocations()
方法移动到 ,OfflineDelivery
并为该类创建一个新getSoftwareOptions()
方法OnlineDelivery
(因为这更适合在线递送)。
class BookDelivery {
String title;
int userID;
}
class OfflineDelivery extends BookDelivery {
void getDeliveryLocations() {...}
}
class OnlineDelivery extends BookDelivery {
void getSoftwareOptions() {...}
}
在重构的代码中,HardcoverDelivery
将是的子类OfflineDelivery
,它将getDeliveryLocations()
用自己的功能覆盖该方法。
AudiobookDelivery
将是其子类OnlineDelivery
,这是一个好消息,因为现在它不必处理该getDeliveryLocations()
方法。相反,它可以getSoftwareOptions()
用自己的实现覆盖其父类的方法(例如,列出并嵌入可用的音频播放器)。
class HardcoverDelivery extends OfflineDelivery {
@Override
void getDeliveryLocations() {...}
}
class AudiobookDelivery extends OnlineDelivery {
@Override
void getSoftwareOptions() {...}
}
重构之后,我们可以使用任何子类代替其超类,而不会破坏应用程序。
在下面的 UML 图上,您可以看到,通过应用里氏替换原则,我们在继承层次结构中增加了一层。虽然新的架构更加复杂,但它为我们提供了更灵活的设计。
接口隔离原则
接口隔离原则是 SOLID 设计原则中的第四个,缩写形式为“I”。Robert C Martin 首次定义了该原则,指出“客户端不应该被迫依赖于它们不使用的方法”。他所说的客户端指的是实现接口的类。换句话说,接口不应该包含太多功能。
违反接口隔离原则会损害代码的可读性,并迫使程序员编写毫无作用的虚拟方法。在设计良好的应用程序中,应该避免接口污染(也称为胖接口)。解决方案是创建更小、更灵活的接口。
接口隔离原则示例
让我们为在线书店添加一些用户操作,以便顾客在购买之前与内容进行交互。为此,我们创建了一个BookAction
包含三个方法的接口:seeReviews()
、searchSecondHand()
和listenSample()
。
public interface BookAction {
void seeReviews();
void searchSecondhand();
void listenSample();
}
然后,我们创建两个类:HardcoverUI
和一个,它们用各自的功能AudiobookUI
实现接口:BookAction
class HardcoverUI implements BookAction {
@Override
public void seeReviews() {...}
@Override
public void searchSecondhand() {...}
@Override
public void listenSample() {...}
}
class AudiobookUI implements BookAction {
@Override
public void seeReviews() {...}
@Override
public void searchSecondhand() {...}
@Override
public void listenSample() {...}
}
这两个类都依赖于它们不使用的方法,所以我们违反了接口隔离原则。精装书无法收听,所以这个HardcoverUI
类不需要这个listenSample()
方法。同样,有声读物没有二手书,所以这个AudiobookUI
类也不需要它。
然而,由于该BookAction
接口包含这些方法,它的所有依赖类都必须实现它们。换句话说,BookAction
它是一个需要隔离的污染接口。让我们扩展它,添加两个更具体的子接口:HardcoverAction
和AudioAction
。
public interface BookAction {
void seeReviews();
}
public interface HardcoverAction extends BookAction {
void searchSecondhand();
}
public interface AudioAction extends BookAction {
void listenSample();
}
现在,HardcoverUI
类可以实现HardcoverAction
接口,AudiobookUI
类也可以实现AudioAction
接口。
这样,两个类都可以实现父接口seeReviews()
的方法。但是,不必实现不相关的方法,也不必实现。BookAction
HardcoverUI
listenSample()
AudioUI
searchSecondhand()
class HardcoverUI implements HardcoverAction {
@Override
public void seeReviews() {...}
@Override
public void searchSecondhand() {...}
}
class AudiobookUI implements AudioAction {
@Override
public void seeReviews() {...}
@Override
public void listenSample() {...}
}
重构后的代码遵循接口隔离原则,因为两个类都不依赖于它们不使用的方法。下面的 UML 图清晰地展示了隔离接口后,类会变得更简单,只实现它们真正需要的方法:
依赖倒置原则
依赖倒置原则是SOLID 设计原则中的第五个原则,最后一个“D”代表该原则,由 Robert C Martin 提出。依赖倒置原则的目标是避免代码紧耦合,因为这种代码很容易破坏应用程序。该原则指出:
高级模块不应该依赖于低级模块。两者都应该依赖于抽象。
抽象不应该依赖于细节。细节应该依赖于抽象。
换句话说,你需要解耦高级类和低级类。高级类通常封装复杂的逻辑,而低级类则包含数据或实用程序。通常,大多数人会希望高级类依赖于低级类。然而,根据依赖倒置原则,你需要反转这种依赖关系。否则,当低级类被替换时,高级类也会受到影响。
作为一种解决方案,您需要为低级类创建一个抽象层,以便高级类可以依赖于抽象而不是具体的实现。
Robert C Martin 还提到,依赖倒置原则是开放/封闭原则和里氏替换原则的特定组合。
依赖倒置原则示例
现在,书店要求我们建立一项新功能,让顾客可以把自己喜欢的书放在书架上。
为了实现新功能,我们创建了一个低级Book
类和一个高级Shelf
类。Book
高级类允许用户查看书架上每本书的评论并试读。Shelf
高级类允许用户将书籍添加到书架并自定义书架。
class Book {
void seeReviews() {...}
void readSample() {...}
}
class Shelf {
Book book;
void addBook(Book book) {...}
void customizeShelf() {...}
}
一切看起来都很好,但由于高级Shelf
类依赖于低级类Book
,上述代码违反了依赖倒置原则。当商店要求我们允许顾客将 DVD 添加到他们的货架上时,这一点就变得非常明显。为了满足这一需求,我们创建了一个新DVD
类:
class DVD {
void seeReviews() {...}
void watchSample() {...}
}
现在,我们应该修改该类Shelf
,使其也能接收 DVD。然而,这显然会违反开放/封闭原则。
解决方案是为较低级别的类(Book
和DVD
)创建一个抽象层。我们将通过引入这两个类都将实现的接口来实现这一点Product
。
public interface Product {
void seeReviews();
void getSample();
}
class Book implements Product {
@Override
public void seeReviews() {...}
@Override
public void getSample() {...}
}
class DVD implements Product {
@Override
public void seeReviews() {...}
@Override
public void getSample() {...}
}
现在,Shelf
可以引用Product
接口而不是其实现(Book
和DVD
)。重构后的代码还允许我们稍后引入新的产品类型(例如Magazine
),客户也可以将其放到货架上。
class Shelf {
Product product;
void addProduct(Product product) {...}
void customizeShelf() {...}
}
上述代码也遵循了里氏替换原则,因为类型 可以用它的两个子类型(和)Product
替换,而不会破坏程序。同时,我们还实现了依赖倒置原则,因为在重构后的代码中,高级类也不依赖于低级类。Book
DVD
正如您在下方 UML 图左侧所见,重构之前,高级Shelf
类依赖于低级类。如果不应用依赖倒置原则,我们也应该让它依赖于低级类。然而,重构之后,高级类和低级类都依赖于抽象接口(引用它,同时实现它)。Book
DVD
Product
Shelf
Book
DVD
您应该如何实施 SOLID 设计原则?
实施 SOLID 设计原则会增加代码库的整体复杂性,但可以提高设计的灵活性。除了单体应用之外,您还可以将 SOLID 设计原则应用于微服务,将每个微服务视为独立的代码模块(类似于上述示例中的类)。
当你违反 SOLID 设计原则时,Java 和其他编译语言可能会抛出异常,但这种情况并非总是如此。软件架构问题很难检测,但APM 工具等高级诊断软件可以提供许多有用的提示。
文章来源:https://dev.to/azaleamollis/solid-design-principles-building-stable-and-flexible-systems--2ph7