1. 首页 >  软件开发 >  里氏替换原则究竟如何理解?

里氏替换原则究竟如何理解?

介绍里氏替换原则的文章非常多,但可能大家看完之后,心中仍然留有疑问,如何去落地实现,如何判断是否影响程序功能。本文将带领大家深入理解里氏替换,一起领略下它的真正面目。但在此之前,有必要阐述一下,为什么会提出设计原则以及设计原则的作用。

什么是设计原则设计原则是指导代码设计的经验沉淀,其目的是为了提高软件开发的可维护性。我们知道,程序世界并非一尘不染的,随着业务的发展,之前所设计的流程,会为了适应业务而不断调整改变。 对于开发来说,需要有业务前瞻性,凡事多往前考虑一步,尽量减少因为未来业务改变,而造成系统大范围的改动。一旦大范围改动,势必造成开发和回归的成本。所以开发的时候,多思考这样的设计是否违背了某些原则,如果能尽量向上述的设计原则靠拢,就能达到可维护性的目的。常用的有以下设计原则,后续会逐步推出系列文章,一一讲解

  • SOLID原则
    • SRP 单一职责原则
    • OCP 开闭原则
    • LSP 里式替换原则
    • ISP 接口隔离原则
    • DIP 依赖倒置原则
  • DRY 原则
  • KISS 原则
  • YAGNI 原则
  • LOD 法则

我们来正式讨论本文主角,里氏替换原则。里氏替换原则的定义是

调 用父类的地方,可以替换成调用子类,但是不会导致程序出错

这里核心的点在于,替换之后,程序不会出错。即不能导致程序逻辑错误,运行错误。

以我的理解,里氏替换原则对于代码设计的约束可以分为两个角度进行讨论,方法结构的约束和方法逻辑约束。下面我们逐个深入讨论

1. 方 法结构的约束(方法定义)

1.1 输入参数不能比父类严格

即入参只能是父类入参或者父类入参的父类。即如果父类方法的入参是 P 类,那么子类方法的入参只能是 P 类,或者 P 类的父类。

我们举例说明一下。 下面的例子中, 我们定义了三个类, A,B, C。 其中 B 和 C 是 A 的子类。B 类的入参是P,比父类的宽松,满足法则。而 C 类的入参是 P2,比父类的严格,不满足法则。

之所以这样做,是为了避免子类使用了更加宽松的入参类型中,特有的一些方法,而导致程序出错。

比如下面的例子中,调用了 A 类的 test 方法,并且入参是 P 类。接着用 C 类替换 A 类的位置,同样执行 test 方法,入参还是 P,但是执行会报错(先不考虑编译问题,假设传参能够成功),因为 C 类的 test 方法中,调用了 p2Method 方法,

而这个方案是 P 的子类 P2 的独有方法。

class P {
    private void method(){};
}

class P1 extend P {
}

class P2 extend P1{
    private void p2Method(){};
}

class A {
    private void test(P1 p){};
}

// 符合
class B extend A {
    private void test(P p){};
}

// 不符合
class C extend A {
    private void test(P2 p){
        p.p2Method();
    };
}


P p = new P();

A a = new A();
a.test(p);

C c = new C();
// 执行失败,因为 P 没有 p2Method 方法。如果用c替换掉a,则会失败
c.test(p);

1.2 返回值不能比父类宽松

即返回值只能是父类返回值,或者父类返回值的子类。

下面的例子中,父类A的返回值是 R1, B的返回值类型是 R2, 满足规则。而 C 的返回值是 R,不满足规则。 之所以要这样做,是因为如果子类返回的类型更加宽松,会导致调用方调用出错。

比如下面的例子中 A 执行了 test方法,返回值是 R1,此时调用 r1Method 是合法的。而如果用 C 类替换掉 A类的位置,因为 C 的test 方法返回是 R,没有r1Method方法,所以调用会出错。(先忽略编译失败的问题)

class R {
    private void method(){};
}

class R1 extend R {
    private void r1Method(){};
}

class R2 extend R1{
    private void r2Method(){};
}

class A {
    private R1 test(){
        ...
    };
}

// 符合
class B extend A {
    private R2 test(){
        ...
    };
}

// 不符合
class C extend A {
    private R test(){
        ...
    };
}


A a = new A();
R1 r = a.test();
r.r1Method();

C c = new C();
R1 r = c.test(p);
// 执行失败,因为 c.test 的返回值实际上是 R 类,没有 r1Method 方法
r.r1Method();

实际上,方法定义上是否满足里氏替换法则,对于静态语言,编译器会做方法定义的合法性校验。更为重要的在于逻辑上是否满足里氏替换原则,这点需要开发人员自己把控, 也更加重要

###

2. 方 法的逻辑

除了在方法定义,在代码逻辑实现的时候,也需要遵循一些约束。做到代码通过了编译校验的同时,在运行中也不会出现意想不到的错误情况。

2.1 对入参的逻辑处理不能比父类严格

