明明白白——虚函数,虚指针,虚表,虚继承

2020-03-22  本文已影响0人  陈星空

多态性

多态性指相同对象收到不同消息或不同对象收到相同消息时产生不同的实现动作。C++支持两种多态性:编译时多态性,运行时多态性。

虚函数、虚表、虚指针

虚函数
虚函数是C++中用于实现多态(polymorphism)的机制。核心理念就是通过基类访问派生类定义的函数。
虚函数是在基类中被声明为virtual,并在派生类中重新定义的成员函数,可实现成员函数的动态覆盖(Override)

虚函数表
编译器会为每个有虚函数的类创建一个虚函数表,该虚函数表将被该类的所有对象共享。类的每个虚成员占据虚函数表中的一行。如果类中有N个虚函数,那么其虚函数表将有N4(x64下是N8)的大小。
派生类的虚函数表存放重写的虚函数,当基类的指针指向派生类的对象时,调用虚函数时都会根据vptr(虚表指针)来选择虚函数,而基类的虚函数在派生类里已经被改写或者说已经不存在了,所以也就只能调用派生类的虚函数版本了.

虚表指针
虚表指针在类对象中,每个同类对象中都有个一个vptr,指向内存中的vtable,所有同类对象,共享一个vtable,但是每个对象都自带一个vptr指向这个vtable,否则调用虚函数的时候会找不到正确的函数入口,(后面将会讲明)虚表指针是对象的第一个数据成员。

class Base 
{
    public:
        virtual void func() {}
}

class Derive : public Base
{
    public:
        void func() {}
}

void main()
{
    Derive d;
    Base *pb = &d;
    b->func();
}

编译器在编译的时候,发现Base类中有虚函数,此时编译器会为每个包含虚函数的类创建一个虚表(即vtable),该表是一个一维数组,在这个数组中存放每个虚函数的地址。由于Base类和Derive类都包含了一个虚函数func(),编译器会为这两个类都建立一个虚表,(即使子类里面没有virtual函数,但是其父类里面有,所以子类中对应的函数也是虚函数)
那么如何定位虚表呢?编译器另外还为每个类的对象提供了一个虚表指针(即vptr),这个指针指向了对象所属类的虚表。在程序运行时,根据对象的类型去初始化vptr,从而让vptr正确的指向所属类的虚表。所以在调用虚函数时,就能够找到正确的函数。

对于上述程序,由于pb实际指向的对象类型是Derive,因此vptr指向的Derive类的vtable,当调用pb->func()时,根据虚表中的函数地址找到的就是Derive类的func()函数。

*正是由于每个对象调用的虚函数都是通过虚表指针来索引的,也就决定了虚表指针的正确初始化是非常重要的。换句话说,在虚表指针没有正确初始化之前,我们不能够去调用虚函数。那么虚表是怎么实现的?虚表存放在哪里?虚表中的数据是在什么时候确定的?对象中的虚表指针又在什么时候赋值的?

答案是在构造函数中进行虚表的创建和虚表指针的初始化。虚表和静态变量一样存在全局数据区,虚表可以理解成类的静态成员,虚表指针和构造函数的执行初始化列表是初始化。

构造函数的调用顺序是,在构造子类对象时,要先调用父类的构造函数,此时编译器只“看到了”父类,并不知道后面是否后还有继承者,它初始化父类对象的虚表指针,该虚表指针指向父类的虚表。当执行子类的构造函数时,子类对象的虚表指针被初始化,指向自身的虚表。对于以上的例子,当Derive类的d对象构造完毕后,其内部的虚表指针也就被初始化为指向Derive类的虚表。在类型转换后,调用pb->func(),由于pb实际指向的是Derive类的对象,该对象内部的虚表指针指向的是Derive类的虚表,因此最终调用的是Derive类的func()函数。

有以下结论:
  • 拥有虚函数的类会有一个虚表,而且这个虚表存放在类定义模块的数据段中。模块的数据段通常存放定义在该模块的全局数据和静态数据,这样我们可以把虚表看作是模块的全局数据或者静态数据
  • 类的虚表会被这个类的所有对象所共享。类的对象可以有很多,但是他们的虚表指针都指向同一个虚表,从这个意义上说,我们可以把虚表简单理解为类的静态数据成员。值得注意的是,虽然虚表是共享的,但是虚表指针并不是,类的每一个对象有一个属于它自己的虚表指针
  • 虚表中存放的是虚函数的地址。
  • 虚表的地址被存放在对象的起始位置,即对象的第一个数据成员就是它的虚表指针。 同时我们还可以注意到,虚表指针的初始化确实发生在构造函数的调用过程中, 但是在执行构造函数体之前,即进入到构造函数的"{"和"}"之前。 为了更好的理解这一问题, 我们可以把构造函数的调用过程细分为两个阶段,即:
    1.进入到构造函数体之间。在这个阶段如果存在虚函数的话,虚表指针被初始化。如果存在构造函数的初始化列表的话,初始化列表也会被执行。
    2.进入到构造函数体内。这一阶段是我们通常意义上说的构造函数

