[5+1]里氏替换原则(二)

前言

面向对象的SOLID设计原则,外加一个迪米特法则,就是我们常说的5+1设计原则。640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1↑ 五个,再加一个,就是5+1个。哈哈哈。
这六个设计原则的位置有点不上不下。论原则性和理论指导意义,它们不如封装继承抽象或者高内聚低耦合,所以在写代码或者code review的时候,它们很难成为“应该这样做”或者“不应该这样做”的一个有说服力的理由。论灵活性和实践操作指南,它们又不如设计模式或者架构模式,所以即使你能说出来某段代码违反了某项原则,常常也很难明确指出错在哪儿、要怎么改。
所以,这里来讨论讨论这六条设计原则的“为什么”和“怎么做”。顺带,作为面向对象设计思想的一环,这里也想聊聊它们与抽象、高内聚低耦合、封装继承多态之间的关系。



[5+1]里氏替换原则(一)

看上一篇请点我。



里氏替换原则


里氏替换与面向对象

“结果导向”和“过程管控”是项目管理的常见思路。
以产品需求为例。
从结果导向的角度来看,我们只要实现了需求中提出的业务功能就大功告成了。至于用了哪些技术、做了什么设计,其实无关紧要。
而从过程管控的角度来看,我们不仅要对最终结果负责,还要对项目进度、方案细节、里程碑产物等过程负责。
显然,二者是对立统一的。片面强调某一方面、忽略另一方面的作用,都会给项目带来不必要的问题。只有“两手抓”,才能“两手都硬”。
里氏替换原则就是这两种项目管理思想在面向对象设计中的体现。
从结果导向的角度来看,我们只要处理好了接口定义的入参、出参就大功告成了。至于接口内是一个“上帝实现类”、还是模板类-实现类、或者是代理类-实现类……其实无关紧要。
而从过程管控的角度来看,我们不仅要对最终接口负责,还要对类的层级结构、代码质量、可读性与可维护性等过程负责。
二者在里氏替换原则中得到了统一。
里氏替换原则要求子类不改变父类的行为,本质上就是要求同样的参数得到同样的结果。但它并不在意功能的实际实现者到底是父类还是子类。这不就是“只要求正确实现功能,并不在意代码是老王来写还是小李来写”么?不就是结果导向的思维方式么?
同时,为了保证子类和父类都能得到同样的结果,里氏替换原则对“子类继承父类”这一操作提出了很多要求。子类不能重写父类的非抽象方法、但可以增加自己的处理方法等等,这都是过程管控的具体措施。
把结果导向与过程管控结合起来,项目管理就能做得有声有色了。同样的,在这两种思维方式的帮助下,面向对象也能做得风生水起。而里氏替换原则,正是让面向对象变得风生水起的不二法门。



里氏替换与抽象

我们反复提到,面向对象中的抽象必须保持稳定,不能朝令夕改。大多数语境下,我们都在谈论编译期的代码稳定性,如保持方法签名不变、入参出参类型不变等。里氏替换原则提出了一种更高的稳定性标准:运行期的功能稳定性。
如果我们破坏了编译期稳定性,例如增加一个方法参数,在代码编译时,Java就会给出Error警报。但是,如果我们破坏了运行期稳定性,没有谁会对着我们的耳朵吼“这个地方有问题”——即使我们做了很多单元测试、集成测试,也很容易遗漏问题。
例如,我们的某个系统使用了SpringBatch+HIbernate来做批处理操作。为了简化代码和配置,有位同事把本应放在Processor中的代码放到了一个自定义的Writer中:

扫描二维码关注公众号,回复: 11873263 查看本文章
public class UpdateOverDueDaysWriter extends HibernateItemWriter<Record> {
    /**
    * 更新逾期天数
    */
    @Override
    public void write(List<Record> items) {
       for(Record r : items) {        
          Plan plan = r.getPlan();            
          // 计算还款记录的逾期天数            
          if(plan.getState() == State.RESERVED) {
              //计算逻辑略
              int overDueDays = caclulate();                
              // 借助Hibernate的Session自动flush机制,set后就可以自动更新到数据库中了。                
              r.setOverDueDays(overDueDays);            
          }        
      }    
  }
 }


按照这位同事的设想,尽管UpdateOverDueDaysWriter这个类并没有显式地更新数据库,但是,在r.setOverDueDays(overDueDays)之后,Hibernate的事务管理器应该可以自动调用Session.flush(),从而把overDueDays的新值更新入库。毕竟,它的父类HIbernateItemWriter就实现了这个功能嘛。因此,虽然不太符合SpringBatch的规范,但这个类的功能应该是没问题的。

