设计中的拙劣设计症状:
- 僵硬性(Rigidity):很难对系统进行改动,因为每个改动会迫使很多对系统其他部分的其他改动。
- 脆弱性(Fragility): 对系统的改动会导致系统中和改动地方在概念上无关的许多地方出现问题。
- 牢固性(Immobility): 很难解开系统的纠结,让其成为在其他系统中重用的组件
- 粘滞性(Viscosity):做正确的事情比做错误的事情困难
- 不必要的复杂性(Needless Complexity):设计中包含不具有任何直接好处的设计
- 不必要的重复(Needless Repetition): 设计中包含重复结构,而重复的结构本可以使用单一的抽象进行统一
- 晦涩性(Opacity):很难阅读、理解,没有很好地表现出意图。
相应的为了防止这些不好的设计,有如下原则需要遵守,即 SOLID:
- 单一职责原则(The Single Responsibility Principle, SRP)
- 开放封闭原则(The Open-Close Principle, OCP)
- Liskov 替换原则(The Liskov Substitution Principle, LSP)
- 接口隔离原则(The Interface Segregation Principle, ISP)
- 依赖倒置原则(The Dependency Inversion Principle, DIP)
我们不能因为设计的退化而责备需求的变化,作为开发人员大多数人都意识到需求是项目中最不稳定的要素,如果我们的设计由于持续、大量的需求变化而失败,那就表明是我们的设计和实现本身是有缺陷的。
书中举了一个很有代表性的例子,你的老板需要你编写一个从键盘输入字符并输出到打印机的程序。正常的设计就是一下三块:
Read KeyBoard -> Copy -> Write Printer
于是写下如下的实现:
void Copy() {
int c;
while((c=RdKbd())!=EOF)
WrtPrt(c);
}
这里运行 OK,开始应用到系统中,几个月后,老板说希望 Copy 程序能从纸带读入信息,这时你的修改方案可能是在 Copy 中添加一个 bool 变量,值为 true 就从纸带读,false 就从键盘读,但是现在改接口不太现实,不用该接口的方式只能增加一个全局变量,然后使用 ? : 操作符。
bool ptFlag = false;
// remeber to reset this flag
void Copy() {
int c;
while((c=( ptFlag ? Rdpt() : RdKbd())) != EOF)
WrtPrt(c);
}
这样项使用纸带读入必须 ptFlag 是 true,并且函数返回以后,还需要重置 ptFlag 成默认的 false,因此你还贴心的加了一行注释来提醒。
几周之后你的老板告诉你,客户有的时候会虚妄 Copy 程序可以输出到纸带穿孔机上。这次的修改跟上次相似,只不过需要另外的一个全局变量和 ? : 操作符。
bool ptFlag = false;
bool punchFlag = false
// remeber to reset these flags
void Copy() {
int c;
while((c=( ptFlag ? Rdpt() : RdKbd())) != EOF)
punchFlag ? WrtPunch(c) : WrtPrt(c);
}
可以看到经过两次需求的变更,上面的代码就表现出了拙劣设计的特征,每次输入设备的变更,都要对while 的循环条件判断进行彻底的重新组织。
在使用敏捷开发方法,初始实现都是相同的简单,但在老板第一次从纸带读入机中读取信息时,他们就会做出下面的反应,修改设计并使修改后的设计对于那一类需求的变化具有弹性。
class Reader {
public:
virtual int read() = 0;
};
class KeyboardReader : public Reader {
public:
virtual int read() { return RdKbd(); }
}
KeyboardReader GdefaultReader;
void Copy(reader& reader = GdefaultReader) {
int c;
while((c=reader.read())!=EOF)
WrtPrt(c);
}
这样在实现新需求时,抓住这次机会去改进设计,以便设计对于将来的同类变化具有弹性,而不是设计去给设计打补丁,以上的代码,不论老板要求一种新的输入设备,团队都可以能以不导致 Copy 程序退化的方式做出反应。以上是遵循了开放-封闭原则。
敏捷开发人员知道要做什么,是因为:
(1)他们遵循敏捷实践去发现问题;
(2)他们应用设计原则去诊断问题;并且
(3)他们应用适当的设计模式去解决问题。
单一职责原则(SRP)
内聚性(cohesion):一个模块的组成元素之间的功能相关性
对一个类而言,应该仅有一个引起它变化的原因。
当一个类承担的职责过多,就等于把这些职责耦合在了一起,会导致代码脆弱。
例如下面的 case :
class Modem {
public:
void dial(String pno);
void hangup();
void send(char c);
void recv();
}
上面的类就有两个职责,第一个是负责连接管理, dial 和 hangup 函数进行调制解调器的连接处理,send 和 recv 则进行数据通信。可以使用一些设计模式进行重构。
开放-封闭原则(OCP)
软件实体(类、模块、函数等)应该是可以扩展的,但是不可修改的。
主要是此模式对于扩展是开放的,对于更改是封闭的。
这个准则的关键就是抽象。模块可以操作一个抽象体,其依赖一个固定的抽象体,它对于更改是可以关闭的,同时通过抽象的派生,对扩展是开放的。
示例 demo:
enum ShapeType{ circle, square};
struct Shape {
ShapeType itsType;
};
struct Circle {
ShapeType itsType;
double itsRadius;
Point itsCenter;
};
struct Square {
ShapeType itsType;
double itsSide;
Point itsTopLeft;
};
typedef struct Shape *ShapePointer;
void DrawAllShape(ShapePointer list[], int n) {
int i;
for(i = 0; i<n;i++) {
struct Shape* s = list[i];
switch (s->itsType) {
case square: DrawSquare((struct Square*)s); break;
case circle: DrawCircle((struct Circle*)s); break;
}
}
}
上面的代码就不符合开放封闭原则,对于新的形状类型的添加不是封闭的,如果继续添加一个三角形绘制,就必须要改这个函数。并且也需要修改 ShapeType enum,因为所有形状都依赖这个 enum 声明,一旦改动,所有形状模块都需要重新编译。并且想在另一个程序中复用 DrawAllShape 还必须带上 Shape 类和 Circle 类,即使那个新程序不需要它们。
遵循 OCP 的改动如下:
class Shape {
public:
virtual void Draw() const = 0;
}
class Square : public Shape {
public:
virtual void Draw() const;
}
class Circle : public Shape {
public:
virtual coid Draw() const;
}
void DrawAllShape(vector<Shape*> &list){
vector<Shape*>::iterator I;
for(auto i=list.begin();i!=list.end();++i)
(*i)->Draw();
}
以上是对于类型的变化封闭了,但是现在变化要求所有圆必须在方块之前绘制,那么这里 DrawAllShape 就没办法对变化封闭了,所以无论模块有多”封闭“,都会存在一些无法封闭的变化,因此这个就要设计人员对他设计的模块哪个变化封闭做出选择了。
Liskov 替换原则(LSP)
子类型必须能够代替掉它们的基类型。
如果违反了 LSP,往往也会潜在地违反了 OCP。
接口隔离原则(ISP)
不应该强迫客户依赖于它们不用的方法。
即现在有基类A和子类B和子类C,现在因为需求变化, B 中需要增加一个接口,为了给子类B用,就要在基类A 中加入这个新方法,同时这个新方法也会被引入到 C 中,这样随着子类的需求要不断被加入到基类的接口,使接原来越臃肿。
这个时候我们就应该使用委托或者多重继承来分离接口。
依赖倒置原则(DIP)
高层模块不应该依赖于底层模块,二者都应该依赖于抽象。抽象不应该依赖于细节,细节应该依赖于抽象。
假如现在有一个简答的系统,一个 Button对象通过Poll消息来获取是否被按下,Lamp 对象则接收到 turn on 的消息就开灯,turn off的消息就关灯。如何设置一个用 Button 来控制 Lamp 对象的系统呢?
class Button {
public:
void poll(){
if(/*some condition*/)
itsLamp.turnOn();
}
private:
Lamp itsLamp;
}
上面是 Button 类直接依赖了 Lamp 类,这个依赖关系表示当 Lamp 类改变时,Button 类会收到影响,并且要通过 Button 来控制一个 Motor 对象是不可能的。上面的方案就违反了 DIP ,应用程序的高层策略没有个底层实现分离,高层策略就自动地依赖了低层模块,抽象就自动地依赖了具体细节。
高层策略,即应用背后的抽象,是那些不随具体细节变化而变化的真理。本例中背后的抽象是检测用户的开/关指令并将指令传给目标对象,用什么检测用户指令,传给的目标对象是什么都不重要。