从规约到 Liskov 替换原则:深入理解子类型多态
规约(Specifications)
规约的概念
程序的作用在于交流:
- 代码中蕴含的 " 设计决策 ":与编译器交流
- 注释形式的 " 设计决策 ":与自己和别人交流
那么什么是规约呢?
- 静态类型声明(static type declarations)是一种规约,可据此进行静态类型检查(static type checking);
- 方法前的注释也是一种规约,但需人工判定其是否满足。
本文后续所指的规约(specfications,下文简称 spec),指的便是方法前注释形式的 " 设计决策 "。
以 JDK List
接口中的 E remove(int index)
为例:
/**
* Removes the element at the specified position in this list.
* Shifts any subsequent elements to the left (subtracts one from their
* indices).
*
* @param index the index of the element to be removed
* @return the element that was removed from the list
* @throws IndexOutOfBoundsException {@inheritDoc}
*/
E remove(int index) {
写在方法前的 /** … */
中的内容,便是 spec。
书写规约的意义
规约是服务端与客户端之间达成的一致:
- 对于服务端开发者而言:没有规约,无法编程;即使写出来,也不知道对错;
- 对于客户端开发者而言:无需阅读调用函数的代码,只需理解 spec 即可。
Spec 给 " 供需双方 " 都确定了责任,在调用的时候双方都要遵守。责任,即前置条件与后置条件,下文会对此进行详细解读。
规约的组成
Spec 是供需双方之间达成的契约,如果前置条件满足了,后置条件必须满足:
- 前置条件(precondition):对客户端的约束,使用方法时必须满足的条件;
- 后置条件(postcondition):对服务端的约束,方法结束时必须满足的条件。
Spec 包含了以下几层含义:
- 前置条件满足,则后置条件必须满足;
- 前置条件不满足,则方法可做任何事情。
继续以 JDK List
接口中的 E remove(int index)
为例:
/**
* Removes the element at the specified position in this list.
* Shifts any subsequent elements to the left (subtracts one from their
* indices).
*
* @param index the index of the element to be removed
* @return the element that was removed from the list
* @throws IndexOutOfBoundsException {@inheritDoc}
*/
E remove(int index) {
其中:
- 前置条件对应于
@param
; - 后置条件对应于
@return
,@throws
。
规约的强度
规约强度的概念来源于规约替换的想法,我们希望强度较高的规约能够替换强度较低的规约。于是,便引出了规约强度的概念。
设两个规约 S 1 , S 2 S_{1}, S_{2} S1,S2,规约 S 2 S_{2} S2 的强度大于等于 S 1 S_{1} S1,如果:
- S 2 S_{2} S2 的前置条件较弱(i.e. Precondition ( S 2 ) ≤ Precondition ( S 1 ) \textrm{Precondition}(S_{2}) \leq \textrm{Precondition}(S_{1}) Precondition(S2)≤Precondition(S1))
- S 2 S_{2} S2 的后置条件较强(i.e. Postcondition ( S 2 ) ≥ Postcondition ( S 1 ) \textrm{Postcondition}(S_{2}) \geq \textrm{Postcondition}(S_{1}) Postcondition(S2)≥Postcondition(S1))
规约的强度对于后续分析子类型多态与 Liskov 替换原则十分重要。
Liskov 替换原则与子类型多态
多态的类型
- 特殊多态:(Ad hoc polymorphism): 体现在功能重载(overload)
- 参数化多态:(Parametric polymorphism): 体现在泛型(generics)
- 子类型多态:(Subtyping polymorphism): 不同类型的对象可以统一的处理而无需区分
本文重点讨论子类型多态。
类型与子类型
- 类型(type):一些值的集合(a set of values)
- 子类型(subtype):子类型是超类型的子集(a subset of the supertype)
例如,Java 中,ArrayList
与 LinkedList
都是 List
的子类型。
我们从两种角度来理解子类型,例如 “B is a subtype of A”:
- 从自然语言的角度来看:“every B is an A”
- 从规约(spec)的角度来看:“every B satisfies the specification for A”,更进一步,B 是 A 的子类型,当且仅当 B 的 spec 不比 A 弱。
Liskov 替换原则
Liskov 替换原则要求:如果 S 是 T 的子类型,则类型 T 的对象可以替换为类型 S 的对象(即类型 T 的对象可以替换为子类型 S 的任何对象),而不改变 T 的任何所需属性。事实上,也就意味着 S 的 spec 不比 T 弱。
具体地,对于子类型重写(override)的方法 spec 要求如下:
@param
:相同类型或者符合逆变@return
:相同类型或者符合协变@throws
:不能抛出额外的异常,抛出相同或者符合协变的异常
协变(co-variance)
协变(co-variance),意味着子类型方法要强化后置条件(返回值与异常),进而强化 spec。
例如,父类方法 T.a()
返回值类型为 Object
,子类方法 S.a()
override 时,可以返回 Object
的子类型 String
,这样便强化了后置条件(返回值)。
class T {
Object a() { … }
}
class S extends T {
@Override
String a() { … }
}
再例如,父类方法 T.b()
异常类型为 Exception
,子类方法 S.b()
override 时,可以抛出 Exception
的子类型 IOException
,这样便强化了后置条件(异常)。当然,S
的子类方法 U.b()
可以不抛出异常,进而也强化了后置条件(异常)。
class T {
void b() throws Exception { … }
}
class S extends T {
@Override
void b() throws IOException { … }
}
class U extends S {
@Override
void b() { … }
}
逆变(contra-variance)
逆变(contra-variance),与协变相反,意味着子类型方法要弱化前置条件(参数),进而强化 spec。
例如,父类方法 T.c()
参数类型为 String
,子类方法 S.c()
override 时,可以返回 String
的父类型 Object
,这样便弱化了前置条件(参数)。
class T {
void c(String s) { … }
}
class S extends T {
@Override
void c(Object s) { … }
}
Note
但是 Java 目前并不支持逆变,这种情形会被当做overload,而非override。
因此,Java 继承机制中,子类型方法 override 时,参数类型必须与父类型方法一致。