C++中成员函数间的各种关系比较复杂,之前介绍过重载和虚函数覆盖,今天再梳理另一种容易让人迷惑的机制--函数隐藏。先看两个例子:
例1.成员函数public继承
class Base{
public:
void fun1(int a){ cout << "Base::fun1(int a)" << endl; }
};
class Drv: public Base {
public:
void fun2(int a) { cout << "Drv::fun2(int a)" << endl; }
};
void main()
{
Drv dr;
dr.fun1(1); //继承自基类,输出Base::fun1(int a)
dr.fun2(2); //派生类自定义,输出Drv::fun2(int a)
}
关于public/protected/private继承及接口/实现继承后文讨论,上例是为说明public继承后,派生类Drv可直接调用基类Base的成员函数,换句话基类普通成员函数(非virtual)在public派生类中默认可见。再看:
例2成员函数同名:
class Base
{
public:
void fun1(int a){ cout<<"Base fun1(int a)"<<endl; } //①
};
class Drv:public Base
{
public:
void fun1(char* x){ cout<<"Drv fun1(char *x)"<endl; } //②
};
void main(){
Drv dr;
char x =0;
dr.fun1(1); //③
dr.fun1(&x); //④
}
据已有知识分析:Drv public继承Base,因而基类成员函数void fun1(int a)①在Drv类域中可见;Drv自身又定义了void fun1(char *x)②,即Drv类域内同时有fun1(int a)和fun1(char *x)。有问题?木有吧,一个类域里包含同名不同参的函数,不就是C++函数重载(overload)么?
而实际编译却报错,提示Drv中没有fun1(int)型函数③,为什么?Base类①处的void fun1(int a)不是被Drv自然继承而可见么?这引出C++继承时的一个额外限制:派生类成员函数某些情况下会屏蔽与其同名的基类成员函数,称为函数隐藏(overwrite),规则如下:
1)不论基类函数是否为virtual,只要有派生类函数与其同名但不同参,该基类函数在派生类中将被隐藏。(别与重载混淆)
2)基类函数非virtual,有派生类函数与此基类函数同名同参,此时该基类函数在派生类中将被隐藏。(别与覆盖混淆)
上例中由于Drv中②处定义了与基类函数同名不同参的fun1(char *x),根据规则1),Base类中fun1(int a)在Drv中被隐藏,Drv域中只有fun1(char *x)而没有fun1(int a),因此③处调用dr.fun1(1)处错。
从命名空间和作用域的视角去理解:隐藏就是“内部作用域”的成员屏蔽同名的"外部作用域“成员,如C局部变量屏蔽同名的全局变量。同样基类和派生类也有各自作用域且基类域(外层)包含派生类域(内层),因此如果派生类没有与基类同名成员(变量或函数),则基类成员在派生类里可见(继承);如果派生类里有同名成员,则基类成员在派生类不可见(隐藏)。
可能感觉不对,既然同名隐藏,为什么上面的函数隐藏规则那么啰嗦呢,隐藏和覆盖/重载又有什么关联?这其实是同一个问题。
世界为什么是现在这个样子L
事物初始几步的定义往往决定了后面千百步,初期为解决某问题引入的方法常导致后面产生更多问题需要解决。环环相扣,无数的妥协和平衡后,软件就变成现在这样。
当初制定C++隐藏规则时,估计会有两个问题摆在面前(完全臆测:)):
1)成员变量同名隐藏没什么可说的,但由于类的成员函数支持重载,隐藏规则必须在“隐藏同名同参的基类函数”还是“隐藏所有同名不同参的基类重载函数”间选择。似乎同名同参才隐藏更合情理,即只隐藏完全相同的基类函数,基类里其他同名不同参的函数可以继承下来,和派生类里同名新函数继续重载。最终C++却决定一杆子打翻一船,别说完全同名,同姓也不行。例2中Drv里定义了fun1就代表它会隐藏基类里所有叫fun1的,不管fun1(int)还是fun1(double)全部一扫空,世界清静了,我的地盘里只有我能姓fun1。
这么做是有考量的,C++允许一个派生类从多个基类继承(多重继承),当程序规模较大,类继承层次较深时,来自不同基类的函数层层继承积累,派生类中同名重载函数可能很多,程序员又未必了解这些来自基类的重载函数。当他设计了与基类函数同名不同参的派生类函数,调用该函数时又不小心写错参数类型或顺序时,问题就大了:C++编译器会认为你本来就想调另一个基类继承的同名重载函数,于是热情的牵线搭桥,而程序员见编译正常还以为调用了正确函数,一运行结果不对,悲催了。特别是基于框架和类库编程时,这个trap更容易出现。
隐藏机制可以预防这点:派生类里定义某函数fun1,所有基类fun1的重载函数版本全被屏蔽,这时如果在派生类里调fun1时写错参数,编译器就会报错,而不会再乱拉郎配。所以《Effetive C++》里说:”隐藏背后原因是为防止在程序库或应用框架内建立新的derived class时从疏远的base classes继承重载函数“。
当然这种株连九族的做法反过来会导致例2中这种误伤,没关系,起码这种错误会被编译器主动抛出,有弥补机会。而且基类函数只是被隐藏,而不是不存在,上帝关一扇门,一定会为你留一扇窗,等会儿带你看窗。
2)决定隐藏所有基类重载函数后问题又来了,都被隐藏,多态怎么实现?最后办法就是给基函数增加virtual关键字,从隐藏机制里划出一部分例外逻辑,用于虚函数覆盖机制。
这就是函数隐藏规则不整齐的原因,重新归纳:如果基类函数为virtual,实现同名同参的派生类函数就是覆盖;除此之外,一旦派生类与基类函数同名(无论是否同参),所有同名基类函数都被隐藏。
如何访问被隐藏的成员
函数被隐藏不代表其不存在,只是藏起来而已,C++有两种方法可以重新启用被隐藏的函数:
1)用using关键字,自定义命名空间一节提到using可将一个作用域中的名字引入另一个作用域中;它的另一个用法是”using Base::fun”,这样派生类中如果定义同名但不同参的函数,基类函数将不会被隐藏,两个函数并存在派生类域中形成新的重载,如:
class Base
{
public:
void fun1(int a){ cout<<"Base fun1(int a)"<endl; }
};
class Drv:publicBase
{
public:
using Base::fun1; //这句使基类中名为fun1的系列函数不再被株连(只可能被隐藏同名同参的一个)
void fun1(char *x){cout<<"Drv fun1(char *x) !"<<endl; }
};
void main(){
Drv dr;
dr.fun(1);
}
运行输出: Base fun1(int a)
派生类中using Base::fun1显式包含基类中函数名为fun1的所有重载版本,这样派生类定义的fun1只能隐藏和它同名同参的基类函数,不能株连其他重载版本。
2)用域限定符::来定位继承自Base却被派生类隐藏的成员,如上例将dr.fun1(1);改为dr.Base::fun1(1);即可,这种方法能调用所有被隐藏的基类成员变量和函数。
这两种方法中2)更通用,派生类中被同名同参函数隐藏的基类函数,以及被隐藏的基类成员变量,只能用::定位启用,另外被覆盖的基类virtual函数也可以用这种方式显式调用。1)只是保护重载系列,同名同参仍然会被隐藏。
总结:
不同于重载和覆盖的正面功能,同名隐藏在编程中应尽量避免:1)由于缺少类似virtual这种明显的语法特征,很多人容易忽略C++“隐藏”机制的存在,当派生类与基类函数同名时就和重载、覆盖等机制混淆,既降低可读性,又易产生bug。2)从面向对象思想的角度,隐藏也应尽量避免。基类里使用普通函数就代表这一功能在类的传承链中应固定不变,所有派生类都直接继承使用,隐藏则表明后来对这种继承链条的否定,体现了一种不变性部分的突变,使得逻辑上不协调。换句话,既然要隐藏,当初基类函数中就应该使用virtual,以代表类功能中可变部分的功能。