SOLID五大设计原则

C#

浏览数:113

2019-8-31

AD:资源代下载服务

Agile Principles, Patterns, and Practices in C#

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)管理连接,DialHangup方法;2)数据通讯,SendRecv方法。
但是这两个职责应该被分到两个类中吗?那就取决于应用系统需要如何改变了。如果应用系统不会对这两个职责做不同的改变,那也就不需要对它们进行拆分了。

开放闭合原则 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。抽象一个ClientInterfaceClient依赖于这个抽象,对于这个抽象的实现则可以是不同的Server对应不同的策略。与Strategy类似,Template Method也是需要一个抽象,然后在这个抽象的基础上派生出一些类来做具体的实现。不同的是模板方法在这个抽象中定义了一个抽象方法和一个模板业务方法,模板业务方法会调用抽象方法。抽象方法的具体实现在继承这个抽象类的具体子类中。

下面用一个常用于说明多态的Shape来举例说明OCP。
我们目前有两种类型的Shape,分别是CircleSquare,现在需要一个方法来画出所有的图形。下面是一个违反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,则需要修改DrawAllShapesShapeType,以及重新编译所有引用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时按照先SquareCircle的顺序画出来。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类中有heightwidthSquare类中要求它们必须相等,那么我们可以在Square中对它们的赋值操作进行重写,即任何对heightwidthset操作都会设置它们俩为同一个值。但是如果用户有一个下面这样的方法来使用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.

接口分离原则指出,客户端不应该被迫依赖于它不会用到的方法。
我曾经遇到过这样一个应用场景,在类P1P2P3中都需要一些config,这些config需要一些其他的操作来获取,而原本这些config的获取散落在P1,P2和P3处理逻辑中间。这导致对获取config相关的代码改动时会影响到毫不相关的处理逻辑。所以,我们希望把这些config decouple出来,让P1P2P3依赖于一个抽象的config,而具体获取config的方法则实现在这个抽象的派生类中。这是下面我们会介绍的另一个原则DIP,但是我要说的是,我当时就试图写一个IConfigureP1P2P3中用到的接口全部定义了,然后再对应的去写几个Configure类来实现IConfigure的一部分。当时只觉得这样写就可以只写一个接口了,可是它却需要变得非常“胖”,很显然违背了ISP。

下面来看一个如何实现接口分离的例子。
假如我们原本有一个Door接口,其中提供了Lock()Unlock()IsOpen()方法。现在我们要新写一个TimedDoor类,当门打开的时间超过一定的限制就要报警。那么我们可以写一个TimeClient接口来定义TimeOut的机制,然后为了让TimedDoor继承TimeClient,我们让它的基类Door直接引用TimeClient,如下图1所示。它显然违反了ISP,因为并不是所有的Door都会需要TimeClient

图1. 违反ISP的TimedDoor

一种解决方案是,在TimedDoorTimeClient之间提供一个Adapter,这样TimedDoor用委托的方式去调用TimeClient,而不需要在它的基类Door上去显示引用TimeClient。(如图2所示)

图2. Separation Through Delegation

另一种解决方案,则是直接让TimedDoor实现多个接口。(如下图3所示)

图3. Separation Through Multiple Inheritance

依赖倒置原则 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层。

图4. Naive layering scheme

为了去除这种依赖,我们可以在两层之间引入一个抽象接口,如下图5所示。这样Policy层不再依赖于Mechanism层,而是依赖于PolicyServiceInterfaceMechanism层会实现PolicyServiceInterface,那这会不会导致Mechanism层需要依赖Policy层呢?其实不然,PolicyServiceInterface虽然在图中放在了Policy层,但是它是一个独立的接口,不涉及任何细节,我们甚至可以把它单独放进一个package里面。

图5. Inverted layers

下面再来看一个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就可以用来控制这个灯了,如果再换成一个东西实现ButtonServerButton依然适用。

图6. Dependency inversion applied to Lamp

好啦!五大原则我就讲完了,工资能不能翻倍就看你们自己了!值得一提的是,光背住上面的条款可没有用哦,只有真正理解了这样设计带来的好处,或者说只有在实践中违反了上述原则并付出了惨痛的代价,才会对上述原则有深刻的体会。Anyway,首先记住这几大原则,写代码和review别人的代码时多想想我们有没有违反这些原则,你一定会有收获的!

作者:丑小丫大笨蛋