0%

温习 C++ 的虚函数

闲静少言,不慕荣利。好读书,不求甚解;每有会意,便欣然忘食。性嗜酒,家贫不能常得。

非常喜欢上面的这段文字,莫名感同身受,这几天从自己的博客上迁移了些文章去简书,不为别的,只是为了更好的阅读和写作体验。可能是酒喝得太多,也有可能是年纪越来越大,也不排除是花哨的东西看得太多,最近越来越喜欢普适性的东西,工具如此,技术如此,真理亦是如此。

当前是离职的高峰季,也就是面试的密集期,希望这一篇对 C++ 虚函数的探讨,能帮你在面试中加得几分。虽说难以致用,但现在的面试就是这样,如果问这方面的问题你没能答上来,面试官会认为你没有刨根问底的态度,技术深度不够(_即便是你长时间不看忘记了_)。

废话有点多,那我们这篇就从最基础的指针开始吧。

指针被玩坏了

指针是什么?相信大家的脑海里立马会浮现一个箭头指在一个长方形上的图形,指针就是一串数字,这个数字对应着内存上的地址。指针很简单,除了内存地址,没有携带任何其它数据,在 32 位系统上它就是 32 位无符号整形,在 64 位系统上便是 64 位无符号整形,所以为什么我们说 32 位系统内存大了也没太多用,因为它指针的寻址范围只有 2^32 Byte = 4G。

那么既然指针只是内存地址,按照这样的理解,我们应该可以对不同类型的指针随意转换,毕竟所有的指针都是相同大小的整形。那么转来转去也无可厚非,反正它指向的地址已经确定了,但真的就是这样了么?指向的地址确定了?来看下这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
struct structA {
virtual void func_a() {}
};

struct structB {
virtual void func_b() {}
};

struct structAB : public structA, public structB {
void func_a() {
printf("func_a\n");
}

void func_b() {
printf("func_b\n");
}
};


int main(int argc, const char * argv[]) {
structAB *pAB = new structAB;
structB *pB = pAB;
void *pVoid = pB;
pAB = (structAB *)pVoid;

pAB->func_a();

return 0;
}

执行结果:

func_b

在这样几次的类型转换后,我们发现指针所指向的地址变了,不可思议?如果这颠覆了你一直以来对指针的认知,那么接下来的内容,我觉得你很有必要仔细阅读下去。这是一个很好的引子,我将尽可能用最通俗易懂的方式,来阐述这里面所暗藏的一切玄机。

虚函数的本质

从面向对象的角度而言,虚函数存在的意义是为了实现多态,什么是多态?

多态性是允许你将父对象设置成为一个或更多的他的子对象相等的技术,赋值之后,父对象就可以根据当前赋值给它的子对象的特性以不同的方式运作。

简单的说,就是允许用子类对象替换父类对象,而一个父类可以对应多个子类的实现,所以相同的父类函数调用,会产生不同的调用结果,这便是多态性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
struct Base {
virtual void func() {}
};

struct ImplA : public Base {
void func() { printf("ImplA\n"); }
};

struct ImplB : public Base {
void func() { printf("ImplB\n"); }
};

void make_call(Base *base) {
base->func(); // 多态性
}


int main(int argc, const char * argv[]) {
make_call(new ImplA);
make_call(new ImplB);

return 0;
}

不同面向对象的高级语言对多态性的实现并不一致,比如在我们的 Objective-C 中,是通过消息处理机制来实现多态的。而在 C++ 里,便是通过虚函数来实现,那么对于 C++ 而言,虚函数的本质是啥?相信有过相关了解的人都会有个大概印象,比如虚函数表虚表指针,说到底,虚函数实现的核心就是编译器在编译时修改了我们的代码,使得我们对虚函数的直接调用被替换成了间接调用。

那么接下来,我们针对单继承多继承两种情况,分别阐述下编译器大致给我们做了怎样的事情。

单继承

在单继承的情况下,虚函数的实现还是比较简单的,我们以下面这段代码来作为展开的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
struct Base {
virtual void func() {} // 虚成员函数
void func_n() {} // 普通成员函数
};

struct Impl : public Base {
void func() {}
};

void make_call(Base *base) {
base->func_n();
base->func();
}

首先,我们要清楚:在 C++ 中,成员函数最终会被编译成类似于 C 中的普通方法,把 this 作为参数传递。虚的成员函数也不例外,所以上面的代码,我们可以转换成类似下面的这段 C 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct _Base;
struct _Impl;

typedef struct _Base Base;
typedef struct _Impl Impl;

void Base_func(Base *const this) {}
void Base_func_n(Base *const this) {}
void Impl_func(Impl *const this) {}

