<C++略识>之多态和虚函数

1. 什么是多态?

** 书本上关于多态的解释:指相同对象收到不同消息或不同对象收到相同消息时产生不同的动作。
** VincentCZW在《C++中虚函数和多态》中的解释:关于多态,简而言之就是用父类型别的指针指向其子类的实例,然后通过父类的指针调用实际子类的成员函数。这种技术可以让父类的指针有“多种形态”,这是一种泛型技术。所谓泛型技术,说白了就是试图使用不变的代码来实现可变的算法。比如:模板技术,RTTI技术,虚函数技术,要么是试图做到在编译时决议,要么试图做到运行时决议。
** 空山苦水禅人在《C++多态》中的解释:“调用’同名函数’却会因上下文不同会有不同的实现的一种机制”。这个名字长是长了点儿,可是比“多态”清楚多了。看这个长的定义,我们可以从中找出多态的三个重要的部分。一是“相同函数名”,二是“依据上下文”,三是“实现却不同”。嘿,还是个顺口溜呢。我们且把它们叫做多态三要素吧。

2. C++中实现多态的方式

C++中共有三种实现多态的方式:第一种是函数重载;第二种是函数模版;第三种是虚函数。

2.1 函数重载实现多态

函数重载是这样一种机制:相同函数名的多个函数,参数个数或参数类型不同。比如有如下三个计算面积的函数:

int calcArea(int width){ }   //正方形计算面积函数
int calcArea(int width, int height){ }  //长方形计算面积函数
double calacArea(double r){ }  //圆计算面积函数

对于重载函数,C++编译器在编译的时候,根据函数参数的不同,自动地调用相应的函数。

2.2 函数模板实现多态

所谓函数模板是这样一个概念:函数的内容有了,但函数的参数类型却是待定的(注意:参数个数不是待定的)。比如说一个(准确的说是一类或一群)函数带有两个参数,它的功能是返回其中的最大值。这样的函数用模板函数来实现是最适合不过的了。如下。

template<typename T>
T getMax(T arg1, T arg2)
{
     return arg1 > arg2 ? arg1:arg2; //代码段1
}

这就是基于模板的多态吗?不是。因为现在我们不论是调用getMax(1, 2)还是调用getMax(3.0, 5.0)都是走的上面的函数定义。它没有根据调用时的上下文不同而执行不同的实现。所以这充其量也就是用了一个模板函数,和多态不沾边。怎样才能和多态沾上边呢?用模板特化呀!像这样:

template<>
char* getMax(char* arg1, char* arg2)
{
    return (strcmp(arg1, arg2) > 0) ? arg1 : arg2;//代码段2
}

这样一来当我们调用getMax(“abc”, “efg”)的时候,就会执行代码段2,而不是代码段1。这样就是多态了。
更有意思的是如果我们再写这样一个函数:

chargetMax(char arg1, char arg2)
{
    return arg1>arg2 ? arg1:arg2; //代码段3
}

当我们调用getMax(‘a’, ‘b’)的时候,执行的会是代码段3,而不是代码段1或代码段2。C++允许对模板函数进行函数重载,就象这个模板函数是一个普通的函数一样。于是我们马上能想到写下面这样一个函数来做三个数中取最大值的处理:

int getMax( int arg1, int arg2, int arg3)
{
    return getMax(arg1, max(arg2, arg3) ); //代码段4
}

同样我们还可以这样写:

template<typename T>
T getMax(T arg1, T arg2, T arg3)
{
    return getMax(arg1, getMax(arg2, arg3) ); //代码段5
}

2.3 虚函数实现多态

所谓虚函数是这样一个概念:基类中有这么一些函数,这些函数允许在派生类中其实现可以和基类的不一样。在C++中用关键字virtual来表示一个函数是虚函数。
接下来让我们说一说为什么要有虚函数,以下面一个例子来说明。
假设有三个类:形状类、圆类、矩形类。圆类和矩形类分别公有继承自形状类。在这三个类中都各自有一个计算面积的函数calcArea,我们知道,这三种图形的计算面积函数公式肯定是不相同的,并且形状类(由于不知道是什么图形)的计算面积函数中我们就只打印出字符串”calcArea()”。三者关系如下:

class Shape
{
public:
    doublecalcArea()
    {
        cout<< "calcArea()" <<endl;
    }   
};

