SOLID:面向对象设计的五个基本原则

2,373 阅读12分钟

文|阿里巴巴淘系技术 田航(马刺)

在程序设计领域,SOLID 是由罗伯特·C·马丁在 21 世纪早期引入的记忆术首字母缩略字,指代了面向对象编程和面向对象设计的五个基本原则。当这些原则被一起应用时,能够使得一个程序员开发一个容易进行维护和扩展的系统变得更加可能。SOLID 是以下五个单词的缩写:

  • Single Responsibility Principle(单一职责原则)
  • Open Closed Principle(开闭原则)
  • Liskov Substitution Principle(里氏替换原则)
  • Interface Segregation Principle(接口隔离原则)
  • Dependency Inversion Principle(依赖倒置原则)

下面我们来详细解读一下这五个基本原则。

单一职责原则

什么是职责?

在The Single Responsibility Principle(SRP)中职责的定义为“变动的原因”(A reason for change)

如果你有多个动机去修改一个类,那么这个类就有多个职责。这可能比较难理解,因为我们通常把一组职责放在一起思考,下面来看一个具体的例子。下面是一个Modem(调制解调器或者叫猫)的接口

interface Modem
{
    public void dial(String pno);
    public void hangup();
    public void send(char c);
    public char recv();

}

上面这个猫的接口中存在两个职责:第一个是管理连接(dial和hangup);第二个是数据传输(send和recv)

这两个职责应该被分开,因为 :

它们没有共同点,而且通常会因为不同的原因被修改; 调用它们的代码通常属于应用的不同部分,而这部分代码也会因为不同的原因被修改。

下面是一个Modem优化后的设计:

通过拆分猫的接口,我们可以在应用的其他部分将猫的设计分开来对待。虽然我们又在猫的实现中(Modem Implementation)将这两部分职责重新耦合在一起,但是除了初始化猫的代码以外,在使用面向接口编程的原则后,其他代码并不需要依赖于猫的实现。

SRP是最简单的一个面向对象设计原则,但也是最难做正确的一个,因为我们习惯于将职责合并,而不是将它们分开来。找到并且拆分这些职责正是软件设计真正需要做的事情。

小结一下单一职责原则就是:

核心思想:应该有且仅有一个原因引起类的变更 好处:类的复杂度降低、可读性提高、可维护性提高、扩展性提高、降低了变更引起的风险。 需注意:单一职责原则提出了一个编写程序的标准,用“职责”或“变化原因”来衡量接口或类设计得是否优良,但是“职责”和“变化原因”都是不可以度量的,因项目和环境而异。

开闭原则

开闭原则的英文是 Open Closed Principle,缩写为 OCP。

开闭原则说的是:软件实体(模块、类、函数等等)应该对扩展是开放的,对修改是关闭的。

对扩展是开放的,意味着软件实体的行为是可扩展的,当需求变更的时候,可以对模块进行扩展,使其满足需求变更的要求。 对修改是关闭的,意味着当对软件实体进行扩展的时候,不需要改动当前的软件实体;不需要修改代码;对于已经完成的类文件不需要重新编辑;对于已经编译打包好的模块,不需要再重新编译。

两者结合起来表述为:添加一个新的功能应该是,在已有代码基础上扩展代码(新增模块、类、方法等),而非修改已有代码(修改模块、类、方法等)。

这里我们以出售电脑为例,首先定义一个顶层接口Computer,然后定义两个实现类,华硕电脑与苹果Mac,类层次结构如下图所示:

上面是我们一开始的需求,但是随着软件发布运行,我们的需求不可能一成不变,肯定要接轨市场。假设现在是双十一,华硕笔记本电脑需要搞促销活动。那么我们的代码肯定要添加新的功能。可能有些刚入职的新人会在原有的代码上做改动,这肯定不符合开闭原则,虽然这种做法最直接,也最简单,但是绝大部分项目中,一个功能的实现远比想像要复杂的多,我们在原有的代码中进行修改,其风险远比扩展和实现一个方法要大的多。正确的做法可以这样:

