C++ 类虚函数原理

作者: CodingCode | 来源:发表于2017-05-25 22:56 被阅读46次

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

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

相关文章

  • C++多态——虚函数表vtable

    纯Swift类的函数调用原理,类似于C++的虚函数表 纯Swift类的函数调用,类似于C++的虚函数表,是编译时决...

  • 深刻剖析之c++博客文章

    三大特性 封装、继承、多态 多态 C++ 虚函数表解析C++多态的实现原理 介绍了类的多态(虚函数和动态/迟绑定)...

  • C++如何实现一个接口类

    原理 C++中,通过类实现面向对象的编程,而在基类中只给出纯虚函数的声明,然后在派生类中实现纯虚函数的具体定义的方...

  • C++知识点

    模板 函数模板、类模板简单实例如下。 C++的虚函数 原理:1.每一个对象多一个虚函数指针。2.每一个类多一张虚函...

  • C++ 类虚函数原理

    学习过C++的童鞋都知道C++类成员函数可以分为虚函数和非虚函数,(java程序员就请绕过这个问题,因为java类...

  • C/C++32位与64位数据类型占用的字节数

    C++继承、虚继承、虚函数类的大小问题

  • C++ 虚函数

    C++ 虚函数 虚函数 基类中使用virtual关键字声明的函数,称为虚函数。虚函数的实现,通过虚函数表来实现的。...

  • 查漏补缺

    C++虚函数: 多态: 静态多态(重载)、动态多态(虚函数) 虚函数 虚函数表:编译器为每个类创建了一个虚函数表...

  • Cpp7 C++的多态实现 -- 虚表

    Cpp7 C++的多态实现 -- 虚表 多态的实现原理 总结:1. 当我们在类中定义虚函数时,就会产生虚表2. 多...

  • C++ 虚函数

    C++多态--虚函数virtual及override 如果 不是虚函数,指向子类对象的基类指针只能调用基类的函数,...

网友评论

    本文标题:C++ 类虚函数原理

    本文链接:https://www.haomeiwen.com/subject/tmfaxxtx.html