class Circle : public Shape
{
    doublecalcArea()
    {
        return 3.24 * m_dR * m_dR;
    }
};
class Rect : public Shape
{
    intcalcArea()
    {
        returnm_iWidth * m_iHeight;
    }
};

接下来的终点是:在main函数中去使用的时候,我们可以使用父类的指针shape1去指向其中的一个子类对象Circle,并且用另外一个父类的指针shape2去指向一个矩形的对象。然后通过shape1和shape2分别调用计算面积的函数calcArea(),如下:

int main()
{
    Shape *shape1 = new Circle(4.0);
    Shape *shape2 = new Rect(3,5);
    shape1->calcArea();
    shape2->calcArea();
    ...
    return 0;
}

大家想一想,这样的结果是什么呢?是不是我们所期望的shape1调用圆类中的calcArea函数,而shape2调用矩形类中的calcArea函数呢??其实,大家尝试一下,就知道了。答案并不是我们所期望的那样,而是打印出两行字符串“calcArea()”的字样,也就是说,shape1和shape2调用的都是父类Shape类中的calcArea函数,并没有去调用Cirlce和Rect中的calcArea函数。
针对这个问题,虚函数这一杀手锏就派上用场了。我们只需要将父类Shape中的calcArea函数前面加上关键字virtual,使其变成一个虚函数,如下:

class Shape
{
public:
    virtual doublecalcArea()
    {
        cout<< "calcArea()" <<endl;
    }   
};

这样一来,子类中的calcArea函数就对父类Shape中的calcArea函数形成了覆盖,再用指向子类对象的父类指针去调用calcArea函数时,调用的就是子类中的calcArea函数,这就是我们所期望实现的。
[注意]:C++中还有一个术语 “覆盖”与虚函数关系密切。所谓覆盖就是说,派生类中的一个函数的声明,与基类中某一个函数的声明一模一样,包括返回值,函数名,参数个数,参数类型,参数次序都不能有差异。说覆盖和虚函数关系密切的原因有两个:一个原因是,只有覆盖基类的虚函数才是安全的。第二个原因是,要想实现基于虚函数的多态就必须在派生类中覆盖基类的虚函数。

小结-1

1、我们把函数重载和函数模板实现多态的方法称之为静态多态(也叫做早绑定)。之所以叫它静态多态,是因为计算机在编译的时候就会自动的调用相应的函数,程序在运行之前的编译阶段就已经确定下来,到底要使用哪个函数了。静态多态还有一个特点就是:“总和参数较劲!”
2、我们把虚函数实现多态的方法称之为动态多态(也叫上晚绑定)。动态多态就是必需是在程序的执行过程中才能确定要真正执行的函数。
3、多态的思想其实早在面向对象的编程出现之前就有了。比如C语言中的+运算符。这个运算符可以对两个int型的变量求和,也可以对两个char的变量求和,也可以对一个int型一个char型的两个变量求和。加法运算的这种特性就是典型的多态。所以说多态的本质是同样的用法在实现上却是不同的。

3. 多态中的虚析构函数

问题:什么情况下,类的析构函数需要声明为虚析构函数?
我们来看一个例子,如下:

#include <iostream>
using namespace std;

class Bird
{
public:
    Bird() { cout<< "Bird()" <<endl; };
    ~Bird() { cout<< "~Bird()" <<endl; };
    virtual void fly() { cout<< "Bird->fly()." <<endl; };
};

class Lark : public Bird
{
public:
    Lark() { cout<< "Lark()" <<endl; };
    ~Lark() { cout<< "~Lark()" <<endl; };
    void fly() { cout<< "Lark->fly()" <<endl; }
};

int main()
{
    Bird *pBird = new Lark();
    pBird->fly();
    delete pBird;

    return 0;
}

运行结果:

Bird()
Lark()
Lark->fly()
~Bird()

