1. 概述
在本教程中,我们介绍面向对象设计的SOLID原则。首先我们探讨它们出现的原因以及为什么我们在设计软件时应该考虑它们,然后我们将概述每个原则并通过一些示例代码进行演示。
2. SOLID原则的原因
SOLID原则是由Robert C.Martin在他2000年的论文“设计原则和设计模式”中引入的,这些概念后来由Michael Feathers建立,他向我们介绍了SOLID的首字母缩写词。在过去的20年里,这五个原则彻底改变了面向对象编程的世界,改变了我们编写软件的方式。
那么,什么是SOLID,它如何帮助我们编写更好的代码?简而言之,Martin和Feathers的设计原则鼓励我们创建更易于维护、更易于理解和更灵活的软件。因此,随着我们的应用程序规模的增长,我们可以降低它们的复杂性,并在以后的道路上为自己省去很多麻烦!
以下五个概念构成了我们的SOLID原则:
- Single Responsibility(单一职责)
- Open/Closed(打开/关闭)
- Liskov Substitution(里氏替换)
- Interface Segregation(接口隔离)
- Dependency Inversion(依赖倒置)
虽然这些概念可能看起来令人生畏,但通过一些简单的代码示例就可以很容易理解它们。在以下各节中,我们将深入探讨这些原则,并通过一个快速的Java示例来说明每一个原则。
3. 单一职责
我们从单一职责原则开始。从名字可以理解出,这个原则规定一个类应该只承担一个责任。此外,它应该只有一个改变的理由。
这个原则如何帮助我们构建更好的软件?让我们看看它的一些好处:
- 测试:一个只负责一个类的测试用例会少得多
- 较低的耦合:单个类中的功能越少,依赖项就越少
- 组织:较小的、组织良好的类比单一的类更容易查找
例如,让我们看一个类来表示一本简单的书:
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, String replacementWord) {
return text.replaceAll(word, replacementWord);
}
public boolean isWordInText(String word) {
return text.contains(word);
}
}
现在我们的Book类运行良好,我们可以在我们的应用程序中存储任意数量的书籍。但是,如果我们无法将文本输出到我们的控制台并阅读它,那么存储信息有什么用呢?
让我们抛开谨慎,添加一个打印方法:
public class BadBook {
//...
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类将我们的文本发送到其他媒体。
无论是电子邮件、日志记录还是其他任何东西,我们都有一个单独的类专门针对这个问题。
4. 对扩展开放,对修改关闭
SOLID中的O被称为开闭原则。简而言之,类应该对扩展开放,对修改关闭。通过这样做,我们可以阻止自己修改现有代码并在原本令人满意的应用程序中导致潜在的新错误。当然,该规则的一个例外是修复现有代码中的错误。
让我们通过一个快速代码示例来探索这个概念,作为一个新项目的一部分,假设我们已经实现了一个吉他类。
它功能齐全,甚至还有一个音量旋钮:
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类,我们可以确保我们现有的应用程序不会受到影响。
5. 里氏替换
接下来是里氏替换,它可以说是五个原则中最复杂的一个。简而言之,如果类A是类B的子类型,我们应该能够在不破坏程序行为的情况下用A替换B。
我们直接进入代码来帮助我们理解这个概念:
public interface Car {
void turnOnEngine();
void accelerate();
}
上面,我们定义了一个简单的Car接口,其中包含所有汽车都应该能够实现的几个方法:turnOnEngine()和accelerate()。
下面实现我们的接口并为这些方法提供一些代码:
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!
}
}
通过将没有引擎的汽车投入其中,我们从本质上改变了我们程序的行为。这是对里氏替换的公然违反,并且比我们之前的两个原则更难解决。
一种可能的解决方案是将我们的模型重新设计为考虑到Car的无引擎状态的接口。
6. 接口隔离
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类从我们之前的示例中拆分出来,以同样的方式使用接口隔离。通过使用单个print方法实现Printer接口,我们可以实例化单独的ConsoleBookPrinter和OtherMediaBookPrinter类。
7. 依赖倒置
依赖倒置原则指的是软件模块的解耦。这样,高层模块将不再依赖于低层模块,而是两者都依赖于抽象。
为了演示这一点,让我们走老路,用代码使Windows 98计算机栩栩如生:
public class Windows98Machine {
}
但是没有显示器和键盘的电脑有什么用呢?让我们将每一个添加到我们的构造函数中,以便我们实例化的每个Windows98电脑都预装了Monitor和StandardKeyboard:
public class Windows98Machine {
private final StandardKeyboard keyboard;
private final Monitor monitor;
public Windows98Machine() {
monitor = new Monitor();
keyboard = new StandardKeyboard();
}
}
这段代码将起作用,并且我们将能够在我们的Windows98Machine类中自由使用StandardKeyboard和Monitor。
问题解决了?不完全的。通过使用new关键字声明StandardKeyboard和Monitor,我们将这三个类紧密耦合在一起。
这不仅使我们的Windows98Machine难以测试,而且我们也失去了在需要时将StandardKeyboard类切换为其他类的能力,而且我们也坚持使用Monitor类。
让我们通过添加一个更通用的Keyboard接口并在我们的类中使用它来将我们的机器与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 {
}
现在我们的类已解耦并通过Keyboard抽象进行通信。如果需要,我们可以使用不同的接口实现轻松地切换机器中的键盘类型。类似地,我们可以对Monitor类遵循相同的原则。
非常好!我们已经解耦了依赖关系,并且可以自由地使用我们选择的任何测试框架来测试我们的Windows98Machine。
8. 总结
在本文中,我们深入探讨了面向对象设计的SOLID原则。
首先介绍了SOLID的历史和这些原则存在的原因,然后我们深入探讨了每个原则的含义和如何在Java中实现它们。
与往常一样,本教程的完整源代码可在GitHub上获得。