C++虚表与虚表指针详解

类的虚表

每个包含了虚函数的类都包含一个虚表。 

当一个类(B)继承另一个类(A)时,类B会继承类A的函数的调用权。所以如果一个基类包含了虚函数,那么其继承类也可调用这些虚函数,换句话说,一个类继承了包含虚函数的基类,那么这个类也拥有自己的虚表。

来看以下的代码。类A包含虚函数vfunc1,vfunc2,由于类A包含虚函数,故类A拥有一个虚表。

class A {
public:
    virtual void vfunc1()
    {    
        cout << "A::vfunc1" << endl;
    }
    virtual void vfunc2()
    {    
        cout << "A::vfunc2" << endl;
    }

    void func1();
    void func2();
private:
    int m_data1, m_data2;
};

类A的虚表如图1所示。 

 虚表是一个指针数组,其元素是虚函数的指针,每个元素对应一个虚函数的函数指针。需要指出的是,普通的函数即非虚函数,其调用并不需要经过虚表,所以虚表的元素并不包括普通函数的函数指针。

虚函数指针的赋值发生在编译器的编译阶段,也就是说在代码的编译阶段,虚表就可以构造出来了。

虚表是属于类的,而不是属于某个具体的对象,一个类只需要一个虚表即可。同一个类的所有对象都使用同一个虚表。

虚表指针

为了指定对象的虚表,对象内部包含一个虚表的指针,来指向自己所使用的虚表。为了让每个包含虚表的类的对象都拥有一个虚表指针,编译器在类中添加了一个指针,*__vptr,用来指向虚表。这样,当类的对象在创建时便拥有了这个指针,且这个指针的值会自动被设置为指向类的虚表。

 上面指出,一个继承类的基类如果包含虚函数,那个这个继承类也有拥有自己的虚表,故这个继承类的对象也包含一个虚表指针,用来指向它的虚表。

虚函数表存储位置

首先虚函数表存储在只读数据段(.rodata)、虚函数存储在代码段(.text)、虚表指针的存储的位置与对象存储的位置相同,可能在栈、也可能在堆或数据段等。

虚函数

  • 只有类的成员函数才能声明为虚函数,虚函数仅适用于有继承关系的类对象。普通函数不能声明为虚函数。
  • virtual具有继承性:父类中定义为virtual的函数在子类中重写的函数也自动成为虚函数。
  • 静态成员函数不能是虚函数,因为静态成员函数不受限于某个对象。
  • 内联函数(inline)不能是虚函数,因为内联函数不能在运行中动态确定位置。
  • 构造函数不能是虚函数。
  1. 构造一个对象的时候,必须知道对象的实际类型,而虚函数是在运行期间确定实际类型的。如果构造函数为虚函数,则在构造一个对象时,由于对象还未构造成功,编译器还无法知道对象的实际类型,是该类本身还是派生类。无法确定。
  2. 虚函数的执行依赖于虚函数表,而虚函数表是在构造函数中初始化的,即初始化vptr,让它指向虚函数表。如果构造函数为虚函数,则在构造对象期间,虚函数表还没有被初始化,将无法进行。
  • 析构函数可以是虚函数,,而且有时是必须声明为虚函数。

虚析构函数是为了解决这样的一个问题:基类的指针指向派生类对象,并用基类的指针删除派生类对象时,要使用虚析构函数。

  1. 此时 vtable 已经初始化了,完全可以把析构函数放在虚函数表里面来调用。
  2. C++类有继承时,基类的析构函数必须为虚函数。如果不是虚函数,则使用时可能存在内存泄漏的问题。

如果我们以这种方式创建对象 

SubClass* pObj = new SubClass();
delete pObj;

 没有实现多态,不管析构函数是否是虚函数(即是否加virtual关键词),delete时基类和子类都会被释放;

如果我们要实现多态,令基类指针指向子类,即以这种方式创建对象:

BaseClass* pObj = new SubClass();
delete pObj;
  1.  若析构函数是虚函数(即加上virtual关键词),delete时基类和子类都会被释放;
  2. 若析构函数不是虚函数(即不加virtual关键词),只会调用基类的析构函数,delete时只释放基类,不释放子类,会造成内存泄漏问题。

构造函数或者析构函数中调用虚函数会怎样

  1. 由于类的构造次序是由基类到派生类,所以在构造函数中调用虚函数,派生类还没有完全构造,虚函数是不会呈现出多态的。
  2. 类的析构是从派生类到基类,当调用继承层次中某一层次的类的析构函数时意味着其派生类部分已经析构掉,所以也不会呈现多态。

动态绑定

  1. 静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态,比如:函数重载
  2. 动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态。

C++是如何利用虚表和虚表指针来实现动态绑定的。我们先看下面的代码。

class A
{
public:
    virtual void vfunc1()
    {
        cout << "A::vfunc1" << endl;
    }
    virtual void vfunc2()
    {
        cout << "A::vfunc2" << endl;
    }
    void func1();
    void func2();

private:
    int m_data1, m_data2;
};

