C++重载、覆盖和遮蔽

多态函数是指在运行期才将函数入口地址与函数名绑定的函数,仅有虚函数才是多态。但是除了虚函数以外,重载和遮蔽同样具有函数名相同的特征,在此做一下区分。

为了说明方便,我们引入函数签名这一概念。函数签名包括函数名、函数参数的个数和顺序以及各个参数的数据类型。

【例 1】
void f( )
void g( )
void f(int)

【例 2】
void f( int)
void f(double)

【例 3】
void f(double, int)
void f(int, double)
为了理解函数签名的含义,我们先来看一下上面的三个例子:
  • 例 1 中,函数 f() 和函数 g() 的函数名不同,因此这两个函数的函数签名不同。f() 函数和 f(int) 函数一个有参数,一个没有参数,函数签名同样不同。g() 函数和 f(int) 函数函数名不同并且函数参数个数也不同,因此它们的函数签名也是不相同的。
  • 例 2 中,两个函数的函数名相同,参数个数相同,但是参数的类型不同,因此这两个函数的函数签名也不相同。
  • 例 3 中,两个函数的函数名相同,参数个数相同,参数类型也相同,都是一个 double 类型和一个 int 类型。但是,由于函数参数的顺序不相同,这两个函数的函数签名同样是不相同的。

需要注意的是,函数签名并不包含函数返回值部分,如果两个函数仅仅只有函数返回值不同,那么系统是无法区分这两个函数的,此时编译器会提示语法错误。

【例 4】
int f(int, double)
void f(int, double)
在本例中,两个函数的函数名相同,参数个数相同,参数类型相同,参数顺序相同,这两个函数的函数签名就是相同的。虽然两个函数的返回值类型不同,仅凭函数返回值,编译器无法区分这两个函数,编译器提示语法错误。

了解了函数签名的含义之后,我们再来看一下重载、覆盖和遮蔽。

1、重载

函数重载是指两个函数具有相同的函数名,但是函数参数的个数或者类型不同。函数重载多发生在顶层函数之间或者同一个类中,函数重载不需要构成继承关系。

【例 5】
class base
{
public :
    base();
    base(int a);
    base(int a, int b);
    base( base &);
    int fun(int a);
    int fun(double a);
    int fun(int a, int b);
private:
    int x;
    int y;
};

int g(int a);
int g(double a);
int g(int a, int b);
在本例中,我们列出了几种函数重载的情形:
1) 首先是函数的构造函数重载,类中声明了四个构造函数,这四个函数构成重载的关系,前面三个函数之间只是函数参数数目不同,第四个构造函数为拷贝构造函数,该函数与默认构造函数和两个带参构造函数的参数类型不同。

2) 类中的成员函数同样可以进行重载,如本例中 base 类的三个 fun() 函数。

3) 前两种情况是类内部的函数重载,在类外部顶层函数也同样能够成函数重载关系。如本例中的 g() 函数,这三个函数都是顶层函数,由于函数名相同,但是函数参数不同,构成函数重载关系。

函数重载是编译期绑定,它并不是多态。

2、覆盖

覆盖构成条件和多态构成条件是相同的,覆盖是一种函数间的表现关系,而多态描述的是函数的一种性质,二者所描述的其实是同一种语法现象。

覆盖首先要求有继承关系,其次是要求构成继承关系的两个类中必须具有相同函数签名的成员函数,并且这两个成员函数必须是虚成员函数。具备这三个条件后,派生类中的虚成员函数会覆盖基类中相同签名的虚成员函数。如果我们通过基类指针或引用来调用虚成员函数,则会形成多态。

【例 6】
#include<iostream>
using namespace std;

class base
{
public :
    virtual void vir1(){}
    virtual void vir2(){}
};

class derived : public base
{
public:
    void vir1(){}
    void vir2(){}
};

int main()
{
    base * p;
    p = new derived;
    p->vir1();
    p->vir2();
    delete p;
    return 0;
}
本例中,base 类和 derived 类构成继承关系,在这两个类中成员函数 vir1() 和 vir2() 同名,并且这两个同名函数都被声明为了虚函数。如此一来就构成了函数覆盖,派生类中的 vir1() 函数覆盖了基类中的 vir1() 函数,派生类中的 vir2() 函数覆盖了基类中的 vir2() 函数。在主函数中,通过基类指针调用 vir1() 和 vir2() 虚函数,构成多态,这两个函数的运行为运行期绑定。

函数覆盖属于运行期绑定,但是要注意:如果函数不是虚函数,则无论采用什么方法调用函数均为编译期绑定。如果我们将例 6 中的基类中的两个 virtual 关键字去掉,则主函数中调用 vir1() 和 vir2() 函数属于编译期绑定,无论 p 指向的是派生类对象或者是基类对象,执行的都将会是基类的 vir1() 和 vir2() 函数。

3、遮蔽

函数遮蔽同样要求构成继承关系,构成继承关系的两个类中具有相同函数名的函数,如果这两个函数不够成覆盖关系,则就构成了遮蔽关系。遮蔽理解起来很简单,只要派生类与基类中具有相同函数名(注意不是相同函数签名,只需要相同函数名就可以了)并且不构成覆盖关系即为遮蔽。

遮蔽可以分为两种情况,一种是非虚函数之间,另一种则是虚函数之间。我们通过程序示例来分别介绍这两种遮蔽情况。

【例 7】
#include<iostream>
using namespace std;

class base
{
public :
    void vir1(){cout<<"base vir1"<<endl;}
    void vir2(){cout<<"base vir2"<<endl;}
};

