Java中的SOLID原则
前言
Robert C. Martin提出了5条面向对象的设计原则,并将其缩写为SOLID。这个首字母缩写词的每一个字母都在谈论Java中的原则。当以一种组合的方式使用所有的SOLID原则时,就会更容易开发出易于管理的软件。
Robert C. Martin,世界级编程大师,设计模式和敏捷开发先驱,敏捷联盟首任主席,C++ Report前主编。20世纪70年代初成为职业程序员,后创办Object Mentor公司并任总裁。后辈程序员亲切地称之为“Bob大叔”。
SOLID什么意思?
如上所述,SOLID代表了Java的五个原则,分别是:
- S:单一职责原则
- O:开闭原则
- L:里氏替换原则
- I:接口隔离原则
- D:依赖倒置原则
本期内容小黑会深入讨论每个原则。首先考虑第一个原则,即单一职责原则。
单一职责原则(SRP)
单一职责原则规定只能有一个原因可以修改类。如果有多个原因要修改类,那么应该根据功能将类重构为多个类。
为了便于理解,可以通过下面的代码。假设现在有一个Customer
类。
import java.util.List;
public class Customer {
String name;
int age;
long bill;
List<Item> listsOfItems;
Customer(String name,int age){
this.name=name;
this.age=age;
}
// 计算账单不应该是Customer的责任
public long calculateBill(long tax){
for (Item item:listsOfItems) {
bill+=item.getPrice();
}
bill+=tax;
this.setBill(bill);
return bill;
}
// 生成报告也不应该是Customer的责任
public void generateReport(String reportType){
if(reportType.equalsIgnoreCase("CSV")){
System.out.println("Generate CSV report");
}
if(reportType.equalsIgnoreCase("XML")){
System.out.println("Generate XML report");
}
}
// 省略getter,setter
}
上面这个例子有以下问题:
- 如果账单的计算逻辑有任何变化,那么我们需要更改Customer类;
- 如果您想要再添加一个要生成的报告类型,那么我们需要更改Customer类;
那么按照原则,账单计算和报告生成不应该是Customer类的责任,我们应该拆分成多个类。
创建一个单独的类用来做账单计算。
import java.util.List;
public class BillCalculator {
public long calculateBill(Customer customer,long tax){
long bill=0;
List listsOfItems=customer.getListsOfItems();
for (Item item:listsOfItems) {
bill+=item.getPrice();
}
bill+=tax;
customer.setBill(bill);
return bill;
}
}
创建一个单独的类用来做报告生成。
public class ReportGenerator {
public void generateReport(Customer customer,String reportType){
if(reportType.equalsIgnoreCase("CSV")) {
System.out.println("Generate CSV report");
}
if(reportType.equalsIgnoreCase("XML")) {
System.out.println("Generate XML report");
}
}
}
如果我们需要更改账单计算中的任何内容,我们不需要修改Customer类,我们将在BillCalculator类中进行更改。
如果想添加另一种报告类型,那么您需要在ReportGenerator类而不是Customer类中进行更改。
开闭原则
实体或对象应该对扩展保持开放,但对修改保持关闭。
一旦我们编写了类并测试了它,就不应该一次又一次地修改它,而是应该对扩展开放。如果我们修改了已经测试的类,可能会导致大量额外的工作来测试它,而且可能会引入新的bug。
策略设计模式是开闭原则的一种实现方式。服务类可以根据需求使用不同的策略来执行特定的任务,因此我们将保持服务类的封闭,但同时,通过引入新的策略来实现策略接口,系统对扩展是开放的。在运行时,您可以根据需要使用任何新的策略。
我们来通过一个简单的代码例子来理解这个原则。
假设我们需要根据输入的类型决定创建两种格式的报告文件,比如CSV和XML,注意在设计时要考虑以后可能会增加新的格式。
首先我们先通过枚举定义文件格式。
public enum ReportingType {
CSV,XML;
}
然后创建一个服务类用来生成文件。
public class ReportingService {
public void generateReportBasedOnType(ReportingType reportingType){
if("CSV".equalsIgnoreCase(reportingType.toString())) {
generateCSVReport();
} else if("XML".equalsIgnoreCase(reportingType.toString())) {
generateXMLReport();
}
}
private void generateCSVReport(){
System.out.println("Generate CSV Report");
}
private void generateXMLReport(){
System.out.println("Generate XML Report");
}
}
这段代码逻辑很简单,能够实现我们的需求,并且经过了测试。
现在我们需要增加一种新的报告文件格式,比如EXCEL,那我们的代码首先要修改枚举类:
public enum ReportingType {
CSV,XML,EXCEL;
}
接下来我们要对已经经过测试的服务类进行修改。
public class ReportingService {
public void generateReportBasedOnType(ReportingType reportingType){
if("CSV".equalsIgnoreCase(reportingType.toString())) {
generateCSVReport();
} else if("XML".equalsIgnoreCase(reportingType.toString())) {
generateXMLReport();
} else if("Excel".equalsIgnoreCase(reportingType.toString())) {
// 增加excel生成逻辑
generateExcelReport();
}
}
private void generateCSVReport(){
System.out.println("Generate CSV Report");
}
private void generateXMLReport(){
System.out.println("Generate XML Report");
}
// 新增生成excel的方法
private void generateExcelReport(){
System.out.println("Generate Excel Report");
}
}
因为我们在原来已经测试过了的代码上进行了修改,为了不出BUG,我们不得不重新对所有功能进行测试。这显然太不优雅了,主要原因就是这个方式不符合开闭原则。
接下来我们用开闭原则的方式重新完成我们的服务类。
public class ReportingService {
public void generateReportBasedOnStrategy(ReportingStrategy reportingStrategy)
{
// 通过一个策略生成报告文件
reportingStrategy.generateReport();
}
}
创建一个名为ReportingStrategy
的接口,代表报告文件生成的策略。
public interface ReportingStrategy {
void generateReport();
}
然后我们分别创建出生成CSV和XML各自的策略实现。
public class CSVReportingStrategy implements ReportingStrategy {
@Override
public void generateReport(){
System.out.println("Generate CSV Report");
}
}
public class XMLReportingStrategy implements ReportingStrategy {
@Override
public void generateReport(){
System.out.println("Generate XML Report");
}
}
这样,我们在使用时,只需要按照需要创建不同的策略。
public class GenerateReportMain {
public static void main(String[] args){
ReportingService rs=new ReportingService();
//生成CSV文件
ReportingStrategy csvReportingStrategy=new CSVReportingStrategy();
rs.generateReportBasedOnStrategy(csvReportingStrategy);
//生成XML文件
ReportingStrategy xmlReportingStrategy=new XMLReportingStrategy();
rs.generateReportBasedOnStrategy(xmlReportingStrategy);
}
}
当需要增加Excel类型时,只需要新增一个Excel策略即可。
public class ExcelReportingStrategy implements ReportingStrategy {
@Override
public void generateReport(){
System.out.println("Generate Excel Report");
}
}
没有对ReportingService
做任何修改。我们刚刚添加了新的类ExcelReportingStrategy
,只需要测试Excel相关的逻辑,不用再重新测试服务类和其他文件格式的代码。
还有一个例子可以帮助你更清晰地理解开闭原则。想必你应该在chrome浏览器中安装过扩展插件吧。
chrome浏览器的主要功能是浏览不同的网站。当你用chrome浏览器浏览外语网站时,你想进行翻译安装一个翻译插件就可以了。
这种为增加浏览器功能而添加内容的机制是一种扩展。因此,浏览器是一个对扩展开放但对修改关闭的完美例子。
里氏替换原则
里氏替换原则的定义是每个子类或派生类都应该可以替换它们的父类或基类。
它是一个独特的面向对象原则。一个特定的父类型的子类型在没有造成任何复杂或破坏的情况下应该有能力代替父类型。
接口隔离原则
简单地说,接口隔离原则规定不应该强制客户端实现它不使用的方法。
可以将你不支持的方法抛出UnsupportedOperationException,但不建议这样做,这会让你的类很难使用。
同样为了理解这个原则,我们通过下面的代码示例来更好的理解。
假设现在有一个接口Set。
public interface Set<E> {
boolean add(E e);
boolean contains(Object o);
E ceiling(E e);
E floor(E e);
}
创建一个TreeSet来实现Set接口。
public class TreeSet implements Set{
@Override
public boolean add(Object e){
// Implement this method
return false;
}
@Override
public boolean contains(Object o){
// Implement this method
return false;
}
@Override
public Object ceiling(Object e){
// Implement this method
return null;
}
@Override
public Object floor(Object e){
// Implement this method
return null;
}
}
再创建一个HashSet。
public class HashSet implements Set{
@Override
public boolean add(Object e){
return false;
}
@Override
public boolean contains(Object o){
return false;
}
@Override
public Object ceiling(Object e){
return null;
}
@Override
public Object floor(Object e){
return null;
}
}
你发现了,即使在hashSet中不需要ceiling()
方法和floor()
方法,也要进行实现。
这个问题可以用下面的方式实现:
创建另一个名为NavigableSet
的接口,它将具有ceiling()
和floor()
方法。
public interface NavigableSet<E> {
E ceiling(E e);
E floor(E e);
}
将Set接口进行修改。
public interface Set<E> {
boolean add(E e);
boolean contains(Object o);
}
现在TreeSet
可以实现两个接口Set
和NavigableSet
。
public class TreeSet implements Set,NaviagableSet{
@Override
public boolean add(Object e){
// Implement this method
return false;
}
@Override
public boolean contains(Object o){
// Implement this method
return false;
}
@Override
public Object ceiling(Object e){
// Implement this method
return null;
}
@Override
public Object floor(Object e){
// Implement this method
return null;
}
}
而HashSet
只需要实现Set
接口。
public class HashSet implements Set{
@Override
public boolean add(Object e){
return false;
}
@Override
public boolean contains(Object o){
return false;
}
}
这就是java中的接口隔离原则。如果你去看JDK源码的话,会发现Set接口依赖体系就是这种方式。
依赖倒置原则
依赖倒置原则定义,实体应该只依赖于抽象的而不是具体的。高级模块不能依赖于任何低级模块,而应该依赖于抽象。
让我们再通过另一个实际例子来更好的理解这个原则。
当你去商场买东西,你决定用你的银行卡付款,当你把银行卡给收银员时,收银员不会关注你使用的是哪个银行的卡,即使你给了一张农村信用社的卡,他也不会拿出专门的信用社的刷卡机,甚至你使用的是借记卡还是信用卡都不重要,都可以成功的刷卡付款。
在这个例子中,收银员的刷卡机依赖的是银行卡的抽象,而不会关心银行卡的具体细节,这就是依赖倒置原则。
总结
现在想必你已经知道了SOLID所有五个组成部分的基本定义,分别是单一职责原则、开闭原则、里氏替换原则、接口隔离原则和依赖倒置原则。
所以当你写代码的时候,你应该把这些核心原则牢记于心,把这些原则作为你写代码的规范,让你的代码更加高效优雅。