C++ 类虚函数原理

学习过C++的童鞋都知道C++类成员函数可以分为虚函数和非虚函数,(java程序员就请绕过这个问题,因为java类全是虚函数),那么这两类函数有什么区别呢,这篇文章主要介绍C++虚函数的运行原理。


我们还是通过一个例子,来说明两者的差异,然后讨论引发差异的原因:C++程序如下:

#include <stdio.h>
#include <string>

class P {
public:
    void foo1() { printf("in P::foo1()\n"); }
    virtual void foo2() { printf("in P::foo2()\n"); }
};

class C: public P {
public:
    void foo1() { printf("in C::foo1()\n"); }
    virtual void foo2() { printf("in C::foo2()\n"); }
};

void sub(P * o) {
    printf("in sub(P *)\n");
    o->foo1();
    o->foo2();
}

void sub(C * o) {
    printf("in sub(C *)\n");
    o->foo1();
    o->foo2();
}

int main(int argc, char * argv[]) {
    P * p = new P();
    C * c = new C();

    sub(p);
    sub(c);
    sub(dynamic_cast<P *>(c));
    return 0;
}

例子程序定义了两个类,父类P和子类C,分别定义了非虚函数foo1()和虚函数foo2();然后定义了两个全局函数sub(P *)和sub(C *),这两个函数的功能是一样的,都是调用参数o的foo1()和foo2()函数,不同点是参数类型,一个是P类,另一个是C类。

再看main()函数,分别对p和c调用sub()函数,然后把c对象强制转换成P类型对象,调用sub()函数。看运行结果:

in sub(P *)
in P::foo1()
in P::foo2()
in sub(C *)
in C::foo1()
in C::foo2()
in sub(P *)
in P::foo1()
in C::foo2()
  • 第一个调用 sub(p),因为p就是一个P类型实例,没有疑问会调用 sub(P *)函数,然后在sub内再调用P的foo1()和foo2()函数。
  • 第二个调用 sub(c),因为c就是一个C类型实例,没有疑问会调用 sub(C *)函数,然后在sub内再调用C的foo1()和foo2()函数。
  • 第三个调用 sub(dynamic_cast<P *>(c)),有点特殊,首先c是一个C类,但是这里把c对象cast成了一个P对象,那么编译器会按照P类型调用sub(P *)这个函数,再看foo1()和foo2()的调用,这里就不一样了,我们看到foo1()调用的是P类的,而foo2()调用的又是C类的。

这才是C++虚函数的本质,即尽管把c对象强制转换成了P类型对象,最终还是能够掉到C类的函数,因为毕竟c是一个C类的实例。


下面我们分析虚函数和非虚函数的实现差异,我们打开sub(P *)和 sub(C *)的汇编代码以查看:

void sub(P *)                 +   void sub(C *)
------------------------------+----------------------------------
_Z3subP1P:                    +   _Z3subP1C:
    pushq   %rbp              +     pushq   %rbp
    movq    %rsp, %rbp        +     movq    %rsp, %rbp
    subq    $16, %rsp         +     subq    $16, %rsp
    movq    %rdi, -8(%rbp)    +     movq    %rdi, -8(%rbp)
    movl    $.LC4, %edi       +     movl    $.LC5, %edi
    call    puts              +     call    puts
    movq    -8(%rbp), %rax    +     movq    -8(%rbp), %rax
    movq    %rax, %rdi        +     movq    %rax, %rdi
    call    _ZN1P4foo1Ev      +     call    _ZN1C4foo1Ev
    movq    -8(%rbp), %rax    +     movq    -8(%rbp), %rax
    movq    (%rax), %rax      +     movq    (%rax), %rax
    movq    (%rax), %rdx      +     movq    (%rax), %rdx
    movq    -8(%rbp), %rax    +     movq    -8(%rbp), %rax
    movq    %rax, %rdi        +     movq    %rax, %rdi
    call    *%rdx             +     call    *%rdx
    leave                     +     leave
    ret                       +     ret

我们可以看到这两个函数的代码结构是一样的,我们还可以看到不管是sub(P *)还是sub(C *),他们调用foo1()和foo2()的代码都是不一样的:

  • 调用foo1()是通过call _ZN1P4foo1Ev和_ZN1C4foo1Ev来完成的。
  • 调用foo2()是通过call *%rdx来完成的。

为什么两者的调用方式不一样呢,答案foo2()是虚函数,而foo1()不是,这正好体现了两者的差异,也就是函数声明成virtual和没有virtual的关键差异。

  • foo1()不是虚函数,那么调用foo1()的时候直接使用了符号表地址,也就是说不
    管参数p是一个P类实例,还是P的子类C的实例,都是调用P::foo1(),这个函数调用是在编译时刻就确定了的。
  • foo2()是虚函数,调用foo2()的时候没有直接使用foo2()的符号表,而是使用一个存储在%rdx寄存器里的间接地址,这个地址是通过如下三条汇编指令得到的:
