欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

[软件构造] 03 OOP中的Liskov替换原则(LSP)

程序员文章站 2022-03-10 14:15:01
...

[软件构造] 03 OOP中的Liskov替换原则(LSP)

1. 什么是Liskov替换原则?

首先开门见山,Barbara Liskov和Jeannette Wing在1994年的一篇论文中用下面一句话描述这个原则:

Liskov substitution principle(LSP):
Let ϕ\phi(xx) be a property provable about objects xx of type TT. Then ϕ\phi(yy) should be true for objects yy of type SS where SS is a subtype of TT.

如果 ϕ\phi(xx) 是类型为 TT 的对象 xx 的一个可验证的特性,SS 是类型 TT 的子类型,那么对于任何类型为 SS 的对象 yy,同一特性 ϕ\phi(yy) 对于 yy 也是成立的。

这个原则概括一下就是说:
派生类(子类)对象可以在程序中代替其基类(超类)对象,而不会改变程序任何原有的特性。

2. 行为子类型

LSP定义了一种特定的子类型关系,被称为行为子类型(behavioral subtyping)。也就是只有满足行为子类型才能够保证Liskov替换原则。

为了实现行为子类型,Java 编译器强制执行了一些特定规则,并对它们进行了静态类型检查:

  • 子类型可以增加方法,但不可以移除方法
  • 具体子类型需要实现抽象类型中的所有未实现方法
  • 子类型中重写的方法的返回值应和父类中方法的返回值相同,或者是父类中方法的返回值的子类型
  • 子类型中重写的方法必须接受同样类型的参数
  • 子类型中重写的方法不能抛出额外的异常

3. 契约式设计

Bertrand Meyer 在 1988 年阐述了 LSP 原则与契约式设计之间的关系。使用契约式设计,类中的方法需要声明前置条件,后置条件,并且要确保不变量始终成立。

前置条件,后置条件和不变量的概念如下:
前置条件:在执行某方法前必须成立的条件,表现为对方法参数的限制。
后置条件:在方法返回时必须达到的要求,表现为是否抛出异常,程序状态的变化等。
不变量:在程序的任何时刻都保持成立的特性。

前置条件为真,则方法才能被执行。而在方法调用完成之后,方法本身将确保后置条件也成立。

而为了满足行为子类型的特定关系,需要子类型的每个方法较之父类中的方法都满足以下的三个条件:

  1. 相同的或者更强的不变量
    Preconditions cannot be strengthened in a subtype.
  2. 相同的或者更弱的前置条件
    Postconditions cannot be weakened in a subtype.
  3. 相同的或者更强的后置条件
    Invariants of the supertype must be preserved in a subtype.

对于强弱我的理解是如果对其限制比原来的更多或者更狭隘,那么它变得更强;如果和原来的一样,那么它和原来的是相同的,反之则它变得更弱。

4. 一个违反LSP的经典例子

//长方形类
class Rectangle {
    //@ invariant h>0 && w>0;  
    int h, w;
    Rectangle(int h, int w) {  
        this.h=h; this.w=w;
    }

    //@ requires neww > 0;  
    void setWidth(int neww) {
        w=neww;
    }
}

//正方形类
class Square extends Rectangle {
    //@ invariant h>0 && w>0;
    //@ invariant h==w;  
    Square(int w) {
        super(w, w);
    }
}

这个例子显然是违反 LSP 原则,因为倘若 Square 类的对象调用继承自 Rectangle 类的 setWidth 方法之后,并且设置的参数 neww!= h的话,就一定会破坏 Square 类的不变量 h==w,因而 Square 类就不是 Rectangle 类的行为子类型。
因而我们很自然的思路就是可不可以重写 Square 类的 setWidth 方法,从而使得 Square 类满足行为子类型的定义。从而可能会写出如下的代码。

//长方形类
class Rectangle {
//@ invariant h>0 && w>0;  
    int h, w;
    Rectangle(int h, int w) {
        this.h=h; this.w=w;
    }

    //@ requires neww > 0;
    //@ ensures w=neww && h not changed  
    void setWidth(int neww) {
        w=neww;
    }
}

//正方形类
class Square extends Rectangle {
    //@ invariant h>0 && w>0;
    //@ invariant h==w;
    Square(int w) {
        super(w, w);
    }

    //@ requires neww > 0;
    //@ ensures w=neww && h=neww
    @Override
    void setWidth(int neww) {  
        w=neww;
        h=neww;
    }
}

现在,通过运行时多态,调用 setWidth 方法时,设置 Square 对象的 w,它的 h 也会相应跟着变化。Square 对象仍然是一个看起来很合理的数学中的正方形,满足不变量的约束。
但是这个例子对 LSP 原则的违背方式十分微妙。

5. 问题的根源

此时此刻我们有了两个类,Square 和 Rectangle,而且看起来都可以正常工作。无论你对 Square 对象做什么,它仍可以维持 Square 的不变量。而且也不管你对 Rectangle 对象做什么,它也将维持 Rectangle 的不变量。并且当你传递一个 Square 对象到一个可以接收 Rectangle 引用的方法中时,由于运行时多态,Square 仍然可以维持 Square 的不变量。

问题在于:Square 类中的 setWidth 方法明确地修改了 h 的值,而我们的 Rectangle 类中的 setWidth 方法的后置条件则要求 h 的值不发生改变。从而倘若用 Square 类来替代 Rectangle 类,就会破坏 Rectangle 类的 setWidth 方法的后置条件,从而并不满足行为子类型的定义。(也可以通过后置条件的强弱性来解释,如此修改之后,两个方法的后置条件的强弱性不可比较)

6. 协变与逆变

Liskov 替换原则在一些较新的面向对象编程语言中还对方法的签名提出了一些标准要求:

  1. 子类型方法参数:逆变
  2. 子类型方法的返回值:协变
  3. 除了父类型方法的异常及其子类型可以被抛出外,子类型不可以抛出其他新的异常。也就是说异常类型遵循协变原则。
class T {
    Object a() {}
}

class S extends T {  
    @Override
    String a() {}
}

返回值协变:方法重写的方法返回一个更窄的类型。这么做是要保证方法的后置条件不会变弱。

class T {
    void c( String s ) {}
}

class S extends T {  
    @Override
    void c( Object s ) {}
}

方法参数的逆变:子类重写的方法接受更宽的类型。这么做是要保证方法的前置条件不会变强。
但在 Java 中,如上这样写代码编译器会报错的,Java 并不支持重写的方法接受更宽的参数,必须保证方法的参数与父类的方法完全一致,因而要想实现该方法,只能够去掉@Override,把它当做重载来对待。

7. 写在最后

Barbara Liskov 是我比较敬佩的一位女计算机科学家,记得她说过她最初的方向是人工智能,在读博士读到一半的时候,决定放弃人工智能转而研究计算机系统。但为了学位她决定上学的时候先不转型,拿到学位以后再转型。这种能够清晰地找到兴趣所在,做到各种利益之间的权衡的,持续深入地在自己擅长的领域进行研究的头脑是值得学习的。