a). 如果对每一个类型为S的对象o1,都有类型为T的对象o2,
使得以T定义的所有程序P在所有的对象o1都代换成o2时,程序P的行为没有变化,
那么类型S是类型T的子类型。
b). 子类型必须能够替换它的基类型。LSP又称里氏替换原则。
对于这个原则,通俗一些的理解就是,父类的方法都要在子类中实现或者重写。 。
a). 在软件中如果能够使用基类对象,那么一定能够使用其子类对象。
把基类都替换成它的子类,程序将不会产生任何错误和异常,
反过来则不成立,如果一个软件实体使用的是一个子类的话,那么它不一定能够使用基类。
b). 里氏代换原则是实现开闭原则的重要方式之一,
由于使用基类对象的地方都可以使用子类对象,因此,
在程序定义中,尽量使用基类类型来对对象进行定义,
在程序运行时,再确定其子类类型,用子类对象来替换父类对象。
某系统需要实现对重要数据(如用户密码)的加密处理,
在数据操作类(DataOperator)中需要调用加密类中定义的加密算法,
系统提供了两个不同的加密类,CipherA和CipherB,它们实现不同的加密方法,
在DataOperator中可以选择其中的一个实现加密操作。
如图所示:
里氏代换原则-图-1
里氏代换原则-图-2
图(一)和图(二)分析:
(图一)中,
如果需要更换一个加密算法类或者增加并使用一个新的加密算法类,
如将CipherA改为CipherAA, 增加一个CipherC,则需要修改客户类Client和数据操作类DataOperator的源代码,违背了开闭原则。
现使用里氏代换原则对其进行重构,使得系统可以灵活扩展,符合开闭原则。
(图二)中,
在程序定义时:
子类CipherB继承了基类CipherA,
并在DataOperator类中定义了基类对象cipherA
在程序运行时,
客户端类Client使用了配置文件config.xml,配置文件指明了运行时使用的子类加密算法CipherB
对于依赖倒置原则,说的是父类不能依赖子类,它们都要依赖抽象类。
这种依赖是我们实现代码扩展和运行期内绑定(多态)的基础。
因为一旦类的使用者依赖某个具体的类,那么对该依赖的扩展就无从谈起;
而依赖某个抽象类,则只要实现了该抽象类的子类,都可以被类的使用者使用,从而实现了系统的扩展。
但是,光有依赖倒置原则,并不一定就使我们的代码真正具有良好的扩展性和运行期内绑定。
请看下面的代码:
public class Animal{
private string name;
public Animal(string name){
this.name = name;
}
public void Description(){
Console.WriteLine("This is a(an) " + name);
}
}
//下面是它的子类猫类:
public class Cat : Animal{
public Cat(string name){
}
public void Mew(){
Console.WriteLine("The cat is saying like 'mew'");
}
}
//下面是它的子类狗类:
public class Dog : Animal{
public Dog(string name){
}
public void Bark(){
Console.WriteLine("The dog is saying like 'bark'");
}
}
//最后,我们来看客户端的调用:
public void DecriptionTheAnimal(Animal animal){
if (typeof(animal) is Cat){
Cat cat = (Cat)animal;
Cat.Decription();
Cat.Mew();
}
else if (typeof(animal) is Dog){
Dog dog = (Dog)animal;
Dog.Decription();
Dog.Bark();
}
}
通过上面的代码,我们可以看到虽然客户端的依赖是对抽象的依赖,
但依然这个设计的扩展性不好,运行期绑定没有实现。
是什么原因呢?
其实就是因为不满足里氏替换原则:
子类如Cat有Mew()方法父类根本没有,Dog类有Bark()方法父类也没有,
两个子类都不能替换父类。
这样导致了系统的扩展性不好和没有实现运行期内绑定。
现在看来,一个系统或子系统要拥有良好的扩展性和实现运行期内绑定,有两个必要条件:
第一是依赖倒置原则;
第二是里氏替换原则。
这两个原则缺一不可。
我们知道,在我们的大多数的模式中,我们都有一个共同的接口,然后子类和扩展类都去实现该接口。
下面是一段原始代码:
if(action.Equals(“add”)){
//do add action
}
else if(action.Equals(“view”)){
//do view action
}
else if(action.Equals(“delete”)){
//do delete action
}
else if(action.Equals(“modify”)){
//do modify action
}
我们首先想到的是把这些动作分离出来,就可能写出如下的代码:
public class AddAction{
public void add(){
//do add action
}
}
public class ViewAction{
public void view(){
//do view action
}
}
public class deleteAction{
public void delete(){
//do delete action
}
}
public class ModifyAction{
public void modify(){
//do modify action
}
}
我们可以看到,这样代码将各个行为独立出来,满足了单一职责原则,
但这远远不够,因为它不满足依赖颠倒原则和里氏替换原则。
下面我们来看看命令模式对该问题的解决方法:
public interface Action{
public void doAction();
}
//然后是各个实现:
public class AddAction : Action{
public void doAction(){
//do add action
}
}
public class ViewAction : Action{
public void doAction(){
//do view action
}
}
public class deleteAction : Action{
public void doAction(){
//do delete action
}
}
public class ModifyAction : Action{
public void doAction(){
//do modify action
}
}
//这样,客户端的调用大概如下:
public void execute(Action action){
action.doAction();
}
看,上面的客户端代码再也没有出现过typeof这样的语句,扩展性良好,也有了运行期内绑定的优点。
子类型必须能够替换掉它们的父类。
LSP优点:
1)、保证系统或子系统有良好的扩展性。
只有子类能够完全替换父类,才能保证系统或子系统在运行期内识别子类就可以了,
因而使得系统或子系统有了良好的扩展性。
2)、实现运行期内绑定,即保证了面向对象多态性的顺利进行。
这节省了大量的代码重复或冗余。
避免了类似instanceof这样的语句,或者getClass()这样的语句,这些语句是面向对象所忌讳的。
3)、有利于实现契约式编程。
契约式编程有利于系统的分析和设计,指我们在分析和设计的时候,定义好系统的接口,然后再编码的时候实现这些接口即可。
在父类里定义好子类需要实现的功能,而子类只要实现这些功能即可。
使用LSP注意点:
1)、此原则和OCP的作用有点类似,其实这些面向对象的基本原则就2条:
A:面向接口编程,而不是面向实现;
B:用组合而不主张用继承
2)、LSP是保证OCP的重要原则
3)、这些基本的原则在实现方法上也有个共同层次,就是使用中间接口层,以此来达到类对象的低偶合,也就是抽象偶合!
4)、派生类的退化函数:
派生类的某些函数退化(变得没有用处),Base的使用者不知道不能调用f,会导致替换违规。
在派生类中存在退化函数并不总是表示违反了LSP,但是当存在这种情况时,应该引起注意。
5)、从派生类抛出异常:
如果在派生类的方法中添加了其基类不会抛出的异常。
如果基类的使用者不期望这些异常,那么把他们添加到派生类的方法中就可以能会导致不可替换性。