struct _Base {};
struct _Impl {};

void make_call(Base *base) {
Base_func_n(base); // 直接调用
// base->func(); ???
}

可以看到,我们对 base->func_n() 这样的普通成员函数可以替换成 C 模拟方法的直接调用,但对于虚成员函数 base->func() 而言,我们没办法直接替换,因为这里要支持多态性(_Base_func? Impl_func?_)。

那么 C++ 编译器大体是这样操作的:编译器在面对到有虚成员函数的类型时,会在类型开始位置插入一个指针,这个指针指向一个表,表里面记录了自身类型对应的虚函数地址。这个指针一般叫做虚表指针,表一般称作虚函数表,有了这两个东西,我们就可以通过虚表指针间接的来调用虚成员函数,而在类型向上转型时便体现了多态性。描述可能会看懵,看代码应该会更加清楚:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
struct _Base;
struct _Impl;

typedef struct _Base Base;
typedef struct _Impl Impl;

void Base_func(Base *const this) {}
void Base_func_n(Base *const this) {}
void Impl_func(Impl *const this) {}

void *BaseVTable[] = {&Base_func};
void *ImplVTable[] = {&Impl_func};

struct _Base {
void *vtbl_ptr;
};

struct _Impl {
void *vtbl_ptr;
};

void make_call(Base *base) {
Base_func_n(base);

// 从虚函数表中取到要调用的函数地址
void *method_addr = ((void **)base->vtbl_ptr)[0];
// 将函数地址转成函数指针调用
((void (*)(Base *))method_addr)(base);
}

int main(int argc, char *argv[]) {
Base *pBase = (Base *)malloc(sizeof(Base));
pBase->vtbl_ptr = BaseVTable;

Impl *pImpl = (Impl *)malloc(sizeof(pImpl));
pImpl->vtbl_ptr = ImplVTable;

make_call(pBase);
make_call((Base *)pImpl);

return 0;
}

上面代码虽然有点多,但并不复杂,核心点在于类型初始化后,这个类型所拥有的虚表指针(_vtabl_ptr_)指向的地址是确定的(_编译器做的事情_),所以向上转型时(_Impl -> Base_),虚表指针指向的虚函数是不会变的(_vtabl_ptr 的值不会因类型转换而改变_),另外,编译器把我们对虚函数的调用,替换成了通过虚表指针的间接调用,最终调用到了子类型的实现里。也就是上述 C++ 代码中:base->func() 的调用,最终落入了 Impl::func 里面。

我觉得对于虚函数表和虚表指针应该已经阐述的非常清楚了,比较低级的面试里面会经常让你去计算一个类型的大小,如果它包含虚函数或者它的继承链里面包含虚函数,记得一定要加上一个虚表指针的大小。另外,如果子类型里没有覆写父类型的虚函数,那么子类型的虚函数表里,存放的便是父类型中定义的虚函数地址。

1
2
3
4
5
6
7
8
9
10
11
struct Base {
virtual void func() {}
virtual void func_b() {}
};

struct Impl : public Base {
void func() {}
};

// Impl 虚函数表类似这样
void *ImplVTable[] = {&Impl::func, &Base::func_b};

单继承情况下,虚函数的实现原理还是比较简单的,放到多继承的环境中,其实也复杂不了多少,接下来我们就来说说多继承。

多继承

我相信在你使用 C++ 的时候,肯定会使用多重继承,因为 C++ 中没有接口的概念,也就是没有类似 Objective-C 中的 Protocol,所以用纯虚类来模拟,以此来实现观察者之类的常用模式。在讲多继承中的虚函数之前,我们要先了解下多继承类型的内存布局,看看下面这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
struct structA {
int a1;
int a2;
};

struct structB {
int b1;
int b2;
};

struct structAB : public structA, public structB {
int ab1;
};

那么 structAB 实例的内存布局图在 C++ 中应该如下:

structAB 内存布局

其实这张图很好理解,就相当于我们把这三个结构体,直接从上到下堆叠在了一起,从主观的感受上是这样,实际的布局也的确就是这样。所以,继承的先后顺序,决定了成员最终在内存中的位置。那么我们分析下下面这段非常简单的代码(_这里涉及到的知识点,和前面那个被玩坏的指针有关_):

1
2
3
structAB *pAB = new structAB;
structB *pB = pAB;
pB->b1 = 2;

我们对最开始定义的 structAB 指针做了次类型转换,转换成了父类型 structB 的类型指针,然后对其成员 b1 赋值。要知道,在 C++ 中对成员的访问都是以偏移地址的方式,所以 pB->b1 可以转述为:获取相对于指针 pB 偏移为 0 的那个 int,因为 b1structB 中属于第一个成员,所以偏移为 0。但这里有个问题:在 structABb1 的偏移并不是为 0,如果 pABpB 相等的话,后续的所有 pB->b1 都不能按照常规的偏移值来取,而仅仅通过 pB 又是无法知道它的实际类型的。为了解决这样的问题,编译器又为我们做了类似下面这样的事情:

