⾥⽒替换原则
架构师之路之⾥⽒代换原则(Liskov Substitution Principle, LSP)
1 什么是⾥⽒代换原则
⾥⽒代换原则是由⿇省理⼯学院(MIT)计算机科学实验室的Liskov⼥⼠,在1987年的OOPSLA⼤会上发表的⼀篇⽂章《Data Abstraction and Hierarchy》⾥⾯提出来的,主要阐述了有关继承的⼀些原则,也就是什么时候应该使⽤继承,什么时候不应该使⽤继承,以及其中的蕴涵的原理。2002年,我们前⾯单⼀职责原则中提到的软件⼯程⼤师Robert C. Martin,出版了⼀本《Agile Software Development Principles Patterns and Practices》,在⽂中他把⾥⽒代换原则最终简化为⼀句话:“Subtypes mu st be substitutable for their ba types”。也就是,⼦类必须能够替换成它们的基类。
我们把⾥⽒代换原则解释得更完整⼀些:在⼀个软件系统中,⼦类应该可以替换任何基类能够出现的地⽅,并且经过替换以后,代码还能正常⼯作。
2 第⼀个例⼦:正⽅形不是长⽅形
“正⽅形不是长⽅形”是⼀个理解⾥⽒代换原则的最经典的例⼦。在数学领域⾥,正⽅形毫⽆疑问是长⽅形,它是⼀个长宽相等的长⽅形。所以,我们开发的⼀个与⼏何图形相关的软件系统中,让正⽅形继承
⾃长⽅形是顺利成章的事情。现在,我们截取该系统的⼀个代码⽚段进⾏分析:
长⽅形类Rectangle:
class Rectangle {
double length;
double width;
public double getLength() { return length; }
public void tLength(double height) { this.length = length; }
public double getWidth() { return width; }
public void tWidth(double width) { this.width = width; }
}
正⽅形类Square:
class Square extends Rectangle {
public void tWidth(double width) {
super.tLength(width);
super.tWidth(width);
}
public void tLength(double length) {
super.tLength(length);
super.tWidth(length);
}
}土建施工组织设计
由于正⽅形的度和宽度必须相等,所以在⽅法tLength和tWidth中,对长度和宽度赋值相同。类Te
stRectangle是我们的软件系统中的⼀个组件,它有⼀个resize⽅法要⽤到基类Rectangle,resize⽅法的功能是模拟长⽅形宽度逐步增长的效果:
测试类TestRectangle:
class TestRectangle {
public void resize(Rectangle objRect) {
Width() <= Length() ) {
objRect.tWidth( Width () + 1 );
大米的做法}
}
}
我们运⾏⼀下这段代码就会发现,假如我们把⼀个普通长⽅形作为参数传⼊resize⽅法,就会看到长⽅形宽度逐渐增长的效果,当宽度⼤于长度,代码就会停⽌,这种⾏为的结果符合我们的预期;假如
我们再把⼀个正⽅形作为参数传⼊resize⽅法后,就会看到正⽅形的宽度和长度都在不断增长,代码会⼀直运⾏下去,直⾄系统产⽣溢出错误。所以,普通的长⽅形是适合这段代码的,正⽅形不适合。
我们得出结论:在resize⽅法中,Rectangle类型的参数是不能被Square类型的参数所代替,如果进⾏了替换就得不到预期结果。因此,Square类和Rectangle类之间的继承关系违反了⾥⽒代换原则,它们之间的继承关系不成⽴,正⽅形不是长⽅形。
3 第⼆个例⼦:鸵鸟不是鸟
“鸵鸟⾮鸟”也是⼀个理解⾥⽒代换原则的经典的例⼦。“鸵鸟⾮鸟”的另⼀个版本是“企鹅⾮鸟”,这两种说法本质上没有区别,前提条件都是这种鸟不会飞。⽣物学中对于鸟类的定义:“恒温动物,卵⽣,全⾝披有⽻⽑,⾝体呈流线形,有⾓质的喙,眼在头的两侧。前肢退化成翼,后肢有鳞状外⽪,有四趾”。所以,从⽣物学⾓度来看,鸵鸟肯定是⼀种鸟。
我们设计⼀个与鸟有关的系统,鸵鸟类顺理成章地由鸟类派⽣,鸟类所有的特性和⾏为都被鸵鸟类继承。⼤多数的鸟类在⼈们的印象中都是会飞的,所以,我们给鸟类设计了⼀个名字为fly的⽅法,还给出了与飞⾏相关的⼀些属性,⽐如飞⾏速度(velocity)。
鸟类Bird:
class Bird {
double velocity;
public fly() { //I am flying; };
public tVelocity(double velocity) { this.velocity = velocity; };
public getVelocity() { return this.velocity; };
}
鸵鸟不会飞怎么办?我们就让它扇扇翅膀表⽰⼀下吧,在fly⽅法⾥什么都不做。⾄于它的飞⾏速度,不会飞就只能设定为0了,于是我们就有了鸵鸟类的设计。
鸵鸟类Ostrich:
class Ostrich extends Bird {
public fly() { //I do nothing; };
public tVelocity(double velocity) { this.velocity = 0; };
public getVelocity() { return 0; };
}
好了,所有的类都设计完成,我们把类Bird提供给了其它的代码(消费者)使⽤。现在,消费者使⽤Bird类完成这样⼀个需求:计算鸟飞越黄河所需的时间。
对于Bird类的消费者⽽⾔,它只看到了Bird类中有fly和getVelocity两个⽅法,⾄于⾥⾯的实现细节,它不关⼼,⽽且也⽆需关⼼,于是给出了实现代码:
测试类TestBird:
class TestBird {
public calcFlyTime(Bird bird) {
try{
double riverWidth = 3000;
System.out.println(riverWidth / Velocity());
}catch(Exception err){
System.out.println("An error occured!");
西服搭配
}
};
}
如果我们拿⼀种飞鸟来测试这段代码,没有问题,结果正确,符合我们的预期,系统输出了飞鸟飞越黄河的所需要的时间;如果我们再拿鸵鸟来测试这段代码,结果代码发⽣了系统除零的异常,明显不符合我们的预期。
对于TestBird类⽽⾔,它只是Bird类的⼀个消费者,它在使⽤Bird类的时候,只需要根据Bird类提供的⽅法进⾏相应的使⽤,根本不会关⼼鸵鸟会不会飞这样的问题,⽽且也⽆须知道。它就是要按照“所需时间 = 黄河的宽度 / 鸟的飞⾏速度”的规则来计算鸟飞越黄河所需要的时间。
我们得出结论:在calcFlyTime⽅法中,Bird类型的参数是不能被Ostrich类型的参数所代替,如果进⾏了替换就得不到预期结果。因此,Ostrich类和Bird类之间的继承关系违反了⾥⽒代换原则,它们之间的继承关系不成⽴,鸵鸟不是鸟。
4 鸵鸟到底是不是鸟?
“鸵鸟到底是不是鸟”,鸵鸟是鸟也不是鸟,这个结论似乎就是个悖论。产⽣这种混乱有两⽅⾯的原因:
原因⼀:对类的继承关系的定义没有搞清楚。
⾯向对象的设计关注的是对象的⾏为,它是使⽤“⾏为”来对对象进⾏分类的,只有⾏为⼀致的对象才能抽象出⼀个类来。我经常说类的继承关系就是⼀种“Is-A”关系,实际上指的是⾏为上的“Is-A”关系,可以把它描述为“Act-As”。关于类的继承的细节,我们可以单独再讲。
我们再来看“正⽅形不是长⽅形”这个例⼦,正⽅形在设置长度和宽度这两个⾏为上,与长⽅形显然是不同的。长⽅形的⾏为:设置长⽅形的长度的时候,它的宽度保持不变,设置宽度的时候,长度保持不变。正⽅形的⾏为:设置正⽅形的长度的时候,宽度随之改变;设置宽度的时候,长度随之改变。所以,如果我们把这种⾏为加到基类长⽅形的时候,就导致了正⽅形⽆法继承这种⾏为。我们“强⾏”把正⽅形从长⽅形继承过来,就造成⽆法达到预期的结果。
“鸵鸟⾮鸟”基本上也是同样的道理。我们⼀讲到鸟,就认为它能飞,有的鸟确实能飞,但不是所有的鸟都能飞。问题就是出在这⾥。如果以“飞”的⾏为作为衡量“鸟”的标准的话,鸵鸟显然不是鸟;如果按
照⽣物学的划分标准:有翅膀、有⽻⽑等特性作为衡量“鸟”的标准的话,鸵鸟理所当然就是鸟了。鸵鸟没有“飞”的⾏为,我们强⾏给它加上了这个⾏为,所以在⾯对“飞越黄河”的需求时,代码就会出现运⾏期故障。
原因⼆:设计要依赖于⽤户要求和具体环境。
继承关系要求⼦类要具有基类全部的⾏为。这⾥的⾏为是指落在需求范围内的⾏为. A需求期望鸟类提供与飞翔有关的⾏为,即使鸵鸟跟普通的鸟在外观上就是100%的相像,但在A需求范围内,鸵鸟在飞翔这⼀点上跟其它普通的鸟是不⼀致的,它没有这个能⼒,所以,鸵鸟类⽆法从鸟类派⽣,鸵鸟不是鸟。
B需求期望鸟类提供与⽻⽑有关的⾏为,那么鸵鸟在这⼀点上跟其它普通的鸟⼀致的。虽然它不会飞,但是这⼀点不在B需求范围内,所以,它具备了鸟类全部的⾏为特征,鸵鸟类就能够从鸟类派⽣,鸵鸟就是鸟。
所有派⽣类的⾏为功能必须和使⽤者对其基类的期望保持⼀致,如果派⽣类达不到这⼀点,那么必然违反⾥⽒替换原则。在实际的开发过程中,不正确的派⽣关系是⾮常有害的。伴随着软件开发规模的扩⼤,参与的开发⼈员也越来越多,每个⼈都在使⽤别⼈提供的组件,也会为别⼈提供组件。最终,所有⼈的开发的组件经过层层包装和不断组合,被集成为⼀个完整的系统。每个开发⼈员在使⽤别⼈
的组件时,只需知道组件的对外裸露的接⼝,那就是它全部⾏为的集合,⾄于内部到底是怎么实现的,⽆法知道,也⽆须知道。所以,对于使⽤者⽽⾔,它只能通过接⼝实现⾃⼰的预期,如果组件接⼝提供的⾏为与使⽤者的预期不符,错误便产⽣了。⾥⽒代换原则就是在设计时避免出现派⽣类与基类不⼀致的⾏为。
5 如何正确地运⽤⾥⽒代换原则
⾥⽒代换原则⽬的就是要保证继承关系的正确性。我们在实际的项⽬中,是不是对于每⼀个继承关系都得费这么⼤劲去斟酌?不需要,⼤多数情况下按照“Is-A”去设计继承关系是没有问题的,只有极少的情况下,需要你仔细处理⼀下,这类情况对于有点开发经验的⼈,⼀般都会觉察到,是有规律可循的。最典型的就是使⽤者的代码中必须包含依据⼦类类型执⾏相应的动作的代码:
动物类Animal:
public class Animal{
String name;
public Animal(String name) {
this.name = name;
}
public void printName(){
try{
System.out.println("I am a " + name + "!");
}catch(Exception err){
System.out.println("An error occured!");
}
}
}
猫类Cat:
public class Cat extends Animal{
public Cat(String name){dv拍摄
super(name);
}
public void Mew(){
try{
System.out.println("Mew~~~ ");
}catch(Exception err){
System.out.println("An error occured!");
}
}
}
狗类Dog:
public class Dog extends Animal {
public Dog(String name) {
聊斋之龙飞相公super(name);
}
public void Bark(){
try{
System.out.println("Bark~~~ ");
}catch(Exception err){
System.out.println("An error occured!");
}
}
}
不同的用英语怎么说测试类:TestAnimal
public class TestAnimal {
public void TestLSP(Animal animal){
if (animal instanceof Cat ){关于雨水的诗句
Cat cat = (Cat)animal;
cat.printName();
cat.Mew();
}
if (animal instanceof Dog ){
重生现代丹神仙妻Dog dog = (Dog)animal;
dog.printName();
dog.Bark();
}
}
}
象这种代码是明显不符合⾥⽒代换原则的,它给使⽤者使⽤造成很⼤的⿇烦,甚⾄⽆法使⽤,对于以后的维护和扩展带来巨⼤的隐患。实现开闭原则的关键步骤是抽象化,基类与⼦类之间的继承关系就是⼀种抽象化的体现。因此,⾥⽒代换原则是实现抽象化的⼀种规范。违反⾥⽒代换原则意味着违反了开闭原则,反之未必。⾥⽒代换原则是使代码符合开闭原则的⼀个重要保证。
我们常见这样的代码,⾄少我以前的Java和php项⽬中就出现过。⽐如有⼀个⽹页,要实现对于客户资料的查看、增加、修改、删除功能,⼀般Server端对应的处理类中都有这么⼀段:
if(action.Equals(“add”)){
//do add action
}
el if(action.Equals(“view”)){
//do view action
}
el if(action.Equals(“delete”)){
//do delete action
}
el if(action.Equals(“modify”)){
//do modify action
}
⼤家都很熟悉吧,其实这是违背⾥⽒代换原则的,结果就是可维护性和可扩展性会变差。有⼈说:我这么⽤,效果好像不错,⼲嘛讲究那么多呢,实现需求是第⼀位的。另外,这种写法看起来很很直观的,有利于维护。其实,每个⼈所处的环境不同,对具体问题的理解不同,难免局限在⾃⼰的领域内思考问题。对于这个说法,我觉得应该这么解释:作为⼀个设计原则,是⼈们经过很多的项⽬实践,最终提炼出来的指导性的内容。如果对于你的项⽬来讲,显著增加了⼯作量和复杂度,那我觉得适度的违反并不为过。做任何事情都是个度的问题,过犹不及都不好。在⼤中型的项⽬中,是⼀定要讲究软件⼯程的思想,讲究规范和流程的,否则⼈员协作和后期维护将会是⾮常困难的。对于⼩型的项⽬可能相应的要简化很多,可能取决于时间、资源、商业等各种因素,但是多从软件⼯程的⾓度去思考问题,对于系统的健壮性、可维护性等性能指标的提⾼是⾮常有益的。像⽣命周期只有⼀个⽉的系统,你还去考虑⼀⼤堆原则,除⾮脑袋被驴踢了。
实现开闭原则的关键步骤是抽象化,基类与⼦类之间的继承关系就是⼀种抽象化的体现。因此,⾥⽒代换原则是实现抽象化的⼀种规范。违反⾥⽒代换原则意味着违反了开闭原则,反之未必。⾥⽒代换原则是使代码符合开闭原则的⼀个重要保证。
通过⾥⽒代换原则给我们带来了什么样的启⽰?
类的继承原则:如果⼀个继承类的对象可能会在基类出现的地⽅出现运⾏错误,则该⼦类不应该从该基类继承,或者说,应该重新设计它们之间的关系。
动作正确性保证:符合⾥⽒代换原则的类扩展不会给已有的系统引⼊新的错误。