最近团队在学习Agile 和 Clean Code。然后对面向对象设计的一些原则进行了一些学习和整理。包括SOLID、合成复用原则与迪米特法则。
Robert C.Martin认为一个可维护性较低
的软件设计,通常由于如下四个原因造成:
• 过于僵硬(Rigidity)
• 过于脆弱(Fragility)
• 复用率低(Immobility)
• 黏度过高(Viscosity)
Peter Coad认为,一个好的系统
设计应该具备如下三个性质:
• 可扩展性(Extensibility)
• 灵活性(Flexibility)
• 可插入性(Pluggability)
先看下设计原则,这里我列举了常说的 SOLID 和 另外两大原则。
设计原则名称 | 简介 | 出处 |
单一职责原则 (Single Responsibility Principle, SRP) |
就一个类而言,应该仅有一个引起它变化的原因。 | Robert C. Martin《敏捷软件开发:原则、模式与实践》第八章 |
开闭原则 (Open-Closed Principle, OCP) |
软件实体对扩展是开放的,但对修改是关闭的。即在不修改一个软件实体的基础上去扩展其功能。 | Bertrand Meyer《面向对象软件构造(Object Oriented Software Construction)》 |
里氏代换原则 (Liskov Substitution Principle, LSP) |
Subtypes must be substitutable for their base types。 子类必须能够替换成它们的基类。 |
Liskov女士《Data Abstraction and Hierarchy》 |
依赖倒转原则 (Dependency Inversion Principle, DIP) |
A. High-level modules should not depend on low-level modules. Both should depend on abstractions. B. Abstractions should not depend on details. Details should depend on abstractions. A.高层次的模块不应该依赖于低层次的模块,他们都应该依赖于抽象。 B.抽象不应该依赖于具体实现,具体实现应该依赖于抽象。 作用:降低了客户与实现模块间的耦合 |
《敏捷软件开发—原则、模式与实践》第十一章 |
接口隔离原则 (Interface Segregation Principle, ISP) |
使用多个专门的接口来取代一个统一的接口。 | 《ISP: The Interface Segregation Principle》 |
合成复用原则 (Composite Reuse Principle, CRP) |
就是在一个新的对象里面使用一些已有的对象,使之成为新对象的一部分;新的对象通过向这些对象的委派达到复用已有功能的目的。 简单就是:要尽量使用组合,尽量不要使用继承。 | |
迪米特法则 (Law of Demeter, LoD) |
又叫作最少知识原则(Least Knowledge Principle 简写LKP),就是说一个对象应当对其他对象有尽可能少的了解,不和陌生人说话。 | 《The Pragmatic Programmer》即《程序员修炼之道:从小工到专家》 |
所谓职责,即“变化的原因”。如果你能够想到多于一个的动机去改变一个类,那么这个类就具有多于一个的职责。就要拆分这个类。而拆分后的类内聚性提高。
相信你也看到了,类的拆分会导致产生大量短小的类。不过软件开发中有个说法:系统应该由许多短小的类而不是少量巨大的类组成。因此,个人觉得这不是个缺点
该原则要求,软件实现应该对扩展开放,对修改关闭。意思就是说一个软件实体应该通过扩展来实现变化,而不是通过修改已有的代码来实现变化的。
举个例子:
打字员利用打印机打印文档,即可以打印黑白色的,也需要打印彩色的。
之后如果系统增加一种新的打印机:UniversalPrinter。此时就对系统有不小的修改。
相反,如果采用下面的设计,Typist 依赖抽象类 Printer。这样有需求增加变化时,只需要增加一个子类即可。
可能你也发现了,实现开闭原则的关键就是“抽象”。把可能的行为(这里是print)抽象成一个抽象层。之后的扩展都是对这个抽象的实现。
里氏代换原则是对开闭原则的补充,它讲的是基类和子类的关系。
“鸵鸟不是鸟",”正方形是长方形"都是理解里氏代换原则的最经典的例子。小学数学的时候就知道,正方形是长方形,即一个长宽相等的长方形。由此,应该让正方形继承自长方形。
此时代码如下:
public class Rectangle {
private int height;
private int width;
// 省略getter setter
}
要保证,正方形长和宽始终一样,要覆写两个setter:
public class Square extends Rectangle {
@Override
public void setWidth(int width) {
super.setWidth(width);
super.setHeight(width);
}
@Override
public void setHeight(int height) {
super.setWidth(height);
super.setHeight(height);
}
}
两个类在以下代码中,表现不一样:
public class TestSquare {
public static void main(String[] args) {
TestSquare test = new TestSquare();
Rectangle rectangle = new Rectangle();
rectangle.setHeight(5);
rectangle.setWidth(4);
test.zoom(rectangle, 2, 3);
Square square = new Square();
square.setHeight(5);
square.setWidth(4);
test.zoom(square, 2, 3);
}
public void zoom(Rectangle rectangle, int width, int height) {
rectangle.setWidth(rectangle.getWidth() + width);
rectangle.setHeight(rectangle.getHeight() + height);
}
}
所谓依赖倒转(Dependency Inversion Principle)有两条:
A.高层次的模块不应该依赖于低层次的模块,他们都应该依赖于抽象。
B.抽象不应该依赖于具体实现,具体实现应该依赖于抽象。
看下图:
在这个图中,从上到下的依赖是传递的,因此底层的任何修改都会影响到上层。
再看下面这个图:
这里,上层和下层都依赖抽象层。抽象层是稳定的,它的存在屏蔽了实现层修改带来的影响。
客户端不应该依赖它不需要的接口;一个类对另一个类的依赖应该建立在最小的接口上。
下面类图中,三个客户端依赖一个大的接口 AbstractService。事实上,对ClientA 来说, operationB、operationC 都是不需要的。
利用接口隔离原则,将大的接口进行拆分:
这样对每个客户端就隐藏了它不需要的功能。
这个原则很简单:多用组合,少用继承。
我们知道,继承是面向对象三大特征之一:封装、继承和多态。而且继承实现简单,易于扩展。
不过继承是有缺陷的:
1.父类变,子类就必须变。
2.继承破坏了封装,对父类而言,它的实现细节对子类来说都是透明的。
3.继承是一种强耦合关系。
1.下边类图,这里,麻雀、鸽子、鸭子都继承类-鸟,都拥有 鸣叫 方法。然后为 父类加 “飞”时,子类“鸭子”却不需要这个方法。
2. 继承破坏了封装。网上有很多这样说的,但又不解释啥意思。查了很多资料,发现是这个意思。
对于子类来说,父类方法的细节是透明的,也就是不可见的。子类不知道里面的内容,但是当父类修改了自己的方法时,子类方法就会受影响发生变化。子类方法本来是封装的,但是父类的改变了这点。即父类破坏了子类的封装。
那继承如何破坏封装的呢?看下面代码,来源于《Java编程的逻辑》
public class Base {
private static final int MAX_NUM = 1000;
private int[] arr = new int[MAX_NUM];
private int count;
public void add(int number) {
if (count < MAX_NUM) {
arr[count++] = number;
}
}
public void addAll(int[] numbers) {
for (int num : numbers) {
add(num);
}
}
}
public class Child extends Base {
private long sum;
@Override
public void add(int number) {
super.add(number);
sum += number;
}
@Override
public void addAll(int[] numbers) {
super.addAll(numbers);
for (int i = 0; i < numbers.length; i++) {
sum += numbers[i];
}
}
public long getSum() {
return sum;
}
public static void main(String[] args) {
Child c = new Child();
c.addAll(new int[] { 1, 2, 3 });
System.out.println(c.getSum());
}
}
addAll 接受参数1、2、3,期望输出是6。可是上边的输出是12!。代码里父类和子类共计算了两次。可以看出,如果子类不知道父类方法实现的细节,他就不能正确的扩展。
修改父类:
public void addAll(int[] numbers) {
for (int num : numbers) {
if (count < MAX_NUM) {
arr[count++] = num;
}
}
}
这里修改了父类的addAll 方法,但是子类再次运行为 0。出错了!
总结下来:
1.对于子类而言,通过继承实现是没有安全保障的。因为父类修改的内部实现细节,子类的功能就可能被破坏。
2.对于父类而言,有子类继承和重写它的方法时,父类的方法就不能任意修改。
即最少知道原则。在《代码整洁之道》中翻译为得墨忒耳法则。设计模式中的外观模式(Facade)和中介模式(Mediator),都是迪米特法则应用的例子。
得墨忒耳法则认为,类C的方法f 只应该调用以下对象的方法:
1. C
2. 由f 创建的对象
3. 作为参数传递给f的对象
4. 由C的实体遍历持有的对象
简单来说:只与朋友谈话,不与陌生人谈话。
从这点来讲,下面我们常用的这行代码就违反了这个规则:
System.out.println("Hello world");
这是外观模式的类图,Facade 提供统一的接口,Client只与 Facade 进行通信,与子系统之间的耦合很低。
1.在类的划分上,应该创建有弱耦合的类
1.在类的结构设计上,每一个类都应当尽量降低成员的访问权限
3.在类的设计上,只要有可能,一个类应当设计成不变类
4.在对其他类的引用上,一个对象对其它对象的引用应当降到最低
5.尽量降低类的访问权限
6.不要暴露类成员,而应该提供相应的访问器
缺点:
迪米特法则有个缺点:系统中会产生大量的小方法。