【面向对象设计原则】之里氏替换原则(LSP)
定义
里氏替换原则(Liskov Substitution principle, LSP)是对子类型的特别定义。它由2008年图灵奖得主、美国第一位计算机科学女博士 芭芭拉·利斯科夫(Barbara Liskov)教授在1987年在一次会议上名为“数据的抽象与层次”的演说中首先提出。
里氏替换原则的内容可以描述为: “派生类(子类)对象可以在程式中代替其基类(超类)对象。
” 以上内容并非利斯科夫的原文,而是译自 罗伯特·马丁(Robert Martin) 对原文的解读。其原文为:
Let q(x) be a property provable about objects x of type T. Then q(y) should be true for objects y of type S where S is a subtype of T
芭芭拉·利斯科夫 与 周以真(Jeannette Wing) 在1994年发表论文并提出以上的Liskov代换原则。
从这个概念可以看出这个原则是面向对象多态的一种具体实践。通俗来讲 “老爸能干的事情,儿子都能干”, 因为儿子继承了老爸的基因。 反过来讲就不对了,时代在变化,新一代虽然继承了老一代的优良传统,但是在时代的影响下,新一代有了一些新的特性,老一代可能就不具备了,比如现在的年轻人会打游戏,但是他爸不一定会。老爸会骑自行车,换成儿子也能骑。
同样的里氏代换原则告诉我们,在软件中将一个基类对象替换成它的子类对象,程序将不会产生任何错误和异常,反过来则不成立,如果一个软件实体使用的是一个子类对象的话,那么它不一定能够使用父类对象。
可替换案例
我们定义一个父类叫 Animal
, 其包含一个方法叫 Say
如下:
1
2
3
4
5
6
7
8
9
10
11
public class Animal {
private readonly string _sayContent;
public Animal(string sayContent) {
_sayContent = sayContent;
}
public virtual void Say() {
Console.WriteLine($"Animal Say:{_sayContent}");
}
}
再定义一个子类 Pig
集成自 Animal
,并覆盖父类中的 Say
方法如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
public class Pig:Animal {
private readonly string _sayContent;
public Pig(string sayContent) : base(sayContent)
{
_sayContent = sayContent;
}
public override void Say()
{
Console.WriteLine($"Pig Say:{_sayContent}");
}
}
现在我们在调用方创建一个 Animal
的对象并调用 Say
方法:
1
2
Animal animal = new Animal("This is a parent class.");
animal.Say();
输出结果:
1
Animal Say:This is a parent class.
下来我们创建一个 Pig
对象赋给 animal
对象并调用 Say
方法:
1
2
3
4
5
6
7
8
9
static void Main(string[] args) {
Animal animal = new Animal("This is a parent class.");
animal.Say();
animal = new Pig("This is a sub class.");
animal.Say();
Console.ReadKey();
}
可以看出将子类的对象赋给父类的对象,并且得到了我们期望的结果。
不可替换案例
正方形不是长方形
新建一个长方形类,代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Rectangle {
private int width;
private int height;
public Rectangle(int width,int height){
this.width=width;
this.height=height;
}
public int getWidth() {
return width;
}
public void setWidth(int width) {
this.width = width;
}
public int getHeight() {
return height;
}
public void setHeight(int height) {
this.height = height;
}
}
定义一个正方形类继承自长方形类,代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
class Square extends Rectangle{
public Square(int size) {
super(size, size);
}
public void setWidth(int width) {
super.setWidth(width);
super.setHeight(width);
}
public void setHeight(int height) {
setWidth(height);
}
}
因为正方形的高度和宽度是相等的,所以我们需要重载父类 Rectangle
的 setWidth
和 setHeight
方法,确保无论是设置宽度、还是设置高度,最后的宽度和高度都是一样的,即保证是正方形。
最后编写一个类,来测试 Rectangle
类,代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
public class TestRectangle {
public static void main(String[] args) {
resize(new Rectangle(10,8));
}
static public void resize(Rectangle rect) {
while (rect.getHeight() <= rect.getWidth()) {
rect.setHeight(rect.getHeight() + 1);
System.out.println("Width:" + rect.getWidth() + ",Height:" + rect.getHeight());
}
}
}
执行上面代码,程序输出:
1
2
3
Width:10,Height:9
Width:10,Height:10
Width:10,Height:11
可以看出,当 Height
超过 Width
时,程序就结束了,与 resize
方法的预期一致。
按照”里氏替换原则
”的说法:“子类应该可以替换任何基类能够出现的地方,并且经过替换以后,代码还能正常工作”。我们用 Square
替换 TestRectangle
类中 main
方法中的 Rectangle
类,代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
public class TestRectangle {
public static void main(String[] args) {
resize(new Square(8));
}
static public void resize(Rectangle rect) {
while (rect.getHeight() <= rect.getWidth()) {
rect.setHeight(rect.getHeight() + 1);
System.out.println("Width:" + rect.getWidth() + ",Height:" + rect.getHeight());
}
}
}
运行上面程序,发现 resize
方法进入了死循环,说明替换后的代码出了问题,所以上边的这个例子是不符合“里氏替换原则”的。这说明,从面向对象继承的特性来兰,正方形不是长方形。
鸵鸟不是鸟
新建一个 Bird
类,代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
class Bird {
private int velocity;
public int getVelocity() {
return velocity;
}
public void setVelocity(int velocity) {
this.velocity = velocity;
}
public void fly(){
System.out.println("I can fly!");
}
}
定义一个鸵鸟 Ostrich
类继承 Bird
类,代码如下:
1
2
3
4
5
6
7
8
9
10
11
class Ostrich extends Bird{
public int getVelocity() {
return super.getVelocity();
}
public void setVelocity(int velocity) {
super.setVelocity(0);
}
public void fly(){
System.out.println("I can not fly!");
}
}
因为鸵鸟是无法飞的,其飞行速度(velocity
)应该为0
,所以需要重写父类的相关方法,如上面代码,使之符合鸵鸟的特点。
最后编写一个类,来使用 Bird
类,代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
public class TestBird{
public static void main(String[] args) {
Bird bird = new Bird();
bird.setVelocity(100);
calcFlyTime(bird,1000);
}
static void calcFlyTime(Bird bird,int length){
int time = length/bird.getVelocity();
System.out.println("flyTime:"+time);
}
}
上面代码是用于计算鸟飞行给定路程所需的时间。执行上面代码,程序输出:
1
flyTime:10
可以看出,程序运行正常,与预期一致。
按照”里氏替换原则”
的说法:“子类应该可以替换任何基类能够出现的地方,并且经过替换以后,代码还能正常工作
”。我们用 Ostrich
替换 TestBird
类中 main
方法中的 Bird
类,代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
public class TestBird{
public static void main(String[] args) {
Bird bird = new Ostrich();
bird.setVelocity(100);
calcFlyTime(bird,1000);
}
static void calcFlyTime(Bird bird,int length){
int time = length/bird.getVelocity();
System.out.println("flyTime:"+time);
}
}
运行上面程序,发现程序抛出“java.lang.ArithmeticException: / by zero
”异常,出现整数除零异常。这是因为 Ostrich
类的 velocity
为 0
,所以会出现异常。
替换后代码出了问题,所以上边的这个例子是不符合“里氏替换原则”的
。这说明,从面向对象继承的特性来看,鸵鸟不是鸟。
讨论
问: JAVA中,多态是不是违背了里氏替换原则??
里氏替换原则要求子类避免重写父类方法,而多态的条件之一却是要求子类重写父类的方法。所以,我搞不懂里氏替换原则与继承,多态之间的关系。求大神解答,初学小弟跪拜。
作者:techtalk
链接:https://www.zhihu.com/question/27191817/answer/145013324
LSP的原定义比较复杂,我们一般对里氏替换原则 LSP的解释为:子类对象能够替换父类对象,而程序逻辑不变。里氏替换原则有至少以下两种含义:
- 如果继承是为了实现代码重用,也就是为了共享方法,那么共享的父类方法就应该保持不变,不能被子类重新定义。子类只能通过新添加方法来扩展功能,父类和子类都可以实例化,而子类继承的方法和父类是一样的,父类调用方法的地方,子类也可以调用同一个继承得来的,逻辑和父类一致的方法,这时用子类对象将父类对象替换掉时,当然逻辑一致,相安无事。
- 如果继承的目的是为了多态,而多态的前提就是子类覆盖并重新定义父类的方法,为了符合LSP,我们应该将父类定义为抽象类,并定义抽象方法,让子类重新定义这些方法,当父类是抽象类时,父类就是不能实例化,所以也不存在可实例化的父类对象在程序里。也就不存在子类替换父类实例(根本不存在父类实例了)时逻辑不一致的可能。
不符合LSP的最常见的情况是,父类和子类都是可实例化的非抽象类,且父类的方法被子类重新定义,这一类的实现继承会造成父类和子类间的强耦合,也就是实际上并不相关的属性和方法牵强附会在一起,不利于程序扩展和维护。
如何符合LSP?总结一句话 ——
就是尽量不要从可实例化的父类中继承,而是要使用基于抽象类和接口的继承
。说白了,就是大家都基于抽象去编程,而不要基于具体。这样也就可以实现:对扩展(基于抽象)是开放的,对变更(基于具体)是禁止的。
里氏转换原则要求子类从抽象继承而不是从具体继承,如果从抽象继承,子类必然要重写父类方法。因此里氏转换原则和多态是相辅相成的!至于你说的第一条没有听说过。
刚才看了几篇文章,作者说的是,里氏转换原则要避免重写父类的非抽象方法,而多态的实现是通过重写抽象方法实现的,所以并不冲突。
不违反里氏替换的多态:重写父类的抽象方法
其核心思想是:子类必须能够替换其基类。这一思想体现为对继承机制的约束规范,只有子类能够替换基类时,才能保证系统在运行期内识别子类,这是保证继承复用的基础。在父类和子类的具体行为中,必须严格把握继承层次中的关系和特征,将基类替换为子类,程序的行为不会发生任何变化。同时,这一约束反过来则是不成立的,子类可以替换基类,但是基类不一定能替换子类。 Liskov替换原则,主要着眼于对抽象和多态建立在继承的基础上,因此只有遵循了Liskov替换原则,才能保证继承复用是可靠地。实现的方法是面向接口编程:将公共部分抽象为基类接口或抽象类,通过
Extract Abstract Class
,在子类中通过覆写父类的方法实现新的方式支持同样的职责。 Liskov替换原则是关于继承机制的设计原则,违反了Liskov替换原则就必然导致违反开放封闭原则。 Liskov替换原则能够保证系统具有良好的拓展性,同时实现基于多态的抽象机制,能够减少代码冗余,避免运行期的类型判别。作者:Denley丶垒
链接:https://www.jianshu.com/p/5d3b8a8dabc6
总结
我们上面通过两个经典的案例来说明了“里氏替换原则”,可以看出,违背“里氏替换原则”的继承会给程序带来问题。
我们还可以从另外一个角度来理解“里氏替换原则”,即 “子类可以扩展父类的功能,但不能改变父类原有的功能
”。从上面两个例子可以看出,正是因为子类改变了父类的行为(重写了父类的非抽象方法),给程序带来问题。为了避免违背“里氏替换原则”,我们在进行子类设计时需要遵循如下4点规则:
- 子类可以实现父类的抽象方法,但不能覆盖父类的非抽象方法。
- 子类中可以增加自己特有的方法。
- 当子类的方法重载父类的方法时,方法的前置条件(即方法的形参)要比父类方法的输入参数更宽松。
- 当子类的方法实现父类的抽象方法时,方法的后置条件(即方法的返回值)要比父类更严格。
看到这里,大家可能有个疑问,因为回顾下自己写的代码,我们很多时候会发现自己写的代码常常会违背“里氏替换原则”,程序照样跑的好好的,是不是说明违背“里氏替换原则”也没啥关系。这只能说,在当前的场景下代码是没问题的,但是你这个代码出问题的几率将会大大增加。一旦场景发生变化,代码进行变更后,就可能出现意想不到的问题。
出现违背“里氏替换原则”的原因在于错误的使用了继承。我们知道,继承的一个目的就是为了共享,使子类可以直接使用父类的功能,避免重复实现。上面两个案例中,无论是正方形和长方形,还是鸵鸟和一般的鸟,它们之间都有很多共有的功能,那不能用继承,我们该怎么解决共享问题呢?这里有两个解决建议,我们以正方形和长方形为例:
- 方案一:设计一个基类,实现正方形和长方形的一些公用功能,然后让正方形和长方形分别继承这个基类。
- 方案二:将正方形和长方形的一些公用功能,放到一个通用的类中。然后让正方形和长方形类分别包含(引用)这个通用类,然后使用这个通用类中的功能。
最后,我们再说一点关于面向对象的继承特性。 继承作为面向对象三大特性之一,在给程序设计带来便利的同时,也带来了弊端。比如使用继承会给程序带来侵入性,程序的可移植性降低,增加了类之间间的耦合性,如果一个类被其他的类所继承,则当这个类需要修改时,必须考虑到所有的子类,并且父类修改后,所有涉及到子类的功能都有可能会产生故障。 所以,现在的面向对象的设计,一般建议少用继承,多用组合。如果一定要用继承,必须严格的遵守“里氏替换原则”。
里氏替换原则
是实现 开闭原则
的重要方式之一(其实其它原则都是实现 开闭原则OCP
重要方式之一,上一篇 【面向对象设计原则】之开闭原则(OCP) 有提及),由于使用父类对象的地方都可以使用子类对象,因此在程序中尽量使用父类类型来对对象进行定义,而在运行时再确定其子类类型,用子类对象来替换父类对象。通常我们会使用接口或者抽象方法定义基类,然后子类中实现父类的方法,并在运行时通过各种手段进行类型选择调用(比如反射)。
在使用里氏替换原则时需要注意如下几个问题:
-
子类的所有方法必须在父类中声明,或子类必须实现父类中声明的所有方法。根据里氏替换原则,为了保证系统的扩展性,在程序中通常使用父类来进行定义,如果一个方法只存在子类中,在父类中不提供相应的声明,则无法在以父类定义的对象中使用该方法。
-
我们在运用里氏替换原则时,尽量把父类设计为抽象类或者接口,让子类继承父类或实现父接口,并实现在父类中声明的方法,运行时,子类实例替换父类实例,我们可以很方便地扩展系统的功能,同时无须修改原有子类的代码,增加新的功能可以通过增加一个新的子类来实现。
里氏替换原则
是开闭原则
的具体实现手段之一。这也就是我们应该更多的依赖抽象,尽量少的依赖实现细节, 其实就是我们 下一篇 要讲的依赖倒置原则(DIP)
。