10k

设计模式之美-课程笔记7-设计原则3-里氏替换(LSP)

理论三:里式替换(LSP)跟多态有何区别?哪些代码违背了LSP?

如何理解里氏替换原则?

  1. LSP: Functions that use pointers of references to base classes must be able to use objects of derived classes without knowing it.

    1. 这个原则来源于更早另一个哥们提出的 Liskov Substitution principle: If S is a subtype of T, then objects of type T may be replaced with objects of type S, without breaking program.
  2. 子类对象能够替换程序中父类对象出现的地方,并且保证原来程序的逻辑行为不变及正确性不被破坏。(那子类重写父类的方法,替换后不就不行了?多态?)

  3. 看个例子:父类 Transporter 使用 org.apache.http 库中的 HttpClient 类来传输网络数据。子类 SecurityTransporter 继承父类 Transporter,增加了额外的功能,支持传输 appId 和 appToken 安全认证信息。

    public class Transporter {
      private HttpClient httpClient;
      
      public Transporter(HttpClient httpClient) {
        this.httpClient = httpClient;
      }
    
      public Response sendRequest(Request request) {
        // ...use httpClient to send request
      }
    }
    
    public class SecurityTransporter extends Transporter {
      private String appId;
      private String appToken;
    
      public SecurityTransporter(HttpClient httpClient, String appId, String appToken) {
        super(httpClient);
        this.appId = appId;
        this.appToken = appToken;
      }
    
      @Override
      public Response sendRequest(Request request) {
        if (StringUtils.isNotBlank(appId) && StringUtils.isNotBlank(appToken)) {
          request.addPayload("app-id", appId);
          request.addPayload("app-token", appToken);
        }
        return super.sendRequest(request);
      }
    }
    
    public class Demo {    
      public void demoFunction(Transporter transporter) {    
        Reuqest request = new Request();
        //...省略设置request中数据值的代码...
        Response response = transporter.sendRequest(request);
        //...省略其他逻辑...
      }
    }
    
    // 里式替换原则
    Demo demo = new Demo();
    demo.demofunction(new SecurityTransporter(/*省略参数*/););

上述代码看起来不过是利用了多态,正如我在第二点中的疑惑一样。 但是多态只是面向对象的一个特性/能力,但是我们在设计子类的时候,要遵循里氏替换原则。

  1. 如果我们在SecurityTranspoter中sendRequest没有设置appid and appToken, code会抛出异常。
    // 改造前:
    public class SecurityTransporter extends Transporter {
      //...省略其他代码..
      @Override
      public Response sendRequest(Request request) {
        if (StringUtils.isNotBlank(appId) && StringUtils.isNotBlank(appToken)) {
          request.addPayload("app-id", appId);
          request.addPayload("app-token", appToken);
        }
        return super.sendRequest(request);
      }
    }
    
    // 改造后:
    public class SecurityTransporter extends Transporter {
      //...省略其他代码..
      @Override
      public Response sendRequest(Request request) {
        if (StringUtils.isBlank(appId) || StringUtils.isBlank(appToken)) {
          throw new NoAuthorizationRuntimeException(...);
        }
        request.addPayload("app-id", appId);
        request.addPayload("app-token", appToken);
        return super.sendRequest(request);
      }
    }

此时就违背了里氏替换原则,子类是无法替换父类方法的。因为原来的父类方法本身就没有appId和token,必然会报异常。这个程序的逻辑有了变化。

哪些代码明显违背了里氏替换原则

  1. LSP有另外一个更落地的描述:Design by contract.
  2. 子类在设计的时候,要遵循父类的行为约定。父类定义了函数的行为约定,那子类可以改变函数的内部实现逻辑,但不能改变函数原有的行为约定。这里的行为约定包括:函数声明要实现的功能;对输入、输出、异常的约定;甚至包括注释中所罗列的任何特殊说明。实际上,定义中父类和子类之间的关系,也可以替换成接口和实现类之间的关系。

1. 子类违背父类要实现的功能

比如父类有一个方法是sortOrdersByAmount(),是按照金额从小到大来给订单排序的,儿子类重写这个函数后按照创建日期排序,那就是违背了LSP。

2. 子类违背父类对输入、输出、异常的约定

父类某个函数运行出错返回null,数据为空返回空集合;子类中重载之后运行出错返回异常,获取不到数据返回null。这也是违背LSP。

父类输入要求是整数,但是子类只允许正整数。对输入更加严格,也是违背了。

在父类的函数约定种,只会抛出ArgumentNullException但是在子类中如果抛出其他异常的话也是违背LSP。

3. 子类违背父类注释中的说明

父类中定义的 withdraw() 提现函数的注释是这么写的:“用户的提现金额不得超过账户余额……”,而子类重写 withdraw() 函数之后,针对 VIP 账号实现了透支提现的功能,也就是提现金额可以大于账户余额,那这个子类的设计也是不符合里式替换原则的。

一个小方法:用子类的方法去跑父类的单测,没跑过的话可能说明有的子类违背了LSP。

LSP是用来指导继承中子类如何实现。

Thoughts? Leave a comment