设计原则之【里式替换原则】

设计原则是指导我们代码设计的一些经验总结,也就是“心法”;面向对象就是我们的“武器”;设计模式就是“招式”。

以心法为基础,以武器运用招式应对复杂的编程问题。

实习生表妹上班又闯祸了

表妹:今天上班又闯祸了😔

我:发生什么事情啦?

表妹:我不小心改了后端接口名的大小写,前端页面报错了


你看,这不就类似我们软件开发中的里式替换原则嘛。

子类对象能够替换程序中父类对象出现的任何地方,并且保证原来程序的逻辑行为不变及正确性不被破坏。

如何理解“里式替换原则”?

实际上,里式替换原则还有一个更加能落地、更有指导意义的描述,那就是“按照协议来设计”。

子类在设计的时候,要遵守父类的行为约定(或者叫协议)。父类定义了函数的行为约定,那子类可以改变函数内部实现逻辑(重写),但不能改变函数原有的行为约定。

这里的行为约定包括:函数声明要实现的功能对输入、输出、异常的约定;甚至包含注释中所罗列的任何特殊说明

前后端协商好的接口文档,就相当于“协议”。前端和后端都分别按照这个协议独立开发,具体的实现逻辑,是递归、动态规划还是贪心,由开发者决定。

实际上,定义中父类和子类之间的关系,也可以替换成接口和实现类之间的关系。

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

 1 public class Transporter {
 2     private HttpClient httpClient;
 3     
 4     public Transporter(HttpClient httpClient) {
 5         this.httpClient = httpClient;
 6     }
 7     
 8     public Response sendRequest(Request request) {
 9         // ...use httpClient to send request
10     }
11 }
12 13 public class SecurityTransporter extends Transporter {
14     private String appID;
15     private String appToken;
16     
17     public SecurityTransporter(HttpClient httpClient, String appID, String appToken) {
18         super(httpClient);
19         this.appID = appID;
20         this.appToken = appToken;
21     }
22     
23     @Override
24     public Response sendRequest(Request request) {
25         if (StringUtils.isNotBlank(appID) && StringUtils.isNotBlank(appToken)) {
26             request.addPayload("app-id", appID);
27             request.addPayload("app-token", appToken);
28         }
29         return super.sendRequest(request);
30     }
31 }
32 33 public class Demo {
34     public void demoFunction(Transporter transporter) {
35         Request request = new Request();
36         // ...省略设置request中数据值的代码...
37         Response response = transporter.sendRequest(request);
38         // ...省略其他逻辑...
39     }
40 }
41 42 // 里式替换原则
43 Demo demo = new Demo();
44 demo.demoFunction(new SecurityTransporter(/*省略参数*/););

 

在上面的代码中,子类SecurityTransporter的设计完全符合里式替换原则,可以替换父类出现的任何位置,并且原来代码的逻辑行为(传输网络数据)不变且正确性也没有被破坏。

你可能会问,上面的代码设计,不就是简单利用了面向对象的多态特性吗?

“里式替换原则”就是多态吗?

里式替换原则,是实现开闭原则的重要方式之一,由于使用父类对象的地方可以使用子类对象,因此,在程序中尽量使用父类类型来对对象进行定义,而在运行时再确定其子类类型,用子类对象来替换父类对象。

那么,“里式替换原则”就是多态吗?

还是刚才那个例子,不过需要对SecurityTransporter类中sendRequest()函数稍加改造一下。改造前,我们不校验appID或者appToken是否设置;改造后,如果appID和appToken没有设置,则直接抛出NoAuthorizationRuntimeException未授权异常。改造前后的代码对比如下:

 1 // 改造前:
 2 public class SecurityTransporter extends Transporter {
 3     // ...省略其他代码...
 4     @override
 5     public Response sendRequest(Request request) {
 6         if (StringUtils.isNotBlank(appID) && StringUtils.isNotBlank(appToken)) {
 7             request.addPayload("app-id", appID);
 8             request.addPayload("app-token", appToken);
 9         }
10         return super.sendRequest(request);
11     }
12 }
13 14 // 改造后:
15 public class SecurityTransporter extends Transporter {
16     // ...省略其他代码...
17     @override
18     public Response sendRequest(Request request) {
19         if (StringUtils.isBlank(appID) || StringUtils.isBlank(appToken)) {
20             throw new NoAuthorizationRuntimeException(...);
21         }
22         request.addPayload("add-id", appID);
23         request.addPayload("app-token", appToken);
24         return super.sendRequest(request);
25     }
26 }

 

你看,使用改造后的代码后,如果传进demoFunction()函数的是父类Transporter对象,那demoFunction()函数并不会有异常抛出,但如果传递给demoFunction()函数的是子类SecurityTransporter对象,那demoFunction()就有可能有异常抛出。

尽管代码中抛出的是运行时异常,我们可以不在代码中显式地捕获处理,但子类替换父类传递进demoFunction函数之后,整个程序的逻辑行为就发生了改变。

虽然从定义描述和代码实现上看,多态和里式替换有点类似,但是它们关注的角度是不一样的。

多态是面向对象编程的一大特性,也是面向对象编程语言的一种语法,是一种代码实现的思路。

而里式替换是一种设计原则,是用来指导继承关系中,子类该如何设计,子类的设计要保证在替换父类的时候,不改变原有程序的逻辑以及不破坏原有程序的正确性。

哪些代码明显违背了“里式替换原则”?

  • 子类违背父类声明要实现的功能

父类中提供的sortOrderByAmount()订单排序函数,是按照金额从小到大来给订单排序的,而子类重写这个sortOrderByAmount()订单排序函数之后,是按照创建日期来给订单排序的。

那么,这个子类的设计就违背了里式替换原则。

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

在父类中,某个函数约定:运行出错的时候返回null;获取数据为空的时候返回空集合。而子类重载函数之后,实现变了,运行出错返回异常,获取不到数据返回null。

那么,这个子类的设计就违背了里式替换原则。

  • 子类违背父类注释中所罗列的任何特殊说明

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

那么,这个子类的设计就违背了里式替换原则。

实际上,你发现没有,里式替换原则是非常宽松的。判断子类的设计实现是否违背了里式替换原则,可以拿父类的单元测试去验证子类的代码。

如果某些单元测试运行失败,就有可能说明,子类的设计实现没有完全遵守父类的约定,子类有可能违背了里式替换原则。

总结

里式替换原则就是子类完美继承父类的设计初衷,并做了增强(增加自己特有的方法)。

大白话就是,可以青出于蓝胜于蓝,但是祖传的东西不能变。

好啦,每个设计原则是否应用得当,应该根据具体的业务场景,具体分析。

参考

极客时间专栏《设计模式之美》