C++多态分析:虚函数调用是如何实现的?

什么是虚函数?

  1. 简单来说,虚函数是动态调用。相比于一般的函数调用在编译期确定了函数地址,而调用虚函数是在运行时决定调用的函数地址。
  2. 虚函数怎么使用相信大家都比较清楚,这里简单带过一下。C++中父类的指针可以指向子类实例,通过父类指针调用虚函数时会因为指向的不同的实例类型来调用不同的函数。
  3. C++多态性主要体现在虚函数上,某种程度上来说也只体现在虚函数上。(泛型是否属于多态这种PL问题我并不能准确回答。)

虚函数是如何实现的?

  1. 虚表指针(vfptr)
  2. 虚函数表(vtable)
  3. 动态调用。

虚表指针

在单继承情况下,如果父类存在虚函数,子类实例首地址开始4字节(在32位编译器下)会用来存放虚表指针。比如下面这两个类:

struct base {
    int x;
    virtual void func1() {
        printf("base func1\n");
    }
};
struct sub:base {
    int y;
};

在这个例子中,父类和子类分别拥有一个4字节成员。如果没有虚函数的情况下,sub类型所占的空间应该是8字节,但是因为父类中存在虚函数,就需要额外4字节用来存放虚表指针,所以用sizeof(sub)返回的结果会是12字节。

虚函数表

在sub实例开始的4字节所指向的就是虚函数表,这个表可以认为是一个由函数指针组成的数组。现在我们来看一下刚才示例中两个类的虚表,下面是base实例对象的内存,前4个字节就是虚表指针,后四个字节是成员x,因为debug没有初始化所以是CC。

base实例对象(其中00420F94指向的就是base的虚表, 虚表只有一个元素就是base::func1的指针
0040101E):

x86内存中因为是小端存储,所以“看起来”是反的,需要肉眼parse…

0019FF38  94 0F 42 00  // 虚表指针 
0019FF3C  CC CC CC CC

base的虚表:

00420F94  1E 10 40 00 // base::func1的函数指针

base::func1函数:

0040101E E9 4D 00 00 00       jmp         base::func1 (00401070)

sub的虚表内容相信大家也已经想到了,虽然在内存中这是两张表,但是因为sub并没有重写任何虚函数,所以虚表的内容和base是完全一样的,都是只有一个指向base::func1的函数指针。
sub的虚表:

0042003C  1E 10 40 00 // 和base虚表内容一样指向base::func1
示例2(重写了父类虚函数):

写到这里我发现刚刚的例子可能不太好,因为子类中没有重写虚函数,而且只有一个虚函数,没有直观的体现出作用。现在让我们来丰富一下刚才的两个类。

struct base {
    int x;
    virtual void func1() {
        printf("base func1\n");
    }
    virtual void func2() {
        printf("base func2\n");
    }
};
struct sub:base {
    int y;
    void func1() {
        printf("sub func1\n");
    }
    void func4() {
        printf("sub func4\n");
    }
};
void vPrint(base* ptr) {
    ptr->func1(); // 通过虚表指针动态调用函数
}

在这个例子中,sub重写了父类的func1函数。并且我们也可以通过传给vPrint不同类型的指针,来调用不同的函数:

int  main() {
    base b;
    vPrint(&b);
    sub s;
    vPrint(&s);
    return 0;
} 

输出如下:

base func1
sub func1

这个效果和我们想要的一样,现在我们再看一下sub的虚函数表。
sub的虚表:

00420058  6E 10 40 00  // sub::func1的指针
0042005C  5A 10 40 00 // base::func2的指针
0040105A E9 11 03 00 00       jmp         base::func2 (00401370)
0040106E E9 AD 03 00 00       jmp         sub::func1 (00401420)

可以看到因为重写了func1,所以虚表中第一个位置换成了sub::func1。func2没有被重写,所以还是和父类一样指向base::func2. 而func4因为没有被声明为虚函数,所以不会在虚表中存在。(func1虽然在子类中没有声明确声明为虚函数,但是C++中规定与父类虚函数同名的函数都会自动被声明为虚函数,当然这个同名指的是包括整个函数名、返回值、参数列表。)

base的虚表就不贴出来了,因为base没有继承其他类,所以base的虚表中只能是func1和func2两个函数的指针。

动态调用

说了这么久的虚表指针和虚表,现在终于可以看看是如何使用它们来实现动态调用的。
让我们来看一下vPrint函数的反汇编:

mov         eax,dword ptr [ebp+8]  // ebp+8是vPrint函数的第一个参数base* ptr
mov         edx,dword ptr [eax]    // 将实例对象的前4个字节,也就是虚表指针放在edx中
mov         ecx,dword ptr [ebp+8]  // 传参this指针,不用管
call        dword ptr [edx]        // call虚表中的第一个元素,根据传进来的对象的虚表不同,调用不同的函数

可以清晰的看到call的函数地址并不是一个立即数,而是edx指向的数据。这就是动态调用实现的关键了,根据对象中携带的虚表指针,来调用不同对象关联的不同函数。

总结

虚函数调用是通过call虚表数据来实现运行时调用。传递的参数类型不同,虚表指针就不同、虚表指针不同,指向的虚函数表就不同、虚函数表不同,指向的函数就不同。

附言

这篇文章中只讲了单继承的情况,而在多继承的情况下子类对象实例中就会有多个虚表指针,为了不使文章太过冗长,就不一一列出来了。

大家感兴趣的可以自己动手试一下,看看多继承的情况下内存分布如何,在传参时的偏移如何。

赵克,写于2017年01月13日。

如需转载请与我联系,并注明出处。

    原文作者:我爱这世界
    原文地址: https://www.jianshu.com/p/587ab12f7c88
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