**我们来分析一下:**Bird作为基类描述鸟类的一般行为和属性,因为不同鸟类的飞行特点不同,所以基类Bird将fly()声明为virtrual,希望派生类重写(overriding)该方法。Lark(百灵鸟)继承自Bird,并重写了fly()。
main函数中基类Bird类型指针指向派生类Lark类型对象,并以基类指针调用fly方法,根据c++的多态特性,实际调用的是Lark的fly方法。我们从运行结果中打印出的”Lark->fly()”可以看到,pBird->fly()的确调用的是派生类Lark的fly()方法。但对象析构时只调用了基类Bird的析构函数,却没有调用派生类Lark的析构函数,这种现象叫做“部分析构”。这样执行不到派生类的析构函数,就会造成内存泄漏的风险。
产生这个问题的原因是:当一个派生类对象通过一个基类指针删除,并且这个基类的析构函数是非虚的,C++将不会调用整个析构函数链,结果是未定义的。所以这种情况下,只调用了基类Bird的析构函数,对象的派生部分并没有被销毁。
解决办法就是将多态基类的析构函数设置为virtual。多态基类指的是基类中至少存在一个virtual函数。将Bird的析构函数设置为virtual,再看程序的输出结果。

Bird()
Lark()
Lark->fly()
~Lark()
~Bird()

[注意]:并不是所有C++类都应该将析构函数设置为虚析构函数。只有具有虚函数的多态基类(或者其它想当基类的类)才应该将析构函数设置为虚析构函数,对于普通的类则无必要。因为虚函数的实现要求对象携带额外信息,也就是维护一个指向虚函数表的指针vptr(virtual table pointer),vptr指向虚函数表vtbl(virtual table)。当调用一个对象的虚函数时,就会通过vptr找到vtbl,在vtbl中寻找正确的函数指针调用。由于vptr的加入,导致对象大小增加。所以对于非多态基类,没必要将析构函数声明为虚析构函数以带来额外负担。

小结-2

  1. 根据什么考虑是否把一个成员函数声明为虚函数?
    (1)成员函数所在的类是否会作为基类。
    (2)成员函数在类的继承后有无可能被更改功能,如果希望更改其功能的,一般应该将它声明为虚函数。如果成员函数在类被继承后功能不需修改,或派生类用不到该函数,则不要把它声明为虚函数。
    不要仅仅考虑到作为基类而把类中的所有成员函数都声明为虚函数。
    (3)调用是通过对象名还是通过基类指针或引用去访问,如果是通过基类指针或引用去访问的,则应当声明为虚函数。
    说明:使用虚函数,系统要有一定的空间开销。当一个类带有虚函数时,编译系统会为该类构造一个虚函数表,它是一个指针数组,存放每个虚函数的入口地址。
  2. 析构函数的作用是在对象撤销之前把类的对象从内存中撤销。通常系统只会执行基类的析构函数,不执行派生类的析构函数。当我们需要把基类的析构函数声明为虚函数,即虚析构函数时,这样就会撤销基类对象的同时撤销派生类的对象了,这个过程是动态关联完成的。
  3. 如果将基类的析构函数声明为虚析构函数时,由该基类所派生的所有派生类的析构函数都自动成为虚析构函数,即使派生类的析构函数与基类的析构函数名字不相同。
    最好把基类的析构函数声明为虚函数。这将使所有派生类的析构函数自动成为虚函数,如果程序中显式delete运算符删除一个对象,而操作对象用了指向派生类对象的基类指针,系统会调用相应类的析构函数。
  4. 构造函数不能声明为虚函数:这是因为,如果构造函数为虚函数的话,它将在执行期间被构造,而执行期则需要对象已经建立。构造函数所完成的工作就是为了建立合适的对象,因此在没有构建好的对象上不可能执行多态(虚函数的目的就在于实现多态性)的工作。在继承体系中,构造的顺序就是从基类到派生类,其目的就在于确保对象能够成功地构建。构造函数同时承担着虚函数表的建立,如果它本身都是虚函数的话,如何确保虚函数表的构建成功呢?
  5. 类的静态成员函数不能为虚函数:这是因为如果将类的静态成员函数定义为虚函数,那么它就是动态绑定的,也就是说,在派生类中可以被覆盖的,这与静态成员函数的定义(在内存中只有一份拷贝;通过类名或对象引用访问静态成员)本身就是相矛盾的。
  6. 纯虚函数
    有时候,基类中的虚函数是为了派生类中的使用而声明定义的,其在基类中没有任何意义。此类函数我们叫做纯虚函数,不需要写成空函数的形式,只需要声明即可,其一般形式为:virtual 函数类型函数名(形参列表) = 0;
    [注意]:纯虚函数没有函数体;最后面的“=0“并不代表函数返回值为0,只是形式上的作用,告诉编译器”这是纯虚函数”;另外,这是一个声明语句,最后应有分号。纯虚函数只有函数的名字但不具备函数的功能,不能被调用。在派生类中对此函数提供定义后,才能具备函数的功能,可以被调用。
    (1)声明了纯虚函数的类是一个抽象类,它不能生成对象,所以,用户不能创建类的实例,只能创建它的派生类的实例。
    (2)抽象类只能作为基类来使用,其纯虚函数的实现由派生类给出。如果派生类中没有重新定义纯虚函数,而只是继承基类的纯虚函数,则这个派生类仍然还是一个抽象类。如果派生类中给出了基类纯虚函数的实现,则该派生类就不再是抽象类了,它是一个可以建立对象的具体的类。
    (3)纯虚函数最显著的特征是:它们必须在继承类中重新声明函数(不要后面的=0,否则该派生类也不能实例化),而且它们在抽象类中往往没有定义。
    (4)定义纯虚函数的目的在于,使基类(也就是抽象类)仅仅只是继承函数的接口。
    (5)纯虚函数的意义,让所有的类对象(主要是派生类对象)都可以执行纯虚函数的动作,但类无法为纯虚函数提供一个合理的缺省实现。所以类纯虚函数的声明就是在告诉子类的设计者,“你必须提供一个纯虚函数的实现,但我不知道你会怎样实现它”。

