C++学习笔记(六)多态(下)

1、静态成员函数

只有非静态成员函数才可以成为虚函数,而静态成员函数不能声明为虚函数。

例1:

在本例中定义了一个test类,这个类中有一个指针成员变量a,test类中有五个成员函数,在本例中将析构函数和普通成员函数f声明为虚函数是没有问题的,将构造函数和静态成员函数声明为虚函数则会出现编译错误,这两种做法都是有违C++语法规定的。

2、重载、覆盖和遮蔽

多态函数是指在运行期才将函数入口地址与函数名绑定的函数仅有虚函数才是多态。但是除了虚函数以外,重载和遮蔽同样具有函数名相同的特征,在此做一下区分。为了说明方便,我们引入函数签名这一概念。函数签名包括函数名和函数参数的个数、顺序以及参数数据类型。

例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:

在本例中,我们列出了几种函数重载的情形。首先是函数的构造函数重载,我们在类中声明了四个构造函数,这四个函数构成重载的关系,前面三个函数之间只是函数参数数目不同,第四个构造函数为拷贝构造函数,该函数与前面的默认构造函数和两个带参构造函数参数类型不同。类中的成员函数同样可以进行重载,如本例中base类的三个fun函数。这两种情况是类内部的函数重载,在类外部顶层函数也同样能够成函数重载关系,如本例中的g函数,这三个函数都是顶层函数,由于函数名相同,但是函数参数不同,构成函数重载关系。

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

2) 覆盖

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

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

例6:

本例是一个非常简单的多态的示例程序,base类和derived类构成继承关系,在这两个类中成员函数vir1和vir2同名,并且这两个同名函数都被声明为了虚函数。如此一来则构成了函数覆盖,派生类中的vir1函数覆盖了基类中的vir1函数,派生类中的vir2函数覆盖了基类中的vir2函数。在主函数中通过基类指针调用vir1和vir2虚函数,构成多态,这两个函数的运行为运行期绑定

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

3) 遮蔽

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

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

例7:

在本例中没有虚函数,base类和derived类构成继承关系,因为构成继承关系的两个类中有同名函数,因此构成了函数遮蔽。派生类中的vir1函数遮蔽了基类中的vir1函数,派生类中的vir2函数遮蔽了基类中的vir1函数。需要注意的是虽然派生类中的vir2函数和基类中的vir2函数的函数签名不同,但是只需要函数名相同就构成函数遮蔽。我们接着来分析一下主函数,主函数中我们先是定义了基类类型的指针,指针指向的是基类对象,然后通过指针调用函数vir1和vir2,这个时候因为并不构成多态,因此调用的还是基类的vir1和vir2函数。之后定义了一个派生类对象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:

在这个程序中,定义了两个类,base类和derived类,这两个类构成继承关系,派生类和基类中包含同名的函数,并且同名的函数均为虚函数。针对这两个同名函数,我们一个一个来分析一下,首先来看一下vir1,基类和派生类中的vir1函数的函数签名是相同的,而且又是虚函数,构成了函数覆盖关系。再来看一下vir2函数,基类中的vir2函数和派生类中的vir2函数函数名相同,但函数参数不同,则它们的函数签名不同,因此派生类中的vir2函数和基类中的vir1函数不构成函数覆盖,既然函数名相同,那么可以构成函数遮蔽。

接着我们同样来看一下主函数,在主函数中,我们定义了一个基类类型的指针,指针指向派生类对象,之后通过该指针分别调用vir1和vir2函数。由于vir1是构成函数覆盖,因此通过基类指针调用vir1构成多态,由于p指针指向的是派生类对象,故调用的vir1函数是派生类中的vir1函数。派生类中的vir2函数和基类中的vir2函数只构成函数遮蔽,因此通过基类类型指针调用vir2函数并不会形成多态,最终调用的是基类中的vir2函数。之后定义了派生类对象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

以上总结了函数名相同的所有情况,函数名相同利用的好可以为程序设计带来较大的便利,使用的不好则容易误导程序设计人员。一般来讲,函数名相同通常会用在以下几种情况中:

1)顶层函数的函数重载。对于程序设计人员而言,实现功能相同但所处理数据类型不同的函数时,采用函数重载的方式将会带来极大的方便。例如设计一个求绝对值函数,针对整型和double类型各设及一个abs函数,调用时而无需关注参数类型,这样的设计是很方便的。
2)类中的成员函数的重载,这种函数重载和顶层函数重载同样能给我们的程序带来方便。
3)类中的构造函数重载,设计多个构造函数,用于不同的初始化对象方式。
4)在继承层次中为了使用多态特性而采用相同函数签名。

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

