【设计原则篇】里氏替换原则(LSP)
作者:互联网
OCP背后的主要机制是抽象(abstraction)和多态(polymorphism)。在静态类型语言中,比如C++和Java,支持抽象和多态的关键机制之一是继承(inheritance)。正式使用了继承,我们才可以创建实现其基类(base class)中抽象方法的派生类。
是什么设计规则在支配着这种特殊的继承用法呢?最佳的继承层次的特征又是什么呢?怎样的情况会使我们创建的类层次结构掉进不符合OCP的陷阱中去呢?这些正是Liskov替换原则要解答的问题。
1、Liskov里氏替换原则(Liskov Substitution Principle)
对于LSP可以做如下解释:
子类型(subtype)必须能够替换掉它们的基类型(base type)。
任何父类出现的地方都可以使用其子类进行替换。
想想违反该原则的后果,LSP的重要性就不言而喻了。假设有一个函数f,它的参数为指向某个基类B的指针(pointer)或者引用(reference)。同样假设B的某个派生类D,如果把D的对象作为B类型传递给f。会导致f出现错误的行为。那么D就违反了LSP。显然,D对于f来说是脆弱的。
f的编写者会想去对D进行一些测试,以便于在把D的对象传递给f时,可以使f具有正确的行为。这个测试违反了OCP,因为此时f对于B的所有不同的派生类都不再是封闭的(因为如果要满足要求,势必需要修改f的逻辑,让其适应B的所有派生类,这就违反了开闭原则)。
这样的测试是一种代码的臭味,他是缺乏经验的开发人员(或者匆忙的开发人员)在违反LSP时所产生的后果。
public class B { }
public class D : B { }
public class A
{
public void f(B b)
{
// your code
}
}
public class Program
{
static void Main(string[] args)
{
var b = new B();
var a = new A();
// right
a.f(b);
// run error
// D违背了LSP
var d = new D();
a.f(d);
Console.ReadLine();
}
}
2、一个违反LSP的简单例子
对于LSP的违背,常常会导致以明显违反OCP的方式使用运行时类型辨别(Runtime type identification).这种方式常常是使用一个显示的if语句或者if/else链去确定一个对象的类型,从而选择针对该类型的正确行为。
参考一下代码片段1
代码片段 1 对LSP的违背导致了对OCP的违背
public class Point
{
double x;
double y;
}
public enum ShapeType
{
Square,
Circle
}
public class Shape
{
public ShapeType ItsType;
public Shape(ShapeType shareType)
{
this.ItsType = shareType;
}
}
public class Circle : Shape
{
public Circle() : base(ShapeType.Circle)
{ }
public void Draw()
{
// your code
}
// 圆心
Point ItsCenter;
// 半径
double ItsRadius;
}
public class Square : Shape
{
public Square() : base(ShapeType.Square)
{ }
public void Draw()
{
// your code
}
// 正方形的坐标
Point ItsTopLeft;
// 正方形的边
double ItsSide;
}
public class Program
{
static void Main(string args)
{
}
static void DrawShape(Shape shape)
{
if (shape.ItsType == ShapeType.Square)
{
(shape as Square).Draw();
}
else if (shape.ItsType == ShapeType.Circle)
{
(shape as Circle).Draw();
}
}
}
很显然,程序1中的DrawShape函数违反了OCP。它必须知道Shape类所有的派生类,并且每次创建一个从Shape类派生的新类时都必须要更改它,甚至很多人肯定地认为这种函数结构简直是对良好设计的诅咒。那么,是什么促使程序员编写出这样的一个函数呢?
假设Joe是一个工程师。他学习过面向对象的技术,并且认为多态的开销大的难以忍受。因此他定义了一个没有任何虚函数的Shape类。类(结构)Square和Circle从Shape类派生,并具有Draw()函数,但是它们没有覆写(overide)Shape类中的函数。因为Circle类和Square类不能替换Shape类,所以DrawShape函数必须要仔细检查输入的Shape对象,确定他的类型,接着调用正确的Draw函数。
Square类和Circle类不能替换Shape类其实是违反了LSP,这个违反又迫使DrawShape函数违反了OCP,因而,对于LSP的违反也潜在地违反了OCP。
3、正方形和矩形,更微妙的违规
当然存在更微妙违反LSP的方式。考虑一个使用了代码片段2中描述的Rectangle类。
代码片段2 Rectangle类
public class Rectangle
{
private Point ItsTopLeft;
private double ItsWidth;
private double ItsHeight;
public void SetWidth(double width)
{
ItsWidth= width;
}
public void SetHeight(double height)
{
ItsHeight= height;
}
public double GetHeight()
{
return ItsHeight;
}
public double GetWidth()
{
return ItsWidth;
}
}
假设这个应用程序运行的很好,并被在许多地方引用。和任何一个成功的软件一样,用户的需求不时会发生变化。某一天,用户不满足于仅仅操作矩形,要求添加操作正方形的功能。
我们经常说继承是IS-A的关系。也就是说,如果一个新类型的对象被认为和一个已有类的对象之间满足IS-A的关系,那么这个新对象的类型应该是从这个已有对象的类的派生。
从一般意义上来讲,一个正方形就是一个矩形。因此,把Square类视为从Rectangle类派生是合乎逻辑的。参见图1.
图 1 Sqare从Rectangle继承
IS-A关系的这种用法有时被认为是面向对象分析(Object Oriented Analysis,简称OOA)基本技术之一。一个正方形是一个矩形,所以Square类就应该派生自Rectangle类。不过,这种想法会带来一些微妙但极为值得重视的问题。一般来说,这些问题是很难预见的,直到我们编写代码时才会发现它们。
我们首先注意到出问题的地方是,Square类并不同时需要成员变量ItsHeight和ItsWidth。但是Square仍会从Rectangle中继承它们。显然这是浪费。在许多情况下,这种浪费是无关紧要的。但是,如果我们必须要创建成百上千个Square对象,比如,在CAD(计算机辅助设计)/CAM(计算机辅助制造)中复杂的电路的每个元件的管脚引线都作为正方形进行绘制,浪费的程度则是巨大的。
假设目前我们并不关心内存效率。从Rectangle派生Square也会产生其他一些问题。Square会继承SetWidth和SetHeight函数。这两个函数对于Square来说是不合适的,因为正方形的长和宽是相等的。这是表明存在问题的重要标志。不过这个问题是可以避免的。我们可以按照如下方式覆写SetWidth和SetHeight;
public new void SetWidth(double width)
{
base.SetWidth(width);
base.SetHeight(width);
}
public new void SetHeight(double height)
{
base.SetWidth (height);
base.SetHeight (height);
}
现在,当设置Square对象的宽时,它的长会相应地改变。当设置长时,宽也会随之改变。这样就保持了Square要求的不变性。Square对象是具有严格数学意义下的正方形。
但是考虑下面的这个函数
void f(Rectangle r)
{
r.SetWidth(2);
}
如果我们向这个函数传递一个指向Square对象的引用,这个Square对象就会被破坏,因为它的长不会改变。这违反了LSP,如果要这个函数满足Square的要求,间接也会违背OCP。以Rectangle的派生类的对象作为参数传入时,函数f不能正确运行。错误的原因是在Rectangle中没有把SetWidth和SetHeight声明为虚函数;因此它们不是多态的
这个错误很容易修正。然而,如果新派生类的创建会导致我们改变基类,这就常常意味着设计是有缺陷的。当然也违反了OCP。也许有人会反驳说,真正的设计缺陷是忘记把SetWidth和SetHeight声明为虚函数,而我们已经做了修正。可是,这很难让人信服,因为设置一个长方形的长和宽是非常基本的操作。如果不是预见到Square的存在,我们凭什么要把这两个函数声明为虚函数呢?
尽管如此,假设我们接受这个理由并修正这些类。修正后的代码如代码片段3所示。
代码片段 3 自相容的Rectangle类和Square类
public class Point
{
double x;
double y;
}
public class Rectangle
{
private Point ItsTopLeft;
private double ItsWidth;
private double ItsHeight;
public virtual void SetWidth(double width)
{
ItsWidth = width;
}
public virtual void SetHeight(double height)
{
ItsHeight = height;
}
public double GetHeight()
{
return ItsHeight;
}
public double GetWidth()
{
return ItsWidth;
}
public double Area()
{
return ItsWidth * ItsHeight;
}
}
public class Square : Rectangle
{
public override void SetWidth(double width)
{
base.SetWidth(width);
base.SetHeight(width);
}
public override void SetHeight(double height)
{
base.SetWidth(height);
base.SetHeight(height);
}
}
真正的问题
现在Square和Rectangle看起来都能都工作。无论对Square对象进行什么样的操作,他都和数学意义上的正方形保持一致。无论我们对Rectangle对象进行什么样的操作,它都和数学意义上的长方形保持一致。此外,可以向接受指向Rectangle的指针或者引用的函数传递Square,而Square依然保持正方形的特性,与数学意义上的正方形一致。
这样看来该设计似乎是自相容的、正确的。可是,这个结论是错误的。一个自相容的设计未必就和所有的用户程序相容。考虑下面的函数g
void g(Rectangle r)
{
r.SetWidth(5);
r.SetHeight(4);
Assert.AreEqual(r.Area(),20);
}
这个函数认为所传递进来的一定是Rectangle,并调用了其成员函数SetWidth和SetHeight。对于Rectangle来说,此函数运行正确,但是如果传递进来的是Square对象就会发生断言错误(assertion error)。所以,真正的问题是:函数g的编写者假设改变Rectangle的宽不会导致其长的改变。
很显然,改变一个长方形的宽不会影响它的长的假设是合理的!然而,并不是所有可以作为Rectangle传递的对象都满足这个假设。如果把一个Square类的实例传递给像g这样做了该假设的函数,那么这个函数就会出现错误的行为。函数g对于Square/Rectangle层次结构来说是脆弱的。
函数g的表现说明有一些使用指向Rectangle对象的指针或者引用的函数,不能正确地操作Square对象。对于这些函数来说,Square不能够替换Rectangle,因此Square和Rectangle之间的关系是违反了LSP的。
4、结论
OCP是OOD中很多说法的核心。如果这个原则应用得有效,应用程序就会具有更多的可维护性、可重用性以及健壮性。LSP是使OCP成为可能的主要原则之一。正是子类型的可替换性才使得使用基类类型的模块在无需修改的情况下就可以扩展。这种可替换性必须是开发人员可以隐式依赖的东西。因此,如果没有显示地强制基类类型的限制,那么代码就必须良好地并且明显地表达出这一点,当然更好的设计是OCP与LSP两个原则可以同时满足。
References:
《Agile Software Development Principles,Patterns,and Practices》(Robert C.Martin)
标签:Square,函数,原则,里氏,void,LSP,double,public,Rectangle 来源: https://www.cnblogs.com/harley-chang/p/16325335.html