《Agile Principles, Patterns, and Practices in C#》
by Micah Martin; Robert C. Martin
前段时间听同事说,传言面试者如果知道SOLID五大原则,工资可以翻一倍。我赶紧跑去查了查这五大原则,决定背下来!哈哈,开个玩笑,其实很多面试官可能自己都不知道这五大原则,而且即使知道这些原则,在开发过程中严格按照这些原则写的人就更少了。我只觉得这个传言说明这五大原则很重要,它们是我们能够写出更clean更易扩展和更易维护的代码的基础。
废话少说,先列出这五大原则:
S:The Single-Responsibility Principle (SRP)
O:The Open/Closed Principle (OCP)
L:The Liskov Substitution Principle (LSP)
I:The Interface Segregation Principle (ISP)
D:The Dependency-Inversion Principle (DIP)
单一职责原则 The Single-Responsibility Principle (SRP)
The Single-Responsibility Principle: A class should have only one reason to change.
单一职责原则,就是说一个类仅有一个引起它变化的原因。虽然这一原则明确是在说类的设计,但是实际中在一个模块或者一个方法上同样适用。
例如,我们有一个Rectangle
类,它有两个方法,其中一个是把矩形画在屏幕上,另一个则是计算它的面积。有两个不同的应用会用到Rectangle
类,其中一个是用来做几何计算的,它需要知道矩形的面积,但是不会需要画出它。另一个应用则是绘图,它需要把矩形画出来。那么上面设计的Rectangle
类就违反了单一职责的原则(Violates SRP)。
违反这一原则会有什么问题呢?第一,单纯做计算的应用程序并没有用到任何GUI
的东西,但是由于它用到了Rectangle
,所以需要引用GUI
。既然被引用了,那么GUI
就需要跟随计算应用程序被编译和部署。第二,如果绘图应用程序的变化需要引起Rectangle
的改变,那么计算应用程序也需要重新编译、测试和部署,否则可能引起不可预测的问题。
也有人把它解释为只做一件事情,虽然也说得通,但是这并不是作者的本意。这里的职责并不是负责的事情,而是‘A reason for change’。
An axis of change is an axis of change only if the changes occur. It is not wise to apply SRP—or any other principle, for that matter—if there is no symptom。也就是说不要过度解读这个原则,一个类也可以做不止一件事情,只要让它改变的原因只有一个就好。
例如,下面是Modem
接口,它是否违反SRP就看你怎么用它了。
public interface Modem
{
public void Dial(string pno);
public void Hangup();
public void Send(char c);
public char Recv();
}
如果从所做的事情来看的话,这里Modem
做了两件事:1)管理连接,Dial
和Hangup
方法;2)数据通讯,Send
和Recv
方法。
但是这两个职责应该被分到两个类中吗?那就取决于应用系统需要如何改变了。如果应用系统不会对这两个职责做不同的改变,那也就不需要对它们进行拆分了。
开放闭合原则 The Open/Closed Principle (OCP)
The Open/Closed Principle (OCP): Software entities (classes, modules, functions, etc.) should be open for extension but closed for modification.
所有的系统在其生命周期里都会改变。如果我们期望自己的系统能够活过第一个版本,那么开发的时候一定要谨记这句话。需求会变是正常的,好的系统不会拒绝变化,只会需要添加code或者修改很少的code就能支持这些变化。
开放闭合原则同样适用于类、模块和方法等,它强调对扩展开放,对修改闭合。看起来说了两点实际上就是一点:为了适应新的需求,尽量不要修改原始代码,而是扩展原有的代码。
要遵循这一原则,最好的办法就是抽象(abstract),最常用的设计模式方法有策略模式(Strategy)和模板方法(Template Method)。例如,Client
类依赖于Server
类,但是一旦将来换一个Server
,则Client
里面涉及到Server
的地方全部要改变。这个时候就可以用到Strategy
。抽象一个ClientInterface
,Client
依赖于这个抽象,对于这个抽象的实现则可以是不同的Server
对应不同的策略。与Strategy
类似,Template Method
也是需要一个抽象,然后在这个抽象的基础上派生出一些类来做具体的实现。不同的是模板方法在这个抽象中定义了一个抽象方法和一个模板业务方法,模板业务方法会调用抽象方法。抽象方法的具体实现在继承这个抽象类的具体子类中。
下面用一个常用于说明多态的Shape
来举例说明OCP。
我们目前有两种类型的Shape
,分别是Circle
和Square
,现在需要一个方法来画出所有的图形。下面是一个违反OCP的实现。
//shape.h
enum ShapeType {circle, square};
struct Shape
{
ShapeType itsType;
};
//circle.h
struct Circle
{
ShapeType itsType;
double itsRadius;
Point itsCenter;
};
void DrawCircle(struct Circle*);
//square.h
struct Square
{
ShapeType itsType;
double itsSide;
Point itsTopLeft;
};
void DrawSquare(struct Square*);
//drawAllShapes.cc
typedef struct Shape *ShapePointer;
void DrawAllShapes(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;
}
}
}
为什么说它违反了OCP呢?因为如果我们要再添加一个新的Shape
,则需要修改DrawAllShapes
和ShapeType
,以及重新编译所有引用ShapeType
的地方。如果我们按照上述的抽象思想去写Shape
,那么可以做到新增一种Shape
时只需要新加一个Shape
的派生类即可。
public interface Shape
{
void Draw();
}
public class Square : Shape
{
public void Draw()
{
//draw a square
}
}
public class Circle : Shape
{
public void Draw()
{
//draw a circle
}
}
public void DrawAllShapes(IList shapes)
{
foreach(Shape shape in shapes)
shape.Draw();
}
There is no model that is natural to all contexts!当然,也会有一些新的需求对上述系统依旧需要做一些修改,比如说,我们现在要求在输出所有的Shape
时按照先Square
再Circle
的顺序画出来。Robert C. Martin很喜欢一句话,“Fool me once, shame on you. Fool me twice, shame on me.”。面对一些我们不得不对老的code做出一些修改的时候,我们除了痛骂怎么会有这么变态的需求的同时,可以多想想,如果再来一些这么变态的需求,我现在的这种修改方式可以支持吗?
比如说,对于上面的例子,我们现在要求先画Square
再画Circle
。你可能觉得这个修改很简单啊,我在DrawAllShapes
里面先拿出Square
来绘制,再来画Circle
就好了。这就大错特错了,又违反了OCP。这样如果再加入其它的Shape
的话,你还是得修改DrawAllShapes
。要解决这个问题,还是得抽象。先画A再画B,不就是顺序的问题吗?那我们可以让Shape
实现IComparable
,这样画图的时候先对这些图形进行排序不就可以了吗?
public interface Shape : IComparable
{
void Draw();
}
public void DrawAllShapes(ArrayList shapes)
{
shapes.Sort();
foreach(Shape shape in shapes)
shape.Draw();
}
不幸的是,这种方法依然违反了OCP,因为在每个Shape
的派生类中都要实现CompareTo
,而CompareTo
肯定需要知道所有的其他子类。
public class Circle : Shape
{
public int CompareTo(object o)
{
if(o is Square)
return -1;
else
return 0;
}
}
那么,我们就要从Data-Driven Approach的角度来考虑这个问题了,对于这个例子,我们可以用一个table-driven的方法来处理。我们用一个额外的类来实现Shape
之间的比较,而只有这里会需要一个Shape
所有派生类的列表。这样新增一个派生类就只需要修改这个列表。另外,为了让增加Shape
的派生类不需要重新编译旧的代码,我们可以把ShapeComparer
类与Shape
相关的模块分离。
public class ShapeComparer : IComparer
{
private static Hashtable priorities = new Hashtable();
static ShapeComparer()
{
priorities.Add(typeof(Circle), 1);
priorities.Add(typeof(Square), 2);
}
private int PriorityFor(Type type)
{
if(priorities.Contains(type))
return (int)priorities[type];
else
return 0;
}
public int Compare(object o1, object o2)
{
int priority1 = PriorityFor(o1.GetType());
int priority2 = PriorityFor(o2.GetType());
return priority1.CompareTo(priority2);
}
}
里氏替换原则 The Liskov Substitution Principle (LSP)
The Liskov Substitution Principle: Subtypes must be substitutable for their base types.
里氏替换原则的内容是,子类型必须能够替换它的基类型。OCP的实现机制是抽象和多态,而它们的关键是继承。LSP所强调的就是继承的实现规则。
首先,看一下违反LSP会怎样?如果违反LSP,类继承就会混乱,如果子类作为一个参数传递给参数为基类的方法,将会出现未知行为;如果违反LSP,适用于基类的单元测试将不能成功用于测试子类。
假如说我们不能保证LSP,即子类不一定能够替换它的基类,那么我们来看看上一节中关于Shape
的例子。如果不保证LSP,那么DrawAllShapes
就得按照下面的方式写,这样就违反了OCP。所以说,A violation of LSP is a latent violation of OCP。
public static void DrawAllShapes(Shape s)
{
if(s.type == ShapeType.square)
(s as Square).Draw();
else if(s.type == ShapeType.circle)
(s as Circle).Draw();
}
然而,现实中很多违反LSP的情况并不像上例这么明显。比如说,我们有一个基类是Rectangle
,我们在它的基础上派生出Square
。一般说派生类满足IS-A(Square is a rectangle)就可以,理论上看也确实满足。但是Rectangle
类中有height
和width
,Square
类中要求它们必须相等,那么我们可以在Square
中对它们的赋值操作进行重写,即任何对height
和width
的set
操作都会设置它们俩为同一个值。但是如果用户有一个下面这样的方法来使用Rectangle
,则这里的设计就违反了LSP。
void g(Rectangle r)
{
r.Width = 5;
r.Height = 4;
if(r.Area() != 20)
throw new Exception("Bad area!");
}
接口分离原则 The Interface Segregation Principle (ISP)
The Interface Segregation Principle: Clients should not be forced to depend on methods they do not use.
接口分离原则指出,客户端不应该被迫依赖于它不会用到的方法。
我曾经遇到过这样一个应用场景,在类P1
,P2
和P3
中都需要一些config,这些config需要一些其他的操作来获取,而原本这些config的获取散落在P1,P2和P3处理逻辑中间。这导致对获取config相关的代码改动时会影响到毫不相关的处理逻辑。所以,我们希望把这些config decouple出来,让P1
,P2
和P3
依赖于一个抽象的config,而具体获取config的方法则实现在这个抽象的派生类中。这是下面我们会介绍的另一个原则DIP,但是我要说的是,我当时就试图写一个IConfigure
把P1
,P2
和P3
中用到的接口全部定义了,然后再对应的去写几个Configure
类来实现IConfigure
的一部分。当时只觉得这样写就可以只写一个接口了,可是它却需要变得非常“胖”,很显然违背了ISP。
下面来看一个如何实现接口分离的例子。
假如我们原本有一个Door
接口,其中提供了Lock()
、Unlock()
和IsOpen()
方法。现在我们要新写一个TimedDoor
类,当门打开的时间超过一定的限制就要报警。那么我们可以写一个TimeClient
接口来定义TimeOut
的机制,然后为了让TimedDoor
继承TimeClient
,我们让它的基类Door
直接引用TimeClient
,如下图1所示。它显然违反了ISP,因为并不是所有的Door
都会需要TimeClient
。
一种解决方案是,在TimedDoor
和TimeClient
之间提供一个Adapter,这样TimedDoor
用委托的方式去调用TimeClient
,而不需要在它的基类Door
上去显示引用TimeClient
。(如图2所示)
另一种解决方案,则是直接让TimedDoor
实现多个接口。(如下图3所示)
依赖倒置原则 The Dependency-Inversion Principle (DIP)
The Dependency-Inversion Principle: A). High-level modules should not depend on low-level modules. Both should depend on abstractions. B). Abstractions should not depend upon details. Details should depend upon abstractions.
依赖倒置原则说的是, 高层模块不应该依赖低层模块,两者都应该依赖其抽象;抽象不应该依赖细节,细节应该依赖于抽象。
为什么要考虑依赖倒置呢?如果我们违反了这一原则,修改低层模块会影响高层模块,甚至迫使高层模块(policy decisions and business models)做出相应的修改。另外,我们在低层模块上的复用已经做得很好了,但是若高层模块依赖于低层模块,那么高层模块是很难复用的。
我们来看看下面这个例子。如图4所示,high-level的Policy
层依赖于low-level的Mechanism
层,而low-level的Mechanism
层又依赖于detailed-level的Utility
层。这样由于依赖的传递性,就导致了Policy
层依赖于Utility
层。
为了去除这种依赖,我们可以在两层之间引入一个抽象接口,如下图5所示。这样Policy
层不再依赖于Mechanism
层,而是依赖于PolicyServiceInterface
。Mechanism
层会实现PolicyServiceInterface
,那这会不会导致Mechanism
层需要依赖Policy
层呢?其实不然,PolicyServiceInterface
虽然在图中放在了Policy
层,但是它是一个独立的接口,不涉及任何细节,我们甚至可以把它单独放进一个package里面。
下面再来看一个DIP的例子。
我们有Button
类和Lamp
类,其中Button
感知外部环境,收到Pull
消息后决定用户是否按了这个按钮;而Lamp
则用于影响外部环境,它会接收Turn On/Off
消息,根据消息来决定是开灯还是关灯。
下面是一个违反DIP的写法,这里直接让Button
依赖于Lamp
。这样做的坏处就是Lamp
如果有改变,这个Button
类也得跟着改变,而且这个Button
根本没法重用,它只能用来控制Lamp
的开关。
public class Button
{
private Lamp lamp;
public void Poll()
{
if (/*some condition*/)
lamp.TurnOn();
else lamp.TurnOff();
}
}
对于这个例子,我们首先需要找到其中的抽象。这里的抽象就是根据用户的动作来决定状态on/off。它可以既与Button
无关,又与Lamp
无关。所以我们可以定义一个ButtonServer
(或者考虑到要与Button无关,叫做SwitchableDevice
)的接口,这个接口可以让Button
控制来开关某个东西。然后让Lamp
实现这个接口,Button
就可以用来控制这个灯了,如果再换成一个东西实现ButtonServer
,Button
依然适用。
好啦!五大原则我就讲完了,工资能不能翻倍就看你们自己了!值得一提的是,光背住上面的条款可没有用哦,只有真正理解了这样设计带来的好处,或者说只有在实践中违反了上述原则并付出了惨痛的代价,才会对上述原则有深刻的体会。Anyway,首先记住这几大原则,写代码和review别人的代码时多想想我们有没有违反这些原则,你一定会有收获的!