3、抽象基类和纯虚成员函数

公共接口是指一系列成员函数的集合(mine:接口,即声明了一系列函数(即要实现的功能))支持该接口的类必须以合适的方式重新定义这些成员函数,否则就无法创建对象。在C++中,可以通过抽象基类来实现公共接口。为了介绍抽象基类,我们需要先来了解一下纯虚成员函数

纯虚成员函数的声明语法如下:
    virtual 函数返回类型 函数名 (函数参数) = 0;

纯虚成员函数没有函数体,只有函数声明,在纯虚函数声明结尾加上“=0”表明此函数为纯虚成员函数。

包含纯虚成员函数的类即为抽象基类,之所以说它抽象,那是因为它无法实例化,也即无法用于创建对象。

例1:

如本例所示,本例中只定义了一个base类,该类中声明了一个纯虚成员函数(mine:本身是虚,还没有函数体),包含纯虚成员函数的类即为抽象基类,因此base类为抽象基类。抽象基类是无法用于创建对象的,而主函数中我们尝试创建base类的对象,这是不允许的,编译提示语法错误。

纯虚成员函数可以被派生类继承,如果派生类不重新定义抽象基类中的所有(有多个则要重新定义多个)纯虚成员函数,则派生类同样会成为抽象基类,因而也不能用于创建对象。

例2:

在本例中定义了三个类,一个base类,base类中有一个整型成员变量x,成员函数有两个构造函数、一个getx普通成员函数和一个纯虚成员函数display。之后定义了一个derived1类,该类继承base类,在该类中新增一个整型的成员变量y,并且定义了一个构造函数。之后又定义了一个derived2类,这个类同样也新增了一个整型的成员变量z,定义了一个带参的构造函数,并显式调用了基类中的构造函数,除此之外derived2类还重新定义了基类中的纯虚成员函数display,派生类中的display函数与基类中的纯虚成员函数构成函数覆盖。我们再来看一下主函数中的情况,主函数中首先尝试创建base类的对象,因为base类包含一个纯虚成员函数,因此是抽象基类,不能创建对象。之后又尝试创建derived1的对象,derived1类继承了基类base中的纯虚成员函数,并且没有重新定义该函数,因此derived1类虽然是base类的派生类,但它仍然是抽象基类,因此同样不能创建对象。之后尝试创建derived2类的对象,该类同样是base类的派生类,同样从base类中继承了纯虚成员函数display,但是该类中同时也重新定义了该函数,因此覆盖了基类的纯虚成员函数,该类不是抽象基类,因此可以创建对象。创建derived2类的对象时调用了类中的带参构造函数,之后通过对象调用display函数,打印出成员变量x和y的值。

一个纯虚成员函数就可以使类成为抽象基类,但是抽象基类中除了包含纯虚成员函数外,同样可以包含其它成员函数或成员变量。如例2中所示的base类,类中除了包含纯虚成员函数之外,还包含了一个private成员变量x和两个构造函数及一个普通成员函数getx。

只有类中的虚函数才能被声明为纯虚成员函数,普通成员函数和顶层函数均不能声明为纯虚成员函数。如例3中企图将顶层函数和普通的成员函数声明为纯虚成员函数,这都是不允许的。

例3:

抽象基类可以用于实现公共接口,在抽象基类中声明的纯虚成员函数,派生类如果想要能够创建对象,则必须全部重新定义这些纯虚成员函数。

4、dynamic_cast操作符

在C++中,编译期的类型转换有可能会在运行时出现错误,特别是涉及到类对象的指针或引用操作时,更容易产生错误。Dynamic_cast操作符则可以在运行期对可能产生问题的类型转换进行测试

例1:

本例中定义了两个类:base类和derived类,这两个类构成继承关系。在base类中定义了m函数,derived类中定义了f函数。在前面介绍多态时,我们一直是用基类指针指向派生类或基类对象,而本例则不同了。本例主函数中定义的是一个派生类指针,当我们将其指向一个基类对象时,这是错误的,会导致编译错误。但是通过强制类型转换我们可以将派生类指针指向一个基类对象,p = static_cast(new base);语句实现的就是这样一个功能,这样的一种强制类型转换时合乎C++语法规定的,但是是非常不明智的,它会带来一定的危险。在程序中p是一个派生类对象,我们将其强制指向一个基类对象,首先通过p指针调用m函数,因为基类中包含有m函数,这一句没有问题,之后通过p指针调用f函数。一般来讲,因为p指针是一个派生类类型的指针,而派生类中拥有f函数,因此p->f();这一语句不会有问题,但是本例中p指针指向的确实基类的对象,而基类中并没有声明f函数,虽然p->f();这一语句虽然仍没有语法错误,但是它却产生了一个运行时的错误换言之,p指针是派生类指针,这表明程序设计人员可以通过p指针调用派生类的成员函数f,但是在实际的程序设计过程中却误将p指针指向了一个基类对象,这就导致了一个运行期错误。

