面向对象设计原则SOLID

参考:

https://scotch.io/bar-talk/s-o-l-i-d-the-first-five-principles-of-object-oriented-design

http://www.cnblogs.com/hellojava/archive/2013/03/18/2966684.html

1. S – Single Responsibility Principle 职责单一原则

对象应该仅具有一种单一功能。
概念上和unix的设计原则 “Do one thing and do it well”很相似。听起来很简单但是实践起来很难。

比如,现在有一些图形要计算所有图形的面积。

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
class Circle {

private double radius;

public Circle Circle(double radius) {
this.radius = radius;
}

private double getRadius() {
return this.radius;
}
}

class Square {

private double length;

public Square(double length) {
this.length = length;
}

public double getLength() {
return this.length;
}
}

首先,创建图形类,包含构造函数和需要的属性参数。
接下来创建面积计算类AreaCalculator,然后编写方法计算提供的图形参数的面积总和。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class AreaCalculator {

private List shapes;

public AreaCalculator(List shapes) {
this.shapes = shapes;
}

public double sum() {
// logic to sum the areas
}

public void output() {
return System.out.println(String.format("Sum of the areas of provided shapes: %s", this.sum());
}
}

简单实例化AreaCalculator对象,然后传入一个图形集合,然后调用output方法显示面积总和。

1
2
AreaCalculator areaCalculator = new AreaCalculator(Arrays.asList(new Circle(10.0f), new Square(12.0f)));
areaCalculator.output()

这里output方法的问题是AreaCalculator处理计算逻辑然后输出字符串结果。那么,如果用户想用json或者其他格式输出计算结果呢?
所有的操作都放到AreaCalculator中做违背了职责单一原则。AreaCalculator应该只计算提供的图形的面积总和,而不用关心如何输入结果。

因此,需要新建一个SumCalculatorOutputter类用来处理统计结果输出操作。

SumCalculatorOutputter类如下:

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
class SumCalculatorOutputter {

private AreaCalculator areaCalculator;

public SumCalculatorOutputter(AreaCalculator areaCalculator) {
this.areaCalculator = areaCalculator;
}

public void outputJson() {
double sum = this.areaCalculator.sum();
}

public void outputHtml() {

}

public void outputString() {

}

}

AreaCalculator areaCalculator = new AreaCalculator(Arrays.asList(new Circle(10.0f), new Square(12.0f)));

SumCalculatorOutputter output = new SumCalculatorOutputter(areaCalculator);
output.outputJson();
output.outputHtml();
output.outputString();

现在不管你要把结果按照何种方式输出都放在SumCalculatorOutputter中处理。

2. O – Open-Closed Principle 开放封闭原则

对象应该是对于扩展开放的,但是对于修改封闭的。
这意味着一个类应该可以容易地扩展,而不需要修改类本身。

我们来看下AreaCalculator类的sum方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
public double sum() {
double sum = 0.0f;
for(Object shape : this.shapes) {
if(shape instanceof Circle) {
Circle shape = (Circle)shape;
sum += Math.pi * Math.sqrt(shape.getRadius());
} else if(shape instanceof Square) {
Square shape = (Square)shape;
sum += Math.sqrt(shape.getLength());
}
}
return sum;
}

如果我们要支持更多的图形就需要添加更多的“else if”代码块儿,这就违背了开闭原则。
一种解决方式是把面积计算的逻辑迁移到图形类中,AreaCalculator类的sum方法调用图形类的面积计算方法。

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
29
30
31
32
33
class Circle {

private double radius;

public Circle Circle(double radius) {
this.radius = radius;
}

public double area(){
return Math.PI * Math.sqrt(this.radius);
}

private double getRadius() {
return this.radius;
}
}

class Square {

private double length;

public Square(double length) {
this.length = length;
}

public double area(){
return Math.sqrt(this.length);
}

public double getLength() {
return this.length;
}
}

然后我们抽取公共方法area作为接口ShapeInterface的方法,这样新增图形的时候只要实现ShapeInterface接口。

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
29
30
31
32
33
34
35
36
37
38
39
interface ShapeInterface {
double area();
}

class Circle implements ShapeInterface {

private double radius;

public Circle Circle(double radius) {
this.radius = radius;
}

@override
public double area(){
return Math.PI * Math.sqrt(this.radius);
}

private double getRadius() {
return this.radius;
}
}

class Square implements ShapeInterface {

private double length;

public Square(double length) {
this.length = length;
}

@override
public double area(){
return Math.sqrt(this.length);
}

public double getLength() {
return this.length;
}
}

现在修改AreaCalculator类的sum方法:

1
2
3
4
5
6
7
8
9
10
11
12
class AreaCalculator {

private List<ShapeInterface> shapes;

public AreaCalculator(List<ShapeInterface> shapes) {
this.shapes = shapes;
}

public double sum() {
return this.shapes.stream.map(ShapeInterface::sum).mapToDouble(Double::doubleValue).sum();
}
}

3. L – Liskov Substitution Principle 里氏替换原则

对象应该是可以在不改变程序正确性的前提下被它的子类所替换的。

子类可以扩展父类的功能,但不能改变父类原有的功能。

子类可以实现父类的抽象方法,但不能覆盖父类的非抽象方法。

子类中可以增加自己特有的方法。

当子类的方法重载父类的方法时,方法的前置条件(即方法的形参)要比父类方法的输入参数更宽松。

当子类的方法实现父类的抽象方法时,方法的后置条件(即方法的返回值)要比父类更严格。

违背里氏替换原则的例子,我们新增一个体积计算器类VolumeCalculator继承面积计算器类,覆盖sum方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
class VolumeCalculator extends AreaCalulator {

public VolumeCalculator(List<ShapeInterface> shapes) {
super(shapes);
}

@override
public double sum() {
// 新增其他逻辑操作变为体积统计
return this.shapes.stream.map(ShapeInterface::sum).mapToDouble(Double::doubleValue).sum();
}

}

遵循里氏替换原则的例子如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class VolumeCalculator extends AreaCalulator {

public VolumeCalculator(List<ShapeInterface> shapes) {
super(shapes);
}

/**
* 新增体积统计方法
*/
public double volume() {
return 0.0d;
}

}

4. I – Interface Segregation Principle 接口隔离原则

多个特定客户端接口要好于一个宽泛用途的接口。

客户端不应该强行依赖它不需要的接口。

类间的依赖关系应该建立在最小的接口上。

客户端不应该依赖它不需要的接口,意思就是说客户端只要依赖它需要的接口,它需要什么接口,就提供什么接口,不提供多余的接口。

“类间的依赖关系应该建立在最小的接口上”也表达这一层意思。

通俗的讲就是:接口中的方法应该尽量少,不要使接口过于臃肿,不要有很多不相关的逻辑方法。

我们继续以形状举例,形状有立体形状所以给形状接口ShapeInterface提供体积计算方法volume:

1
2
3
4
interface ShapeInterface {
double area();
double volume();
}

所有实现ShapeInterface接口的形状类都要实现volume方法:

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
class Circle implements ShapeInterface {

private double radius;

public Circle Circle(double radius) {
this.radius = radius;
}

@override
public double area() {
return Math.PI * Math.sqrt(this.radius);
}

@override
public double volume() {
return 0.0d;
}

private double getRadius() {
return this.radius;
}
}

class Square implements ShapeInterface {

private double length;

public Square(double length) {
this.length = length;
}

@override
public double area() {
return Math.sqrt(this.length);
}

@override
public double volume() {
return 0.0d;
}

public double getLength() {
return this.length;
}
}

class Cube implements ShapeInterface {

private double length;

public Cube(double length) {
this.length = length;
}

@override
public double area() {
return 6 * Math.sqrt(this.length);
}

@override
public double volume() {
return Math.pow(this.length, 3);
}

public double getLength() {
return this.length;
}
}

这么做会导致Circle和Square这样的平面图形必须要实现一个不必要的volume方法。
可以提供一个立体形状接口SolidShapeInterface,提供volume供立体形状来实现:

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
interface ShapeInterface {
double area();
}

interface SolidShapeInterface {
double volume();
}

class Circle implements ShapeInterface {

private double radius;

public Circle Circle(double radius) {
this.radius = radius;
}

@override
public double area() {
return Math.PI * Math.sqrt(this.radius);
}

private double getRadius() {
return this.radius;
}
}

class Square implements ShapeInterface {

private double length;

public Square(double length) {
this.length = length;
}

@override
public double area() {
return Math.sqrt(this.length);
}

public double getLength() {
return this.length;
}
}

class Cube implements ShapeInterface, SolidShapeInterface {

private double length;

public Cube(double length) {
this.length = length;
}

@override
public double area() {
return 6 * Math.sqrt(this.length);
}

@override
public double volume() {
return Math.pow(this.length, 3);
}

public double getLength() {
return this.length;
}
}

5. D – Dependency Inversion Principle 依赖倒置原则

一个方法应该遵从“依赖于抽象而不是一个实例”

高层模块不应该依赖低层模块,两者都应该依赖其抽象;

抽象不应该依赖细节;

细节应该依赖抽象。

抽象:即抽象类或接口,两者是不能够实例化的。

细节:即具体的实现类,实现接口或者继承抽象类所产生的类,两者可以通过关键字new直接被实例化。

类A直接依赖于类B,假如要将类A修改为依赖类C,则必须通过修改类A的代码来达成。

这种场景下,类A一般是高层模块,负责复杂的业务逻辑。

类B和C是底层模块,负责基本的原子操作。

假如修改类A,将会给程序带来不必要的风险。

而遵循依赖倒置原则的程序设计可以解决这一问题。

下面以代码示例说明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//具体Jim人类
public class Jim {
public void eat(Apple apple){
System.out.println("Jim eat " + apple.getName());
}
}
//具体苹果类
public class Apple {
public String getName(){
return "apple";
}
}
public class Client {
public static void main(String[] args) {
Jim jim = new Jim();
Apple apple = new Apple();
jim.eat(apple);
}
}

上面代码看起来比较简单,但其实是一个非常脆弱的设计。现在Jim可以吃苹果了,但是不能只吃苹果而不吃别的水果啊,这样下去肯定会造成营养失衡。现在想让Jim吃香蕉了(好像香蕉里含钾元素比较多,吃点比较有益),突然发现Jim是吃不了香蕉的,那怎么办呢?看来只有修改代码了啊,由于上面代码中Jim类依赖于Apple类,所以导致不得不去改动Jim类里面的代码。那如果下次Jim又要吃别的水果了呢?继续修改代码?这种处理方式显然是不可取的,频繁修改会带来很大的系统风险,改着改着可能就发现Jim不会吃水果了。

上面的代码之所以会出现上述难堪的问题,就是因为Jim类依赖于Apple类,两者是紧耦合的关系,其导致的结果就是系统的可维护性大大降低。要增加香蕉类却要去修改Jim类代码,这是不可忍受的,你改你的代码为什么要动我的啊,显然Jim不乐意了。我们常说要设计一个健壮稳定的系统,而这里只是增加了一个香蕉类,就要去修改Jim类,健壮和稳定还从何谈起。

而根据依赖倒置原则,我们可以对上述代码做些修改,提取抽象的部分。首先我们提取出两个接口:People和Fruit,都提供各自必需的抽象方法,这样以后无论是增加Jim人类,还是增加Apple、Banana等各种水果,都只需要增加自己的实现类就可以了。由于遵循依赖倒置原则,只依赖于抽象,而不依赖于细节,所以增加类无需修改其他类。

代码如下:

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
29
30
31
32
33
34
35
//人接口
public interface People {
public void eat(Fruit fruit);//人都有吃的方法,不然都饿死了
}
//水果接口
public interface Fruit {
public String getName();//水果都是有名字的
}
//具体Jim人类
public class Jim implements People{
public void eat(Fruit fruit){
System.out.println("Jim eat " + fruit.getName());
}
}
//具体苹果类
public class Apple implements Fruit{
public String getName(){
return "apple";
}
}
//具体香蕉类
public class Banana implements Fruit{
public String getName(){
return "banana";
}
}
public class Client {
public static void main(String[] args) {
People jim = new Jim();
Fruit apple = new Apple();
Fruit Banana = new Banana();//这里符合了里氏替换原则
jim.eat(apple);
jim.eat(Banana);
}
}
  • People类是复杂的业务逻辑,属于高层模块,而Fruit是原子模块,属于低层模块。People依赖于抽象的Fruit接口,这就做到了:高层模块不应该依赖低层模块,两者都应该依赖于抽象(抽象类或接口)。
  • Pople和Fruit接口与各自的实现类没有关系,增加实现类不会影响接口,这就做到了:抽象(抽象类或接口)不应该依赖于细节(具体实现类)。
  • Jim、Apple、Banana实现类都要去实现各自的接口所定义的抽象方法,所以是依赖于接口的。这就做到了:细节(具体实现类)应该依赖抽象。
    通过上面的代码段我们可以看到,高级和低级模块都取决于抽象。

到了这里,我们对依赖倒置原则的“依赖”就很好理解了,但是什么是“倒置”呢。是这样子的,刚开始按照正常人的一般思维方式,我想吃香蕉就是吃香蕉,想吃苹果就吃苹果,编程也是这样,都是按照面向实现的思维方式来设计。而现在要倒置思维,提取公共的抽象,面向接口(抽象类)编程。不再依赖于具体实现了,而是依赖于接口或抽象类,这就是依赖的思维方式“倒置”了。

依赖倒置常用的三种实现方式:

  1. 依赖接口作为参数使用
  2. 依赖接口通过构造函数传递
  3. 依赖接口通过setter方法传递

总结

面向对象设计的这五条基本原则是设计模式和重构的基础。
只有充分理解这五条原则才能更好的理解设计模式的思想,更好的进行代码的重构。