教科书上说,面向对象三大特性是封装、继承、多态。其中封装和多态并非面向对象原创,比如说USB口可以插入U盘、手机、ipod等设备,这就是多态。插入之后电脑内部怎么运转不用管,看显示器操作就行,这就是封装。在软件开发领域,面向对象出现之前,封装和多态虽然没有具体概念,但是其思想却已经广为应用。我认为,最能代表面向对象的,也是最值得喷的特性,就是继承。
将世界看成对象的集合
据说,面向对象是比较适合人的思维的,因为人在看世界的时候就是一个个对象,因此面向对象有利于程序的构建。这个观点是非常形而上(美其名曰哲学)的,其正确性先不去讨论。为了模拟对象,class关键字引入各种面向对象语言,把属性与操作(方法)封装于其中。在现实世界中对象似乎有一定层次关系,比如鸡鸭牛羊属于动物,动物属于生物,生物属于对象……以此为据,面向对象先驱们设计了继承这一思想,让class之间也形成继承关系,这样既有助于理解类的概念,又让代码有层次性,而且还提高了代码的复用性——少写了不少代码,提高生产力。自此,以class、继承为基础的面向对象思想、语言、设计方法大行其道,直至今日。
继承的问题
直觉上看,继承确实符合人认识事物的思维。但是在实践中,我发现继承有时不那么好用。比如番茄属于植物,属于食物,然而植物和食物之间的关系不是继承也不是并列,而是有交集。在食物类中,番茄同时具有水果和蔬菜的特性,即便现实世界也很难区分。这就是继承的问题所在:面向对象语言中的继承体系是比较严格的树形关系,而现实世界或者软件需求却不一定是树形,很可能关系错综复杂。
我在“设计模式的本质思想”一文中提到一个例子,现在将这个例子简化一下。有个Bird类,类内有Sing(鸣叫)和Move(移动)两个方法,具体操作什么由子类实现。先设计基类,如下
//纯面向对象语言java登场
abstract class Bird {
abstract void Sing();
abstract void Move();
}
Java号称纯面向对象语言,一切都是对象。看看又是abstract又是class,真有面向对象风格,基类设计的没太大问题。然后我们实现(泛化)一个麻雀类,叫声是"Jijijiji",移动方式是"Fly"(不要联想JJFly),一个鸽子类,叫声是"gugugu",移动方式是"Fly"。如下
class Sparrow extends Bird{
@Override
void Sing() {
System.out.println("jiji");
}
@Override
void Move() {
System.out.println("Fly");
}
}
class Dove extends Bird{
@Override
void Sing() {
System.out.println("gugugu");
}
@Override
void Move() {
System.out.println("Fly");
}
}
至此,一个简单的鸟继承体系完成。在应用层使用Bird对象的时候不必管具体子类会怎么实现,似乎很好地实现了模块化。
不过仔细一看有重复代码,Fly那一部分似乎是重复的。那我们提取一个FlyBird类,移动方式是"Fly",叫声由子类实现,麻雀和鸽子都继承自FlyBird。看起来不错。
现在我们在加入小鸡类,叫声是"jijiji"而移动方式是"walk",叫声那部分代码可能也会重复。有兴趣的同志可以试试,麻雀、鸽子、小鸡这三个类只用单继承,很难避免代码重复。出现这一问题的原因是,类之间的关系很复杂,只靠简单的树形继承无法表达这些关系。
解决方案——设计模式诞生
设计模式远远没有大家想的和他自己鼓吹的那么高大上,其出现主要还是为了解决上面这个问题。看看如何用策略模式解决这一问题
//策略模式的思想是组合代替继承
//先声明两个接口
interface Move{
void move();
}
interface Sing{
void sing();
}
//基类的行为不直接定义,而通过接口实现
class Bird {
Bird(Move m, Sing s){
this.m = m;
this.s = s;
}
private Move m;
private Sing s;
public void Sing(){
s.sing();
}
public void Move(){
m.move();
}
}
//实现飞行动作
class Fly implements Move{
public void move(){
System.out.println("Fly");
}
}
//实现Jiji叫声
class Jiji implements Sing{
public void sing() {
System.out.println("jijiji");
}
}
//麻雀类
class Sparrow extends Bird{
Sparrow(){
super(new Fly(), new Jiji());
}
}
按照这种方式,用组合代替继承,合理的避免了重复代码,软件再扩大时也比较容易管理。确实是个不错的解决方案。
其他面向对象解决方式
多重继承
单一继承的树形关系不是难以模拟需求吗?我用多重继承,实现FlyBird/WalkBird,以及JijiBird/GuguBird,麻雀继承自FlyBird and JijiBird(别想歪了),鸽子继承自GuguBird and FlyBird。理论上在这个问题中,多重继承也能合理的组织类的关系,但是多重继承问题很多,最主要的,继承自两个类,两个类的属性和方法保存一份还是两份?保存谁的?如果问题再复杂点,多重继承恐怕也很难满足需求。
方法2,将所有操作,比如Fly/Walk/Jiji/Gugu,都放在基类中实现,子类只负责调用。当子类没有新属性的时候这不失为一种解决办法。看起来很扯淡,其实是有应用的,比如基类是单链表,子类封装起来调用插入删除方法可以形成栈与队列。
其他语言的解决方式
C语言
#include <stdio.h>
struct Bird{
void *property;
};
//函数指针模拟虚函数
typedef void (*MoveFun)(Bird*);
typedef void (*SingFun)(Bird*);
void Fly(Bird*){
printf("Fly\n");
}
void Gugu(Bird*){
printf("Gugu\n");
}
int main(){
//下面三行初始化一个鸽子
Bird b;
MoveFun doveMove = Fly;
SingFun doveSing = Gugu;
doveMove(&b);
doveSing(&b);
}
我学C++面向对象经历了这么个过程:class不就是struct加函数么。。。原来面向对象虚函数可以实现应用实现分离,不错。。。C语言加函数指针也能实现虚函数。。。有些东西不太好设计,还好我有设计模式。。。这TM不就是struct加函数指针么!!!
在此稍微总结一下,通过策略模式和C语言的两个例子,我们发现组合通常比继承来的爽快,能够更简单更直接地解决问题。然而面向对象语言通常不把类内方法当成可以赋值的变量(函数不是first-class),而是写了之后就固定了,和类型严格绑定,这可能是造成继承不灵活的根本原因。在python语言中,类内的方法强制带self参数,可以调用类外的函数模拟对类内函数的赋值,实为面向对象史上一大进步(此前对python有误解,现已修改)。
即使应用如上的策略模式,运行时如果不允许替换方法,面向对象的缺陷还是很大,比如小鸡长成大鸡,叫声从Jijiji变成Gugugu,恐怕要重新设计了(简单的处理方式是有的,比如把两个接口对象改成public随意改动,不过这肯定会因为太不OO而被否决)。然而用C语言就不需要所谓的设计。
Lua语言
function Fly()
print("Fly")
end
function Gugu()
print("Gugu")
end
function GetDove()
dove = {}
dove.Move = Fly
dove.Sing = Gugu
return dove
end
dove = GetDove()
dove.Move()
dove.Sing()
lua不支持面向对象,然而用table可以完美的模拟面向对象做得到和做不到的事。table只是一个key-value的集合,不去刻意区分属性和方法,不管属性还是方法都能随意替换。同时Lua是弱类型语言,如果把dove对象的Sing改成Jiji,那么dove就变成了一个麻雀,这就是传说中的“鸭子类型”。
继承的适用范围
实践来看,如果你想通过继承实现开闭原则——新需求只需要在已有类的基础上添加而不需要改动已有类——简直是痴人说梦。如果在设计之初就考虑应对变化,那么往往也在浪费时间,你设想的变化通常不会出现,出现的都是你没想到的。继承为主的面向对象思想,不仅不适合变化快速繁多的场合,反而对需求变化不太大的模块更能胜任:GUI中的控件(同类控件除了绘制和鼠标消息基本没什么变化的需求,一个控件写好了到处通用N个版本也不会改)、基本数据结构和算法(基本没有需求变化)、编译原理的词法分析模块(至多对内部算法优化,对外接口不会有变动) 。
总结
为了弥补继承的缺陷,编程语言引入各种概念,多重继承(c++、python)、Mixin(模拟多重继承)、delegate(C#,不过delegate作为lambda比C的函数指针要强)、functor(C++)、interface(Java and others)以及各种设计模式。C语言和lua的table有更简单更直接的实现方式,不过人家不支持面向对象,请读者不要在这扯皮说C也有面向对象思想精髓之类。
在此引用宏哥一句话:OO就是把屁股朝天拉屎, 不拉在裤子上的就叫做"OO设计师"。整个OO的设计精髓就在于不要把屎拉裤子上。问题是, 为什么要朝天拉屎呢 -- 为了OO。