我用了两个“应该”——“应该可以”、“应该没问题”。事实上,这两个“应该”全都落空了:数据库中的overDueDays一直没有被更新。
这是为什么呢?
我们来看一下父类HibernateItemWriter的关键源代码:

public class HibernateItemWriter<T> implements ItemWriter<T>, InitializingBean {
 private SessionFactory sessionFactory;
 private boolean clearSession = true;
 @Override  public void write(List<? extends T> items) {    
   doWrite(sessionFactory, items);
    
    // 注意这个地方:这里手动调用了Flush方法
   
   sessionFactory.getCurrentSession().flush();
   
   if(clearSession) {
     
     sessionFactory.getCurrentSession().clear();
   
   }
 
 }
 
}

注意我加了注释的那一行。HibernateItemWriter在这里显式的调用了Session.flush()方法,而不是交给Hibernate的事务管理机制去处理。虽然不太确定为什么,不过这是一个重要的提示:Session.flush()方法不是由HIbernate事务管理器自动调用的,而需要代码显示调用,以保证将HIbernate Session中的数据更新到数据库中去。

但是,我们的子类UpdateOverDueDaysWriter在重写HIbernateItemWriter.wite()方法时,虽然没有变更接口、方法签名或返回值类型,但父类方法中调用Session.flush()的代码,却被子类完全抛弃:子类重写并改变了父类方法的功能,导致数据无法更新入库。
换句话说,子类UpdateOverDueDaysWriter在重写HIbernateItemWriter.wite()方法时,违反了里氏替换原则,破坏了write()方法的功能稳定性,最终导致了功能缺失,产生了线上bug。
更令人后怕的是,我们的代码编译、静态检查、代码审查、单元测试、QA测试、UAT测试以及线上部署都没有发现这个问题,因为与这个批处理任务同时启动的另一个批处理任务也更新了这个字段——后者是实实在在地更新入库了。直到两年后,第二个批处理任务功德圆满、删代码下线了,我们才发现:为什么两天过去了,overDueDays字段还没被更新?
幸运的是,事发两天我们就发现并解决了这个问题。如果线上bug发生在国庆或春节期间,其后果简直不堪设想。
这就是破坏功能稳定性的可怕之处:我们没有什么办法可以保证在发生线上故障之前发现问题。事实上,里氏替换法则也无法解决这个问题,所以它换了一种思路:变事后修复为事前预防。我们甚至可以说,抽象的功能稳定性,与它遵守里氏替换原则的严格程度是成正比的。



里氏替换与高内聚低耦合

里氏替换原则与高内聚、低耦合并没有什么很强的关联。遵循里氏替换原则,我们也有可能写出低内聚、高耦合的代码来。
不过,里氏替换原则要求我们更加深入地审视类之间的层级关系,把代码和功能放到更恰当的位置上去。这样做了之后,通常我们都能得到更加高内聚低耦合的类。
例如,假定我们已有这样一个类:


public class SomeService implements SomeInterface{    @Override    public Result service(Param param){        valid(param);         ServiceDO sDo = doService(param);         return transToResult(SDo);    }     protected void valid(Param param) throws ValidException{        // 略    }    protected ServiceDO doService(Param param){        // 略    }     protected Result transToResult(ServiceDO sDo){        // 略    }}


当我们需要增加一种新业务时,可以简单地增加一个子类:


public class OtherService extends SomeService{
    @Override
    protected void valid(Param param)throws ValidException{
        // 另一种校验逻辑,略
    }
    
