面向对象设计之里氏替换原则

设计模式专栏:http://t.csdnimg.cn/4Mt4u
思考:什么样的代码才算违反里氏替换原则?

目录

1.里氏替换原则的定义

2.里氏替换原则与多态的区别

3.违反里氏替换原则的反模式

4.总结


1.里氏替换原则的定义

里氏替换原则(Liskov Substitution principle)是由芭芭拉·利斯科夫(Barbara Liskov)在1987年在一次会议上名为"数据的抽象与层次"的演说中首先提出。他当时是这样描述这条原则的:如果S是T的子类型,那么T的对象可以被S的对象所替换,并不影响代码的运行。1966年,Robert Martin在他的SOLID原则中重新描述了里氏替换原则:使用父类对象的函数可以在不了解子类的情况下替换为使用子类对象。

结合Bartbara Liskov和Robert Martin 的描述,我们将里氏替换原则描述为:子类对象(object of subtype/derived class)能够替换到程序(program)中父类对象(object of base/parent class)出现的任何地方,并且保证程序原有的逻辑行为(behavior)不变和正确性不被破坏。

里氏替换原则的定义比较抽象,我们通过一个代码示例进行解释。其中,父类 Transporter 使用org.apache.http库中的 HttpChient 类传输网络数据;子类 SecurityTransporter 继承父类 Transporter增加了一些额外的功能,支持在传输数据的同时传输 appld和 appToken 安全认证信息。

java 复制代码
public class Transporter {
    private Httpclient httpclient;
    public Transporter(Httpclient httpclient){
        this.httpclient = httpclient;
    }
    
    public Response sendRequest(Request request){
        //...省略使用httpclient发送请求的代码逻辑...
    }
}

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 demorunction(Transporter transporter){
        Reugest request = new Request();
        //...省略设置request中数据值的代码.Response         
        response = transporter.sendRequest (request);
        //...省略其他逻辑...
    }
}

//里氏替换原则
Demo demo = new Demo();
demo.demofunction (new securityTransporter(/*省略參数*/);)

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

2.里氏替换原则与多态的区别

不过,读者可能会有疑问:上述代码设计不就是简单利用了面向对象的多态特性吗?多态和里氏替换原则是不是一回事?从上面的代码示例和里氏替换原则的定义来看,里氏替类与多态看起来类似,但实际上它们完全是两回事。

我们还是通过上面的代码示例进行解释。不过,我们需要对SecuityTransporer类中sendRequest0函数稍加改造。改造前,如果appld或 appToken 没有设置,则不做安全校验改造后,如果 appId或 appToken 没有设置,则直接抛出 NoAuthorizationRunfimeException未授权异常。改造前后的代码对比如下。

java 复制代码
//改造前:
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(approken)){
            throw new NoAuthorizationRuntimeException(...);
        }
        request.addPayload ("app-id", appId) ;
        request .addPayload ("app-token", appToken);
        return super.sendRequest(request);
    }
}

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

虽然改造之后的代码仍然可以通过Java的多态语法动态地使用子类 SecwriyTansport替换父类Tansporer,也并不会导致程序编译或运行报错,但是,从设计思路上来讲SecurityTransporter的设计是不符合里氏替换原则的。多态是一种代码实现思路、而里氏替换原则是一种设计原则,用来指导维承关系中子类的设计:在换父类时、确保不改变程的逻辑行为,以及不破坏程序的正确性。

3.违反里氏替换原则的反模式

实际上,里氏替换原则还有一个能落地且更有指导意义的描述,那就是按照协议来设计。在设计子类时,需要遵守父类的行为约定(或称为协议)。父类定义了函数的行为约定,子类可以改变函数内部实现逻辑,但本能改变函数原有的行为约定。这里的行为约定包括函数声明要实现的功能,对输入、输出和异常的约定,以及注释中罗列的任何特殊情况说明等。实际上、这里所讲的父类和子类的关系可以替换成接口和实现类的关系。

为了更好地理解上述内容,我们提供若干违反里氏替换原则的例子。
1.子类违反父类声明要实现的功能

例如,父类定义了一个订单排序函数sortOrdersByAmount(),该函数按照金额从小到大来给订单排序,而子类重写sorOrdersByAmount()之后,按照创建日期来给订单排序。那么,这个子类的设计就违反了里氏替换原则。
2.子类违反父类对输入、输出和异常的约定

