《敏捷软件开发》— 设计原则(一)
一、单一职责原则(SRP)
这项设计原则体现了模块的内聚性要求。当需求变化时,该变化会反映为类的功能的变化。如果一个类承担了多于一个的职责,那么就可能有多个原因导致其发生变化。一个职责的变化可能会抑制这个类完成其他职责的能力。这种耦合会导致脆弱的设计,无法很好的响应变化。
1、职责
在 SRP 中,我们把职责定义为变化的原因。如果能想到多于一个的动机去改变一个类,那么这个类就是具有多于一个的职责。有时,我们很难注意到这一点。我们习惯于以组的形式去考虑职责。例如,考虑下面 Modem 接口:
interface Modem {
public void dial(String pno);
public void hangup();
public void send(char c);
public void recv();
}
初始看来,这个接口很合理。然而, 它实际承担了两个职责:连接和通信。那么这两个职责应该被分开吗?这依赖于需求的变化。如果二者的变化总是独立的,例如经常需要在保持通信方式的情况下,修改连接接口,那么这两种职责就应该被分离;另一方面,如果它们总是成对地发生变化,那么就不需要分离它们。
2、分离耦合的职责
二、开放-封闭原则(OCP)
如果程序中的一处改动会产生连锁反应,导致一系列相关模块的改动,那么设计就具有僵化性的臭味。OCP 建议我们应该对系统进行重构,这样以后对系统再进行那样的改动时,就不会导致更多的修改。如果正确使用 OCP, 那么以后再进行同样的改动时,就只需要添加新的代码,而不必改动已经正常运行的代码。
1、抽象
实现 OCP 的基础在于抽象。如果模块 A 依赖于模块 B,而模块 B 的实现又经常发生变化,那么我们可以让 A 仅依赖于 B 的抽象接口类:
这样,当我们新增服务器类型时,不需要修改 Client 类(显然,配置类关系的地方需要修改)。
2、Shape 程序
一个很常见的违反OCP的例子如下:
// Shape.h
enum ShapeType {
e_Circle,
e_Square
};
struct Shape
{
ShapeType shapeType;
};
// Circle.h
struct Circle : public Shape
{
double radius;
Point center;
};
// Square.h
struct Square : public Shape
{
double side;
Point topLeft;
};
// drawAllShapes.cpp
void drawAllShapes(Shape* shapes[], int num)
{
for (int i = 0; i < num; ++i)
{
Shape* shape = shapes[i];
switch (shape->shapeType)
{
case e_Circle:
{
DrawCircle((struct Square*)shape);
}
case e_Square:
{
DrawSquare((struct Square*)shape);
}
}
}
}
上述代码的问题在于它对于类型的增加不是封闭的,我们需要修改实现类中的代码。此外,每增加一种类型,我们就需要修改枚举常量的数目,造成所有的文件都需要重新编译。
针对上述问题,只要我们使用面向对象的继承和多态两大特性就可以解决。
3、识别变化
使用继承和多态真的使上述代码完全封闭了吗?不见得。如果我们要求所有的圆必须在正方形之前绘制,那么程序无法很好地拥抱这种变化。
如果我们预测到了这种变化,那么就可以设计一个抽象来隔离它。我们使用的抽象 Shape 类对新的需求来说,并不是一个合适的抽象。因为新的需求更重视形状的顺序而非形状的类型。
这导致无论模块是多么封闭,总会有一些无法对之封闭的变化。没有对所有的情况都贴切的模型。因此,设计人员必须对于他设计的模块应该对哪种变化封闭做出选择。我们进行适当的调查,提出正确的问题,并且使用我们的经验和一般常识。最终,我们会一直等到变化发生时才采取行动。
4、刺激变化
我们选择在变化发生时才进行抽象。但是这种变化如果出现的很晚,一方面会造成抽象的困难(模块的耦合度可能已经很高了);另一方面将会造成项目的延期或是质量不过关。因此,我们希望能够刺激变化尽早地出现:
- TDD。TDD 实际上是号召我们站在需求提出方的角度思考问题,这会让我们更好地识别变化。
- 在加入基础结构前就开发特性,并经常性地把那些特性展示给涉众。
- 使用较短的迭代和发布周期。
- 根据特性优先级的顺序安排开发顺序。
5、使用抽象获得显式封闭
回到前面所说的图形顺序问题,我们怎样借助抽象封闭变化呢?显然,我们需要某种抽象的排序接口。该抽象接口能够根据对象的类型决定绘制顺序:
// Shape.h
class Shape
{
public:
virtual void draw() = 0;
virtual bool precede(Shape* shape) = 0;
};
// Circle.h
#include "Square.h"
class Circle : public Shape
{
double radius;
Point center;
public:
virtual void draw() override
{
cout << "draw Circle" << endl;
}
virtual bool precede(Shape* shape) override
{
try
{
dynamic_cast<Square*>(shape);
return true;
}
catch (bad_cast& e)
{
return false;
}
}
};
// Square.h
class Square : public Shape
{
double side;
Point topLeft;
public:
virtual void draw() override
{
cout << "draw Square" << endl;
}
virtual bool precede(Shape* shape) override
{
return false;
}
};
// drawAllShapes.cpp
void drawAllShapes(Shape* shapes[], int num)
{
sort(shapes, shapes + 5, [](Shape* left, Shape* right) {return left->precede(right); });
for (int i = 0; i < num; ++i)
{
Shape* shape = shapes[i];
shape->draw();
}
}
虽然我们找到了一个抽象方法,但是显然派生类中实现的 precede 方法并不符合 OCP。它竟然需要知道 Square 类的存在。如果以后再进行类型的扩展和绘制顺序的变更,都将导致前面的具体类需要修改,这又违反了 SRP 原则。此外,Shape 接口承担了两个相关性比较差的责任,不符合接口隔离原则。
针对最后一个问题,我们可以使用类似 java 中的 Comparable 接口解决;针对前两个问题,我们很自然地想到使用表驱动模式来解决。
6、使用数据驱动的方式获得封闭性
首先,我们增加一个比较接口进行接口隔离,同时提供 Shape 的比较接口实现类。其中,shapePrecedes 保存了表驱动的必要数据(如果在 java 中,可以使用匿名内部类来实现 ComparableShape,因为我们已经把变化的部分隔离到 shapePrecedes 的初始化过程中了):
class Comparable
{
virtual bool operator()(T* left, T* right) = 0;
};
multimap<string, string> shapePrecedes;
class ComparableShape : public Comparable<Shape>
{
public:
virtual bool operator()(Shape* left, Shape* right) override
{
auto range = shapePrecedes.equal_range(typeid(*left).name());
if (range.first != range.second)
{
auto iter = find_if(range.first, range.second, [right](auto strIter)
{
return strIter.second == typeid(*right).name();
});
return iter != range.second;
}
return false;
}
};
表格的初始化如下:
void init_shapePrecedes()
{
shapePrecedes.insert(make_pair(typeid(Circle).name(), typeid(Square).name()));
}
实际我们可以通过读取配置文件或数据库等方式来获取该表。
绘图函数的声明如下:
void drawAllShapes(Shape* shapes[], int num, const ComparableShape& comparableShape)
{
sort(shapes, shapes + 5, comparableShape);
for (int i = 0; i < num; ++i)
{
Shape* shape = shapes[i];
shape->draw();
}
}
其余各类和我们没有引入排序需求时一样。这样的实现能够满足我们前面提到的这些设计原则。