一、五大设计原则概览
说到面向对象编程,有一个原则几乎每个程序员都知道,那就是 SOLID 原则。关于它的资料介绍也非常丰富,实践例子也很多。但实际上你很可能把 SOLID 原则都用错了,并且还无意识地一直在滥用它。
之所以这么说,一方面是因为很多时候你都将每一个原则分开使用,容易造成过度解读。比如,在使用接口隔离原则时容易只关心接口,而忽略不同实现,或者不关心接口之间的关系以及和整体系统之间的关系。另一方面是因为它总是能让你无意识地将简单的问题复杂化。比如,明明只需要写一个一次性同步数据的方法,然后写完即扔,但是突然想到 SOLID 原则,于是又搞出来十几个多余的类。有了锤子,总是容易想去找钉子,殊不知有时就完全不需要锤子,只需要一把小刀即可解决问题。
那么 SOLID 原则到底长什么样子?各原则之间有什么联系和区别?该如何正确理解呢?今天,我们就来一起学习下这五大面向对象设计原则,也就是我们所说的 SOLID 原则。
2000 年,Robert C. Martin 在他的《设计原理和设计模式》这一论文中首次提出 SOLID 原则的概念。然后,在过去的 20 年中,这 5 条原则彻底改变了面向对象编程的世界,改变了我们编写软件的方式。
SOLID 原则的核心理念是帮助我们构建可维护和可扩展的软件。因为随着软件规模的扩大,一个人维护所有的代码越来越困难,这时就需要更多的人来维护代码,而多人协作的关键在于相互通信与协作,恰好 SOLID 原则提供了这样一个框架。
“SOILD”是由五大原则的英文首字母拼写而成,具体对应情况如下。
- S(Single Responsibility Principle,简称 SRP):单一职责原则,意思是对象应该仅具有一种单一的功能。
- O(Open–Closed Principle,简称 OCP):开闭原则,也就是程序对于扩展开放,对于修改封闭。
- L(Liskov Substitution Principle,简称 LSP):里氏替换原则,程序中的对象应该是可以在不改变程序正确性的前提下被它的子类所替换的。
- I(Interface Segregation Principle,简称 ISP):接口隔离原则,多个特定客户端接口要好于一个宽泛用途的接口。
- D(Dependency Inversion Principle,简称 DIP):依赖反转原则,该原则认为一个方法应该遵从“依赖于抽象而不是一个实例”。
接下来,我们就来逐一学习它们。
1. 单一职责原则(SRP)
单一职责原则(SRP)的原意是:对一个类而言,应该仅有一个引起它变化的原因。
但对此,我们通常更容易这么来理解:
- 只有一个类或方法;
- 写好就不能修改的类或方法;
- 一个接口对应唯一一个实现。
上面这些理解不能说完全错,但是只抓住了单一职责原则(SRP)本质上重要的两点中的一点——单一,而忘记了另一个也很重要的点——职责。在上一讲中,我们有介绍过“职责”可以定义为“变化的原因”。下面我们就来讲讲为什么那三种理解方式不够准确。
- 职责不一定只有一个类或方法,还可能有多个类或方法,比如,上传文件是单一职责,而上传方法、增删改查 URL 方法、校验方法都服务于上传文件。
- 不能修改的类或方法本质上有很多影响因素,比如,代码长时间没有维护、设计时没有预留扩展接口,等等。
- 一个接口对应一个实现并不能说职责是单一的,因为一个接口中可能会存在没有划分清楚的职责。
为帮助你更好地理解,这里我们举一个简单的例子,假设有一个书籍类,保存书籍的名称、作者、内容,提供文字修订服务和查询服务,代码如下:
public class Book {
private String name;
private String author;
private String text;
//constructor, getters and setters
public String replaceWordInText(String word){
return text.replaceAll(word, text);
}
public boolean isWordInText(String word){
return text.contains(word);
}
}
整体服务运行良好。但是,有人说只是保存书的信息,不提供打印和阅读功能,岂不是很浪费资源。于是,我们立即加了打印和阅读的服务,如下所示:
public class Book {
//...
//打印服务
void printText(){
//具体实现
}
//阅读服务
void getToRead(){
//具体实现
}
}
到此,你很容易发现,当打印服务需要针对不同的客服端进行适配时,书籍类就需要多次反复地进行修改,那么不同的类实例需要修改的地方就会越来越多,系统明显变得更加脆弱,同时也违反了 SRP。
所以说,当你在理解 SRP 时,一定要抓住一个重点:职责是否具有唯一性。当你有多个动机来改变一个类时,那么职责就多于一个,也就违反了 SRP。
2. 开闭原则(OCP)
开闭原则最初是由 Bertrand Meyer 在 20 世纪 80 年代提出的,被称为“面向对象设计中的最重要原理”。
开闭原则是指软件组件(类、方法、模块等)应该对扩展开发,对修改关闭。这就意味着当你在设计或修改程序代码时,应该尽量去扩展原有程序,而不是修改原有程序。
不过在我看来,开闭原则更像是一个框架的设计原则,而不是具体的业务编码技巧。因为在实际业务编码实现中,需求变化总是快于技术更新,直接修改业务代码的时间成本有时会比扩展的时间成本低很多,所以说,在非常细节的业务编码实现中,只扩展而不修改原始的代码几乎很难做到,反倒是在框架、类库或架构设计中常常更容易实现开闭原则。即便强行在编码实现中这样做,也会导致过多的冗余类产生,并导致最终系统整体调用关系复杂。
同样,这里我们用一个简单的例子来帮助你理解开闭原则。如果你基于 Spring JDBC 写过不同 DataSource 进行读写分离的代码,就会对开闭原则有一个大致了解。具体代码如下:
public abstract class Demo extends AbstractDataSource {
private int readDsSize;
@Override
public Connection getConnection() throws SQLException {
return this.determineTargetDataSource().getConnection();
}
@Override
public Connection getConnection(String username, String password) throws SQLException {
return this.determineTargetDataSource().getConnection(username, password);
}
protected DataSource determineTargetDataSource() {
if (determineCurrentLookupKey() && this.readDsSize > 0){
//读库做负载均衡(从库)
return this.loadBalance();
} else {
//写库使用主库
return this.getResolvedMasterDataSource();
}
}
protected abstract boolean determineCurrentLookupKey();
//其他代码省略
}
上面这段代码的大致意思是说,通过继承 AbstractDataSource 类,可以重新构造不同的 DataSource,以达到读库使用从库(做负载均衡)、写库使用主库的目的。这里虽然我们不能修改 Spring JDBC 的代码,但是我们可以通过扩展来实现更复杂的场景。
这就是我们前面说的开闭原则的一种具体体现,很多时候我们总是会使用各种不同的工具和框架,不可能做到所有工具的自行开发,这时通过开闭原则就能进行很多的扩展与改进,而不需要重复造轮子。
3. 里氏替换原则(LSP)
里氏替换原则(LSP)这个名字看上去虽然有点奇怪,但是在面向对象编程中,它却是使用频率非常高的一个原则。
里氏替换原则(LSP)的原意是:子类应该能够完全替换掉它的基类。换句话说,在进行代码设计时,应该尽量保持子类和父类方法行为的一致性。这样做的好处在于,即便是扩展子类,也不会丢失父类的特性。同时,里氏替换原则(LSP)也是针对接口编程的最佳实践原则之一,因为某一个接口定义的功能不改变,那么就可以使用很多不同算法的代码来替换同一个接口的功能。
比如,Spring 中提供的自定义属性编辑器,可以解析 HTTP 请求参数中的自定义格式进行绑定并转换为格式输出。只要遵循基类(PropertyEditorSupport)的约束定义,就能为某种数据类型注册一个属性编辑器。我们先定义一个类 DefineFormat,具体代码如下:
public class DefineFormat{
private String rawStingFormat;
private String uid;
private String toAppCode;
private String fromAppCode;
private Sting timestamp;
// 省略构造函数和get, set方法
}
然后,创建一个 Restful API 接口,用于输入自定义的请求 URL。
@GetMapping(value = "/api/{d-format}",
public DefineFormat parseDefineFormat (
@PathVariable("d-format") DefineFormat defineFormat) {
return defineFormat;
}
接下来,创建 DefineFormatEditor,实现输入自定义字符串,返回自定义格式 json 数据。
public class DefineFormatEditor extends PropertyEditorSupport {
//setAsText() 用于将String转换为另一个对象
@Override
public void setAsText(String text) throws IllegalArgumentException {
if (StringUtils.isEmpty(text)) {
setValue(null);
} else {
DefineFormat df = new DefineFormat();
df.setRawStingFormat(text);
String[] data = text.spilt("-");
if (data.length == 4) {
df.setUid(data[0]);
df.setToAppCode(data[1]);
df.setFromAppCode(data[2]);
df.setTimestamp(data[3]);
setValue(df);
} else {
setValue(null);
}
}
}
//将对象序列化为String时,将调用getAsText()方法
@Override
public String getAsText() {
DefineFormat defineFormat= (DefineFormat) getValue();
return null == defineFormat ? "" : defineFormat.getRawStingFormat();
}
}
到此你会发现,使用里氏替换原则(LSP)的本质就是通过继承实现多态行为,这在面向对象编程中是非常重要的一个技巧,对于提高代码的扩展性是很有帮助的。
4. 接口隔离原则(ISP)
如果说单一职责原则(SRP)是适用于类的设计原则,那么接口隔离原则(ISP)就是适合接口的设计原则。
接口隔离原则(ISP)的原意是:不应该强迫用户依赖于他们不用的方法。那么什么情况下会造成“被强迫”呢?答案就是:当你在接口中有多余的定义时。比如下面代码中的接口定义:
public interface ICRUD<T> {
void add(T t);
void update(T t);
void delete();
T query();
void sync();
}
增删改查是应用最多的方法定义之一,但是你发现上面这段代码中的问题没?
虽然我们定义的五个方法职责没有重合,但是其中有一个方法对于很多人来说,可能就是低频的方法,那就是同步(sync())。这样显然没有做到接口的隔离,只是在一个接口中做了方法隔离,当你使用 ICRUD 接口时就会被强迫实现 sync() 方法。其实,正确的做法应该是将 sync() 方法分离出来,如下所示:
public interface ICRUD<T> {
void add(T t);
void update(T t);
void delete();
T query();
}
public interface ISync {
void sync();
}
这样代码就变得清晰了很多,我们将增删改查放在一起、同步放在一起,这才算得上实现了接口的隔离。换句话说,好的接口隔离不是只考虑一个接口中方法的隔离,还应该多考虑整体系统中的职责。
实际上,在应用接口隔离原则(ISP)时同样需要注意职责的单一度,而不能简单地认为只要定义的方法间没有重叠就算是隔离了。
5. 依赖反转原则(DIP)
依赖反转原则(DIP)的原意是:
- 高层模块不应该依赖底层模块,二者都应该依赖于抽象;
- 抽象不应该依赖于细节,细节应该依赖于抽象。
简单来说,就是我们应该在编程时寻找好的抽象。这里的抽象不是简单地指 Java 中的 interface,而是指可以创建出固定却能够描述一组任意个可能行为的抽象体。
而好的抽象就是指具备一些共性规律并能经得起实践检验的抽象。比如,关系型数据库(RDMS)就是对数据存储与查询的一种正确抽象。再比如,我们非常熟悉的 JDBC 协议就是一种对数据库增删改查使用的正确抽象,还有我们课程后面模块要讲的设计模式也是某种场景下的正确抽象。
当然,好的抽象并不容易找到,更多的时候你还是得做很多定制化的开发。
总之,依赖反转原则(DIP)给我们的启示是:要尽量通过寻找好的抽象来解决大量重复工作的效率问题。
二、五大设计原则之间的关系
虽然 SOLID 原则在面向对象编程中应用广泛,但是你可能更多时候还是在死记硬背式地使用。在我看来,SOLID 原则之间其实是有一定联系的,搞清楚这些联系,不仅能帮助你理解记忆 SOLID 原则,而且还能更好地应用它们。
这里,我结合多年编程实践,总结了一张 SOLID 五大原则之间的关系图,如下所示:
首先,开闭原则是 SOLID 原则追求的最终目标。为什么这么说呢?因为修改代码非常容易引入 Bug,即便是很小的改动都有可能引起未知的 Bug。而一旦系统因为 Bug 出现故障,担责的一定是我们。没有人愿意担责,所以,我们都更喜欢写新代码而不是修改旧代码。除此之外,在设计之初就尽量以实现开闭原则为目标,它能为你在未来的实际开发中提供更高的代码扩展性。不过,这里需要注意一下,开闭原则不是封闭原则,千万不要把遵守开闭原则当作必要条件,如果代码需要适应现实的需求变化而必须要修改的话,那么这时就应该违反原则。当然尽量还是要做到开闭。
其次,单一职责原则是重要的基础原则,它帮助实现了里氏替换原则、接口隔离原则和开闭原则。你只要仔细分析各个原则的含义就能发现,它们都涉及了两个关键动作:分离和替换。那么是逻辑揉在一起、接口定义模糊的代码容易分离和替换,还是职责单一、接口抽象清晰的代码容易分离和替换呢?答案很明显是后者。之所以说单一职责原则是基础,就是因为要想实现代码的灵活扩展性需要更容易理解的模块。而职责单一的模块,更容易被组合起来用于更大的职责,也能进行快速替换和修改。
最后,依赖反转原则是一种指导原则,同样是用来分离和替换代码的。只不过它作用在更高层次、更广的范围内,因为它太重要了,下一讲我会专门详细介绍它。
三、总结
SOLID 原则是面向对象编程中非常重要的指导原则,相信你在学习了上面的内容后,对于每个原则应该已经有一个大致的了解了。
不过,你会发现,当你使用 SOLID 越多,代码的可重用性变得越来越高的同时,代码逻辑却也相应地变得越复杂。之所以会变得复杂,是因为 SOLID 原则太过于重视分离与灵活替换,这就意味着我们可能需要创建很多单一的类和方法,再通过接口把它们连接起来,这样反而容易让模块和模块之间的调用关系变得更错综复杂,增加了整体的复杂性。这显然违背了 KISS 原则。
所以,当你想要兼顾 KISS 原则和 SOLID 原则时,最简单的办法就是控制接口的数量。尽量抽象某一个职责下的通用接口类(可以有多个实现类),而不是搞出很多的一个接口只对应一个实现类的模块,这看上去是在依赖抽象,实际上还是在依赖单个的实现。
SOLID 原则本质上就是为了在不修改原有模块的情况下有更好的扩展功能,也就是实现开闭原则,但是要想真正做到,一定不能忽略一个隐含的前提条件,那就是在设计时就要提前考虑模块的扩展性。如果一个系统在设计时就只有一个大模块,所有的功能都揉在里面,这样即便你想要应用 SOLID 原则,也是做不到的。
另外,在应用 SOLID 原则时一定要结合五个原则综合考虑,并结合实际业务进行合理取舍。千万不要在某个原则上过度解读,而误认为要满足所有 SOLID 原则才算是应用了 SOLID 原则。
四、课后思考
在实际工作中,你有哪些成功使用了 SOLID 原则的实践案例呢?或者失败的实践案例?