C++ 类虚函数原理

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


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

#include 
#include 

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

(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

    (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()的地址。

C++ 类虚函数原理_第1张图片
1.jpg

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


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

#include 
#include 

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 
#include 

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系统下就把虚函数表指针放在对象内存的结尾部。

最后,这篇文章主要介绍虚函数的原理,通过最普通的场景来描述虚函数的实现原理,对于复杂的场景,例如多继续,虚继承等会导致类实例结构发生变化,而虚函数表的处理也会发生变化,不在此描述。

你可能感兴趣的:(C++ 类虚函数原理)