逆变和里氏替换原则

第一次认真思考逆变问题是在学习 Scala 语言的时候, 里边说到 Scala 的函数特质的参数是逆变的, 而返回值是协变的. 并且给出了一个例子 def printBookList(info: Book => AnyRef) 方法可以接收 def getTitle(p: Publication): String = p.title 并称 getTitle 方法是 Book => AnyRef 函数对象的子类对象, 其中参数类型 Publication 是 Book 的父类, 属于逆变, 而返回值的类型 String 是 AnyRef 的子类型属于协变.

问题的关键在于如何判断一个既包含逆变又包含协变的类型的层次关系? 基本的事实是可以将派生类对象赋值给基类对象引用. 但是协变和逆变的层次方向是刚好相反的. 协变认为如果类型参数是子类, 那么修饰的对象也是子类, 而逆变认为如果类型参数是父类, 那么修饰的对象是子类. 需要说明的是逆变、协变、不变都是泛型中的概念.

协变 (covariant) [+T] 的含义是, 可以使用比原始指定的类型派生程度更大的类型. 意为如果类型参数是子类, 那么由类型参数修饰的对象也是子类. 如:

1
var objects: List[AnyRef] = List[String]("abc", "123")  //List[+T]

自然而然会认为 String 的 List 就是 AnyRef 的 List 的子类. Java 中的数组是协变的, 但是其泛型是不变 (invariant) 的, 不可将 List<String> 赋值给 List<Object>, 在 Java 中多了一种 <? extends T> 的方式来支持协变.

逆变 (contravariant) [-T] 的含义是, 可以使用比原始指定的类型更泛型的(派生程度更小的)的类型. 意为如果类型参数是子类, 那么由类型参数修饰的对象是父类. 如:

1
2
3
4
class OutputChannel[-T] {
  def write(x: T)
}
var opc: OutputChannel[String] = new OutputChannel[AnyRef]

这里认为 AnyRef 的输出通道是 String 的输出通道的子类型. 从行为看来是非常好理解的: 能够输出任何对象的输出通道肯定可以输出字符串, 反过来则不行. 逆变在行为上好理解, 在形式上却刚好与协变的书写形式完全相反. 在 Java 中使用 <? super T> 的方式支持逆变.

需要注意的是如果 objects 是 MutableList 类型, 并且添加 Int 类型元素那么就会失败, 因为本质上 objects 的类型是只能添加 String 元素的. Scala 中包含了一套复杂的规则能够在编译期检查出哪些位置可以使用协变. 而目前实例代码中是可以运行的, 关键在于 List 是不可变对象, 而 Scala 在 List[AnyRef] 中添加 Int 时会将构造新的 List[AnyRef] 对象替换原来的 List[String] 对象.

哪些位置可以使用协变, 哪些位置可以使用逆变有一个较为复杂的判断规则. 这里只给出两个较为简单的, 对于集合类型从中读取的时候使用协变, 而向其中写入时使用逆变. 对于函数对象参数使用逆变, 返回值使用协变.

回归正题, 如何判断同时有协变、逆变的函数对象的层次关系. 这里引入了里氏替换原则 (LSP), 其定义是如果你能在使用类型 U 的地方替换成类型 T, 那么类型 T 就是 U 的子类型. 考虑最开始的问题便是这样子的, 任何使用 info: Book => AnyRef 的地方都使用 getTitle: Publication => String 替换. 相较于 info 来说, getTitle 要求的更少而提供的更多. getTitle 只要求参数是出版物, 而 info 要求参数必须是 Book, 而提供的却是更为具体的 String. 因而可以认为 getTitle 是 info 的子类. 这与契约设计 (Design by Contract) 如出一辙, 子类必须要求比父类更弱的前置条件, 而提供比父类更强的后置条件.

里氏替换原则实际上面向对象设计原则, 其中经典的违背实例是正方形和长方形的继承关系设计. 里氏替换原则要求可以让客户代码对父类的操作可以不加任何修改用于子类上. 考虑正方形情况, 当修改 width 属性时, 也会隐式修改 height 属性, 但长方形的两个属性是独立的. 那么以下代码将只能运用于长方形而不能运用于正方形.

1
2
3
4
5
void g(Rectangle r) {
  r.setWidth(5);
  r.setHeight(4);
  Assert.areEqual(r.getWidth() * r.getHeight(), 20);
}

写此函数的程序员可以很自然的假设修改 width 属性不会同时修改 height 属性. 很明显违背了任何使用 Rectangle 的地方可以替换成 Square 的里氏替换原则. 从中可以一窥类设计的重要理念, 即是否应该实现为子类需要看其行为是否与父类的行为一致, 而不仅仅考虑在现实分类中的层次关系.

以上例子还违背了子类提供更强的后置条件的契约设计原则. 考虑长方形的在调用 setWidth 的后置条件.

1
Contract.ensure((_width == w) && (_height == Contract.OldValue<double>(_height)));

长方形的要求调用完 setWidth 之后必须提供之前的高度不变的后置条件, 而正方形却提供了一个更弱的后置条件.