文章目录
- 概述
- 里氏替换原则问题由来
- 里氏替换的原则
- 里氏替换原则的作用
- Case
- Bad Impl
- Better Impl
- 抽象银行卡类
- 储蓄卡实现类
- 信用卡实现类
- 单元测试
- 小结
概述
里氏替换原则(Liskov Substitution Principle , LSP) 由麻省理工学院计算机科学西教授 Barbara Liskov 于1987年提出, 她提出: 继承必须确保超类所拥有的性质在子类中仍然成立。
里氏替换原则问题由来
有一功能 P1,由类 A 完成。
现需要将功能 P1 进行扩展,扩展后的功能为 P,其中P由原有功能 P1 与新功能 P2 组成。
新功能 P 由类 A 的子类 B 来完成,则子类 B 在完成新功能 P2 的同时,有可能会导致原有功能 P1 发生故障。
里氏替换的原则
如果S是T的子类型,那么所有T类型的对象都可以在不破坏程序的情况下被S类型的对象替换。
简单来说: 子类可以扩展父类的功能,但不能改变父类原有的功能。 也就是说,当子类继承父类时,除了添加新的方法完成新增功能外,尽量不要重写父类的方法。
上面这就话包括了四点含义:
- 子类可以实现父类的抽象方法,但不能覆盖父类的非抽象方法
- 子类可以增加自己特有的方法
- 当子类的方法重载父类的方法时,方法的前置条件(即方法的输入参数)要比父类更宽松
- 当子类的方法实现父类的方法(重写、重载或者实现抽象方法)时,方法的后置条件(即方法的输出或者返回值)要比父类的方法更严格或与父类的方法相等
里氏替换原则的作用
- 里氏替换原则是实现开闭原则的重要方式之一
- 里氏替换解决了继承中重写父类造成的可复用性变差的问题
- 是动作正确性的保证,即类的扩展不会给已有系统引入新的错误,降低了代码出错的可能性
- 加强程序的健壮性,同时变更时可以做到非常好的兼容性,提高程序的维护性、可扩展性,降低需求变更时引入的风险。
Case
关于里氏替换的场景,最有名的就是“正方形不是长方形” , 当然了还有一些关于动物的例子,比如鸵鸟、企鹅都是鸟,但是不会飞。 这样的例子可以非常形象的帮助我们理解里氏替换中关于两个类的继承不能破坏原有特性的含义。
这里我们用个银行卡的场景来描述一下:
储蓄卡、信用卡都可以消费,但信用卡不宜提现,否则产生高额利息。两个类
- 储蓄卡类
- 信用卡类
为了让信用卡可以使用储蓄卡的一些方法,选择由信用卡类继承储蓄卡类,讨论一下里氏替换原则产生的一些要点。
Bad Impl
【储蓄卡】
public class CashCard {
private Logger logger = LoggerFactory.getLogger(CashCard.class);
/**
* 提现
*
* @param orderId 单号
* @param amount 金额
* @return 状态码 0000成功、0001失败、0002重复
*/
public String withdrawal(String orderId, BigDecimal amount) {
// 模拟支付成功
logger.info("提现成功,单号:{} 金额:{}", orderId, amount);
return "0000";
}
/**
* 储蓄
*
* @param orderId 单号
* @param amount 金额
*/
public String recharge(String orderId, BigDecimal amount) {
// 模拟充值成功
logger.info("储蓄成功,单号:{} 金额:{}", orderId, amount);
return "0000";
}
/**
* 交易流水查询
* @return 交易流水
*/
public List<String> tradeFlow() {
logger.info("交易流水查询成功");
List<String> tradeList = new ArrayList<String>();
tradeList.add("100001,100.00");
tradeList.add("100001,80.00");
tradeList.add("100001,76.50");
tradeList.add("100001,126.00");
return tradeList;
}
}
在储蓄卡中包括三个方法: 提现、储蓄、交易流水查询, 这都是模拟储蓄卡的基本功能。
接下来我们通过继承储蓄卡的功能实现信用卡的服务。
【信用卡】
public class CreditCard extends CashCard {
private Logger logger = LoggerFactory.getLogger(CashCard.class);
@Override
public String withdrawal(String orderId, BigDecimal amount) {
// 校验
if (amount.compareTo(new BigDecimal(1000)) >= 0){
logger.info("贷款金额校验(限额1000元),单号:{} 金额:{}", orderId, amount);
return "0001";
}
// 模拟生成贷款单
logger.info("生成贷款单,单号:{} 金额:{}", orderId, amount);
// 模拟支付成功
logger.info("贷款成功,单号:{} 金额:{}", orderId, amount);
return "0000";
}
@Override
public String recharge(String orderId, BigDecimal amount) {
// 模拟生成还款单
logger.info("生成还款单,单号:{} 金额:{}", orderId, amount);
// 模拟还款成功
logger.info("还款成功,单号:{} 金额:{}", orderId, amount);
return "0000";
}
@Override
public List<String> tradeFlow() {
return super.tradeFlow();
}
信用卡的功能实现是在继承了储蓄卡后,进行方法的重写: 支付、还款。 其实交易流水可以复用,也可以不用重写这个。
那看看单元测试是如何使用的?
public class Test {
private Logger logger = LoggerFactory.getLogger(ApiTest.class);
@Test
public void test_CashCard() {
CashCard cashCard = new CashCard();
// 提现
cashCard.withdrawal("100001", new BigDecimal(100));
// 储蓄
cashCard.recharge("100001", new BigDecimal(100));
// 交易流水
List<String> tradeFlow = cashCard.tradeFlow();
logger.info("查询交易流水,{}", JSON.toJSONString(tradeFlow));
}
@Test
public void test_CreditCard() {
CreditCard creditCard = new CreditCard();
// 支付
creditCard.withdrawal("100001", new BigDecimal(100));
// 还款
creditCard.recharge("100001", new BigDecimal(100));
// 交易流水
List<String> tradeFlow = creditCard.tradeFlow();
logger.info("查询交易流水,{}", JSON.toJSONString(tradeFlow));
}
}
这种继承父类方式的优点是复用了父类的核心逻辑功能, 但是也破坏了原有的方法。 此时继承父类实现的信用卡的类并不满足里氏替换的原则。 也就是说,此时的子类不能承担原父类的功能,直接给储蓄卡使用。
Better Impl
信用卡和储蓄卡在功能上有些许类似,在实际开发的过程中也有很多共同的可服用的属性及逻辑。
实现这样的类的最好的方式就是提取出一个抽象类 , 由抽象类定义所有卡的共同核心属性、逻辑, 把卡的支付和还款等动作抽象成正向和逆向操作。
抽象银行卡类
public abstract class BankCard {
private Logger logger = LoggerFactory.getLogger(BankCard.class);
private String cardNo; // 卡号
private String cardDate; // 开卡时间
public BankCard(String cardNo, String cardDate) {
this.cardNo = cardNo;
this.cardDate = cardDate;
}
abstract boolean rule(BigDecimal amount);
// 正向入账,+ 钱
public String positive(String orderId, BigDecimal amount) {
// 入款成功,存款、还款
logger.info("卡号{} 入款成功,单号:{} 金额:{}", cardNo, orderId, amount);
return "0000";
}
// 逆向入账,- 钱
public String negative(String orderId, BigDecimal amount) {
// 入款成功,存款、还款
logger.info("卡号{} 出款成功,单号:{} 金额:{}", cardNo, orderId, amount);
return "0000";
}
/**
* 交易流水查询
*
* @return 交易流水
*/
public List<String> tradeFlow() {
logger.info("交易流水查询成功");
List<String> tradeList = new ArrayList<String>();
tradeList.add("100001,100.00");
tradeList.add("100001,80.00");
tradeList.add("100001,76.50");
tradeList.add("100001,126.00");
return tradeList;
}
public String getCardNo() {
return cardNo;
}
public String getCardDate() {
return cardDate;
}
}
抽象类中提供了卡的基本属性(卡号、开卡时间)及 核心方法。
正向入账: 加钱
逆向入账: 减钱。
接下来我们继承这个抽象类,实现储蓄卡的功能逻辑
储蓄卡实现类
public class CashCard extends BankCard {
private Logger logger = LoggerFactory.getLogger(CashCard.class);
public CashCard(String cardNo, String cardDate) {
super(cardNo, cardDate);
}
boolean rule(BigDecimal amount) {
return true;
}
/**
* 提现
*
* @param orderId 单号
* @param amount 金额
* @return 状态码 0000成功、0001失败、0002重复
*/
public String withdrawal(String orderId, BigDecimal amount) {
// 模拟支付成功
logger.info("提现成功,单号:{} 金额:{}", orderId, amount);
return super.negative(orderId, amount);
}
/**
* 储蓄
*
* @param orderId 单号
* @param amount 金额
*/
public String recharge(String orderId, BigDecimal amount) {
// 模拟充值成功
logger.info("储蓄成功,单号:{} 金额:{}", orderId, amount);
return super.positive(orderId, amount);
}
/**
* 风险校验
*
* @param cardNo 卡号
* @param orderId 单号
* @param amount 金额
* @return 状态
*/
public boolean checkRisk(String cardNo, String orderId, BigDecimal amount) {
// 模拟风控校验
logger.info("风控校验,卡号:{} 单号:{} 金额:{}", cardNo, orderId, amount);
return true;
}
}
储蓄卡类继承抽象父类BankCard ,实现了核心的功能包括规则过滤rule、提现、储蓄 (super.xx), 以及新增的扩展方法:风险校控checkRisk.
这样的实现方式基本满足里氏替换的基本原则:既实现抽象类的抽象方法,又没有破坏父类中的原有方法。
接下来的信用卡类,既可以继承抽象父类,也可以继承储蓄卡类, 但无论那种实现方式,都需要遵从里氏替换原则,不可以破坏父类原有的方法。
信用卡实现类
public class CreditCard extends CashCard {
private Logger logger = LoggerFactory.getLogger(CreditCard.class);
public CreditCard(String cardNo, String cardDate) {
super(cardNo, cardDate);
}
boolean rule2(BigDecimal amount) {
return amount.compareTo(new BigDecimal(1000)) <= 0;
}
/**
* 提现,信用卡贷款
*
* @param orderId 单号
* @param amount 金额
* @return 状态码
*/
public String loan(String orderId, BigDecimal amount) {
boolean rule = rule2(amount);
if (!rule) {
logger.info("生成贷款单失败,金额超限。单号:{} 金额:{}", orderId, amount);
return "0001";
}
// 模拟生成贷款单
logger.info("生成贷款单,单号:{} 金额:{}", orderId, amount);
// 模拟支付成功
logger.info("贷款成功,单号:{} 金额:{}", orderId, amount);
return super.negative(orderId, amount);
}
/**
* 还款,信用卡还款
*
* @param orderId 单号
* @param amount 金额
* @return 状态码
*/
public String repayment(String orderId, BigDecimal amount) {
// 模拟生成还款单
logger.info("生成还款单,单号:{} 金额:{}", orderId, amount);
// 模拟还款成功
logger.info("还款成功,单号:{} 金额:{}", orderId, amount);
return super.positive(orderId, amount);
}
}
信用卡类在继承弗雷后,使用了公共的属性,即卡号、开卡时间, 同时新增了符合信用卡的新方法: loan、repayment, 并且在两个方法中都使用了抽象类的核心功能。
另外,信用卡中新增了自己的规则rule2 , 并没有破话储蓄卡中的校验方法rule .
以上的实现方式都遵循了里氏替换原则下完成的,即子类可以随时替代存储卡类。
单元测试
@Test
public void test_bankCard() {
logger.info("里氏替换前,CashCard类:");
CashCard bankCard = new CashCard("123456", "2023-01-01");
// 提现
bankCard.withdrawal("100001", new BigDecimal(100));
// 储蓄
bankCard.recharge("100001", new BigDecimal(100));
}
【测试信用卡】
@Test
public void test_CreditCard(){
CreditCard creditCard = new CreditCard("123456", "2023-01-01");
// 支付,贷款
creditCard.loan("100001", new BigDecimal(100));
// 还款
creditCard.repayment("100001", new BigDecimal(100));
}
【测试信用卡替换储蓄卡】
@Test
public void test_bankCard() {
logger.info("里氏替换后,CreditCard类:");
CashCard creditCard = new CreditCard("123456", "2023-01-01");
// 提现
creditCard.withdrawal("100001", new BigDecimal(1000000));
// 储蓄
creditCard.recharge("100001", new BigDecimal(100));
}
可以看到,储蓄卡功能正常, 继承储蓄卡实现的信用卡的功能也正常。
同时,原有储蓄卡的功能可以由信用卡类支持,即 CashCard creditCard = new CreditCard
小结
继承作为面向对象的重要特征,虽然给程序的开发带来了极大的便利,但也引入了一些弊端。
继承的开发方式给代码带来了侵入性,可移植能力降低, 类之间的耦合度较高。 当对父类修改时,就要考虑一整套子类的实现是否有风险,测试成本较高。
里氏替换原则的目的是使用约定的方式,让使用继承后的代码具备更良好的扩展性和兼容性。
有些公司的代码规范中是不允许使用多层继承,尤其是一些核心服务的扩展。 而继承多数用在系统架构初期定义好的逻辑上或抽象出的核心功能里。 如果使用了继承,就一定要遵循里氏替换原则,否则会让代码出问题的概率大增。