我们实现一个关于折扣的子类,其中包含一个关于折扣的方法,这方法相当于一个扩展方法。可以看到这个子类是AsusComputer的,那为什么不把他设计成一个共用的折扣类呢,比如DiscountComputer,所有实现类都继承这个折扣类。这是因为每种实现类的折扣方案可能是不一样的。所以我们最好能把它作为每个实现类的子类单独实现。如果你能确保你的业务中的新功能能兼容所有相关联的需求你也可以共用一个。

小结一下开闭原则就是:

核心思想:尽量通过扩展软件实体来解决需求变化,而不是通过修改已有的代码来完成变化

通俗来讲:一个软件产品在生命周期内,都会发生变化,既然变化是一个既定的事实,我们就应该在设计的时候尽量适应这些变化,以提高项目的稳定性和灵活性。

里氏替换原则

里氏替换原则由Barbara Liskov提出,这个原则很明显,Java的多态或者C++的虚函数本身就允许把指向基类的指针或引用,在调用其方法或函数的时候,调用实际类型的方法或函数。我们来看一个简单的例子:Circle 和 Square 继承了基类 Shape,然后在应用的方法中,根据输入 Shape 对象类型进行判断,根据对象类型选择不同的绘图函数将图形画出来。

void drawShape(Shape shape) {
    if (shape.type == Shape.Circle ) {
        drawCircle((Circle) shape);
    } else if (shape.type == Shape.Square) {
        drawSquare((Square) shape);
    } else {
        ……
    }

}

这种写法的代码既常见又糟糕,它同时违反了开闭原则和里氏替换原则。

首先看到这样的 if/else 代码,就可以判断违反了(我们刚刚在上个部分讲过的)开闭原则:当增加新的 Shape 类型的时候,必须修改这个方法,增加 else if 代码。 其次也因为同样的原因违反了里氏替换原则:当增加新的Shape 类型的时候,如果没有修改这个方法,没有增加 else if 代码,那么这个新类型就无法替换基类 Shape。

要解决这个问题其实也很简单,只需要在基类 Shape 中定义 draw 方法,所有 Shape 的子类,Circle、Square 都实现这个方法就可以了:

public abstract Shape{
  public abstract void draw();
}

上面那段 drawShape() 代码也就可以变得更简单:

void drawShape(Shape shape) {
  shape.draw();
}

这段代码既满足开闭原则:增加新的类型不需要修改任何代码。也满足里氏替换原则:在使用基类的这个方法中,可以用子类替换,程序正常运行。

小结一下里氏替换原则就是:

核心思想:在使用基类的的地方可以任意使用其子类,能保证子类完美替换基类。

通俗来讲:只要父类能出现的地方子类就能出现。反之,父类则未必能胜任。

好处:增强程序的健壮性,即使增加了子类,原有的子类还可以继续运行。 需注意:如果子类不能完整地实现父类的方法,或者父类的某些方法在子类中已经发生“畸变”,则建议断开父子继承关系 采用依赖、聚合、组合等关系代替继承。

接口隔离原则

接口隔离原则的英文是 SInterface Segregation Principle,缩写为 ISP。这个原则是说:客户端不应该强迫依赖它不需要的接口。

我们在设计微服务或者类库接口的时候,如果部分接口只被部分调用者使用,那我们就需要将这部分接口隔离出来,单独给对应的调用者使用,而不是强迫其他调用者也依赖这部分不会被用到的接口。举一个简单的例子:

public interface UserService {
  boolean register(String cellphone, String password);
  boolean login(String cellphone, String password);
  UserInfo getUserInfoById(long id);
  UserInfo getUserInfoByCellphone(String cellphone);
}

public interface RestrictedUserService {
  boolean deleteUserByCellphone(String cellphone);
  boolean deleteUserById(long id);
}

public class UserServiceImpl implements UserService, RestrictedUserService {
  // ...省略实现代码...

}

删除用户是一个非常慎重的操作,我们只希望通过后台管理系统来执行,所以这个接口只限于给后台管理系统使用。如果我们把它放到 UserService 中,那所有使用到 UserService 的系统,都可以调用这个接口。不加限制地被其他业务系统调用,就有可能导致误删用户。