在父类中,某个函数约定:运行出错时返回null,获取数据为空时返回空集合(empty collection)。而子类重载此函数之后,重新定义了返回值:运行出错时返回异常(exception),

获取不到数据时返回 null。那么,这个子类的设计就违反了里氏替换原则。

在父类中,某个函数约定:输入数据可以是任意整数,但子类重载此函数之后,只允许输入数据是正整数,如果是负数,就抛出异常,也就是说,子类对输入数据的校验比父类更加产格。那么,这个子类的设计就违反了里氏替换原则。

在父类中,某个函数约定只抛出 ArgumentNullException 异常,那么子类重载此函数之后也只允许抛出 ArgumentNullException异常,否则子类就违反了里氏替换原则。
3.子类违反父类注释中罗列的任何特殊说明

在父类中,定义了一个提现函数 withdraw(),其注释是这样写的:"用户的提现金额不得超过账户余额......",而子类重写 withdraw() 函数之后,针对 VIP 账号实现了透支提现的功能,也就是提现金额可以大于账户余额。那么,这个子类的设计就不符合里氏替换原则。如果想要这个子类的设计符合里氏替换原则,那么,较为简单的办法是修改父类的注释。

以上便是3种典型的违反里氏替换原则的反模式。

除此之外,判断子类的设计实现是否违反里氏替换原则,还有一个小窍门,那就是用父类的单元测试验证子类的代码。如果某些单元测试运行失败,就说明子类的设计实现没有完全遵守父类的约定,子类有可能违反了里氏替换原则。

4.总结

里氏替换原则是面向对象设计的基本原则之一。它强调在软件设计中,子类对象应当能够替换其父类对象,并且替换后,程序的行为应当保持不变。这一原则确保了软件系统的稳定性和可扩展性。

里氏替换原则的核心思想可以概括为:

  1. 子类应当能够替换其父类,并且在替换后,程序的行为应当保持不变。这意味着子类必须完全遵守父类的行为约定,即子类不能改变父类原有的功能。
  2. 子类可以扩展父类的功能,但不能改变父类原有的功能。也就是说,子类在继承父类的基础上,可以添加新的方法或属性,但不能覆盖或修改父类的非抽象方法。

里氏替换原则的实现有助于保持软件系统的稳定性和灵活性。通过使用基类类型来对对象进行定义,可以在运行时根据实际需要替换为不同的子类对象,从而实现多态性。这种设计方式使得软件系统更加易于维护和扩展,同时也提高了代码的可重用性。

然而,在实际应用中,要完全遵守里氏替换原则并不容易。有时,为了实现特定的功能或优化性能,可能会需要对父类的方法进行覆盖或修改。在这种情况下,需要仔细权衡利弊,确保修改后的子类仍然能够保持与父类相似的行为,并且不会对现有的代码产生不良影响。

总之,里氏替换原则是面向对象设计中的重要原则之一,它有助于确保软件系统的稳定性和可扩展性。在设计和开发过程中,应当尽量遵守这一原则,以实现高质量的软件系统。

相关推荐
阿伟*rui36 分钟前
配置管理,雪崩问题分析,sentinel的使用
java·spring boot·sentinel
XiaoLeisj3 小时前
【JavaEE初阶 — 多线程】单例模式 & 指令重排序问题
java·开发语言·java-ee
paopaokaka_luck3 小时前
【360】基于springboot的志愿服务管理系统
java·spring boot·后端·spring·毕业设计
dayouziei3 小时前
java的类加载机制的学习
java·学习
Yaml45 小时前
Spring Boot 与 Vue 共筑二手书籍交易卓越平台
java·spring boot·后端·mysql·spring·vue·二手书籍
小小小妮子~5 小时前
Spring Boot详解:从入门到精通
java·spring boot·后端
hong1616885 小时前
Spring Boot中实现多数据源连接和切换的方案
java·spring boot·后端
aloha_7895 小时前
从零记录搭建一个干净的mybatis环境
java·笔记·spring·spring cloud·maven·mybatis·springboot
记录成长java6 小时前
ServletContext,Cookie,HttpSession的使用
java·开发语言·servlet
睡觉谁叫~~~6 小时前
一文解秘Rust如何与Java互操作
java·开发语言·后端·rust