面向对象设计 >> 当前页

SOLID面向对象设计原则

 SOLID原则是SRP单一职责原则、OCP开闭原则、LSP里氏代换原则 、ISP接口隔离原则 、DIP依赖倒置原则 四个原则的第一个字母简称,如今还多了一个依赖注入DI。本文介绍了SOLID原则和现在新的流行OO设计思想。

设计七宗罪

  • 刚性 - 使其难以改变
  • 脆弱性 - 可以很容易地打破
  • 不可移动 - 使其难以重用
  • 粘度 - 使其难以做正确的事
  • 不必要的复杂性 – 过分设计
  • 不必要的重复 - 容易出错

规律

  • 根据应用常识自然之道
    不要太教条/宗教
    每个决定都是权衡
  • 所有其他原则都只是
    方针
    “最佳实践”
    是否应该违背他们时慎重考虑- 但是,知道你可以的。

面向对象五个基本原则

  • OCP开闭原则
  • SRP单一职责原则
  • ISP接口隔离原则
  • LSP里氏代换原则
  • DIP依赖倒置原则
  • 依赖注入

OCP开闭原则

  • 软件实体(类,模块,方法等)应该对扩展开放,但对修改关闭
  • 也被称为受保护的变化
  • Parnas方法:他创造了信息隐藏
  • 最早的软件开发方法是由D.Parnas在1972年提出的

  “对扩展开放”意味着你的类应该很容易因为需求变化添加新功能,“对修改封闭”意味着一旦你已经开发和测试完成一个类,那就不要轻易去修改它。

  这个原则的开放和封闭两个部分好像是矛盾的,关键是:如果你有正确的类结构和依赖关系,你就无需更改原来的代码,只要通过增加新代码实现新功能,在这方面Gof设计模式为我们提供了很多参考方式。

  通常你可以使用抽象方式来实现,比如接口和抽象类,这样当有新功能需要增加时,我们只要通过实现一个新的接口子类就可以。

  使用开闭原则能够限制对现有代码的修改,这会降低引入新的BUG的风险,其实我们在对原有代码修正Bug时也会引入更多BUG,所以,如果原有代码的Bug不是很致命,或者可以通过拓展增加代码来避免,那么尽量不要破坏封装。

以下案例:

void checkOut(Receipt receipt) {

  Money total = Money.zero;
  for (item : items) {
    total += item.getPrice();
    receipt.addItem(item);
  }

  Payment p = acceptCash(total);
  receipt.addPayment(p);

}

在这个Checkout案例中,原来是现金money交易,如果我们需要增加信用卡怎么办?直观朴素的做法是引入一个IF判断语句,但是这样破坏开闭原则。如下:

Payment p;

if (credit)
  p = acceptCredit(total);
else
  p = acceptCash(total);
receipt.addPayment(p);

比较好的解决方案:

public interface PaymentMethod {void acceptPayment(Money total);}

 void checkOut(Receipt receipt, PaymentMethod pm) {

  Money total = Money.zero;

  for (item : items) {
   total += item.getPrice();
   receipt.addItem(item);
}

  Payment p = pm.acceptPayment(total);
receipt.addPayment(p);

}

这里我们增加了一个支付方法的接口PaymentMethod,现金和信用卡不同的支付只要实现PaymentMethod即可,当然如果有新的第三种支付方式,也是采取实现PaymentMethod接口方式。

OCP只有在你的改变是可预期的情况下有效,如果两次重复修改以上发生,你就要使用OCP。

 

为什么要 OCP

  • 如果对一个程序的单一的改变导致依赖模块的级联式改变。(一发动全身)
  • 该方案变得脆弱,僵硬,不可预测和非可重复使用。

OCP 实现

  • 类成员使用private修饰符
  • 自动添加Getter方法  
  • 使用抽象
  • 通过继承,
  • 控制反转(IoC)

OCP实现.NET模板类:

 

