虚表、虚函数

什么是虚函数?

使用 virtual 关键字修饰的函数即为虚函数,virtual 关键字只能对类中的非静态函数使用。一种特殊的虚函数为纯虚函数,纯虚函数没有具体的实现,在函数形参列表后加上 =0,定义为纯虚函数。含有纯虚函数的类称为抽象类,抽象类不能进行实例化。

virtual void fun1() {      //虚函数的定义方式
...
};
virtual void fun2() = 0;   //纯虚函数的定义方式
虚函数的作用是什么?

虚函数用于 C++ 三大特性之一多态的实现,C++ 没有 interface 关键字,为了实现接口的重用,需要利用虚函数。

  多态的表现为,同样是基类的指针,但由于指向的派生类不同,所带来的实现也不同。
class Base{
public:
    virtual void fun(){
        cout << "Base fun" << endl;
    }
};

class Derived1 : public Base{
public:
    virtual void fun(){
        cout << "Derived1 fun" << endl;
    }
};

class Derived2 : public Base{
public:
    virtual void fun(){
        cout << "Derived2 fun" << endl;
    }
};
int main()
{
    Base *pb  = new Base();       //基类指针指向基类对象
    Base *pd1 = new Derived1();   //基类指针指向派生类对象
    Base *pd2 = new Derived2();   //基类指针指向派生类对象

    pb->fun();       //Base fun
    pd1->fun();      //Derived1 fun
    pd2->fun();      //Derived2 fun
}

上述代码中,pb、pd1、pd2 都是 Base 类型的指针,但由于指向的对象类型不同,同样的 fun() 实现也不同。具体的原理稍后详述。这里我们先注意到,我们使用了一个基类类型的指针去指向派生类的对象,也就是指针的类型和指向对象的类型是不一致的。这里涉及到一个静态类型动态类型的关系。
静态类型:指针或者引用申明的类型
动态类型:指针实际指向的类型

对于基本类型来说,是不会出现指针类型和指向的实际类型不一致的情况,例如一个指向 int 的指针去指向一个 char ,编译器会有如下错误

int *p = new char();   //"char *" 类型的值不能用于初始化 "int *" 类型的实体

而在基类指针指向派生类中,派生类继承了基类,可以粗浅的理解为基类是派生类的一个子集(不准确),派生类包含了一个完整的基类,而基类指针实际上指向的是派生类中的基类部分,所以这种情况下,静态类型和动态类型不一致是合法的。
  那么问题来了 —— 既然基类指针指向的是派生类中的基类部分,那么逻辑上来说3个 fun()的结果应该都是 “Base fun”,为什么会出现三种不一样的结果呢?这就涉及到 C++ 多态实现的一个关键点 —— 虚函数表。

虚函数表

虚函数表,字面意思理解就是存放虚函数的表。虚函数表是一个数组,数组元素储存的是虚函数的指针。一个类如果存在虚函数,那么就会有一个虚指针(vptr),这个虚指针指向虚函数表。
  而当一个派生类继承了一个有虚函数的基类时,派生类不会产生新的虚表,而是在原来的虚表上更改,自身与基类虚表中相同的函数会覆盖掉虚表中的对应函数,与虚表中不同的虚函数会添加到虚表的尾部

派生类仅有一个基类:
//类的定义如下
class Base {
public:
    virtual void fun_1();
    virtual void fun_2();
};

class Derived :public Base{
public:
    virtual void fun_1();
    virtual void fun_3();
};
结构:

vtanle.png

  可以看到,Base 的定义中存在 fun_1 和 fun_2 两个虚函数,所以 Base 中虚表存放了两个虚函数。而 Derived 的定义中存在 fun_1 和 fun_3 两个虚函数,那么首先 Derived 会将 Base 的虚表结构拷贝下来,当发现 fun_1 的虚函数时,就会将自身的 fun_1 替换父类的 fun_1,而存在不“冲突”的虚函数时,会将自身的虚函数依次放置在虚表的末尾。当出现基类指针指向派生类时,这样的构造方式方便虚函数的的调用。

vptr的位置

既然存在虚指针和虚表,虚指针是存放在类的什么部位?我们可不可以通过虚指针直接来访问虚函数呢?
  答案是可以的。为了能够正确取得虚函数,编译器会将虚指针放在对象实例的最开始的位置。可以用以下代码验证:

typedef void(*FUN)(void); //返回类型为 void,参数为 void

class Base{
private:
    virtual void fun1(){
        cout << "private: Base fun1" << endl;
    }
public:
    virtual void fun2(){
        cout << "public: Base fun2" << endl;
    }
};
int main()
{
    Base tmp;
    FUN pf; //函数指针 pf
    pf = (FUN)*((int*)*(int*)(&tmp)); 
    pf();
    pf = (FUN)*((int*)*(int*)(&tmp) + 1);
    pf();
}

输出结果

private: Base fun1
public: Base fun2

这个输出结果就比较有趣了,我们利用虚指针,得到了虚函数表中的函数并且进行了调用,验证了我们的猜想 —— 可以直接通过对象的首地址来获得虚指针,并且可以利用虚指针调用虚函数。但同时,我们调用了一个 private 成员函数,也就是说,我们利用这种方式绕过了访问权限。
  C++会尽量规范使用者的行为,但由于它的灵活性,其实并不能防止使用者做一些危险的事。

派生类有多个基类:

