设计模式:里式替换(LSP)跟多态有何区别?哪些代码违反了LSP?

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

什么是“里式替换原则”

SOLID 中的“L”对应的里式替换原则。里式替换原则的英文翻译是:Liskov Substitution Principle,缩写为 LSP。

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

举个例子。如下代码中,父类 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(/* 省略参数 */););

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

问题:刚刚的代码设计不就是简单利用了面向对象的多态特性吗?多态和里式替换原则说的是不是一回事呢?

里式替换原则跟多态看起来确实有点类似,但实际上它们完全是两回事。为什么这么说呢?

还是以上面例子为例。不过,我们需要对SecurityTransporter类中sendRequest()函数稍加改造一下。改造前,如果appId或者appToken没有设置,我们就不做校验;改造后,如果appId或者appToken没有设置,则直接抛出NoAuthorizationRuntimeException未授权异常。改造前后的代码如下:

// 改造前:
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);
	}
}

在改造之后的代码中,如果传递进demoFunction()函数的是父类Transporter对象,那demoFunction()函数并不会有异常抛出,但如果传递给demoFunction()函数的是子类SecurityTransporter对象,那demoFunction()有可能有异常抛出。尽管代码中抛出的是运行时异常,我们可以不在代码中显示的捕获处理,但子类替换父类传递进demoFunction函数之后,整个程序的逻辑行为有了改变。

虽然改造后的代码仍然可以通过java的多态语法,动态的用子类SecurityTransporter来替换父类Transporter,也不会导致程序编译或者运行报错。但是,从设计思路来讲,SecurityTransporter的设计是不符合里式替换原则的。

总结:虽然从定义描述和代码实现上来看,多态和里式替换很像,但它们关注的角度不一样:

  • 多态是面向对象编程的一大特性,也是面向对象编程语言的一种语法。它是代码实现的一种思路
  • 里式替换是一种设计原则,是用来指导继承关系中子类应该如何设计的,子类的设计要保证在替换父类的时候,不改变原有程序的逻辑以及不破坏原有程序的正确性

哪些代码明显违背了 LSP?

实际上,里式替换原则还有另外一个更加能落地、更有指导意义的模式,那就是“Design By Contract”,即“按照协议来设计”。就是说:

  • 子类在设计的时候,要遵守父类的行为约定(或者协议)
  • 父类定义了函数的行为约定,那子类可以改变函数的内部逻辑,但不能改变函数原有的行为约定。
  • 这里的行为约定包括:函数声明要实现的功能;对输入、输出、异常的约定;甚至包括注释中所罗列的任何特殊说明。
  • 实际上,定义中父类和子类之间的关系,也可以替换成接口和实现类之间的关系。

为了更好的理解上面,这里有几个明显违反里式替换原则的例子

(1)子类违背父类声明要实现的功能

  • 父类中提供的sortOrdersByAmount()订单排序函数,是按照金额从小到大给订单排序的。;子类重写的这个sortOrdersByAmount()订单排序函数,是按照创建日期来给订单排序的。那子类的设计就违背里式替换原则。

(2)子类违背父类对输出、输出、异常的约定

  • 在父类中,某个函数约定:运行出错的时候返回 null;获取数据为空的时候返回空集合(empty collection)。而子类重载函数之后,实现变了,运行出错返回异常(exception),获取不到数据返回 null。那子类的设计就违背里式替换原则。
  • 在父类中,某个函数约定,输入数据可以是任意整数,但子类实现的时候,只允许输入数据是正整数,负数就抛出,也就是说,子类对输入的数据的校验比父类更加严格,那子类的设计就违背了里式替换原则。
  • 在父类中,某个函数约定,只会抛出 ArgumentNullException 异常,那子类的设计实现中只允许抛出 ArgumentNullException 异常,任何其他异常的抛出,都会导致子类违背里式替换原则。

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

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

以上便是三种典型的违背里式替换原则的情况。除此之外,判断子类的设计实现是否违背里式替换原则,还有一个小窍门,那就是拿父类的单元测试去验证子类的代码。如果某些单元测试运行失败,就有可能说明,子类的设计实现没有完全地遵守父类的约定,子类有可能违背了里式替换原则。

为什么要有里式替换原则

  • 从接口或父类的角度出发,顶层的接口/父类要设计的足够通用,并且可扩展,不要为子类或实现类指定实现逻辑,尽量只定义接口规范以及必要的通用性逻辑,这样实现类就可以根据具体场景选择具体实现逻辑而不必担心破坏顶层的接口规范。
  • 从子类或实现类角度出发,底层实现不应该轻易破坏顶层规定的接口规范或通用逻辑

里式替换原则保证子类的实现不超过父类的接口定义规范,只是对功能的扩展,而不是对功能的修改,满足”对扩展开发,修改关闭“。同时在父类定义的框架下,子类虽然可以扩展,但也不能超过父类定义的范围,也在一定基础上满足了单一设计原则。

里氏替换最终一句话还是对扩展开放,对修改关闭,不能改变父类的入参,返回,但是子类可以自己扩展方法中的逻辑。父类方法名很明显限定了逻辑内容,比如按金额排序这种,子类就不要去重写金额排序,改成日期排序之类的,而应该抽出一个排序方法,然后再写一个获取排序的方法,父类获取排序调用金额排序,子类就重写调用排序方法,获取日期排序。

里氏替换就是子类完美继承父类的设计初衷,并做了增强

里氏替换就是说父亲能干的事儿子也别挑,该怎么干就怎么干,儿子可以比父亲更有能力,但传统不能变

一个词说里氏替换原则就是:合约。子类要遵守父类设定的合约,也就是设计的初衷。子类改变逻辑思维边界是父类声明的合约。

总结

  • 里式替换原则是用来指导,继承关系中子类应该如何设计的一个原则。理解里式替换原则,最核心的就是理解“design by contract,按照协议来设计”这几个字。父类定义了函数的“约定”(或者叫做“协议”),那子类可以改变函数的内部实现逻辑,但不能改变函数原有的“约定”。这里的约定包括:函数声明要实现的功能;对输入、输出、异常的约定;甚至包括注释中所罗列的任何特殊说明
  • 理解这个原则,我们还要弄明白里式替换原则跟多态的区别。虽然从定义描述和代码实现上来看,多态和里式替换有点类似,但它们关注的角度是不一样的。
    • 多态是面向对象编程的一大特性,也是面向对象编程语言中的一种语法。它是一种代码实现的思路
    • 里式替换是一种设计原则,用来指导继承关系中子类该如何设计,子类的设计要保证在替换父类的时候,不改变原有程序的逻辑以及不破坏原有程序的正确性
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值