面向对象设计的 SOLID 原则

[toc]
  编程最基本的原则就是要追求高内聚和低耦合的解决方案和代码模块设计。SOLID 是面向对象设计5大重要原则的首字母缩写:

SRP Single Responsibility Principle 单一责任原则
OCP Open Closed Principle 开放封闭原则
LSP Liskov Substitution Principle 里氏替换原则
ISP Interface Segregation Principle 接口分离原则
DIP Dependency Inversion Principle 依赖倒置原则

单一责任原则(SRP)

  每一个模块或者类所对应的职责,应对应系统若干功能中的某个单一部分,同时关于该职责的封装都应当通过这个类来完成。
  一个类只做一种类型责任,当这个类需要承担其他类型的责任的时候,就需要分解这个类。 类被修改的几率很大,因此应该专注于单一的功能。如果把多个功能放在同一个类中,功能之间就形成了关联,改变其中一个功能,有可能中止另一个功能。
  SRP 核心是把整个问题分为小部分,并且每个小部分都将通过一个单独的类负责。

开放封闭原则(OCP)

  软件实体应该是可扩展,而不可修改的。也就是说,对扩展是开放的,而对修改是封闭的。
  当需要对已有代码作出一些修改时,请切记以下两点:
  1. 保持函数、类、模块当前它们本身的状态,或者是近似于它们一般情况下的状态(即不可修改性)
  2. 使用组合的方式(避免使用继承方式)来扩展现有的类,函数或模块,以使它们可能以不同的名称来暴露新的特性或功能

  开闭原则所带来最有用的好处就是,当实现抽象层代码时,就可以对未来可能需要作出改变的地方拥有一个比较完整的设想,这样当真正面临改变时,所对原有代码的修改,更贴近于改变本身,而不是一味的修改已有的抽象代码。 也就是要预测未来可能的修改,留有扩展的余地。
  示例:

1
2
3
4
5
6
7
public boolean sendByEmail(String addr, String title, String content) {
}
public boolean sendBySMS(String addr, String content) {
}
// 在其它地方调用上述方法发送信息
sendByEmail(addr, title, content);
sendBySMS(addr, content);

  如果现在又多了一种发送信息的方式,比如可以通过QQ发送信息,那么不仅需要增加一个方法sendByQQ(),还需要在调用它的地方进行修改,违反了OCP原则,更好的方式是: 抽象出一个Send接口,里面有个send()方法,然后让SendByEmail和SendBySMS去实现它既可。这样即使多了一个通过QQ发送的请求,那么只要再添加一个SendByQQ实现类实现Send接口既可。这样就不需要修改已有的接口定义和已实现类,很好的遵循了OCP原则。

里氏替换原则(LSP)

  某个对象实例的子类实例应当可以在不影响程序正确性的基础上替换它们。这句话的意思是说,当传递一个父抽象的子类型时,需要保证不会修改任何关于这个父抽象的行为和状态语义。

  如何实践里氏替换原则?
  里氏替换原则推荐通过在子类中调用父类的公有方法来获取一些内部状态变量,而不是直接使用它。这样就可以保证父类抽象中正确的状态语义,从而避免了副作用和非法的状态转变。
  它也推荐应当尽可能的使基本抽象保持简单和最小化,因为对于子类来说,有助于提供父类的扩展性。如果一个父类是比较复杂的,那么子类在覆盖它的时候,在不影响父类状态语义的情况下进行扩展绝非易事。
  对于内部系统做可行的后置条件检查也是一个不错的方式,这种检查通常会验证是否子类会搅乱一些关键代码的运行路径。

接口隔离原则(ISP)

  类不应该被迫依赖它们不使用的方法。换句话说,使用多个专门的接口而不是使用单一的总接口。

  例如一个电子商务的网站,需要有一个购物车和关联订单处理机制。有一个接口 IOrderProcessor,它用包含一个验证信用卡是否有效的方法(ValidateCardInfo)以及收件人地址是否有效的方法(ValidateShippingAddress)。与此同时,一个OnlineOrderProcessor 的类表示在线支付。
  现在考虑另一种情形,假设在线信用卡支付不再有效,可以接受货到付款支付。乍一看,这个解决方案听起来很简单,可以创建一个CashOnDeliveryProcessor并实现 IOrderProcessor 接口。货到付款的购买方式不会涉及任何信贷卡验证,所以,CashOnDeliveryOrderProcessor 类内部的 ValidateCardInfo 方法抛出 NotImplementedException。
  这样的设计在未来可能会出现的潜在问题。假设由于某种原因在线信用用卡付款需要额外的验证步骤。自然,IOrderProcessor 将被修改,它将包括那些额外的方法,于此同时 OnlineOrderProcessor 将实现这些额外的方法。然而,CashOnDeliveryOrderProcessor 尽管不需要任何的附加功能,但必须实现这些附加的功能。显然,这违反了接口隔离原则。

  要符合ISP原则,可以设计两个接口。IOrderProcessor 接口只包含两个方法:ValidateShippingAddress 和 ProcessOrder,而 ValidateCardInfo 抽象到到一个单独的接口:IOnlineOrderProcessor。现在,在线信用卡支付的任何改变只局限于IOnlineOrderProcessor 和它的子类实现,而 CashOnDeliveryOrderProcessor 是不会被影响。因此,新设计符合接口隔离原则。

依赖倒置原则(DIP)

  高层模块不应该依赖低层模块,它们都应该依赖抽象类或者接口。抽象不应当依赖于实现,实现应当依赖于抽象。

  那么什么是高层模块,什么是低层模块呢?通常情况下,我们会在一个类(高层模块)的内部实例化它依赖的对象(低层模块),这样势必造成两者的紧耦合,任何依赖对象的改变都将引起类的改变。
  依赖倒置原则表明高层模块、低层模块都依赖于抽象,举个例子,一个通知系统,当用户改变密码时,邮件通知用户:

1
2
3
4
5
6
7
8
9
10
11
12
public class UserManager
{

public void ChangePassword(string username,string oldpwd,string newpwd)
{
EmailNotifier notifier = new EmailNotifier();

//add some logic and change password
//Notify the user
notifier.Notify("Password was changed on "+DateTime.Now);
}
}

  这样的实现在功能上没有问题,但试想一下,新的需求希望通过SNS形式通知用户,那么只能手动将EmaiNorifier 替换为 SNSNotifier。在这儿,UserManager 就是高层模块,而EmailNotifier 就是低层模块,它们彼此耦合。我们希望解耦,依赖于抽象 INotifier,也就是面向接口的编程。