单一职责SRP

  • A类只有有一个理由去改变 责任职责是那个用来改变的理由
  • 为了得到正确的粒度可能会非常棘手

  SRP认为,不应该也不能有超过一个理由去对类进行改变,这意味着每个类只能实现一个目标。而不能实现多个目的与目标,当然这个职责目标不只是指的是功能,一个职责目标可能有许多功能去完成,就像条条大路通罗马,但是你的目标只能是罗马一个,不能今天罗马,明天巴黎,将这两种目标职责混合在一个类实现就违反了SRP。

  在类中每个事情都和这单个目标有关,这样才能更凝聚,就如同在管理中要求集体凝聚成一股绳,这种凝聚力越强,完成目标的可能性才越大。

  和某个职责目标有关的成员可能有很多,当目标一旦改变,所有类的成员都可能会修改,当然也可能是相关类都会修改。这就是凝聚性。

class Employee {

  public Pay calculatePay() {...}

  public void save() {...}

  public String describeEmployee() {...}

}

这一个类中我们有支付 (calculatePay)和逻辑计算以及数据库逻辑(save)和报表逻辑等功能,如果混合了多个功能在一个类中,那就很难于改变,因为其中一部分变化,很难不影响其他部分。

将职责混合在一起也使得类难以理解,难以测试,降低聚合。解决这个问题的办法就是将这个类切分为三个不同的类,每个类有自己的职责: 数据库访问, 计算支付和报表。

为什么SRP

  • Single Responsibility = 增加凝聚
  • 不遵守会导致不必要的依赖关系
       
    有很多的理由来改变
        刚性好不可移动

详见:对象职责协作

更多SRP详解

 


Liskov替换原则

   如果对于类型S的每个对象O1存在类型T的对象O2,那么对于所有定义了T的程序P来说,当用O1替换 O2并且S是T的子类型时,P的行为不会改变。“通俗地讲,就是子类型能够完全替换父类型,而不会让调用父类型的客户程序从行为上有任何改变

   “派生类(子类)对象能够替换其基类(超类)对象被使用 所有子类都应该永远是可用的,而不是它的父类。 所有派生类必须兑现其基类的合同

  又称为Design by Contract 契约设计 按合同设计。契约设计专题

  LSP使用在继承层次,规定你设计你的类时,当有调用者调用你的类时,需要其他依赖,你应该将这些依赖设计到你的类中,而不是放入调用者中,当你的依赖有变化时,你的调用者不会知晓。

  作为子类必须和他们的基类操作一致,子类中的特殊功能可以与基类不一样,但是大部分方法功能应该两者一致的。子类不只是实现基类的方法,而且必须名符其实。

  通常,如果一个子类的子类做了一些这个子类的调用者并不期望做的事情,这就违背了LSP, 想象一下,如果一个子类抛出Exception错误,而父类并没有,或者一个子类有不可预期的副作用等等,这些都是名不符实,总体来说,子类做的事情要少于他们的父类。

  LSP经典案例是矩形和正方形的案例,正方形是要求长等于宽,如果你应该使用矩形的时候你却使用了正方形,不可预期的错误会发生,因为正方形的尺寸是不能被修改 (修改了就不是正方形,违背事物内在逻辑一致性)。

  这个问题可不容易被解决,如果我们修改正方形的setter方法修改尺寸,这可能违反了正方形的逻辑,但是不修改就违反矩形的后置条件,因为矩形的长宽是可以独立修改的。

public class Rectangle {

  private double height;

  private double width;

  public double area();

  public void setHeight(double height);

  public void setWidth(double width);

}

上面是矩形代码,下面是正方形代码:

public class Square extends Rectangle { 

  public void setHeight(double height) {
    super.setHeight(height);
    super.setWidth(height);
  }

  public void setWidth(double width) {
    setHeight(width);
  }

}

违背LSP将导致没有定义的行为,没有定义的行为意味着它也许在开发阶段工作得很好,但是在产品生存环境掉链子,或者你要花数周时间去调试一天只发生一次的问题,或者你得遍历几百兆的日志去找出哪儿出错了。

 

 