1
2
3
structAB *pAB = new structAB;
structB *pB = pAB; // pB = pAB + sizeof(structA)
pB->b1 = 2;

编译器在面对这样多重继承的转型场景时,主动的将 pB 向后偏移了 structA 大小的地址,我们可以将这两个指针的具体地址打印出来,应该刚好相差 8 个字节(_structA 的大小_),如果向下转型,编译器则会向相反的方向增加偏移。所以开始我们那个被玩坏的指针,结合前面所说的虚表指针,最终的现象大家应该可以理解了吧?我们通过 void * 打破了编译器这种自动偏移机制,所以导致了奇怪的后果。那么有了这样的自动偏移调整机制,以 pB 为基准的偏移取值,都没有问题了,也就是前面我们所说的 pB->b1

了解了转型时的自动偏移,那么我们多重继承中的虚函数就很好理解了,老规矩,先来一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
struct structA {
virtual void func_a() {}
};

struct structB {
virtual void func_b() {}
};

struct structAB : public structA, public structB {
void func_a() {}
void func_b() { printf("a value is: %d\n", a); }

int a;
};



int main(int argc, const char * argv[]) {
structAB *pAB = new structAB;
pAB->a = 5656;

structB *pB = pAB;
pB->func_b();

return 0;
}

上面代码中 structAB 中和 structA 相关的虚函数,和前面所说的单继承完全一致,因为 structA 是第一位被继承的,没有自动偏移。考虑下目前 structAB 的内存布局,其实很简单,两个虚表指针加一个 int a,全部叠加起来即可:

structAB 内存布局

其中 vtabl_ptr_a 指向的虚函数表便是 {&structAB::func_a},但 vtabl_ptr_b 并不能简单的指向 {&structAB::func_b},因为涉及到了 this 指针的偏移,所以编译器构建了 thunk 方法,将 this 偏移纠正了回去,具体看看我们用 C 转换后的代码,应该就能非常清楚了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
struct _structA;
struct _structB;
struct _structAB;

typedef struct _structA structA;
typedef struct _structB structB;
typedef struct _structAB structAB;

void structA_func_a(structA *const this) {}
void structB_func_b(structB *const this) {}
void structAB_func_a(structAB *const this) {}
void structAB_func_b(structAB *const this);
void structAB_thunk_func_b(structB *const this);

void *structA_VTable[] = {&structA_func_a};
void *structB_VTable[] = {&structB_func_b};
void *structAB_A_VTable[] = {&structAB_func_a};
void *structAB_B_VTable[] = {&structAB_thunk_func_b};

struct _structA {
void *vtbl_ptr;
};

struct _structB {
void *vtbl_ptr;
};

struct _structAB {
void *vtbl_ptr_a;
void *vtbl_ptr_b;
int a;
};

// this 的地址对了,this->a 才能取出正确的值
void structAB_func_b(structAB *const this) {
printf("a value is: %d\n", this->a);
}

// 模拟 Thunk 程序,自动将偏移纠正回来
void structAB_thunk_func_b(structB *const this) {
structAB_func_b((structAB *)((void *)this - sizeof(structA)));
}

int main(int argc, char *argv[]) {
structAB *pAB = (structAB *)malloc(sizeof(structAB));
pAB->vtbl_ptr_a = structAB_A_VTable;
pAB->vtbl_ptr_b = structAB_B_VTable;
pAB->a = 5656;

// 模拟编译器自动偏移
structB *pB = (structB *)((void *)pAB + sizeof(structA));

// 偏移之后 pB->vtbl_ptr == pAB->vtbl_ptr_b
void *method_addr = ((void **)pB->vtbl_ptr)[0];
((void (*) (structB *const))(method_addr))(pB);

return 0;
}

对照前面的 C++ 代码,我相信聪明的你一定能够非常快速的接受并深刻理解,多继承中,其他的套路都与单继承类似,唯一不同的是非第一个继承而来的虚表指针, 所指向的虚函数表里面已覆写的虚函数,都是 Thunk 程序,用于纠正 this 指针偏移。

后话

本文还有一个点没有讲到,也就是多级继承下的虚函数表,但我觉得按照前面所讲的那些知识点,应该不难推演出来,所以,大家还是自己动手实践下吧。

温故而知新,希望这么简单而基础的一篇文章,能给大家带来些许收货,我要去喝壶酒提个神了。