class derived : public base
{
public:
    void vir1(){cout<<"derived vir1"<<endl;}
    void vir2(int){cout<<"derived vir2"<<endl;}
};

int main()
{
    base * p;
    p = new derived;
    p->vir1();
    p->vir2();
    delete p;
    derived d;
    d.vir1();
    d.vir2(5);
    d.base::vir1();
    d.base::vir2();
    return 0;
}
在本例中没有虚函数,base 类和 derived 类构成继承关系,因为构成继承关系的两个类中有同名函数,因此构成了函数遮蔽:派生类中的 vir1() 函数遮蔽了基类中的 vir1() 函数,派生类中的 vir2() 函数遮蔽了基类中的 vir1() 函数。

再次强调,虽然派生类中的 vir2() 函数和基类中的 vir2() 函数的函数签名不同,但是只需要函数名相同就构成函数遮蔽。

紧接着分析一下主函数:
1) 先定义了基类类型的指针,指针指向的是基类对象,然后通过指针调用函数 vir1() 和 vir2(),这个时候因为并不构成多态,因此调用的还是基类的 vir1() 和 vir2() 函数。

2) 之后定义了一个派生类对象 d,通过该对象调用 vir1() 和 vir2() 函数,因为派生类中的 vir1() 和 vir2() 遮蔽了基类中的 vir1() 和 vir2() 函数,因此直接调用的将会是派生类中的 vir1() 和 vir2() 函数。这种情况下,如果需要通过派生类对象调用被遮蔽的基类中的函数,则需要通过::域解析操作符来处理,比如d.base::vir1();d.base::vir2();就是这么做的。

这个程序的最终运行结果如下:

base vir1
base vir2
derived vir1
derived vir2
base vir1
base vir2 

如果构成继承关系的两个类中包含同名的虚函数,则情况会变得复杂,判断原则是:如果没有构成覆盖则为遮蔽。覆盖要求的是函数签名相同,而遮蔽只需要函数名相同。

【例 8】
#include<iostream>
using namespace std;

class base
{
public :
    virtual void vir1(){cout<<"base vir1"<<endl;}
    virtual void vir2(){cout<<"base vir2"<<endl;}
};

class derived : public base
{
public:
    virtual void vir1(){cout<<"derived vir1"<<endl;}
    virtual void vir2(int){cout<<"derived vir2"<<endl;}
};

int main()
{
    base * p;
    p = new derived;
    p->vir1();
    p->vir2();
    delete p;
    derived d;
    d.vir1();
    d.vir2(5);
    d.base::vir1();
    d.base::vir2();
    return 0;
}
在这个程序中,base 类和 derived 类构成继承关系,它们包含同名的函数,并且同名的函数均为虚函数。针对这两个同名函数,我们一个一个来分析一下:
  • 基类和派生类中 vir1() 函数的函数签名是相同的,而且又是虚函数,构成了函数覆盖关系。
  • 基类和派生类中 vir2() 函数的函数名相同,但函数参数不同,所以它们的函数签名不同。因此,派生类中的 vir2() 函数和基类中的 vir2() 函数不构成函数覆盖,既然函数名相同,那么可以构成函数遮蔽。

紧接着,我们分析一下主函数:
1) 首先,我们定义了一个基类类型的指针,它指向派生类对象,之后通过该指针分别调用 vir1() 和 vir2() 函数:
  • 由于 vir1() 构成了函数覆盖,因此通过基类指针调用 vir1() 构成多态,由于 p 指针指向的是派生类对象,故调用的 vir1() 函数是派生类中的 vir1() 函数。
  • 派生类中的 vir2() 函数和基类中的 vir2() 函数只构成函数遮蔽,因此通过基类类型指针调用 vir2() 函数并不会形成多态,最终调用的是基类中的 vir2() 函数。

2) 之后,我们又定义了派生类对象 d,通过派生类对象 d 调用的函数只能是派生类中的函数,当然也包括从基类中继承来的函数。d.vir1() 和 d.vir2(5) 这两个函数调用语句调用的都是派生类中新增的成员函数,派生类中的 vir1() 函数虽然和基类中的 vir1() 函数构成覆盖关系,但是由于没有通过基类指针或引用来调用,因此也没有构成多态.

如此一来,如果需要通过对象来调用从基类中继承过来的 vir1() 函数,同样是需要::域解析操作符。派生类中的 vir2() 函数和基类中 vir2() 函数构成遮蔽,因此通过对象和成员选择符调用的仍是派生类中新增的 vir2() 函数,如果想调用基类中的 vir2() 函数,则需要通过::域解析操作符。

例 8 程序的运行结果如下:

derived vir1
base vir2
derived vir1
derived vir2
base vir1
base vir2


以上总结了函数名相同的所有情况,函数名相同利用的好可以为程序设计带来较大的便利,使用的不好则容易误导程序设计人员。一般来讲,函数名相同通常会用在以下几种情况中:
  • 顶层函数的函数重载。对于程序设计人员而言,实现功能相同但所处理数据类型不同的函数时,采用函数重载的方式将会带来极大的方便。例如设计一个求绝对值函数,针对整型和 double 类型各设计一个 abs() 函数,调用时而无需关注参数类型,这样的设计是很方便的。
  • 类中的成员函数的重载,这种函数重载和顶层函数重载同样能给我们的程序带来方便。
  • 类中的构造函数重载,设计多个构造函数,用于不同的初始化对象方式。
  • 在继承层次中为了使用多态特性而采用相同函数签名。

除此之外,函数名相同还会导致继承层次中的函数遮蔽,而函数遮蔽这一特性通常会使得程序难以理解,因此建议谨慎使用函数遮蔽机制。