    @Override    
    protected ServiceDO doService(Param param){
            // 另一种服务逻辑,略    
    }
}


从高内聚低耦合的角度来说,这样做虽然比if-else的方式更好一些,还是“犹有未树也”:SomeService与OtherService之间,产生了不必要的子类耦合。如果SomeService出于自己的业务原因,修改了部分代码,那么OtherService也要受到影响。

这两个类显然违反了里氏替换原则。子类OtherService重写了父类已实现的方法valid()和doService()。如果我们希望给两个方法同时增加一条校验规则,显然,光修改父类SomeService是无济于事的。
遵循里氏替换原则的指导,我们可以把类结构调整成这样:


public abstract class AbstractService implements SomeInterface{
    @Override
    public Result service(Param paran){
        valid(param);
        ServiceDO sDo = doService(param);
        return transToResult(SDo);
    }
    protected abstract void valid(Param param) throws ValidExcption;
    protected abstract ServceDO doService(Param param);
    private Result transToResult(ServiceDO sDo){
        // 略
    }
}

public class SomeService extends AbstractService{
    @Override
    protected void valid(Param param) throws ValidException{
        // 略
    }
    @Override
    protected ServiceDO doService(Param param){
        // 略
    }
}

public class OtherService extends AbstractService{
    @Override
    protected void valid(Param param) throws ValidException{
        // 略
    }
    @Override
    protected ServiceDO doService(Param param){
        // 略
    }
}


这样的类层级结构更符合里氏替换原则的要求;同时,SomeService与OtherService之间的耦合度也更低了:除了增加代码量之外,可谓皆大欢喜。



里氏替换与封装继承多态

里氏替换原则与继承和多态的关系毋庸多言:它可谓是继承和多态的“最佳实践”。但是它与封装之间的关系,就不是那么显而易见了。
当说到“封装”时,通常我们都会想到public/protected/private等可见性修饰符。其实,它们只是封装的工具、而非封装本身——就像禅宗法师们说的那样,这只是“成佛之路”,而非“成佛之事”。
对“封装”来说,所谓“成佛之事”就是一“封”和一“装”:专属于一个类的,就“封”在这个类里;从属于一个类的,就“装”在这个类里。只要做到了这两点,就做到了“封装”。
那么,里氏替换原则把什么东西“封”起来了呢?答案就是“父类中的非抽象方法”。即使这个非抽象方法的修饰符是public、default或者protected,即使这个非抽象方法不是final方法,里氏替换原则也禁止子类重写它。这难道不也是一种“封”么?
里氏替换原则是一个关于“封”的原则,同时也是一种“装”的原则。它规定了子类不能重写父类的非抽象方法,同时也就规定了:如果一个类中的非抽象方法被子类重写了,那么这个方法就不应该放在当前类中。我们应该定义一个新的类,以便“装”下这个方法的抽象定义;同时,让原有的两个类继承这个新的类,以便“装”下抽象方法的两种不同实现。
但是,对里氏替换原则来说,无论是“封”还是“装”,基本都只能靠人进行规范,而难以借助语法、编译器等工具来做约束。这大概是里氏替换原则在实践中较少被提及的又一个原因。



里氏替换与其它设计原则
里氏替换与单一职责

明白了里氏替换原则与封装之间的关系,其实也就厘清了里氏替换原则与单一职责原则之间的关系:当我们把一个方法由子类提升到父类中,或者两个类由父子类转为兄弟类时,我们不仅遵循了里氏替换原则,同时也遵循了单一职责原则。
仍以前面的代码为例。当我们的子类重写了父类中的非抽象方法时,它们的类结构如下图所示:

640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1

在上面这种结构中,ClassA承担了两种职责:它自身的业务功能,以及为ClassB定义流程模板的职责。而ClassB同样也承担了两种职责:它自身的业务功能,以及ClassA中的其它业务功能。显然,它俩都承担了一些不属于自身的功能职责。因而,这两类不仅不符合里氏替换原则,也不符合单一职责原则。
如果我们按照里氏替换原则的要求,把上述类结构改造成这样:

640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1&wx_co=1

改造之后,ClassC只承担了定义流程模板的职责,不承担任何具体的业务功能;ClassA只承担了自己的业务功能,不再承担定义流程模板的职责;ClassB同样只承担了自己的业务功能,而不再包含ClassA的业务功能。这时,我们就可以说:这三个类不仅遵循了里氏替换原则,同样也遵守了单一职责原则。



里氏替换与开闭

开闭原则要求我们“对新增开放、对修改关闭”。里氏替换原则把这一原则细化到了父子类之间:我们可以新增继承层级,也可以新增子类方法,这就是“对新增开放”;但我们不可以修改父类中已实现的方法,这就是“对修改关闭”。



在此前讨论抽象时,我们曾经提到过:抽象是分层次的。通过继承,我们可以把一个抽象“纵向”划分为多个层次;借助多态,我们可以在同一个层次内把抽象“横向”拆分为多个实现类。在这纵横捭阖之间,如果处理不当,错综复杂的父子类耦合会把代码变成一团乱麻。而借助里氏替换原则,我们可以如抽丝剥茧般将抽象复杂度逐步拆分、消弭于无形。
所以,虽然里氏替换原则有点费解、有点难用,但它确实是一把神兵利器,值得我们花点时间去掌握它。
当然,处理抽象复杂度,我们也有其它办法,例如把一个复杂抽象拆分为多个简单抽象。不过,这就是下一章节——接口隔离原则——的内容了。因此,我们下回分解吧。

qrcode?scene=10000004&size=102&__biz=MzUzNzk0NjI1NQ==&mid=2247484831&idx=1&sn=9462f09f48b68e3ec97119a8e9d012aa&send_time=


猜你喜欢

转载自blog.51cto.com/winters1224/2540777