Liskov Substitution Principle里氏代换原则(LSP)
1.什么是LSP
严格定义:Let q(x) be a property provable about objects x of type T, then q(y) should be provable for objects y of type S where S is a subtype of T.
也就是说假设q(x)是关于类型T的对象x的一个可证明性质,那么q(y)应该对于类型S的对象y的也是可证明的,其中S是T的子类型。
通俗地说,也就是在可以使用父类的场景,都可以用其子类代替而不会有任何问题
2.为什么要遵循LSP
LSP原则保证了子类型多态,也就是客户端可用统一的方式处理不同类型的对象
也正因为这个原则, 使得继承复用成为了可能, 只有当子类可以替换掉父类, 软件单位的功能不受到影响时,父类才能真正被复用, 子类也能够在父类的基础上增加新的行为,而正是由于子类型的可替换性才使得使用父类类型的模块在无需修改的情况下就可以扩展
换言之程序遵循Liskov替换原则,就能降低继承带来的复杂度
而管理复杂度是软件的重要使命之一
3.具体要求
- 子类型可以增加方法,但是不可以删除
- 子类型需要实现抽象类型中的所有未实现的方法
- 子类型中重写的方法必须有相同的或子类型的返回值(即协变co-variance)
- 子类型中重写的方法必须使用相同类型的或符合contra-variance(逆变)的参数
- 子类型中重写的方法不能抛出额外的异常
- 子类型中的不变量要求要更强
- 对于方法的spec,需要子类型的spec更强,即前置条件更弱,后置条件更强
协变co-variance也就是说从父类型到子类型,变的越来越具体
而逆变contra-variance则相反,从子类型到父类型,越来越抽象
4.示例
通过例子来看看在设计时什么时候是符合LSP原则的:
假设我们有两个类,一个长方形,一个正方形,正方形是长方形的子类
又有一个方法resize()能够使得输入的长方形的长增加至超过宽
class Rectangle
{
@invariant h>0 && w>0;
private int h;
private int w;
Rectangle(int h,int w)
{
this.h = h;
this.w = w;
}
public void getH()
{
return this.h;
}
public void getW()
{
return this.w;
}
public setH(int h)
{
this.h = h;
}
}
class Square extends Rectangle
{
@invariant h>0 && w>0 && h==w;
Square(int w)
{
super(w,w);
}
public setH(int h)
{
this.h = h;
this.w = h;
}
}
/**
* 长方形的长不短的增加直到超过宽
* @param r
*/
public void resize(Rectangle r)
{
while (r.getH() <= r.getW() )
{
r.setH(r.getH() + 1);
}
}
如果我们在resize()中输入的是父类型长方形,代码显而易见是可以正常运行的,但是将其替换成其子类型时,我们很容易可以预期到,这个正方形会自增它的边直到数据溢出,也就是说,这个时候用子类代替父类得不到相同的结果,也就不符合LSP原则
那么原因出现在哪里?
出现在我们在不经意间override了setH()方法
所以为了保证遵守LSP原则,尽量不要重写方法,这样就可以保证继承自父类的属性和功能在子类同样适用,如果实在需要重写,就需要遵守上述提到的规则3,4,5,尤其是对于mutator,由于我们不清楚外部调用父类的对象的方法,所以如果重写mutaor方法,就很容易导致错误,比如上例
5.总结
在实际编程中,我们常常会通过重写父类的方法来完成新的功能,这样写起来虽然简单,但是这样很可能会违背LSP原则,使得整个继承体系的可复用性会比较差,特别是运用多态比较频繁时,程序运行出错的几率非常大。