ISP接口隔离原则

  • 许多客户端特定的接口,胜过一个通用接口更好
  • 根据每个客户端类型创建接口,而不是每个客户端实例避免不必要的耦合到客户端

  ISP认为:客户端调用者不应该被强迫依赖于它们不使用接口成员,当我们有非凝聚的接口时,ISP会指导你创建多个 小的 凝聚聚合的接口。

  当你使用ISP时, 类和他们的依赖使用正确聚焦的接口通讯,会最小化减少依赖,降低耦合,小的接口更加易于实现,提高灵活性和重用。一个接口只有少量的实现子类,一旦需求引起变化,导致接口变化后,引起的修改范围少,能够提高健壮性。

  想象一个ATM机器,有一个屏幕可以显示不同信息, 如何解决显示不同信息呢?使用SRP, OCP和LSP以后,系统还是难以维护,为什么?代码如下:

public interface Messenger {

  askForCard();
  tellInvalidCard();
  askForPin();
  tellInvalidPin();
  tellCardWasSiezed();
  askForAccount();
  tellNotEnoughMoneyInAccount();
  tellAmountDeposited();
  tellBalance();

}

  也许你可以增加一个方法到Messenger接口,但是这会引起这个接口的所有子类重新编译,系统几乎需要重新部署,这直接违背了OCP原则。

  关键问题出在,这个接口中的方法太散了,几乎没有凝聚性,无法聚焦,我们将这个接口切分成不同的功能接口:

public interface LoginMessenger {

  askForCard();
  tellInvalidCard();
  askForPin();
  tellInvalidPin(); 

}

 

public interface WithdrawalMessenger {

  tellNotEnoughMoneyInAccount();
  askForFeeConfirmation();

}

 

publc class EnglishMessenger implements LoginMessenger, WithdrawalMessenger {

  ...

}

为什么ISP

  • 否则 - 增加了不同客户端之间的耦合
  • 每个客户端对SRP基本上是一个变量

微服务

 

控制反转原则

  • 更高层次的模块不应该依赖于低层模块
  • 两者都应该依赖于抽象
  • 接口或抽象类
  • 抽象不应该依赖于细节

  DIP是定位在高层次模块不应该依赖低层次模块,它们应该只依赖于抽象,这就能帮助我们实现松耦合,使得设计易于修改,DIP允许测试 数据库细节能够如插件一样插入我们的系统。

  案例:

public interface Reader { char getchar(); }

public interface Writer { void putchar(char c)}

class CharCopier {

  void copy(Reader reader, Writer writer) {
    int c;
    while ((c = reader.getchar()) != EOF) {
      writer.putchar();
    }
  }

public Keyboard implements Reader {...}

public Printer implements Writer {…}

这里Reader和Writer接口是抽象的,而Keyboard和Printer是具体细节,依赖于抽象,这是通过实现接口完成的。CharCopier是一个明显的Reader和Writer的低层次细节,你只要传入Reader和Writer接口的任何实现子类都可以正常工作。

Why DIP

  • 增加松耦合

         抽象接口不改变
         具体类实现的接口
         具体类容易扔掉更换

  • 增加流动性
  • 增加隔离

 

DIP 影响

  • 诞生了层 的概念,如三层多层架构
  • 基于接口编程
  • 分离接口      把接口放在不同的包中而不是实施它
  • 依赖注入

 

举例:使用其他类的老办法先是创建它们,如下,问题是违背DIP原则,应该只依赖接口
public class MyApp
{
       public MyApp()
       {
              authenticator = new Authenticator();//创建类实例
              database = new Database();//创建类实例
              logger = new Logger();//创建类实例
              errorHandler = new ErrorHandler();//创建类实例
       }

// More code here...
}


依赖注入

  • DIP 说我们应当依赖接口    那么我们如何得到实例呢?


上面代码的多个重构方法

