Liskov替换原则,接口,契约,类型及自然对应
(一)
<!--[if !supportEmptyParas]-->
<!--[endif]-->
公有继承的意义和用法常常使人迷惑,不少人甚至认为应该禁止使用公有继承,本文试图从Liskov替换原则(LSP, Liskov Substitution Principle)出发对此问题进行探讨。
Liskov替换原则(LSP, Liskov Substitution Principle)是以对于子类型的定义来表述的:
如果对于类型S的每一个对象o1,都有一个类型T的对象o2,使对于任意用类型T定义的程序P,将o2替换为o1,P的行为保持不变,则称S为T的一个子类型。
<!--[if !supportEmptyParas]--> <!--[endif]-->
我们知道,在C+ +中公有派生表达的是ISA的关系,A is a B,即意味着A是B的一个子类型,即在C++中公有派生表达子类型的概念。那么Liskov替换原则在C++中就表达了这样一个意思,如果子类是公有派生 的,那么父类或父类的所有子类,他们所形成的对象之必须应该可以相互替换,替换之后,替换的上下文依然保持逻辑上的完整与一致。
<!--[if !supportEmptyParas]--> <!--[endif]-->
粗 看之下,此原则是显而易见的,但如果细思,则会发现,一个如此显而易见的规则是如此难以保证。一个众所周知的例子是:如果你的程序中有一个长方形和一个正 方形,那么你可能会想当然的将正方形作为长方形的子类加以实现,因为我们的数学知识告诉我们,正方形也是一种长方形,但如果你这样想,就已经违反了LSP,因为你的程序在使用长方形对象时是假设它的长与宽是无关的,但如果换为正方形时,因为正方形的长与宽是相关的,程序最初做的假设不能成立,自然调用程序的上下文逻辑也无法保证完整。
<!--[if !supportEmptyParas]--> <!--[endif]-->
LSP与Design By Contract密切相关,按照Bertrand Meyer的表述,当在子类中重定义一个方法时,其前置条件必须弱于父类,而后置条件必须强于父类。结合LSP,则意味着只有当子类的前置条件是父类前置条件的一个超集,而后置条件则是父类的子集的时候,子类才是父类类型的一个子类型。这里所谓子集超集是从概念的外延讲的。
<!--[if !supportEmptyParas]--> <!--[endif]-->
考虑一个类的前置条件应该包括如下一些:
<!--[if !supportLists]-->1. <!--[endif]-->状态集
<!--[if !supportLists]-->2. <!--[endif]-->行为集
<!--[if !supportLists]-->3. <!--[endif]-->各行为发生前所依赖的对象的状态集
<!--[if !supportLists]-->4. <!--[endif]-->各行为发生前所依赖的输入数据集
<!--[if !supportLists]-->5. <!--[endif]-->各行为发生前所依赖的外部环境。
而一个类的后置条件则应该包括如下一些:
<!--[if !supportLists]-->1. <!--[endif]-->各行为发生后的对象的输出状态集
<!--[if !supportLists]-->2. <!--[endif]-->各行为发生后的输出数据集
<!--[if !supportLists]-->3. <!--[endif]-->各行为发生后的外部环境。
<!--[if !supportEmptyParas]--> <!--[endif]-->
形式化表示就是对于一个五元组∑ =(S, B, I, O, C), 其中:
S – State Set
B – Behavior Set
I – Input Data Set
O – Output Data Set
C – Context of outside
契约是这样一个关系R
R = ∑ x ∑
而∑则就是类。
<!--[if !supportEmptyParas]--> <!--[endif]-->
前面的那个正方形的例子里,正方形中设置边长的动作发生后,对象的特征状态集不是父类长方形同类动作的输出状态集的子集,所以违反了LSP,同时也违反了其与调用环境之间的contract。
<!--[if !supportEmptyParas]--> <!--[endif]-->
我们常说,要为接口而设 计,那么类的接口是什么呢?类的接口就是类的前后置条件的并集,换句话说,接口与契约其实是一个概念。在这里,接口是一个广义的概念,它的作用在于表达一 个类型,因此,接口不一定必须是基类,也不一定必须是抽象类,它可以是一个具体类,也可以有自己的实现代码。
<!--[if !supportEmptyParas]--> <!--[endif]-->
如此,LSP、Design By Contract、 以及为接口而设计等各种设计方法就统一起来了。在此基础上,我们发现,类型、契约、接口实际上是三个等价的概念。他们均在不同层面表达概念的语义。类型适 合于定义概念和思考,契约系统化的定义了类型,而接口则将契约细化分解为不同的子集进行表达。设计时,我们先有了各种概念定义为类型,然后在契约的基础上 思考这些类型的关系,需要的话对类型做进一步的细化,然后以接口的方式对行为状态进行划分。再以上面那个正方形的例子说明:
<!--[if !supportEmptyParas]--> <!--[endif]-->
假设问题域是这样的:在一个函数中传入一个正方形或长方形,然后放大1倍,计算面计输出。设计过程如下:(只是为说明问题,不做完整设计)
<!--[if !supportEmptyParas]--> <!--[endif]-->
类型显然是有两个,长方形和正方形。
长方形的契约如下:
<!--[if !supportLists]-->1. <!--[endif]-->外部环境
<!--[if !supportLists]-->a) <!--[endif]-->一个由外部传入的长方形或正方形的对象
<!--[if !supportLists]-->b) <!--[endif]-->程序
<!--[if !supportLists]--> (1) <!--[endif]-->将容器中所有图形放大1倍。
<!--[if !supportLists]--> (2) <!--[endif]-->计算面积返回。
<!--[if !supportLists]-->2. <!--[endif]-->状态集:(长,宽)
<!--[if !supportLists]-->3. <!--[endif]-->行为集:初始化状态,放大(放大意味着形状保持不变)。
<!--[if !supportLists]-->4. <!--[endif]-->前置条件:
<!--[if !supportLists]-->a) <!--[endif]-->初始化状态:
<!--[if !supportLists]--> (1) <!--[endif]-->输入长或宽;
<!--[if !supportLists]--> (2) <!--[endif]-->状态可修改
<!--[if !supportLists]-->b) <!--[endif]-->放大
<!--[if !supportLists]--> (1) <!--[endif]-->输入放大倍数
<!--[if !supportLists]--> (2) <!--[endif]-->状态可修改
<!--[if !supportLists]-->5. <!--[endif]-->后置条件:
<!--[if !supportLists]-->a) <!--[endif]-->初始化状态:
<!--[if !supportLists]--> (1) <!--[endif]-->状态修改
<!--[if !supportLists]-->b) <!--[endif]-->放大
<!--[if !supportLists]--> (1) <!--[endif]-->状态放大指定倍数
<!--[if !supportLists]--> (2) <!--[endif]-->长和宽的比例不变。
<!--[if !supportEmptyParas]--> <!--[endif]-->
正方形的契约如下:
<!--[if !supportLists]-->1. <!--[endif]-->外部环境
<!--[if !supportLists]-->c) <!--[endif]-->一个长方形或正方形的容器
<!--[if !supportLists]-->d) <!--[endif]-->程序
<!--[if !supportLists]--> (1) <!--[endif]-->创建若干长方形和正方形放入容器;
<!--[if !supportLists]--> (2) <!--[endif]-->将容器中所有图形放大1倍。
<!--[if !supportLists]-->2. <!--[endif]-->状态集:(长,宽)/或边长。
<!--[if !supportLists]-->3. <!--[endif]-->行为集:初始化状态,放大。
<!--[if !supportLists]-->4. <!--[endif]-->前置条件:
<!--[if !supportLists]-->e) <!--[endif]-->初始化状态:
<!--[if !supportLists]--> (1) <!--[endif]-->输入长或宽;
<!--[if !supportLists]--> (2) <!--[endif]-->状态可修改
<!--[if !supportLists]-->f) <!--[endif]-->放大
<!--[if !supportLists]--> (1) <!--[endif]-->输入放大倍数
<!--[if !supportLists]--> (2) <!--[endif]-->状态可修改
<!--[if !supportLists]-->5. <!--[endif]-->后置条件:
<!--[if !supportLists]-->g) <!--[endif]-->初始化状态:
<!--[if !supportLists]--> (1) <!--[endif]-->状态修改
<!--[if !supportLists]--> (2) <!--[endif]-->长和宽始终保持相等。
<!--[if !supportLists]-->h) <!--[endif]-->放大
<!--[if !supportLists]--> (1) <!--[endif]-->状态放大指定倍数
<!--[if !supportLists]--> (2) <!--[endif]-->长和宽的比例不变。
<!--[if !supportEmptyParas]--> <!--[endif]-->
可以看到,除了“初始化状态”的后置条件不同外,其他契约均相容,其中,正方形的行为集是长方形的超集。即当不进行设置状态的动作时,正方形是一种长方形。再仔细考察,我们可以给出更准确的定义,即正方形是一种给定形状的长方形。如此,我们可以有如下的设计
<!--[if !supportEmptyParas]--> <!--[endif]-->
<!--[if !supportEmptyParas]--> <!--[endif]-->
class Fixed_Rentangle {
protected:
float m_fLength;
float m_fWifth;
public:
Fixed_Rentangle(float fl, float fw)
: m_fLength(fl)
, m_fWifth(fw)
{};
void Zoom(float f)
{
m_fLength = m_fLength * f;
m_fWifth = m_fWifth * f;
}
}
<!--[if !supportEmptyParas]--> <!--[endif]-->
class Rentangle : public Fixed_Rentangle {
public:
Rentangle(float fl, float fw)
: Fixed_Rentangle(fl, fw)
{}
}
<!--[if !supportEmptyParas]--> <!--[endif]-->
class Square : public Fixed_Rentangle {
public:
Square (float f)
: Fixed_Rentangle(f, f)
{}
}
int main()
{
//为简化起见,假设只创建一个正方形和一个长方形。
Rentangle_Fixed_Shape* array_rent[2];
array_rent[0] = new Square(10);
array_rent[0] = new Rentangle (15, 10);
for (int i = 0; i < 2; i++)
array_rent[i]-> Zoom(2);
}
<!--[if !supportEmptyParas]--> <!--[endif]-->
这个例子只是为了说明问 题所以比较简单,但我想说的另外一个观点是以上分析所得到的长方形与正方形两个类型之间关系相对外部环境来说是比较稳定的,因为由此得到得类型之间的关系 更符合事物的本质,而此正是一种我将之称为自然对应的设计方法,文章本天成,妙手偶得之。面向对象的好处在于不需要你去设计,你只需要去认识,努力认识问 题域中个对象之间的关系,然后在程序中将它们反映出来,而本文所重点阐述的是如何认识对象之间的血缘关系。
<!--[if !supportEmptyParas]--> <!--[endif]-->
LSP 继承所隐藏的另外一个设计哲学是面向对象的层次模型设计方法:程序中不同层次的模块应该看到不同层次的类型,层次越高的模块所处理的数据的抽象程度也应该越高。 LSP 非常完备的体现了这一思想。