设计原则之【里式替换原则】
设计原则是指导我们代码设计的一些经验总结,也就是“心法”;面向对象就是我们的“武器”;设计模式就是“招式”。
以心法为基础,以武器运用招式应对复杂的编程问题。
实习生表妹上班又闯祸了
表妹:今天上班又闯祸了😔
我:发生什么事情啦?
表妹:我不小心改了后端接口名的大小写,前端页面报错了
你看,这不就类似我们软件开发中的里式替换原则嘛。
子类对象能够替换程序中父类对象出现的任何地方,并且保证原来程序的逻辑行为不变及正确性不被破坏。
如何理解“里式替换原则”?
实际上,里式替换原则还有一个更加能落地、更有指导意义的描述,那就是“按照协议来设计”。
子类在设计的时候,要遵守父类的行为约定(或者叫协议)。父类定义了函数的行为约定,那子类可以改变函数内部实现逻辑(重写),但不能改变函数原有的行为约定。
这里的行为约定包括:函数声明要实现的功能;对输入、输出、异常的约定;甚至包含注释中所罗列的任何特殊说明。
前后端协商好的接口文档,就相当于“协议”。前端和后端都分别按照这个协议独立开发,具体的实现逻辑,是递归、动态规划还是贪心,由开发者决定。
实际上,定义中父类和子类之间的关系,也可以替换成接口和实现类之间的关系。
比如,父类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账号实现了透支提现的功能,也就是提现金额可以大于账户余额。
那么,这个子类的设计就违背了里式替换原则。
实际上,你发现没有,里式替换原则是非常宽松的。判断子类的设计实现是否违背了里式替换原则,可以拿父类的单元测试去验证子类的代码。
如果某些单元测试运行失败,就有可能说明,子类的设计实现没有完全遵守父类的约定,子类有可能违背了里式替换原则。
总结
里式替换原则就是子类完美继承父类的设计初衷,并做了增强(增加自己特有的方法)。
大白话就是,可以青出于蓝胜于蓝,但是祖传的东西不能变。
好啦,每个设计原则是否应用得当,应该根据具体的业务场景,具体分析。
参考
极客时间专栏《设计模式之美》