movq    -8(%rbp), %rax
movq    (%rax), %rax
movq    (%rax), %rdx

可以看到,这个地址的获取和参数p相关(假定-8(%rbp)是参数p在函数栈中的地址),也就是说参数P *p中带有了foo2()的地址信息;仔细分析这三条指令:

》1. 第一条执行把p的值(即类实例的地址)加载到寄存器 rax。
》2. 第二条指令把类实例内存的前8个字节加载到寄存器rax,
》3.. 第三条指令把当前rax寄存器对应地址的头8个字节加载到rax寄存器。
怎么理解这三条指令,其实很简单,目的就是要加载foo2()的函数地址到寄存器rdx,给紧接着下面的call指令使用,这三条指令先加载实例地址,然后从实例对象中找到类的虚函数表,再从虚函数表中找到foo2()的地址。

1.jpg

这三条load指令就是调用一个虚函数额外多出来的指令,而这就是以前很多人觉得虚函数性能差的原因。


通过前面分析我们可以看出,一个对象内存空间的头8个字节存储的是虚函数表指针,即如果一个类有虚函数,那么这个类实例的大小会增加额外的8字节,指向对象类的虚函数表,并且这个指针存储在类实例的起始地址,其他对象实例变量依次往后排。另外不管一个类有多少个虚函数,对象内存空间的大小只会增加一个地址长度,虚函数越多,对应虚函数表的大小就会增大,但是对象实例大小不会增大了,举例子来看:

#include <stdio.h>
#include <string>

class P1 {
private:
    long l;
public:
    void foo1() {}
};
class P2 {
private:
    long l;
public:
    void foo1() {}
    virtual void foo2() {}
};
class P3 {
private:
    long l;
public:
    void foo1() {}
    virtual void foo2() {}
    virtual void foo3() {}
};

int main(int argc, char * argv[]) {
    printf("%d,%d,%d\n", sizeof(P1), sizeof(P2), sizeof(P3));
    return 0;
}

运行输出结果为: 8,16,16
因为P1没有虚函数,成员变量l占用8字节,P2和P3虚函数表指针占用8字节,实例变量l占用8字节,所以总长度为16。


注意虚函数表是属于类的,而不是属于对象的,即一个类有一个虚函数表,所有这个类的实例对象都指向同一个虚函数表,用代码说明:

include <stdio.h>
#include <string>

class P {
private:
    long l;
public:
    void foo1() {}
    virtual void foo2() {}
};

int main(int argc, char * argv[]) {
    P * p1 = new P();
    P * p2 = new P();
    P * p3 = new P();

    printf("p1:0x%x,0x%x\n", p1, *((long *)p1));
    printf("p2:0x%x,0x%x\n", p2, *((long *)p2));
    printf("p3:0x%x,0x%x\n", p3, *((long *)p3));
    return 0;
}
'''
运行结果为

p1:0xd94010,0x4008b0
p2:0xd94030,0x4008b0
p3:0xd94050,0x4008b0

看到类P的三个实例p1,p2,p3他们的实例指针是不相同的,而虚函数表指针是同一个。
***

总结一下,如果C++类中定义过虚函数,不管是只定义了一个还是多个,那么这个类就会生出一个虚函数表,里面包含所有虚函数的地址指针列表,而类的每一个对象实例在内存的开头位置,都额外分配一个指针变量,指向类的虚函数表。
当call一个虚函数的时候,首先从this指针位置读出虚函数表,然后从虚函数表里面拿出虚函数的正确地址,在call到这个地址。

把虚函数表指针放在对象内存的开始位置是linux环境下的结果,不同的编译运行环境可能会不同,比如windows系统下就把虚函数表指针放在对象内存的结尾部。

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

推荐阅读更多精彩内容

  • 一、温故而知新 1. 内存不够怎么办 内存简单分配策略的问题地址空间不隔离内存使用效率低程序运行的地址不确定 关于...
    SeanCST阅读 7,815评论 0 27
  • 收集非原创文章,如遇原作者,请私聊我,我会表明出处! 1--10 1. C++中什么数据分配在栈或堆,静态存储区以...
    Juinjonn阅读 4,942评论 0 30
  • 转至元数据结尾创建: 董潇伟,最新修改于: 十二月 23, 2016 转至元数据起始第一章:isa和Class一....
    40c0490e5268阅读 1,725评论 0 9
  • struct与class的区别 C的struct与C++的class的区别:struct只是作为一种复杂数据类型定...
    geekzph阅读 1,578评论 0 4
  • 一个博客,这个博客记录了他读这本书的笔记,总结得不错。《深度探索C++对象模型》笔记汇总 1. C++对象模型与内...
    Mr希灵阅读 5,590评论 0 13