Performanced C++ 经验规则(5):再谈重载、覆盖和隐藏

jopen 9年前

1、首先说的是重载,有一个前提必须要弄清楚的是,如果不在类作用域内进行讨论,两个(或多个)同名函数之间的关系只可能是重载或隐藏,这里先说重载。考虑以下事实:

int foo(char c){...}  void foo(int x){...}

这两个函数之间的关系是重载(overload),即相同函数名但参数不同,并注意返回类型是否相同并不会对重载产生任何影响

也就是说,如果仅仅是返回类型不相同,而函数名和参数都完全相同的两个函数,不能构成重载,编译器会告知”ambiguous”(二义性)等词以表达其不满:

//Can't be compiled!    int fooo(char c){...}  void fooo(char c){...}    char c = 'A';  fooo(c); // Which one? ambiguous

在第四条中,已经讲述过,重载是编译期绑定的静态行为,不是真正的多态性,那么,编译器是根据什么来进行静态绑定呢?又是如何确定两个(或多个)函数之间的关系是重载呢?

有以下判定依据:

(1)相同的范围:即作用域,这里指在同一个类中,或同一个名字空间,即C++的函数重载不支持跨越作用域进行(读者可再次对比Java在这问题上的神奇处理,既上次Java给我们提供了未卜先知的动态绑定能力后,Java超一流的意识和大局观再次给Java程序员提供了跨类重载的能力,如有兴趣可详细阅读《Thinking in Java》的相关章节,其实对于学好C++来讲,去学一下Java是很有帮助的,它会告诉你,同样或类似的问题,为什么Java要做这样的改进),这也是区别重载和隐藏的最重要依据。

关于“C++不能支持跨类重载”,稍后笔者会给出代码来例证这一点。

(2)函数名字相同(基本前提)

(3)函数参数不同(基本前提,否则在同一作用域内有两个或多个同名同参数的函数,将产生ambiguous,另外注意,对于成员函数,是否是const成员函数,即函数声明之后是否带有const标志, 可理解为“参数不同“),第(2)和第(3)点统称“函数特征标”不同

(4)virtual关键字可有可无不产生影响(因为第(1)点已经指出,这是在同一个类中)

“相同的范围,特征标不同(当然同名是肯定的),发生重载“

2、覆盖(override),真正的多态行为,通过虚函数来实现,所以,编译器根据以下依据来进行判定两个(注意只可能是两个,即使在继承链中,也只是最近两个为一组)函数之间的关系是覆盖:

(1)不同的范围:即使用域,两个函数分别位于基类和派生类中

(2)函数名字相同(基本前提)

(3)函数参数也相同(基本前提),第(2)和第(3)点统称“函数特征标”相同

(4)基类函数必须用virtual关键字修饰

“不同的范围,特征标相同,且基类有virtual声明,发生覆盖“

3、隐藏(Hide),即:

(1)如果派生类函数与基类函数同名,但参数不同(特征标不同),此时,无论是否有virtual关键字,基类的所有同名函数都将被隐藏,而不会重载,因为不在同一个类中;

(2)如果派生类函数与基类函数同名,且参数也相同(特征标相同),但基类函数没有用virtual关键字声明,则基类的所有同名函数都将被隐藏,而不会覆盖,因为没有声明为虚函数。

“不同的范围,特征标不同(当然同名是肯定的),发生隐藏”,或“不同的范围,特征标相同,但基类没有virtual声明,发生隐藏“

可见有两种产生隐藏的情况,分别对应不能满足重载和覆盖条件的情况。

另外必须要注意的是,在类外讨论时,也可能发生隐藏,如在名字空间中,如下述代码所示:

#include <iostream>  using namespace std;    void foo(void) { cout << "global foo()" << endl; }  int foo(int x) { cout << "global foo(int)" << endl; return x; }  namespace a  {          void foo(void) { cout << "a::foo()" << endl; }          void callFoo(void)           { foo();             // foo(10); Can't be compiled! }  }    int main(int argc, char** argv)  {          foo();          a::callFoo();          return 0;  }

输出结果:

global foo()  a::foo()

注意,名字空间a中的foo隐藏了其它作用域(这里是全局作用域)中的所有foo名称,foo(10)不能通过编译,因为全局作用域中的int foo(int)版本也已经被a::foo()隐藏了,除非使用::foo(10)显式进行调用。

这也告诉我们,无论何时,都使用完整名称修饰(作用域解析符调用函数,或指针、对象调用成员函数)是一种好的编程习惯

 好了,上面零零散散说了太多理论的东西,我们需要一段实际的代码,来验证上述所有的结论:

