我听说利斯科夫替换原则(LSP)是面向对象设计的基本原则。它是什么?它的一些使用例子是什么?
当前回答
大局:
利斯科夫替换原理是什么?它是关于什么是(什么不是)给定类型的子类型。 为什么它如此重要?因为子类型和子类之间是有区别的。
例子
与其他答案不同的是,我不会从违反Liskov替换原则(LSP)开始,而是从符合LSP开始。我使用Java,但在每种OOP语言中几乎都是一样的。
圆和彩色圆
几何例子在这里似乎很流行。
class Circle {
private int radius;
public Circle(int radius) {
if (radius < 0) {
throw new RuntimeException("Radius should be >= 0");
}
this.radius = radius;
}
public int getRadius() {
return this.radius;
}
}
半径不允许为负。这是一个子类:
class ColoredCircle extends Circle {
private Color color; // defined elsewhere
public ColoredCircle(int radius, Color color) {
super(radius);
this.color = color;
}
public Color getColor() {
return this.color;
}
}
从LSP来看,这个子类是Circle的子类型。
LSP的状态为:
如果对于类型S的每个对象o1,都有一个类型T的对象o2,使得对于所有用T定义的程序P,当o1取代o2时,P的行为不变,那么S是T的子类型(Barbara Liskov,“数据抽象和层次结构”,SIGPLAN通知,23,5(1988年5月))。
这里,对于每个ColoredCircle实例o1,考虑Circle实例具有相同的半径o2。对于每个使用Circle对象的程序,如果您将o2替换为o1,则任何使用Circle的程序的行为在替换之后都将保持不变。(注意,这只是理论上的:使用ColoredCircle实例会比使用Circle实例更快地耗尽内存,但这与本文无关。)
我们如何根据o1求出o2 ?我们只是去掉color属性,保留radius属性。我称这个变换为o1 - >o2是CircleColor空间在Circle空间上的投影。
反例
让我们再创建一个例子来说明LSP的违反。
圆形和方形
想象一下前面Circle类的子类:
class Square extends Circle {
private int sideSize;
public Square(int sideSize) {
super(0);
this.sideSize = sideSize;
}
@Override
public int getRadius() {
return -1; // I'm a square, I don't care
}
public int getSideSize() {
return this.sideSize;
}
}
LSP违反
现在,看看这个程序:
public class Liskov {
public static void program(Circle c) {
System.out.println("The radius is "+c.getRadius());
}
我们用一个Circle对象和一个Square对象测试程序。
public static void main(String [] args){
Liskov.program(new Circle(2)); // prints "The radius is 2"
Liskov.program(new Square(2)); // prints "The radius is -1"
}
}
发生了什么事?直观地说,虽然Square是Circle的一个子类,但Square不是Circle的子类型,因为没有一个常规的Circle实例的半径是-1。
形式上,这违反了利斯科夫替换原则。
我们有一个用Circle定义的程序,在这个程序中没有Circle对象可以替换新的Square(2)(顺便说一下,也没有任何Square实例),并且保持行为不变:记住,任何Circle的半径总是正的。
子类和子类型
现在我们知道为什么子类并不总是子类型。当子类不是子类型时,即存在LSP违反时,某些程序(至少有一个)的行为并不总是预期的行为。这是非常令人沮丧的,通常被解释为一个错误。
在理想的情况下,编译器或解释器将能够检查给定的子类是否是真正的子类型,但我们并不是在理想的情况下。
静态类型
如果存在一些静态类型,则在编译时被父类签名绑定。Square.getRadius()不能返回String或List。
如果没有静态类型,如果一个参数的类型是错误的(除非类型是弱的)或参数的数量不一致(除非语言是非常允许的),您将在运行时得到一个错误。
关于静态类型的注意:有返回类型的协方差(S的方法可以返回T的相同方法的返回类型的子类)和参数类型的逆变性(S的方法可以接受T的相同方法的相同参数的超类)的机制,这是下面解释的先决条件和后置条件的具体情况。
合同设计
有更多的。有些语言(我想到了Eiffel)提供了一种机制来强制执行LSP。
先不说确定初始对象o1的投影o2,如果用o1代替o2 if,对于任何参数x和任何方法f,我们可以期望任何程序都有相同的行为:
如果o2.f(x)是一个有效调用,那么o1.f(x)也应该是一个有效调用(1)。 o1.f(x)的结果(返回值,在控制台上显示等)应该等于o2.f(x)的结果,或者至少同样有效(2)。 o1.f(x)应该让o1处于内部状态,o2.f(x)应该让o2处于内部状态,这样下一次函数调用将确保(1),(2)和(3)仍然有效(3)。
(注意,如果函数f是纯函数,则(3)是免费给出的。这就是为什么我们喜欢使用不可变对象。)
这些条件是关于类的语义(期望什么),而不仅仅是类的语法。而且,这些条件非常强。但是它们可以用契约式编程设计中的断言来近似。这些断言是确保支持类型语义的一种方法。破坏契约会导致运行时错误。
前提条件定义了什么是有效的调用。当子类化一个类时,前提条件只能被削弱(S.f接受的比T.f多)(a)。 后置条件定义了什么是有效结果。当子类化一个类时,后置条件只能被加强(S.f比T.f提供更多)(b)。 不变量定义了什么是有效的内部状态。当子类化一个类时,不变量必须保持不变(c)。
我们可以大致看到,(a)保证了(1),(b)保证了(2),但是(c)比(3)弱。此外,断言有时难以表达。
假设一个类Counter有一个唯一的方法Counter. Counter(),该方法返回下一个整数。怎么写后置条件呢?假设一个类Random有一个方法Random.高斯(),该方法返回一个介于0.0和1.0之间的浮点数。如何编写后置条件来检查分布是否为高斯分布?这也许是可能的,但成本太高,我们将依赖于测试而不是后置条件。
结论
不幸的是,子类并不总是子类型。这可能会导致意想不到的行为——bug。
面向对象语言提供了避免这种情况的机制。首先在语法层面。在语义层面上也是如此,这取决于编程语言:可以使用断言在程序文本中编码一部分语义。但是,由您来确保子类是子类型。
还记得你什么时候开始学习OOP吗?“如果关系是is - a,那么使用继承”。另一种方式也是如此:如果使用继承,请确保关系是is - a。
LSP在比断言更高的级别上定义了什么是子类型。断言是确保LSP得到维护的有价值的工具。
其他回答
以Board数组的形式实现ThreeDBoard会有用吗?
也许你想把不同平面上的ThreeDBoard切片作为一个板。在这种情况下,您可能希望为Board抽象出一个接口(或抽象类),以允许多种实现。
就外部接口而言,您可能希望为TwoDBoard和ThreeDBoard提取一个Board接口(尽管上述方法都不适合)。
LSP说“对象应该被它们的子类型替换”。 另一方面,这一原则指向
子类永远不应该破坏父类的类型定义。
通过以下示例,可以更好地理解LSP。
没有太阳能发电:
public interface CustomerLayout{
public void render();
}
public FreeCustomer implements CustomerLayout {
...
@Override
public void render(){
//code
}
}
public PremiumCustomer implements CustomerLayout{
...
@Override
public void render(){
if(!hasSeenAd)
return; //it isn`t rendered in this case
//code
}
}
public void renderView(CustomerLayout layout){
layout.render();
}
LSP修复:
public interface CustomerLayout{
public void render();
}
public FreeCustomer implements CustomerLayout {
...
@Override
public void render(){
//code
}
}
public PremiumCustomer implements CustomerLayout{
...
@Override
public void render(){
if(!hasSeenAd)
showAd();//it has a specific behavior based on its requirement
//code
}
}
public void renderView(CustomerLayout layout){
layout.render();
}
利斯科夫替换原理
被重写的方法不应该保持为空 被重写的方法不应该抛出错误 基类或接口行为不应该因为派生类行为而进行修改(重做)。
Liskov替换原理(LSP, LSP)是面向对象编程中的一个概念,它指出:
函数使用指针或 基类的引用必须是 能够使用派生类的对象 在不知不觉中。
LSP的核心是关于接口和契约,以及如何决定何时扩展一个类,还是使用另一种策略(如组合)来实现您的目标。
我所见过的说明这一点的最有效的方法是《Head First OOA&D》。它们呈现的场景是,你是一名致力于为策略游戏构建框架的项目开发者。
他们展示了一个类,它代表一个板子,看起来像这样:
所有的方法都以X和Y坐标作为参数来定位tile在二维tile数组中的位置。这将允许游戏开发者在游戏过程中管理棋盘上的单位。
这本书继续改变了要求,说游戏框架工作也必须支持3D游戏板,以适应有飞行的游戏。因此引入了一个ThreeDBoard类,它扩展了Board。
乍一看,这似乎是个不错的决定。Board提供了高度和宽度属性,ThreeDBoard提供了Z轴。
当你看到从董事会继承的所有其他成员时,它就失效了。AddUnit, GetTile, GetUnits等方法在Board类中都采用X和Y参数,但ThreeDBoard也需要Z参数。
因此,您必须使用Z参数再次实现这些方法。Z参数没有Board类的上下文,从Board类继承的方法失去了意义。试图使用ThreeDBoard类作为其基类Board的代码单元将非常不走运。
也许我们应该另想办法。ThreeDBoard应该由Board对象组成,而不是扩展Board。Z轴上每单位一个板子对象。
这允许我们使用良好的面向对象原则,如封装和重用,并且不违反LSP。
利斯科夫替换原则(来自Mark Seemann的书)指出,我们应该能够在不破坏客户端或实现的情况下,用另一个接口的实现替换一个接口的实现。正是这一原则使我们能够解决未来出现的需求,即使我们今天不能预见它们。
If we unplug the computer from the wall (Implementation), neither the wall outlet (Interface) nor the computer (Client) breaks down (in fact, if it’s a laptop computer, it can even run on its batteries for a period of time). With software, however, a client often expects a service to be available. If the service was removed, we get a NullReferenceException. To deal with this type of situation, we can create an implementation of an interface that does “nothing.” This is a design pattern known as Null Object,[4] and it corresponds roughly to unplugging the computer from the wall. Because we’re using loose coupling, we can replace a real implementation with something that does nothing without causing trouble.