  • 选择 1 – 使用工厂生成Authenticator等实例,但是用户依赖工厂factory,而且Factory依赖目标 。
  • 选择 2 – Locator/Registry/Directory,组件还是控制配对 ,初始化需要顺序 ,依赖于定位器  JNDI依赖于JavaEE容器
  • 选择 3 – Dependency Injection,组装器控制配对

 

邪恶做法            

  • 使用Switch statements
  • 使用If (type())
  • 使用单例Singletons / Global variables全局变量
  • 使用Getters
  • 使用Helper 类

 

组合+依赖 vs. 继承

  直接用代码实现继承某种程度也是邪恶的,使用组合+依赖替代继承案例 ,下面代码是Angular.js的JS代码,完整见这个帖子

  • var Mammal = Backbone.Model.extend({
    isAlive: true,

      init: function () {
         console.log('An animal is born');
      },

      eat: function (food) {
          return 'omnomnom, I\'m eating: ' + food;
      },
      
       sleep: function () {
         return 'zzzzz' ;
       },

    });

    var Cat = Mammal.extend({
        meow: function () {
          return 'meow meow';
       }
    });

    var Dog = Mammal.extend({
       bark: function () {
        return 'woof woof';
       }
    });

拓展问题

  • 猫cat和狗dog继承Mammal,接下来如果需要扩展,比如猫狗是不能用来打猎吃的,如果我们需要豹panther和狼wolf,以及猎人杀死它们,可以看出,豹和狼虽然都是食肉特征,但是它属于不同的分类,添加这些食肉特征会导致重复代码,都有hunt和kill方法:

豹panther和狼wolf

  • var Panther = Cat.extend({
    hunt: function () {
    return 'imma go search for food!';
    },

    kill: function (animal) {
    animal.die();
    return animal + ' is dead!';
    }
    });

    var Wolf = Dog.extend({
    hunt: function () {
    return 'imma go search for food!';
    },

    kill: function (animal) {
    animal.die();
    return animal + ' is dead!';
    }
    });

继承问题

  • 根据达尔文的进化论,the traits that adapt to the environment the most make the organisms survive the best. 最适应环境的特征(trait)能让生物生存的最好。

    许多生物体的特点可以是相似的,即使它们不属于相同的属或科。

组合实现

  • var animals = angular.module('animals', []);

    animals.factory('Mammal', function Mammal () {
    this.init();

    this.init = function () {
      this.isAlive = true;
      console.log('An animal is born');
    };

    this.eat = function (food) {
      return 'omnomnom, I\'m eating: ' + food;
    };

    this.sleep = function () {
       return 'zzzzz' ;
    };

    this.die = function () {
       this.isAlive = false;
       return 'I\'m dead!';
    };
    });

依赖注入组合

  • animals.service('meowingTrait', function () {
      this.meow = function () {
       return 'meow meow';
    }
    });

    animals.service('barkingTrait', function () {
      this.bark = function () {
      return 'woof woof';
    };
    });

    animals.service('huntingTrait', function () {
       this.hunt = function () {
       return 'imma go search for food!';
    };

    this.kill = function (animal) {
      animal.die();
      return animal + ' is dead!';
     };
    });

  上述案例证明了“依赖注入>继承” ,依赖注入优于继承。

 

事件驱动 vs. 依赖注入

聚合 >松耦合>重用 ==> 事件驱动>依赖注入>继承

发现事物内部的凝聚性或一贯性,以聚合为边界切分,能够自然达到松耦合。

否定为松耦合而松耦合

否定为重用而重用

抓住凝聚,纲举目张,松耦合与重用自然解决。比喻:在一群人中找出好人,然后就为找好人而找好人,结果找出来的不一定是好人。换个思路,反者道之动,先找出坏人,剩余的就是好人了。

面向服务与面向领域的不同

 依赖注入与事件编程 

 

#SOLID原则

单一职责原则(SRP)

依赖注入专题

事件驱动EDA专题