XDRush

Java进阶之SOLID原则

1. 什么是SOLID原则?

S.O.L.I.D原则是面向对象设计和编程中几个重要的编码原则首字母的缩写;
SRP:The Single Responsibility Principle,单一职责原则
OCP:The Open Closed Principle,开放封闭原则
LSP:The Liskov Substitution Principle,里氏替换原则
ISP:The Interface Segregation Principle,接口分离原则
DIP:The Dependency Inversion Principle,依赖倒置原则
下面将详细讲解每种原则所代表的意义。

2. SRP(单一职责原则)

当需要修改某个类的时候原因有且只有一个。换句话说就是让一个类只做一种类型责任,当这个类需要承当其他类型的责任的时候,就需要分解这个类。类被修改的几率很大,因此应该专注于单一的功能。如果你把多个功能放在同一个类中,功能之间就形成了关联,改变其中一个功能,有可能中止另一个功能,这时就需要新一轮的测试来避免可能出现的问题,非常耗时耗力。

3. OCP(开放封闭原则)

软件实体应该是可扩展,而不可修改的。也就是说,对扩展是开放的,而对修改是封闭的。这个原则是诸多面向对象编程原则中最抽象、最难理解的一个。

  • 通过增加代码来扩展功能,而不是修改已经存在的代码。
  • 若客户模块和服务模块遵循同一个接口来设计,则客户模块可以不关心服务模块的类型,服务模块可以方便扩展服务(代码)。
  • OCP支持替换的服务,而不用修改客户模块。

示例如下:

1
2
3
4
5
6
7
8
9
10
11
public boolean sendByEmail(String addr, String title, String content) {
}
public boolean sendBySMS(String addr, String content) {
}
// 在其它地方调用上述方法发送信息
sendByEmail(addr, title, content);
sendBySMS(addr, content);

如果现在又多了一种发送信息的方式,比如可以通过微信来发送信息,那么不仅需要增加一个方法sendByWeChat(),还需要在调用它的地方进行修改,违反了OCP原则,更好的方式是抽象出一个Send接口,里面有个send()方法,然后让SendByEmail和SendBySMS去实现它既可。这样即使多了一个通过WeChat发送的请求,那么只要再添加一个SendByWeChat实现类实现Send接口既可。这样就不需要修改已有的接口定义和已实现类,很好的遵循了OCP原则。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public interface Send {
void send(String addr, String title, String content);
}
public class SendByEmail implements Send {
@Override
public void send(String addr, String title, String content) {
System.out.println("SendByEmail");
}
}
public class SendBySMS implements Send {
@Override
public void send(String addr, String title, String content) {
System.out.println("SendBySMS");
}
}
public class SendByWeChar implements Send {
@Override
public void send(String addr, String title, String content) {
System.out.println("SendByWeChar");
}
}

如此,即很好的满足了OCP原则。

4. LSP(里氏替换原则)

当一个子类的实例应该能够替换任何其超类的实例时,它们之间才具有is-A关系。客户模块不应关心服务模块的是如何工作的;同样的接口模块之间,可以在不知道服务模块代码的情况下,进行替换。即接口或父类出现的地方,实现接口的类或子类可以代入。

里氏替换原则通俗的来讲就是:子类可以扩展父类的功能,但不能改变父类原有的功能。它包含以下4层含义:

  • 子类可以实现父类的抽象方法,但不能覆盖父类的非抽象方法;
  • 子类中可以增加自己特有的方法;
  • 当子类的方法重载父类的方法时,方法的前置条件(即方法的形参)要比父类方法的输入参数更宽松;
  • 当子类的方法实现父类的抽象方法时,方法的后置条件(即方法的返回值)要比父类更严格。

举个简单的例子来说明,我们需要完成一个两数相减的功能,由类A来负责:

1
2
3
4
5
6
7
8
9
10
11
12
13
class A {
public int func1 (int a, int b) {
return a - b;
}
}
public class Client {
public static void main(String[] args) {
A a = new A();
System.out.println("100 - 50 = " + a.func1(100, 50));
System.out.println("100 - 80 = " + a.func1(100, 80));
}
}

后来,我们需要增加一个新的功能:完成两数相加,然后再与100求和,由类B来负责。即类B需要完成两个功能:

  • 两数相减。
  • 两数相加,然后再加100。

由于类A已经实现了第一个功能,所以类B继承类A后,只需要再完成第二个功能就可以了,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class B extends A {
public int func1(int a, int b) {
return a + b;
}
public int func2(int a, int b) {
return func1(a, b) + 100;
}
}
public class Client{
public static void main(String[] args) {
B b = new B();
System.out.println("100 - 50 = " + b.func1(100, 50));
System.out.println("100 - 80 = " + b.func1(100, 80));
System.out.println("100 + 20 + 100 = " + b.func2(100, 20));
}
}

运行,不难发现肯定是无法获得正确的结果的,这就印证了上述观点。

5. ISP(接口分离原则)

不能强迫用户去依赖那些他们不使用的接口。换句话说,使用多个专门的接口比使用单一的总接口要好。

客户模块不应该依赖大的接口,应该裁减为小的接口给客户模块使用,以减少依赖性。如Java中一个类实现多个接口,不同的接口给不用的客户模块使用,而不是提供给客户模块一个大的接口。这里比较好理解,就不举例啦。

6. DIP(依赖注入或倒置原则)

  • 高层模块不应该依赖于低层模块,二者都应该依赖于抽象
  • 抽象不应该依赖于细节,细节应该依赖于抽象

先让我们从宏观上来看下,举个例子,我们经常会用到宏观的一种体系结构模式—layer模式,通过层的概念分解和架构系统,比如常见得三层架构等。那么依赖关系应该是自上而下,也就是上层模块依赖于下层模块,而下层模块不依赖于上层,如下图所示。

这应该还是比较容易理解的,因为越底层的模块相对就越稳定,改动也相对越少,而越上层跟需求耦合度越高,改动也会越频繁,所以自上而下的依赖关系使上层发生变更时,不会影响到下层,降低变更带来的风险,保证系统的稳定。上面是立足在整体架构层的基础上的结果,再换个角度,从细节上再分析一下,这里我们暂时只关注UI和Service间的关系,如上面UI和Service这样的依赖关系会有什么样的问题?

  • 当需要追加提供一种新的Service时,我们不得不对UI层进行改动,增加了额外的工作。
  • 这种改动可能会影响到UI,带来风险。
  • 改动后,UI层和Logic层都必须重新再做Unit testing。

那么具体怎么优化依赖关系才能让模块或层间的耦合更低呢?想想前面讲的OCP原则吧,观点是类似的。
我们可以为Service追加一个抽象层,上层UI不依赖于Service的details,UI和Service同时依赖于这个Service的抽象层。如下图是我们的改进后的结果。

这样改进后会有什么好处呢?

  • Service进行扩展时,一般情况下不会影响到UI层,UI不需要改动。
  • Service进行扩展时,UI层不需要再做Unit testing。