这里的逻辑处理指的是条件判断,类型转换等等。

下面用两个例子来说明。第一个例子是关于类型转换的逻辑。这里有三个参数类,P,P1,P2,其中 P1 和 P2 是 P 的子类。

A 类提供了 test 方法,并且接收的 入参是 P 类。B 类继承自 A 类,所不同的是, B 类重新实现了 test 方法。
我们注意到, B 的 test 方法中,对入参 P 类进行了强转,虽然是符合语法约束的,但在某些场景下会出现问题。比如在调用 B 类的 test 方法时,传了 P2,那么强转就会失败了。

class P {
    private void method(){...};
}

class P1 extend P {
    private void p1Method(){...};
}

class P2 extend P {
    private void p2Method(){...};
}

class A {
    private void test(P p){...};
}

class B extend A {
private void test(P p){    // 这里做了强制转换    P1 p = (P1)p;
    p.p1Method();
};
}


// 这样处理没问题
P p = new P1();
A a = new B();
a.test(P1);

// 但如果入参是 P2,就会报错了。因为B类中,会将入参强制转换成 P1,类型转换失败
P p = new P2();
A a = new B();
a.test(P1);

这个例子并非属于极端例子,如果翻看一下实际的应用代码,我相信比比皆是。 那对于这种场景,我们应该怎样去做调整呢?

本质的问题在于,为什么要在代码逻辑中,转成具体的子类 P1 去操作? 因为要使用 P1 的独有方法。 对此我们可以倒推,原本父类定义中的接口入参类型已经满足不了我们的需求。

解决的方法是,考虑重新对父类的入参进行抽象,将子类的 P1 独有方法沉淀进 P 类。第二个例子是关于条件判断的,子类不能比父类严格。B 类重新实现了 test 方法,并且对于入参的前置条件判断,

由原来的 i 改成了 i<=0,比父类多校验了 i=0 的场景,更加严格。这样会导致,当用 B 类替换 A 类的位置时,对于 i=0 的场景就会抛异常。因为前置约束条件不一样了。

class A {
    private void test(int i){
        if(i < 0){            throw new RuntimeException();        }                ...    };
}

class B extend A {
    private void test(int i){
        // 包括0的场景,即比父类严格        if(i <= 0){           throw new RuntimeException();
        }
       ...};};


int i = 0;
A a = new A();
A b = new B();

// 不报错
a.test(i);

// 抛异常。即如果用b替代a的位置,程序会抛异常
b.test(i)

2.2 返回值的逻辑不能比父类宽松

跟入参的逻辑处理类似。 这里直接举例说明。

A 类有个 runTask 方法,用于执行一个任务列表,只有所有任务执行成功,就返回 1,否则为 0。B 类重新实现了该方法,如果任务为空,返回了 2,表示忽略任务。
这样会导致的问题是,对于调用方,2是一个未知的状态,并不知道如何处理,在运行时很有可能会有异常。

 


class A { 

   private int runTask(List<Task> tasks) {

          ...        

          boolean success = false;        

          for(Task task:tasks){          

            success = task.run();          

            if(!success){

               break;          

            }        

          }                

          if(success){     

            return 1;        

          } else if(!success){

            return 0;        

          }    

   }

}



class B extend A {

  private int runTask(List<Task> tasks){

      ...        

      // 如果入参为空,直接返回2,表示忽略  

      if(CollectionUtils.isEmpty(tasks)){   

        return 2;        

      }

      boolean success = false;

      for(Task task:tasks){

        success = task.run();

        if(!success){

          break;          

        }        

        if(success){

          return 1;          

        } else if(!success){

          return 0;        

        }    

      }

  }

}

2.3 不能违背函数声明的语义

这个比较好理解,比如父类的方法要实现的是升序排序,但是子类继承之后实现的是降序排序,这个就严重违背函数本来要实现的功能了。

###

里 氏替换和多态的关系

多态指的是,子类可以替换掉父类。这里更多的是描述面向对象语言的特性。比如 JAVA 中是支持多态的,子类可以替换父类所在位置。

里氏替换原则的实现,是基于多态的能力之上,多了一个条件,就是替换掉之后,得保证功能不变。更多的是一种规则约束。

也就是说,当我们要使用多态能力的时候,需要考虑替换类实现时,是否会影响系统功能,也就是要考虑是否违反里氏替换原则。

结尾

给大家留点思考空间。对于前面所具的例子,我们说子类的入参判断条件 i<=0 比父类的 i 更加严格,因此违背里氏替换原则,因此我们不能这么去做。

那如果今天业务逻辑就是有调整了,为了满足里氏替换原则,我们应该怎么去改动代码?可以在评论区回复讨论


欢迎大家关注我的个人公众号,我会基于个人经验总结,不定时发布互联网原创深度好文,带大家深入理解每个知识点,杜绝水文。

微信搜索公众号 架入豪文



/ruan-jian-kai-fa/li-shi-ti-huan-yuan-ze-jiu-jing-ru-he-li-jie-9898.html