构造函数初始化列表
类成员初始化总在构造函数执行之前
1)从必要性:
a. 成员是类或结构,且构造函数带参数:成员初始化时无法调用缺省(无参)构造函数
b. 成员是常量或引用:成员无法赋值,只能被初始化
2)从效率上:
如果在类构造函数里赋值:在成员初始化时会调用一次其默认的构造函数,在类构造函数里又会调用一次成员的构造函数再赋值
如果在类构造函数使用初始化列表:仅在初始化列表里调用一次成员的构造函数并赋值

如下:

class CExample {
public:
    int a;
    float b;
    //构造函数初始化列表
    CExample(): a(0),b(8.8)
    {}
    //构造函数内部赋值
    CExample()
    {
        a=0;
        b=8.8;
    }
};

上面的例子中两个构造函数的结果是一样的。上面的构造函数(使用初始化列表的构造函数)显式的初始化类的成员;而没使用初始化列表的构造函数是对类的成员赋值,并没有进行显式的初始化。
初始化和赋值对内置类型的成员没有什么大的区别,像上面的任一个构造函数都可以。对非内置类型成员变量,为了避免两次构造,推荐使用类构造函数初始化列表。

另外有的时候必须用带有初始化列表的构造函数
1.成员类型是没有默认构造函数的类。若没有提供显示初始化式,则编译器隐式使用成员类型的默认构造函数,若类没有默认构造函数,则编译器尝试使用默认构造函数将会失败。
2.const成员或引用类型的成员。因为const对象或引用类型只能初始化,不能对他们赋值。

初始化列表的成员初始化顺序:
C++初始化类成员时,是按照声明的顺序初始化的,而不是按照出现在初始化列表中的顺序。

Example:

class CMyClass {
    CMyClass(int x, int y);
    int m_x;
    int m_y;
};
 
CMyClass::CMyClass(int x, int y) : m_y(1), m_x(m_y)
{
}

可能以为上面的代码将会首先做m_y=1,然后做m_x=m_y,最后它们有相同的值。
但是编译器先初始化m_x,然后是m_y,,因为它们是按这样的顺序声明的。结果是m_x将有一个不可预测的值。

虚继承与虚基类

虚继承:在继承定义中包含了virtual关键字的继承关系;
虚基类:在虚继承体系中的通过virtual继承而来的基类;需要注意的是:
class CSubClass : public virtual CBase {};其中CBase称之为CSubClass 的虚基类,而不是CBase就是个虚基类,因为CBase还可以不不是虚继承体系 中的基类。
虚继承是为了解决菱形继承带来的二义性问题

菱形继承.png
类 A 派生出类 B 和类 C,类 D 继承自类 B 和类 C,这个时候类 A 中的成员变量和成员函数继承到类 D 中变成了两份,一份来自 A-->B-->D 这条路径,另一份来自 A-->C-->D 这条路径。
在一个派生类中保留间接基类的多份同名成员,虽然可以在不同的成员变量中分别存放不同的数据,但大多数情况下这是多余的:因为保留多份成员变量不仅占用较多的存储空间,还容易产生命名冲突。假如类 A 有一个成员变量 a,那么在类 D 中直接访问 a 就会产生歧义,编译器不知道它究竟来自 A -->B-->D 这条路径,还是来自 A-->C-->D 这条路径。

虚继承的目的是让某个类做出声明,承诺愿意共享它的基类。其中,这个被共享的基类就称为虚基类(Virtual Base Class),本例中的 A 就是一个虚基类。在这种机制下,不论虚基类在继承体系中出现了多少次,在派生类中都只包含一份虚基类的成员。
实际上为了实现虚继承引入了类似虚函数表指针的vbptr,vbptr 指的是虚基类表指针(virtual base table pointer),该指针指向了一个虚基类表(virtual table),虚表中记录了虚基类与本类的偏移地址;通过偏移地址,这样就找到了虚基类成员,而虚继承也不用像普通多继承那样维持着公共基类(虚基类)的两份同样的拷贝,节省了存储空间。

纯虚函数

a. 纯虚函数是一个在基类中只有声明的虚函数,在基类中无定义。要求在任何派生类中都定义自己的版本;
b. 纯虚函数为各派生类提供一个公共界面(接口的封装和设计,软件的模块功能划分);
c. 纯虚函数声明形式:

virtual void func()=0;  //纯虚函数

抽象类

抽象类就是指含有纯虚函数的类,该类不能创建对象,但是可以声明指针和引用。
一个具有纯虚函数的类称为抽象类。

//抽象类
class A
{

public:
    virtual void func() = 0;
};

class B:public A
{
public:
    virtual void func()  
    {
        cout << "实现父类的纯虚函数" << endl;
    }
};

int main()
{
    B b;
    b.func();
    return 0;
}

结论:
(1). 抽象类对象不能做函数参数,不能创建对象,不能作为函数返回类型;

A a;(×);            //不能创建抽象类的对象
void func(A a);(×)  //不能做函数参数
A func(); (×)       //不能作为函数的返回值

(2). 可以声明抽象类指针,可以声明抽象类的引用;

A * a; (√)         //可以声明抽象类的指针
A & a; (√)         //可以声明抽象类的引用

(3). 子类必须继承父类的纯虚函数才能创建对象。

参考:

  1. 虚函数、虚指针和虚表

  2. 虚表和虚指针

  3. 虚表指针初始化

  4. 参考4:

上一篇 下一篇

猜你喜欢

热点阅读