4. 虚函数表

前面一直在说到虚函数(Virtual Function),对C++ 了解的人都应该知道虚函数是通过一张虚函数表(vtbl: virtual table)和一个指向虚函数表的指针(vptr: virtual table pointer)来实现的。虚函数表对实现多态起着至关重要的作用。在这个表中,主要保存了一个类中的虚函数的地址。每一个包含虚函数的类的实例都包含一个vptr指针,其指向虚函数表的首地址。我们可以通过这个指针来找到要访问的虚函数,完成虚函数的调用,即主要包括:找到虚函数表的首地址(vptr);通过vptr找到要使用虚函数地址;调用虚函数。那么使用虚函数大家总要考虑效率的问题,实际上为了提高效率,C++的编译器是保证虚函数表的指针存在于对象实例中最前面的位置,这是为了保证取到虚函数表的有最高的性能,这意味着我们通过对象实例的地址得到这张虚函数表,然后通过遍历表就可以找到其中的虚函数的地址,然后调用相应的函数。
在讲这一部分之前,我们先来理清几个概念
1. 什么是对象的大小呢?
指在类实例化的对象当中,它的数据成员所占据的内存大小,而不包括成员函数。
2. 什么是对象的地址?
指通过一个类实例化一个对象后,其在内存当中就会占有一定大小的内存单元,那么这个内存单元的首地址就是这个对象的地址。
3. 什么是对象成员的地址?
指当用一个类去实例化一个对象之后,这个对象当中可能有一个或多个数据成员,那么每一个数据成员所占据的地址就是这个对象的成员地址。由于对象的每一个数据成员,因为它的数据类型不同,所以它占据的内存大小也有所不同,从而其地址也是不同的。
4. 什么是虚函数表指针?
指的是在具有虚函数的情况下,实例化一个对象的时候,这个对象的第一块内存当中所存储的是一个指针,这个指针就是函数表的指针。因为其也是一个指针,所以其占据的内存大小也应该是4。那么通过这个特点,我们就可以通过计算对象的大小来证明虚函数表指针的存在。

4.1 含有虚函数类对象的大小

C++中当一个类中存在虚函数或者它的父类中存在虚函数,那么编译器就会为这个类生成虚函数表(vtbl),下面我用代码来验证这个事情。
首先,先写三个类A、类B和类D:

class A
{
};
cout<<sizeof(A)<<endl;

输出结果1,就是说这个类占1个内存单元。

classB
{
public:
   B();
   ~B();
   void fun();
};
cout<<sizeof(B)<<endl;

输出结果1,就是说这个类也占1个内存单元。

classD
{
public:
    D();
    ~D();
    void fun();
private:
    int m_iX;
};
cout<<sizeof(D)<<endl;

输出结果4,就是说这个类占4个内存单元。
[注意]:C++对于一个数据成员都没有的情况,用1个内存单元去标定它,也就是说,这个内存单元只标定了这个对象的存在。
当一个类中仅有普通成员函数,而没有数据成员的时候,这个类也仅占一个内存单元;而当类中不仅有普通成员函数,还有数据成员的时候,类的大小就是每个数据成员大小之和。也就是说,类的大小与普通成员函数无关,仅与数据成员有关。

