SOLID 原则的可靠指南
概述
在本教程中,我们将讨论面向对象设计的 SOLID 原则。
首先,我们将首先探讨它们出现的原因以及为什么我们在设计软件时应该考虑它们。然后,我们将概述每个原则以及一些示例代码。
SOLID 原则的原因
SOLID 原则是由 Robert C. Martin 在他 2000 年的论文“设计原则和设计模式”中引入的。这些概念后来由 Michael Feathers 建立,他向我们介绍了 SOLID 首字母缩略词。在过去的 20 年里,这五项原则彻底改变了面向对象编程的世界,改变了我们编写软件的方式。
那么,什么是 SOLID,它如何帮助我们编写更好的代码呢?简而言之,Martin and Feathers 的设计原则鼓励我们创建更易于维护、更易理解和更灵活的软件。因此,随着应用程序规模的增长,我们可以降低其复杂性,并在未来的道路上节省很多麻烦!
以下五个概念构成了我们的 SOLID 原则:
- Single Responsibility 单一职责
- Open/Closed 开放闭合
- Liskov Substitution 里氏替代
- Interface Segregation 接口分离
- Dependency Inversion 依赖倒置
虽然这些概念可能看起来令人生畏,但可以通过一些简单的代码示例轻松理解它们。在以下各节中,我们将深入研究这些原则,并举一个快速的 Java 示例来说明每个原则。
单一职责
让我们从单一职责原则开始。正如我们所料,这个原则规定一个类应该只有一个职责。此外,它应该只有一个改变的理由。
这个原则如何帮助我们构建更好的软件?让我们看看它的一些好处:
- 测试 – 一个职责单一的类将有更少的测试用例。
- 低耦合 – 单个类中的功能越少,依赖就越少。
- 组织 – 较小的、组织良好的类比单一的类更容易搜索。
例如,让我们看一个类来表示一本简单的书:
public class Book {
private String name;
private String author;
private String text;
//constructor, getters and setters
}
在这段代码中,我们存储与 Book 实例关联的名称、作者和文本。
现在,让我们添加几个方法来查询文本:
public class Book {
private String name;
private String author;
private String text;
//constructor, getters and setters
// methods that directly relate to the book properties
public String replaceWordInText(String word){
return text.replaceAll(word, text);
}
public boolean isWordInText(String word){
return text.contains(word);
}
}
现在,我们的 Book 类运行良好,我们可以在应用程序中存储任意数量的书籍。
但是,如果我们不能将文本输出到控制台并阅读它,那么存储信息又有什么用呢?
让我们谨慎行事,并添加一个打印方法:
public class Book {
//...
void printTextToConsole(){
// our code for formatting and printing the text
}
}
但是,此代码违反了我们之前概述的单一职责原则。
为了解决我们的问题,我们应该实现一个单独的类,只处理打印我们的文本:
public class BookPrinter {
// methods for outputting text
void printTextToConsole(String text){
//our code for formatting and printing the text
}
void printTextToAnotherMedium(String text){
// code for writing to any other location..
}
}
棒极了!我们不仅开发了一个类来减轻 Book 的打印任务,而且我们还可以利用 BookPrinter 类将我们的文本发送到其他媒体。
无论是电子邮件、日志记录还是其他任何内容,我们都有一个单独的课程专门针对这一问题。
开放扩展,关闭修改
现在是 SOLID 中的 O 的时候了,也就是所谓的开闭原则。简而言之,类应该对扩展开放,对修改关闭。通过这样做,我们可以 阻止自己修改现有代码并在原本令人满意的应用程序中造成潜在的新错误。
当然,该规则的一个例外是在修复现有代码中的错误时。
让我们通过一个快速的代码示例来探索这个概念。作为一个新项目的一部分,假设我们已经实现了一个 Guitar 类。
它完全成熟,甚至还有一个音量旋钮:
public class Guitar {
private String make;
private String model;
private int volume;
//Constructors, getters & setters
}
我们启动了应用程序,每个人都喜欢它。但几个月后,我们认为吉他 有点乏味,可以使用酷炫的火焰图案让它看起来更摇滚。
此时,打开 Guitar 类并添加火焰模式可能很诱人——但谁知道我们的应用程序中可能会抛出什么错误。
相反,让我们坚持开闭原则,简单地扩展我们的 Guitar 类:
public class SuperCoolGuitarWithFlames extends Guitar {
private String flameColor;
//constructor, getters + setters
}
通过扩展 Guitar 类,我们可以确保我们现有的应用程序不会受到影响。
里氏替换
这个原则由 Barbara Liskov 定义。他说程序里的对象都应该可以被它的子类实例替换而不用更改系统的正常工作.
它可以说是五项原则中最复杂的。简单地说,如果类 A 是类 B 的子类型,我们应该能够用 A 替换 B 而不会破坏程序的行为。
让我们直接跳到代码来帮助我们理解这个概念:
public interface Car {
void turnOnEngine();
void accelerate();
}
上面,我们定义了一个简单的 Car 接口,其中包含所有汽车都应该能够实现的几个方法:打开引擎并加速前进。
让我们实现我们的接口并为方法提供一些代码:
public class MotorCar implements Car {
private Engine engine;
//Constructors, getters + setters
public void turnOnEngine() {
//turn on the engine!
engine.on();
}
public void accelerate() {
//move forward!
engine.powerOn(1000);
}
}
正如我们的代码所描述的,我们有一个可以打开的引擎,我们可以增加功率。
但是等等——我们现在生活在电动汽车时代:
public class ElectricCar implements Car {
public void turnOnEngine() {
throw new AssertionError("I don't have an engine!");
}
public void accelerate() {
//this acceleration is crazy!
}
}
通过将没有引擎的汽车加入其中,我们本质上改变了程序的行为。这是对 Liskov 替换的公然违反,并且比我们之前的两个原则更难修复。
一种可能的解决方案是将我们的模型重新设计为考虑到 Car 的无引擎状态的接口。
接口隔离
SOLID 中的 I 代表接口隔离,它只是意味着应该将较大的接口拆分为较小的接口。通过这样做,我们可以确保实现类只需要关注它们感兴趣的方法。
对于这个例子,我们将尝试作为动物园管理员。更具体地说,我们将在熊圈工作。
让我们从一个概述我们作为养熊人角色的界面开始:
public interface BearKeeper {
void washTheBear();
void feedTheBear();
void petTheBear();
}
作为狂热的动物园管理员,我们非常乐意为我们心爱的熊清洗和喂食。但我们都非常清楚抚摸它们的危险。不幸的是,我们的接口比较大,我们只能实现代码来宠熊。
让我们通过将我们的大界面分成三个独立的界面来解决这个问题:
public interface BearCleaner {
void washTheBear();
}
public interface BearFeeder {
void feedTheBear();
}
public interface BearPetter {
void petTheBear();
}
现在,由于接口隔离,我们可以自由地只实现对我们重要的方法:
public class BearCarer implements BearCleaner, BearFeeder {
public void washTheBear() {
//I think we missed a spot...
}
public void feedTheBear() {
//Tuna Tuesdays...
}
}
最后,我们可以把危险的东西留给鲁莽的人:
public class CrazyPerson implements BearPetter {
public void petTheBear() {
//Good luck with that!
}
}
更进一步,我们甚至可以将 BookPrinter 类从我们之前的示例中拆分出来,以同样的方式使用接口隔离。通过使用单个 打印 方法实现 Printer 接口,我们可以实例化单独的 ConsoleBookPrinter 和 OtherMediaBookPrinter 类。
依赖倒置
依赖倒置原理是指软件模块的解耦。这样,高级模块不再依赖于低级模块,而是都依赖于抽象。
为了证明这一点,让我们回到老派,用代码让 Windows 98 计算机栩栩如生:
public class Windows98Machine {}
但是没有显示器和键盘的电脑有什么用呢?让我们将每个添加到我们的构造函数中,以便我们实例化的每台 Windows98Computer 都预装了一个 Monitor 和一个 StandardKeyboard:
public class Windows98Machine {
private final StandardKeyboard keyboard;
private final Monitor monitor;
public Windows98Machine() {
monitor = new Monitor();
keyboard = new StandardKeyboard();
}
}
这段代码可以工作,我们将能够在我们的 Windows98Computer 类中自由 使用 StandardKeyboard 和 Monitor 。
问题解决了?不完全的。通过使用 new 关键字声明 StandardKeyboard 和 Monitor ,我们将这三个类紧密耦合在一起。
这不仅使我们的 Windows98Computer 难以测试,而且我们也失去了在需要时将 StandardKeyboard 类换成不同的类的能力。我们也被我们的 Monitor 类所困。
让我们通过添加一个更通用的键盘接口并在我们的类中使用它来将我们的机器与 StandardKeyboard 分离:
public interface Keyboard { }
public class Windows98Machine{
private final Keyboard keyboard;
private final Monitor monitor;
public Windows98Machine(Keyboard keyboard, Monitor monitor) {
this.keyboard = keyboard;
this.monitor = monitor;
}
}
在这里,我们使用依赖注入模式来帮助将键盘依赖添加到 Windows98Machine 类中。
让我们也修改我们的 StandardKeyboard 类来实现 Keyboard 接口,使其适合注入到 Windows98Machine 类中:
public class StandardKeyboard implements Keyboard { }
现在我们的类被解耦并通过键盘抽象进行通信。如果需要,我们可以使用不同的接口实现轻松地切换机器中的键盘类型。我们可以对 Monitor 类遵循相同的原则。
优秀!我们已经解耦了依赖关系,并且可以 使用我们选择的任何测试框架自由地测试我们的 Windows98Machine 。
结论
在本文中,我们深入探讨了面向对象设计的 SOLID 原则。
我们首先简要介绍了 SOLID 的历史以及这些原则存在的原因。
我们用一个违反它的快速代码示例分解了每个原则的含义。然后我们看到了如何修复我们的代码 并使其符合 SOLID 原则。