相信许多人都看过GoF的设计模式以及各种设计模式的书籍。这些繁复的设计模式究竟是为了什么呢?这些设计模式为的是维护一组原则,这组原则使得代码灵活,可维护,远离所谓的“代码臭味”。
这组原则就是 SOLID
Single Responsibility Principle 单一职责原则
Open-Close Principle 开闭原则
Liskov Substitution Principle 里氏替换原则
Interface Segregation Principle 接口隔离原则
Dependency Inversion Principle 依赖倒置原则
这篇文章谈论的,里氏替换原则,一句话描述这个原则就是“子类可以替换基类”。
非常简单,非常容易理解,稍微了解过面向对象编程的继承概念就可以领会这个原则谈论的是什么。初看之下似乎是自然而然的事情。
但程序的困难性和复杂度完全来自于现实,在现实中,才会明白实践这个看起来自然而然的原则是有难度的。
大部分谈论具有对象的语言的书,在对于如何设计继承关系,都会提到“IS-A”判断法,这其实就是里氏替换原则,子类IS-A基类,这样继承关系才得以成立。
举个例子:
class human {
public:
virtual void say() {
cout << "I am human";
}
};
class worker: public human {
public:
virtual void say() {
cout << "I am worker";
}
};
void let_a_human_say_something(shared_ptr<human> p) {
p->say();
}
let_a_human_say_something中,需要一个human,并且调用其say方法来打印一些信息,而如果我们传入worker的指针其实是没问题的,因为worker也拥有say方法,打印相应信息。
在这个很简单的情形下,worker和human的设计其实是符合里氏替换原则的,因为两个类的指针都可以被传给let_a_human_say_something,让其工作。
但是一个误区是认为IS-A就是和现实关系对应,即现实中某个类属于某个更大范畴的类,那么他们存在一个继承关系。在上面所说的例子中,worker is human, 符合常识。但是一旦进入实际的软件开发实践中,我们很快就会发现有所不同。
下面我们看一个《敏捷软件开发:原则,模式与实践》里列出的例子。
class triangle {
public:
virtual void set_height(int h) {
...
}
virtual void set_width(int w) {
...
}
virtual int get_area() {
...
}
};
class square: public triangle {
public:
virtual void set_height(int h) {
...
}
virtual void set_width(int w) {
...
}
virtual int get_area() {
...
}
};
triangle代表矩形,它有三个方法,设置高,设置宽,计算面积。我们可以设置height和width,之后得出面积,我们可以为这个class做一系列功能,一系列测试。。。
但是,现在我们想要一个square类,代表正方形。使用小学数学知识,你很快决定,让square类继承triangle类,因为这太明显了不是吗?正方形是一个矩形,这是一个数学上的事实, 正方形只不过是高和宽相等的矩形而已!square IS-A triangle!
然而问题来了,你要如何让square类具有宽与高相等的性质呢?
方案1: set_width和set_height都设置同一个变量
方案2: 让set_width和set_height其中一个无效
方案3:计算面积时只计入width或者height
...
你可以灵活地想出不少方案,他们都是为了保证这个正方形的性质,你可以轻松的写出来,然后就万事大吉了对吧?
如果我说在这个例子里你这样做就违背了里氏替换原则呢?
我们来看一段代码
void test_triangle(triange* t) {
t->set_width(5);
t->set_height(6);
assert(t->get_area() == 5* 6);
}
这是一个简单的测试,然后很遗憾的,我们的square无论采用上面何种方案都无法通过。因为triangle类中,存在了一些假定,比如宽和高是独立的,面积等于宽乘以高。而继承自triangle的square无论如何去打补丁都会违背这种假定。让square继承triangle一定会打破这些假设,从而违背里氏替换原则。
但是正方形确实是矩形啊,这是哪里出错了? 这是因为IS-A是对于类的行为的判断。也就是说只有一个东西走路像鸭子,叫起来像鸭子,我们才可以认为它同鸭子有继承关系。
square在triangle定义的一系列行为中实际上并不像triangle,也就是说,在这一组行为/方法下,square不是triangle。
只通过现实概念的思考而不去实际考察代码中类的行为/方法就确定继承关系,注定是会有悲剧发生的。
你会发现父类有些方法在子类应该禁止调用,有些方法调用某个父类可以正常工作,但是调用子类就爆炸了,然而你看看类的名字似乎继承关系又是理所当然。这种情况其实就是里氏替换原则被隐秘地违背了。如果不警惕,代码将会走向僵死。