产生这种运行期的错误原因在于static_cast强制类型转换时并不具有保证类型安全的功能,而C++提供的dynamic_cast却能解决这一问题,dynamic_cast可以在程序运行时检测类型转换是否类型安全。当然dynamic_cast使用起来也是有条件的,它要求所转换的操作数必须包含多态类类型(即至少包含一个虚函数的类)。

例2:

在本例中利用dynamic_cast进行强制类型转换,但是因为base类中并不存在虚函数,因此p = dynamic_cast(new base);这一句会编译错误。dynamic_cast能否正确转换与目标类型是否为多态类类型无关,dynamic_cast要求被转换的类型必须为多态类类型。为了解决本例中的语法错误,我们可以将base类中的函数m声明为虚函数,virtual void m(){cout<<"m"<

dynamic_cast还要求<>内部所描述的目标类型必须为指针或引用。如例3所示,如果我们将例2中的主函数换成例3的形式,这也是无法通过编译的。

例3:

在本例中通过dynamic_cast来初始化指针p,在初始化过程中dynamic_cast会检测操作数new base转换为目标类型derived *是否能保证类型安全,如果类型安全则将new base结果赋给p指针,否则返回0,也即false。而本例中是要用基类对象地址去初始化派生类指针,这显然是无法保证类型安全的,因此p最后得到的返回值是0。在主函数中经过判断语句,最终程序输出“Convert not safe!”。

Dynamic_cast转换有自己的规则,下面将通过示例来介绍转换规则。

例4:

本例分别定义了两个类:base类和derived类,这两个类构成继承关系,为了测试dynamic_cast转换规则,我们在类中各自定义了一个虚函数。在本例的主函数中我们分别测试基类转换为派生类和派生类转换为基类时dynamic_cast转换返回值。本例最终运行结果如下:

Base to Derived is error
Derived to Base is ok

从结果可以看出从不能将指向基类对象的指针转换为指向派生类对象的指针,但是可以将指向派生类对象的指针转换为指向基类对象的指针。

例5:

在本例中,定义了两个类A和B,这两个类不构成继承关系,我们尝试将指向两个类对象的指针进行互相转换,看程序运行结果:

B to A is error
A to B is error

从程序运行结果不难看出,任意两个不相关的多态类类型之间的转换也是不能进行的。

总结一下dynamic_cast转换规则,只允许指向派生类对象的指针转换为指向基类对象的指针。

C++提供的两个类型转换操作符static_cast和dynamic_cast,static_cast可以用于任何类型的强制类型转换,但是它不保证转换过程中的类型安全,dynamic_cast只能用于多态类类型的转换,而且要求转换的目的类型必须为指针或引用,并且它可以保证转换过程中类型安全。

5、typeid操作符

typeid操作符用于判断表达式的类型,注意它和sizeof一样是一个操作符而不是函数。如果需要使用typeid操作符,最好加上typeinfo头文件。

给出以下定义

操作符typeid返回的是一个type_info类(用于描述数据类型的一个系统类)对象的引用。这个操作符可以用于表达式和类型名(包括自定的数据类型,比如类)。

例1:

假设我们如本例所示定义了两个类base类和derived类,基于这两个类定义,我们定义指针如下:

base * p = new derived;

下表将给出使用typeid操作符的结果。

对于表达式typeid(p),因为p是base*类型的指针,因此typeid(p) == typeid(base*)为真,而typeid(p) == typeid(derived*)为假。而对于表达式typeid(*p),因为base类具有多态性,因而在计算typeid(*p)时会根据运行时p所指向的实际类型去计算,而本例中p指向的是派生类对象,因此表达式typeid(*p) == typeid(derived)为真,typeid(*p) == typeid(base)为假。

例2:

假设我们根据例2中定义的两个类来定义如下指针:

base * p = new derived;

下表将给出使用typeid操作符的结果。

对于表达式typeid(p),同样,因为p是base*类型的指针,因此typeid(p) == typeid(base*)为真,而typeid(p) == typeid(derived*)为假。而对于表达式typeid(*p),由于此时的基类不具有多态性,因而*p将会采用编译期类型来计算,编译期*p是base对象,因此表达式typeid(*p) == typeid(derived)为假,typeid(*p) == typeid(base)为真。

推荐阅读更多精彩内容