定义

里氏替换原则(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);
	}
}

因为正方形的高度和宽度是相等的,所以我们需要重载父类 RectanglesetWidthsetHeight 方法,确保无论是设置宽度、还是设置高度,最后的宽度和高度都是一样的,即保证是正方形。

最后编写一个类,来测试 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 类的 velocity0 ,所以会出现异常。

替换后代码出了问题,所以上边的这个例子是不符合“里氏替换原则”的。这说明,从面向对象继承的特性来看,鸵鸟不是鸟。

讨论

问: 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)

【面向对象设计原则】之原则概述

【面向对象设计原则】之单一职责原则(SRP)

【面向对象设计原则】之开闭原则(OCP)

【面向对象设计原则】之里氏替换原则(LSP)

【面向对象设计原则】之接口隔离原则(ISP)

【面向对象设计原则】之依赖倒置原则(DIP)

References