C++中有一种特殊的继承方式,就是多继承。那么在多继承的情况下派生类的虚表结构是如何呢?
  其实,在多继承中,虚表的结构是和单继承类似的。派生类对于每一个基类,都会按照单继承的方式将其虚函数保留、替换、增加。所不同的是派生类对于每一个基类都会进行这样的操作,那么相应的,派生类中就会存在多个虚表。同时,虚指针的排列顺序是按照继承顺序排列。

//类的声明如下
class Base1 {
public:
    virtual void fun_1();
    virtual void fun_2();
};

class Base2 {
public:
    virtual void fun_1();
    virtual void fun_3();
};

class Derived :public Base1 , public Base2{
public:
    virtual void fun_1();
};

结构:


vtable2.png

一些补充:

override 关键字:

派生类如果想要覆盖基类的虚函数,那么派生类的虚函数必须和基类声明完全一致。如果有不一致的情况,那么应该属于隐藏而不是覆盖。

class Base{
public:
    virtual void fun(){ cout << " Base fun" << endl; }
};

class Derived :public Base{
public:
    virtual void fun(int a) { cout << " Drtived fun" << endl;}
};

int main()
{
    Base *p = new Drtived();
    p->fun();  // 输出结果为 Base fun
    // p->fun(1); Error:函数调用中的参数过多
}

为了避免写代码时失误将派生类函数写错,C++提供了 override 关键字。使用 override 声明的成员函数如果在基类中找不到对应的虚函数,那么编译器会提醒。 override 使用如下:

   virtual void fun(int a) override { cout << " Drtived fun" << endl;}
   //Error : 使用 “override” 声明的成员函数不能重写基类成员

不过override 关键字需要编译器支持 C++11。

虚表的生命周期

我们来看以下代码:
在构造函数和析构函数中都调用了虚函数 fun

class Base{
public:
    virtual void fun(){ cout << " Base fun" << endl; }
    Base(){ fun(); }
    ~Base(){ fun(); }
};
int main()
{
    Base tmp;
}

输出:

Base fun
Base fun

结果显示,在构造函数调用时虚表已经创建好了。而释放是在析构函数之后的回收过程中。

虚析构函数

虚函数一般的意义是为了接口重用,如果派生类不会对其进行修改,就没有将其定义为虚函数的必要。但有一个例外就是虚析构函数。我们来看以下程序:

class Base{
public:
    Base(){ cout << "Base" << endl; }
    ~Base(){ cout << "~Base" << endl; }

};

class Derived :public Base{
public:
    Derived(){ cout << "Derived " << endl; }
    ~Derived(){ cout << "~Derived " << endl; }
};

int main()
{
    Base *p = new Derived();
    delete p;
}

执行结果:

Base
Derived 
~Base

也就是说,派生类的析构函数并没有被调用。这样的结果就会造成对象析构不完全,造成内存泄漏。如果在基类的析构函数前加上 virtual 修饰:

virtual ~Base(){ cout << "~Base" << endl; }

再次执行以上程序,输出结果为:

Base
Derived 
~Derived 
~Base

派生类析构函数被正确执行。

总结:

1.派生类是对基类的虚表进行修改,但并不是直接修改基类本身,所以基类与派生类的虚指针是不同的。这个很好理解,如果派生类直接对基类虚表进行修改,那直接实例化基类对象的时候不就会执行派生类的函数。而且在多个派生类的情况下,基类虚表结构就不明了。
  2.虚函数的意义其实是为了接口重用,一个类如果不需要根据实例化不同而有不同的展示,那么就没有必要将某些函数定义为虚函数。
  3.如果一个函数的实现本身没有合理的缺省值,那么应该将其定义为纯虚函数,由派生类去实现它。含有纯虚函数的类称为抽象类,抽象类的实例化没有意义,所以它不能直接实例化。
  4.如果派生类没有实现基类的纯虚函数,那么编译器会报错,因为外部调用时不能找到该函数的具体实现。
  5.尽量使用 override 来避免编码失误。
  6.如果一个类有作为基类的可能,那么尽量将析构函数加上 virtual 修饰。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 215,294评论 6 497
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,780评论 3 391
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 161,001评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,593评论 1 289
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,687评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,679评论 1 294
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,667评论 3 415
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,426评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,872评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,180评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,346评论 1 345
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,019评论 5 340
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,658评论 3 323
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,268评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,495评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,275评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,207评论 2 352

推荐阅读更多精彩内容

  • C++虚函数 C++虚函数是多态性实现的重要方式,当某个虚函数通过指针或者引用调用时,编译器产生的代码直到运行时才...
    小白将阅读 1,739评论 4 19
  • 前言 把《C++ Primer》[https://book.douban.com/subject/25708312...
    尤汐Yogy阅读 9,515评论 1 51
  • 这是知乎上c++虚函数的作用https://www.zhihu.com/question/23971699CSDN...
    吴业鹏阅读 1,130评论 0 1
  • 1.面向对象的程序设计思想是什么? 答:把数据结构和对数据结构进行操作的方法封装形成一个个的对象。 2.什么是类?...
    少帅yangjie阅读 4,995评论 0 14
  • 之前使用腾讯云的接口进行ocr识别身份证,腾讯的这个叫做万象优图,腾讯的做法是将图片上传到他们的服务器然后给你识别...
    Smallwolf_JS阅读 1,065评论 0 0