面向对象编程要想实现多态特性,虚函数是一个绕不开的话题。所谓虚函数是指派生类和基类拥有某一个同样的函数名但是其函数实现不同。在C++中,定义虚函数的方是在类成员函数的声明前加上关键字virtual。可是在实际的应用中,即使不用虚函数,也可以用同名成员函数的方法实现多态。
在《C++ Primer Plus》中,斯蒂芬老师假设了一种应该用虚函数的情况——若想用同一种基类指针来指向基类和派生类对象,那么其中用于实现多态特性的成员函数则非设置成虚函数不可。那么是不是反过来说,同名对象加上关键字virtual后就可以用同一种指针调用了呢?最近我就在这个问题上纠结了。
斯蒂芬老师在书上13章的第四道题中分别给出了一个基类和一个派生类的定义:
class Port { private: char *brand; char style[20]; int bottles; public: Port(const char *br="none",const char *st="none",int b=0); Port(const Port &p); virtual ~Port(){delete[] brand;} Port &operator=(const Port &p); Port &operator+=(int b); Port &operator-=(int b); int BottleCount()const{return bottles;} virtual void Show()const; friend ostream &operator<<(ostream &os,const Port &p); }; class VintagePort:public Port { private: char *nickname; int year; public: VintagePort(); VintagePort(const char *br,const char *st,int b,const char *nn,int y); VintagePort(const VintagePort &vp); ~VintagePort(){delete[] nickname;} VintagePort &operator=(const VintagePort &vp); void Show()const; friend ostream &operator<<(ostream &os,const VintagePort &vp); };
题目中有一个小问题问道,为什么声明中两个对“=”号重载的函数没有设置虚函数?这个问题乍一下把我问住了,过去只研究用虚函数的原因,这里叫解释不用虚函数的原因还真一下子解释不出来。那我反过来想,加上给那两个等于号的重载函数加上virtual关键字后,去检测他们的虚函数特性会有什么后果呢?
virtual Port &operator=(const Port &p); virtual VintagePort &operator=(const VintagePort &vp);
下面是一个测试函数,定义了两个派生类对象和一个基类指针,基类指针指向第二个派生类的对象。使用基于指针的域解析操作符“->”来显式地调用“=”号重载函数将第一个派生类对象指向基类指针指向的第二个派生类对象:
int main() { VintagePort p1=VintagePort("Riesling","whitewine",70,"LeiSiLing",1970); VintagePort p2=VintagePort("Cabernet","redwine",30,"JieBaiNa",1990); Port *pp2=&p2; pp2->operator =(p1); //显式调用“=”号重载函数 cout<<p2<<endl; return 0; }
这是程序中用到的方法定义,我自己编写的:
Port::Port(const char *br, const char *st, int b) { brand=new char[strlen(br)+1]; strcpy(brand,br); strcpy(style,st); bottles=b; } Port::Port(const Port &p) { brand=new char[strlen(p.brand)+1]; strcpy(brand,p.brand); strcpy(style,p.style); bottles=p.bottles; } Port &Port::operator =(const Port &p) { if(&p==this) return *this; delete[] brand; brand=new char[strlen(p.brand)+1]; strcpy(brand,p.brand); strcpy(style,p.style); bottles=p.bottles; return *this; } ostream &operator<<(ostream &os,const Port &p) { os<<p.brand<<','<<p.style<<','<<p.bottles; return os; } VintagePort::VintagePort() { nickname=new char[5]; strcpy(nickname,"none"); year=2000; } VintagePort::VintagePort(const char *br, const char *st, int b, const char *nn, int y) :Port(br,st,b) { nickname=new char[strlen(nn)+1]; strcpy(nickname,nn); year=y; } VintagePort &VintagePort::operator =(const VintagePort &vp) { if(&vp==this) return *this; delete[] nickname; Port::operator =(vp); nickname=new char[strlen(vp.nickname)+1]; strcpy(nickname,vp.nickname); year=vp.year; return *this; } ostream &operator<<(ostream &os,const VintagePort &vp) { os<<(const Port &)vp<<','<<vp.nickname<<','<<vp.year; return os; }
运行的结果显示出了一个不伦不类的“Riesling,whitewine,70,JieBaiNa,1990”。原来它只复制的成员的基类部分,派生类添加的部分没动。很明显这个基类的指针仅指向了基类的同名方法。按道理来说设置虚函数后就不应当出现这种情况了啊?我百思不得其解,于是在CSDN里寻找答案。最终一个帖子吸引了我的视线:
http://topic.csdn.net/u/20100318/20/41c77e2a-cc32-4d56-b78a-a32c79f4c288.html
它讨论了运算符重载函数和虚函数的关系,楼主有与我相似的疑问。第11楼的回帖一语惊醒梦中人,我赶忙翻书去找书中对虚函数的描述,书上有这么一句话。
如果重新定义继承的方法,应确保与原来的原型完全相同。
这句话可能有点模棱两可,当时看时也没有引起我的注意。帖子里的回复有的倒是说得很清楚:
按照虚函数的定义,基类和派生类函数的名字,参数类型,个数等都要相同,但现在两个函数的参数不同,虚函数就失去了自身的意义,成为了普通的重载函数.
问题似乎得到了解决,错误出现在对“重新定义”这个词的理解上,光是函数名称相同不能叫重新定义,必须是原型完全相同。然后得具体到虚函数工作原理上,可能才能解释为什么在这里用“->”无法得到满意的结果。虚函数是基于虚函数表VTBL实现的,这是书中对VTBL运作机制的说明:
虚函数表中存储了为类对象进行声明的虚函数地址。例如,基类对象包含一个指针,该指针指向基类中所有函数的地址表。派生类对象也包含一个指向独立地址表的指针。如果派生类提供了虚函数的定义,该函数表将保存新函数的地址;如果派生类没有重新定义虚函数 ,该VTBL将保存原始版本 的地址。
派生类中“=”号重载函数的原型与基类的不同,因此按照之前的表述,可称得上“没有重新定义虚函数”。VTBL自然还是保存了原始版本的地址,即基类的等于号重载函数的入口地址。所以用“->”操作符出现的结果就不足为奇了。
这里再说些题外话,回帖中提到基类和派生类中同名但原型不完全相同的函数会成为普通重载函数,个人感觉该说法不正确。书中提到若两个虚函数在基类和派生类中同名但原型不完全相同,调用派生类对象时基类的版本会被自动隐藏。但对普通的同名但不同原型的函数没有做出解释,根据我的实验,这种情况下基类的同名函数仍然会被隐藏:(下图是在原始无virtual关键字声明的情况下,VS2008中IntelligenceSense的结果。)
IDE只检测到了一个派生类的运算符重载函数,也就是说基类的版本被屏蔽了。这么做我觉得是必要的,因为派生类型可以向上强制转换,基类和派生类的“=”号均可接受派生类对象,这会造成二义性。
绕了这么多,我脑子都糊涂了。当然,我现在所说的仅仅代表一个初学者对C++世界的浅薄理解,我也不敢完全肯定这是绝对正确的。望各位指正!