- 贯穿设计模式:用一个电商项目详解设计模式
- 伟山育琪
- 2292字
- 2024-12-28 11:44:55
1.2.3 里氏替换原则
里氏替换原则(Liskov Substitution Principle,LSP)最初由Barbara Liskov在1987年的一次学术会议中提出,里氏替换原则是一种针对子类和父类关系的设计原则。在我们工作和学习过程中,会经常接触到子类与父类,里氏替换原则早已被我们潜移默化地使用了,并不是什么新鲜事,有基础的读者可以快速浏览或跳过本小节内容。下面我们对该原则进行更加细致的说明。
(1)子类需要实现父类中所有的抽象方法(为实现“替换”做好准备)。
看到这里的读者可能会微微一笑,子类不实现父类的抽象方法,连开发工具(如IDEA、Eclipse等)都不同意,开发工具会自动提示我们进行抽象方法的覆写。那么,为实现“替换”做好准备如何理解呢?
我们先来分别看看ArrayList、LinkedList和AbstractSequentialList的类结构。
我们可以看到,ArrayList和LinkedList都属于List接口的子类,都属于AbstractList抽象类的子类(虽然LinkedList中间还有一个AbstractSequentialList的父类,但在整个继承链上依然是AbstractList的子类)。现在我们基于ArrayList和LinkedList书写以下代码来展示“替换”的精妙之处。
通过以上代码,我们看到了,addElement方法的第一个参数虽然为父类List类型,但可以支持传入任何List类型的子类型。这并不是什么新的伎俩,我们刚刚接触JavaSE对象多态的学习中,就可能已经这样做了,只不过没有那么高级的修饰(里氏替换原则)词罢了。
(2)子类可以加入自己的特有方法及属性。
龙生九子,九子各不同,但都是龙族的血脉。如果子类没有自己的特色,与父类完全一样,那我们何必多此一举进行继承呢?这部分的知识是非常容易理解的,LinkedList中有addFirst方法而ArrayList却没有,这就是LinkedList作为子类的一个独特属性。
总以JDK源码进行说明多少会有些乏味,这次,我们以Spring源码中BeanFactory为例进行说明。
BeanFactory作为最顶端(最基础)的父类接口,仅仅包含了对Bean的获取、类型获取及判断等相关方法,随着我们对Spring的使用越来越深入,这些方法肯定是不能够满足我们日常使用的。那么我们来看看作为BeanFactory子类,Spring中Bean加载的核心类——DefaultListableBeanFactory中的方法,如图1-1所示。
图1-1
我们可以看到,DefaultListableBeanFactory中有了更加细节的方法,比如方法isPrimary(String,Object),它关注Bean上的@Primary注解;再比如getPriority(String,Object)方法,它关注Bean上的@Priority注解。作为子类的DefaultListableBeanFactory有了自己独有的方法,为使用者提供了更加广阔的平台(很遗憾这不是一本解读Spring源码的书籍,所以不能过多展开Spring源码的讲解,我相信未来会有机会为大家进行Spring源码的讲解)。
(3)关于子类覆盖父类已实现方法(父类非抽象方法)的讨论。
相信部分读者在一些博客论坛上看到过这样一句话:“子类覆盖父类已实现方法,可以放大方法入参的类型”。笔者认为,这句话失之偏颇,并不是对里氏替换原则的正确解读。为了避免读者被此观点误导,请允许我首先以非源码的示例对此观点进行描述,因为笔者翻阅了大量源码,没有找到能够印证此观点的源码示例。
一些资料上进行了以下代码示例。
我们可以看到,子类SubClass的process方法放大了参数类型,采用List接口为入参类型;父类BaseClass的process方法入参为ArrayList类型。当我们运行main函数时,发现无论是父类执行方法还是子类执行方法,得到的结果都是打印了“BaseClass take process!”。按照这个观点来说,子类对象可以完美地“替换”父类对象,而不会导致结果的改变。乍一看,好像很有道理,没什么问题,可是大家有没有想过以下问题。
· 我们new SubClass()对象是为了什么?不就是希望使用SubClass中的方法吗?然而因为参数的放大,即便是使用SubClass对象调用方法,还是依然会执行父类的逻辑,这叫“替换”吗?
· 我们在SubClass对象中创建同样的方法是为了什么?不就是为了能够与父类BaseClass的方法执行逻辑有所不同吗?然而因为参数的放大,SubClass中的方法无法执行,总是执行父类BaseClass的方法,这叫“替换”吗?
· 我们为什么要在子类里放大参数类型?正确的父子关系,不都是应该父类采用范围更大的类型(泛型T),然后子类定义确切的类型吗?难道仅仅是为了展现所谓的“替换”思想刻意为之吗?既然使用子类对象调用方法,就是为了使用子类的方法,而不是为了达到所谓的“替换”,进而埋没了子类的方法。而现实开发之中,我们真正创建对象的时候,也都是创建的子类对象,创建List对象,大部分会选用子类new ArrayList(),而不是创建父类对象。
以上的代码示例,仅仅是一个方法“重载”(方法名称一致,返回值一致,方法入参不一致)的障眼法,为什么两次打印结果一致的根本原因是方法入参都是new ArrayList()呢?在JVM中,“重载”是“静态分派”的经典实现,方法参数的“静态类型”在编译期已经确定了,由于“静态类型”在编译期可知,所以在编译阶段,Javac编译器就会根据参数的“静态类型”决定了使用哪个重载版本,所谓的子类参数类型放大而演示出来的效果,无非就是借助了JVM的“静态分派—重载”。
那我们应该如何覆写父类中已经实现的方法呢?很简单,使用@Override,保持方法名称、返回值和方法参数一致即可。就好比我们在覆写Object类中的hashCode方法,代码如下:
此外,我依然想为读者引入Spring源码的示例,一是为了再次印证如何正确地覆写父类方法,二是为了尽最大可能为读者提供更多的扩展内容。我们来看看Spring容器对BeanDefinition注册的设计。
首先定义了父类interface BeanDefinitionRegistry,并添加抽象方法void registerBeanDefinition供子类实现,代码如下:
再来看看Spring容器相关子类GenericApplicationContext对这个方法的实现,直接采用@Override注解进行,对方法整体结构没有任何修改。除此之外,GenericApplicationContext作为Spring的ApplicationContext相关类,也完美地完成了DefaultListableBeanFactory的初始化工作,通过无参构造进行初始化,成为了连接Bean工厂DefaultListableBeanFactory和BeanDefinition注册的容器(容器也称为上下文),代码如下:
到这里依然没有结束,上边的源码中展现了GenericApplicationContext容器类对Bean工厂的初始化,所有的BeanDefinition最终都会注册到Bean工厂,那么我们来看看Bean工厂DefaultListableBeanFactory的源码,依然是使用@Override注解,不改变方法结构,覆写registerBeanDefinition方法,完成最终BeanDefinition的register,代码如下:
无论从实际开发角度还是从源码角度进行印证,正确的子类覆盖父类非抽象方法的途径就是在不改变整体方法结构的前提下直接进行@Override。所谓的子类方法扩大方法入参类型,根本没有任何落地的实际意义。