里氏替换原则究竟如何理解?
介绍里氏替换原则的文章非常多,但可能大家看完之后,心中仍然留有疑问,如何去落地实现,如何判断是否影响程序功能。本文将带领大家深入理解里氏替换,一起领略下它的真正面目。
但在此之前,有必要阐述一下,为什么会提出设计原则以及设计原则的作用。
什么是设计原则
设计原则是指导代码设计的经验沉淀,其目的是为了提高软件开发的可维护性。我们知道,程序世界并非一尘不染的,随着业务的发展,之前所设计的流程,会为了适应业务而不断调整改变。 对于开发来说,需要有业务前瞻性,凡事多往前考虑一步,尽量减少因为未来业务改变,而造成系统大范围的改动。一旦大范围改动,势必造成开发和回归的成本。
所以开发的时候,多思考这样的设计是否违背了某些原则,如果能尽量向上述的设计原则靠拢,就能达到可维护性的目的。
常用的有以下设计原则,后续会逐步推出系列文章,一一讲解
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 的独有方法。
1.2 返回值不能比父类宽松
即返回值只能是父类返回值,或者父类返回值的子类。 下面的例子中,父类 A 的返回值是 R1, B 的返回值类型是 R2, 满足规则。而 C 的返回值是 R,不满足规则。
之所以要这样做,是因为如果子类返回的类型更加宽松,会导致调用方调用出错。比如下面的例子中 A 执行了 test 方法,返回值是 R1,此时调用 r1Method 是合法的。而如果用 C 类替换掉 A 类的位置,因为 C 的 test 方法返回是 R,没有 r1Method 方法,所以调用会出错。(先忽略编译失败的问题)
实际上,方法定义上是否满足里氏替换法则,对于静态语言,编译器会做方法定义的合法性校验。更为重要的在于逻辑上是否满足里氏替换原则,这点需要开发人员自己把控, 也更加重要
2. 方法的逻辑
除了在方法定义,在代码逻辑实现的时候,也需要遵循一些约束。做到代码通过了编译校验的同时,在运行中也不会出现意想不到的错误情况。
2.1 入参的逻辑处理不能比父类严格
这里的逻辑处理指的是条件判断,类型转换等等。下面用两个例子来说明。
第一个例子是关于类型转换的逻辑。这里有三个参数类,P,P1,P2,其中 P1 和 P2 是 P 的子类。
A 类提供了 test 方法,并且接收的 入参是 P 类。B 类继承自 A 类,所不同的是, B 类重新实现了 test 方法。
我们注意到, B 的 test 方法中,对入参 P 类进行了强转,虽然是符合语法约束的,但在某些场景下会出现问题。比如在调用 B 类的 test 方法时,传了 P2,那么强转就会失败了。
这个例子并非属于极端例子,如果翻看一下实际的应用代码,我相信比比皆是。 那对于这种场景,我们应该怎样去做调整呢?本质的问题在于,为什么要在代码逻辑中,转成具体的子类 P1 去操作? 因为要使用 P1 的独有方法。
对此我们可以倒推,原本父类定义中的接口入参类型已经满足不了我们的需求。解决的方法是,考虑重新对父类的入参进行抽象,将子类的 P1 独有方法沉淀进 P 类。
第二个例子是关于条件判断的,子类不能比父类严格。B 类重新实现了 test 方法,并且对于入参的前置条件判断,由原来的 i<0 改成了 i<=0,比父类多校验了 i=0 的场景,更加严格。
这样会导致,当用 B 类替换 A 类的位置时,对于 i=0 的场景就会抛异常。因为前置约束条件不一样了。
2.2 返回值的逻辑处理不能比父类宽松
跟入参的逻辑处理类似。 这里直接举例说明。
A 类有个 runTask 方法,用于执行一个任务列表,只有所有任务执行成功,就返回 1,否则为 0。B 类重新实现了该方法,如果任务为空,返回了 2,表示忽略任务。
这样会导致的问题是,对于调用方,2 是一个未知的状态,并不知道如何处理,在运行时很有可能会有异常。
2.3 不能违背函数声明的语义
这个比较好理解,比如父类的方法要实现的是升序排序,但是子类继承之后实现的是降序排序,这个就严重违背函数本来要实现的功能了。
里氏替换和多态的关系
多态指的是,子类可以替换掉父类。这里更多的是描述面向对象语言的特性。比如 JAVA 中是支持多态的,子类可以替换父类所在位置。
里氏替换原则的实现,是基于多态的能力之上,多了一个条件,就是替换掉之后,得保证功能不变。更多的是一种规则约束。
也就是说,当我们要使用多态能力的时候,需要考虑替换类实现时,是否会影响系统功能,也就是要考虑是否违反里氏替换原则。
结尾
给大家留点思考空间。对于前面所具的例子,我们说子类的入参判断条件 i<=0 比父类的 i<0 更加严格,因此违背里氏替换原则,因此我们不能这么去做。那如果今天业务逻辑就是有调整了,为了满足里氏替换原则,我们应该怎么去改动代码?可以在评论区回复讨论
欢迎大家关注我的个人公众号,我会基于个人经验总结,不定时发布互联网原创深度好文,带大家深入理解每个知识点,杜绝水文。
版权声明: 本文为 InfoQ 作者【磐远】的原创文章。
原文链接:【http://xie.infoq.cn/article/45499e3ef9fad441bc9a43639】。文章转载请联系作者。
评论