#include <iostream>  using namespace std;    class Other  {          void* p;  };    class Base  {  public:          int iBase;          Base():iBase(10){}          virtual void f(int x = 20){ cout << "Base::f()--" << x << endl; }          virtual void g(float f) { cout << "Base::g(float)--" << f << endl; }          void g(Other& o) { cout << "Base::g(Other&)" << endl; }          void g(Other& o) const { cout << "Base::g(Other&) const" << endl;}  };    class Derived : public Base  {  public:          int iDerived;          Derived():iDerived(100){}          void f(int x = 200){ cout << "Derived::f()--" << x << endl; }          virtual void g(int x) { cout << "Derived::g(int)--" << x << endl; }  };    int main(int argc, char** argv)  {          Base* pBase = NULL;          Derived* pDerived = NULL;          Base b;          Derived d;          pBase = &b;          pDerived = &d;          Base* pBD = &d;          const Base* pC = &d;          const Base* const pCCP = &d;          Base* const pCP = &d;            int x = 5;          Other o;          float f = 3.1415926;            b.f();          pBase->f();          d.f();          pDerived->f();          pBD->f();            b.g(x);          b.g(o);          d.g(x);          d.g(f);          // Can't be compiled!          // d.g(o);            pBD->g(x);          pBD->g(f);          pC->g(o);          pCCP->g(o);          pCP->g(o);            return 0;  }

在笔者Ubuntu 12.04 + gcc 4.6.3运行结果:

Base::f()--20 //b.f(),通过对象调用,无虚特性,静态绑定  Base::f()--20 //基类指针指向基类对象,虽然是动态绑定,但没有使用到覆盖  Derived::f()--200 //d.f,通过对象调用,无虚特性,静态绑定  Derived::f()--200 //子类指针指向子类对象,虽然是动态绑定,但没有使用到覆盖  Derived::f()--20 //基类指针指向子类对象,动态绑定,子类f()覆盖基类版本。但函数参数默认值,是静态联编行为,pBD的类型是基类指针,所以使用了基类的参数默认值,注意此处!    Base::g(float)--5 //通过对象调用,int被提升为float  Base::g(Other&) //没什么问题,基类中三个g函数之间的关系是重载  Derived::g(int)--5 //没什么问题  Derived::g(int)--3 //注意基类的g(float)已经被隐藏!所以传入的float参数调用的却是子类的g(int)方法!    Base::g(float)--5 //注意!pBD是基类指针,虽然它指向了子类对象,但基类中的所有g函数版本它是可见的!所以pBD->g(5)调用到了g(float)!虽然产生了动态联编也发生了隐藏,但子类对象的虚表中,仍可以找到g(float)的地址,即基类版本!  Base::g(float)--3.14159 //原理同上    //d.g(o)  //注意此处!再注意代码中被注释了的一行,d.g(o)不能通过编译,因为d是子类对象,在子类中,基类中定义的三个g函数版本都被隐藏了,编译时不可见!不会重载    Base::g(Other&) const //pC是指向const对象的指针,将调用const版本的g函数  Base::g(Other&) const //pCCP是指向const对象的const指针,也调用const版本的g函数  Base::g(Other&) //pCP是指向非cosnt对象的const指针,由于不指向const对象,调用非const版本的g函数

上述结果,是否和预想的是否又有些出入呢?问题主要集中于结果的第5、12、13和15行。

第5行输出结果证明:当函数参数有默认值,又发生多态行为时,函数参数默认值是静态行为,在编译时就已经确定,将使用基类版本的函数参数默认值而不是子类的

而第12、13、15行输出结果则说明,尽管已经证明我们之前说的隐藏是正确的(因为d.g(o)不可以通过编译,确实发生了隐藏),但却可以利用基类指针指向派生类对象后,来绕开这种限制!也就是说,编译器根据参数匹配函数原型的时候,是在编译时根据指针的类型,或对象的类型来确定,指针类型是基类,那么基类中的g函数版本就是可见的;指针类型是子类,由于发生了隐藏,基类中的g函数版本就是不可见的。而到动态绑定时,基类指针指向了子类对象,在子类对象的虚函数表中,就可以找到基类中g虚函数的地址。

写到这里,不知道读者是否已经明白,这些绕来绕去的关系。在实际代码运用中,可能并不会写出含有这么多“陷阱”的测试代码,我们只要弄清楚重载、覆盖和隐藏的具体特征,并头脑清醒地知道,我现在需要的是哪一种功能(通常也不会需要隐藏),就能写出清析的代码。上面的代码其实是一个糟糕的例子,因为在这个例子中,重载、覆盖、隐藏并存,我们编写代码,就是要尽可能防止这种含混不清的情况发生。

记住一个原则:每一个方法,功能和职责尽可能单一,否则,尝试将它拆分成为多个方法