下面我们再写一个类E:

classE
{
public:
    E();
    ~E();
    virtual void fun();
private:
    int m_iX;
};
cout<<sizeof(E)<<endl;

我们看到类E和类D的区别仅仅是在将普通的成员函数fun()变成了一个虚函数,这时的输出结果是8。与之前相比,多出来的4个内存单元,就是因为虚函数表指针的存在。
我们再来看一个类F,让它继承类A,如下:

class A
{
public:
    A();
    ~A();
private:
    int m_iX;
};

class F:public A
{
public:
    F();
    ~F();
    virtual void fun();
private:
   int m_iY;
};
cout<<sizeof(F)<<endl;

这时输出的结果是12,从而我们可以得出结论:类的大小等于vptr的size加上数据成员的size,如果它有父类,则还需要加上父类中数据成员的size。

4.2 虚函数工作原理

每当创建一个包含有虚函数的类或从包含有虚函数的类派生一个类时,编译器就会为这个类自动创建一个虚函数表(vtbl)来保存该类所有虚函数的地址,其实这个vtbl的作用就是保存自己类中所有虚函数的地址。可以把vtbl形象地看成一个函数指针数组,这个数组的每个元素存放的就是虚函数的地址。
类的每个虚成员占据虚函数表中的一行。如果类中有N个虚函数,那么其虚函数表将有N*4字节的大小。
在每个带有虚函数的类中,编译器都会秘密地置入一个指针,称为虚函数表指针(vptr),指向这个对象的vtbl。当构造该派生类对象时,其成员vptr被初始化指向该派生类的vtbl。所以可以认为vtbl是该类的所有对象共有的,在定义该类时被初始化;而vptr则是每个类对象都有独立一份的,且在构造该类对象时被初始化。
4.3 vtbl与vptr示意图
假设我们有如下两个类:
这里写图片描述

通过之前的分析,我们可以得到以下信息:
1. 在Shape类中含有一个虚函数(calcArea()),那么在Shape类定义的时候就同时产生了一张vtbl。
2. 当我们去实例化一个Shape对象时,这个对象中除了包含m_iEdge这个数据成员之外,还含有另外一个隐藏的数据成员:vptr。
3. vptr就指向了上面的vtbl的首地址。这里假设vtbl的首地址为0xCCFF,那么此时通过Shape实例化出来的所有对象的vptr都指向了这个首地址。
4. 在Shape的vtbl中肯定保存有一个函数指针,其指向calcArea这个虚函数的函数入口,假设calcArea函数的入口地址是0x3355。
5. 调用过程则是:先找到vptr找到vtbl通过位置偏移找到相应虚函数指针
—>找到要调用的虚函数。整个过程如下图所示:
这里写图片描述
6. Circle这个类public方式集成了Shape类。虽然Circle当中并没有定义自己的虚函数,但是它却从父类Shape当中继承了虚函数,所以在实例化Circle的时候,也会产生一张vbtl(注意:这个vbtl是Circle自己的vbtl,而不是Shape的vbtl),假设它的首地址是0x6688。
7. Circle自己的vbtl中也保存有一个函数指针,其指向calcArea这个虚函数的函数入口(注意:这里的函数指针,即calcArea函数的入口地址与Shape中vbtl中保存的calcArea函数入口地址是一样的,都是0x3355).如下图所示:
这里写图片描述

以上情况是Circle这个类中没有定义虚函数的情况,那么,如果Circle这个类中也定义了虚函数,如下所示,情况又会是怎样的呢?
这里写图片描述

此时我们有如下结论:
1. 对于Shape这个类来说,它的情况不变,跟之前的情况一样。
2. 对于Circle这个类来说,它的vbtl也跟之前一样,不同的是,由于Circle定义了自己calcArea函数,所以其vbtl中的关于calcArea函数的函数指针就覆盖掉了Shape中原有的函数指针值,也就是说,这个存放的函数指针指向了自己的关于calcArea的函数入口,我们假设Circle自己的calcArea函数的函数入口地址是0x4B2C。
3. 如果我们用Shape的指针去指向Circle对象,那么,它就会通过Circle对象当中的vptr找到Circle的vbtl,通过Circle的vbtl就能够找到Circle的虚函数的函数入口地址,从而执行子类当中的虚函数(这是实现多态机制的关键所在)。整个过程如下:
这里写图片描述

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值