参照接口隔离原则,调用者不应该强迫依赖它不需要的接口,将删除接口单独放到另外一个接口RestrictedUserService 中,然后将 RestrictedUserService 只打包提供给后台管理系统来使用。

小结一下接口隔离原则就是:

核心思想:类间的依赖关系应该建立在最小的接口上

通俗来讲:建立单一接口,不要建立庞大臃肿的接口,尽量细化接口,接口中的方法尽量少。也就是说,我们要为各个类建立专用的接口,而不要试图去建立一个很庞大的接口供所有依赖它的类去调用。

需注意:接口尽量小,但是要有限度。对接口进行细化可以提高程序设计灵活性,但是如果过小,则会造成接口数量过多,使设计复杂化。所以一定要适度提高内聚,减少对外交互。使接口用最少的方法去完成最多的事情为依赖接口的类定制服务。只暴露给调用的类它需要的方法,它不需要的方法则隐藏起来。只有专注地为一个模块提供定制服务,才能建立最小的依赖关系。

依赖倒置原则

依赖倒置原则的英文是 Dependency Inversion Principle,缩写为 DIP。依赖倒置原则说的是:高层模块不依赖低层模块,它们共同依赖同一个抽象,这个抽象接口通常是由高层模块定义,低层模块实现。同时抽象不要依赖具体实现细节,具体实现细节依赖抽象。高层模块就是调用端,低层模块就是具体实现类,抽象就是指接口或抽象类,细节就是实现类。

来看一个简单的例子:假设我们要设计一个很简单的程序,将键盘的输入输出到打印机上。一个简单的设计的程序结构图如下所示。

上面的设计中有三个模块,Copy模块调用Read Keyboard模块来读取输出,然后Copy调用Write Printer模块输出字符。Read Keyboard和Write Printer是两个下层模块,并且很容易被复用。

然而我们的Copy模块却不能被复用于任何不包含键盘和打印机的场景中,而Copy恰恰是这个程序的业务逻辑所在的模块,也是我们最希望能够复用的。

比如,我们还希望将键盘的输入,复制到磁盘文件。我们当然希望复用Copy模块,而事实上,Copy依赖于键盘和打印机,缺一不可,所以不能被复用。我们也可以往Copy中增加一个if条件来支持新的磁盘文件输出,但是这就违背了开闭原则,最终随着功能的变多,代码将变得不可维护。

这个例子中的问题其实是高层级的模块(Copy模块)依赖于层级的模块(Read Keyboard和Write Printer);如果能够找到一个让Copy独立于它所控制的底层级模块的方法,那么我们可以自由地复用这个Copy模块。下图就是一种依赖反转的解决方案。

在这个新的设计中,我们的Copy模块有一个抽象的Reader和一个抽象的Writer。Copy不再直接依赖于具体的实现,不管有几个Reader或Writer的实现,我们都不需要修改Copy。

小结一下依赖倒置原则就是:

核心思想:高层模块不应该依赖底层模块,二者都该依赖其抽象;抽象不应该依赖细节;细节应该依赖抽象;

通俗来讲:依赖倒置原则的本质就是通过抽象(接口或抽象类)使各个类或模块的实现彼此独立,互不影响,实现模块间的松耦合。

好处:依赖倒置的好处在小型项目中很难体现出来。但在大中型项目中可以减少需求变化引起的工作量。使并行开发更友好。

总结

今天的内容一句话概括就是:单一职责原则告诉我们实现类要职责单一;里氏替换原则告诉我们不要破坏继承体系;接口隔离原则告诉我们在设计接口的时候要精简单一;依赖倒置原则告诉我们要面向接口编程。而开闭原则是总纲,他告诉我们要对扩展开放,对修改关闭。

在实际开发过程中,并不是一定要求所有代码都遵循设计原则,我们要考虑人力、时间、成本、质量,不是刻意追求完美,要在适当的场景遵循设计原则,体现的是一种平衡取舍,帮助我们设计出更加优雅的代码结构。