class B : public A
{
public:
    void vfunc1()
    {
        {
            cout << "B::vfunc1" << endl;
        }
    }
    void func1();

private:
    int m_data3;
};

class C : public B
{
public:
    virtual void vfunc2()
    {
        cout << "C::vfunc2" << endl;
    }
    void func2();

private:
    int m_data1, m_data4;
};

类A是基类,类B继承类A,类C又继承类B。类A,类B,类C,其对象模型如下图3所示。

由于这三个类都有虚函数,故编译器为每个类都创建了一个虚表,即类A的虚表(A vtbl),类B的虚表(B vtbl),类C的虚表(C vtbl)。类A,类B,类C的对象都拥有一个虚表指针,*__vptr,用来指向自己所属类的虚表。 

类A包括两个虚函数,故A vtbl包含两个虚函数指针(内容为虚函数地址的指针变量),分别指向A::vfunc1()和A::vfunc2()。

类B继承于类A,故类B可以调用类A的函数,但由于类B重写(覆盖)了B::vfunc1()函数(两个分属基类和派生类的同名函数,返回值,函数名,参数列表都要与基类相同才叫重写,会生成一个具有新地址的虚函数覆盖掉继承下来的虚函数,否则就是重定义,父类指针就不能调用子类),故B vtbl的两个指针分别指向B::vfunc1()和A::vfunc2()。 

类C继承于类B,故类C可以调用类B的函数,但由于类C重写了C::vfunc2()函数,故C vtbl的两个指针分别指向B::vfunc1()(指向继承的最近的一个类的函数)和C::vfunc2()。 

总结:“对象的虚表指针用来指向自己所属类的虚表,如果虚函数没有重写,虚表中的指针会指向其继承的最近的一个类的虚函数,如果重写虚表中的指针会指向重写的新的虚函数的地址且继承下来的虚函数会被覆盖掉”

非虚函数的调用不用经过虚表,故不需要虚表中的指针指向这些函数。

假设我们定义一个类B的对象。由于bObject是类B的一个对象,故bObject包含一个虚表指针,指向类B的虚表。

int main() 
{
    B bObject;
}

现在,我们声明一个类A的指针p来指向对象bObject。虽然p是基类的指针只能指向基类的部分,但是虚表指针亦属于基类部分,所以p可以访问到对象bObject的虚表指针。bObject的虚表指针指向类B的虚表,所以p可以访问到B vtbl。我们使用p来调用vfunc1()函数

int main()
{
    B bObject;
    A *p = &bObject;
    p->vfunc1();
    p->vfunc2();
}

// 输出
B::vfunc1
A::vfunc2

程序在执行p->vfunc1()时,会发现p是个指向对象的指针,且调用的函数是虚函数(非虚函数的话直接调用,虚函数需要借助虚表调用),接下来便会进行以下的步骤。 
首先,根据虚表指针p->__vptr来访问对象bObject对应的虚表。虽然指针p是基类A*类型,但是*__vptr也是基类的一部分,所以可以通过p->__vptr可以访问到对象bObject对应的虚表。 
然后,在虚表中查找所调用的函数对应的条目。由于虚表在编译阶段就可以构造出来了,所以可以根据所调用的函数定位到虚表中的对应条目。对于 p->vfunc1()的调用,B vtbl的第一项即是vfunc1对应的条目。(由于vfun1重写了,覆盖了A中继承的vfunc1,故调用的是B中重写的函数 )
最后,根据虚表中找到的函数指针,调用函数。从图3可以看到,B vtbl的第一项指向B::vfunc1(),所以 p->vfunc1()实质会调用B::vfunc1()函数。

如果p指向类A的对象,情况又是怎么样?

int main() 
{
    A aObject;
    A *p = &aObject;
    p->vfunc1();
}

当aObject在创建时,它的虚表指针__vptr已设置为指向A vtbl,这样p->__vptr就指向A vtbl。vfunc1在A vtbl对应在条目指向了A::vfunc1()函数,所以 p->vfunc1()实质会调用A::vfunc1()函数。

通过使用这些虚函数表,即使使用的是基类的指针来调用函数,也可以达到正确调用运行中实际对象的虚函数。

我们把经过虚表调用虚函数的过程称为动态绑定,其表现出来的现象称为运行时多态。动态绑定区别于传统的函数调用,传统的函数调用我们称之为静态绑定,即函数的调用在编译阶段就可以确定下来了。

多态的概念:通俗来说,就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会产生出不同的状态

执行函数的动态绑定需要符合以下两个条件。

(1) 只有虚函数才能进行动态绑定,非虚函数不进行动态绑定。
(2) 必须通过基类类型的引用或指针进行函数调用。

如果一个函数调用符合以上两个条件,编译器就会把该函数调用编译成动态绑定,其函数的调用过程走的是上述通过虚表的机制。 

通过基类指针或基类引用做形参,当实参传入不同的派生类(或基类)的指针或引用,在函数内部触发动态绑定,从而来运行时实现多态的。

扩展:

  • 编译时多态(静态多态):通过重载函数实现
  • 运行时多态(动态多态):通过虚函数实现

在继承中构成多态的两个条件:

  • 必须通过基类的指针或者引用调用虚函数
  • 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写

override 和 final (C++11)

C++对函数重写的要求比较严格,但是有些情况下由于疏忽,可能会导致函数名字母次序写反而无法构成重写,而这种错误在编译期间是不会报出的,只有在程序运行时没有得到预期结果才来debug会得不偿失,因此:C++11提供了override和final两个关键字,可以帮助用户检测是否重写

  1. final:修饰虚函数,表示该虚函数不能再被重写
class Person
{
public:
	virtual void Print() final
	{
		cout << _No << endl;
	}
	int _No = 1;
};
class Student : public Person
{
public:
	virtual void Print()//不能继承
	{
		cout << _age << endl;
	}
	int _age = 100;
};

 

  2.override: 检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错

class Person
{
public:
	virtual void Print()
	{
		cout << _No << endl;
	}
	int _No = 1;
};
class Student : public Person
{
public:
	virtual void Print(int x) override
	{
		cout << _age << endl;
	}
	int _age = 100;
};

如果我们上面图片中的override去掉,能够编译通过,但是基类Person与派生类Student里面的Print函数不构成动态绑定,而是构成隐藏关系,因为同名函数,不在同一作用域,派生类会隐藏掉基类里面的同名函数。

class A
{
public:
    virtual void vfunc1()
    {
        cout << "A::vfunc1" << endl;
    }
    virtual void vfunc2()
    {
        cout << "A::vfunc2" << endl;
    }
    void func1();
    void func2();

private:
    int m_data1, m_data2;
};

class B : public A
{
public:
    void vfunc1(int x)
    {
        {
            cout << "B::vfunc1" << endl;
        }
    }
    void func1();

private:
    int m_data3;
};

class C : public B
{
public:
    virtual void vfunc2()
    {
        cout << "C::vfunc2" << endl;
    }
    void func2();

private:
    int m_data1, m_data4;
};

int main()
{
    B bObject;
    A *p = &bObject;
    p->vfunc1();
    p->vfunc2();
}

// 输出
A::vfunc1
A::vfunc2
如果改成
int main()
{
    B bObject;
    B *p = &bObject;
    p->vfunc1();
    p->vfunc2();
}
报错 error: no matching function for call to 'B::vfunc1()'

只有子类的虚函数和父类的虚函数定义完全一样才被认为是虚函数,比如父类后面加了const,如果子类不加的话就是隐藏了,不是覆盖. 

 纯虚函数

纯虚函数是一种特殊的虚函数,在许多情况下,在基类中不能对虚函数给出有意义的实现,而把它声明为纯虚函数,它的实现留给该基类的派生类去做。这就是纯虚函数的作用。

纯虚函数声明如下: virtual void funtion1()=0; 纯虚函数一定没有定义,纯虚函数用来规范派生类的行为,即接口。包含纯虚函数的类是抽象类,抽象类不能够实例化,但可以声明指向实现该抽象类的具体类的指针或引用。

为啥引入纯虚函数呢?

  • 为了方便使用多态特性,我们常常需要在基类中定义虚函数。
  • 在很多情况下,基类本身生成对象是不合情理的。例如,动物作为一个基类可以派生出老虎、孔雀等子类,但动物本身生成对象明显不合常理。

为了解决上述问题,引入了纯虚函数的概念,将函数定义为纯虚函数。若要使派生类为非抽象类,则编译器要求在派生类中,必须对纯虚函数予以重写以实现多态性。同时含有纯虚函数的类称为抽象类,它不能生成对象。这样就很好地解决了上述两个问题。 

纯虚函数的意义:让所有的类对象(主要是派生类对象)都可以执行纯虚函数的动作,但类无法为纯虚函数提供一个合理的默认实现。所以类纯虚函数的声明就是在告诉子类的设计者,"你必须提供一个纯虚函数的实现,但我不知道你会怎样实现它"。

#include <iostream>
using namespace std;
 
// 抽象类
class Shape 
{
public:
   // 提供接口框架的纯虚函数
   virtual int getArea() = 0;
   void setWidth(int w)
   {
      width = w;
   }
   void setHeight(int h)
   {
      height = h;
   }
protected:
   int width;
   int height;
};
 
// 派生类
class Rectangle: public Shape
{
public:
   int getArea()
   { 
      return (width * height); 
   }
};
class Triangle: public Shape
{
public:
   int getArea()
   { 
      return (width * height)/2; 
   }
};
 
int main(void)
{
   Rectangle Rect;
   Triangle  Tri;
 
   Rect.setWidth(5);
   Rect.setHeight(7);
   // 输出对象的面积
   cout << "Total Rectangle area: " << Rect.getArea() << endl;
 
   Tri.setWidth(5);
   Tri.setHeight(7);
   // 输出对象的面积
   cout << "Total Triangle area: " << Tri.getArea() << endl; 
 
   return 0;
}

// 输出
Total Rectangle area: 35
Total Triangle area: 17

从上面的实例中,我们可以看到一个抽象类是如何定义一个接口 getArea(),两个派生类是如何通过不同的计算面积的算法来实现这个相同的函数。 

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值