文章目录
- 前言
- 🎱四、多态的原理
- 🔮4.1 虚函数表(`vtable`)
- 🔮4.2 派生类对象中的虚函数表
- 4.2.1 编写程序去访问虚函数表
- 4.2.2 虚表存储位置的验证
- 🎱五、 多态的静态绑定和动态绑定
- 🔮5.1 静态绑定(Static Binding)
- 5.1.1 典型场景:
- 🔮5.2 动态绑定(Dynamic Binding)
- 5.2.1 典型场景:
- 5.2.2 C++ 中静态绑定与动态绑定的区别总结
- 5.2.3 延伸到汇编底层的解释
- 5.2.4 汇编中动态绑定的例子:
- 总结
- 🎱六、多态的常见面试题
- 结语
前言
继上篇解锁C++多态的魔力:灵活与高效的编码艺术(上)
多态性是面向对象编程的重要特性之一,而C++通过虚函数、继承等机制实现了这一强大的功能。多态性使得代码更加灵活和可扩展,允许不同类型的对象以统一的方式进行操作。在本篇文章中,我们将深入探讨C++中多态的实现原理、使用场景及其优劣势,并通过具体代码示例展示如何利用多态来提升代码的可维护性和复用性。
🎱四、多态的原理
C++ 中的 多态性(运行时多态)的底层实现依赖于 虚函数表(vtable
) 和 虚指针(vptr
)。要理解 C++ 中多态的底层原理,需要深入了解虚函数是如何通过这两者来实现的。下面是详细的解释。
🔮4.1 虚函数表(vtable
)
- 笔试题,
sizeof(Base)
是多少?
class Base {
public:virtual void func1(){cout << "func1()" << endl;}
private:int b = 1;
};int main()
{cout << sizeof(Base) << endl;return 0;
}
通过上面的打印结果和调试,我们发现一个 Base 对象是 8 bytes,除了 b
成员,还多了一个 _vfptr
放在对象成员变量的前面。_vfptr
本质上是一个指针,这个指针我们叫做虚函数表指针,一个含有虚函数的类中都至少有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中(虚函数本质上是存在代码段的),虚函数表也简称虚表。
🔮4.2 派生类对象中的虚函数表
上面我们看了一个普通类对象中的虚表,下面我们再来看看派生类中的虚表又是怎样的。
// 针对上面的代码我们做出以下改造
// 1.我们增加一个派生类Derive去继承Base
// 2.Derive中重写Func1
// 3.Base再增加一个虚函数Func2和一个普通函数Func3
class Base {
public:virtual void fun1() { cout << "Base::fun1()" << endl; }virtual void fun2() { cout << "Base::fun2()" << endl; }void fun3() { cout << "Base::fun3()" << endl; }
private:int _b = 1;
};class Derive : public Base {
public:void fun1() {}
private:int _d = 2;
};int main()
{Base b;Derive d;return 0;
}
通过监视窗口我们发现了以下几个问题:
-
派生类对象 d 中也有一个虚表,这个虚表是作为基类成员的一部分被继承下来的。总的来说,d 对象由两部分构成,一部分是父类继承下来的成员,d 对象中虚表指针就是就是这部分成员中的一个。另一部分则是自己的成员。
-
基类 b 对象和派生类 d 对象的虚表是不一样的,上面的代码中
func1
完成了重写,所以 d 的虚表中存的是重写后的Derive::func1
,所以虚函数的重写也叫做覆盖,覆盖就是指虚表中虚函数的覆盖。重写是语法层面的叫法,覆盖是原理层面的叫法。 -
另外
func2
继承下来后是虚函数,所以放进了虚表,func3
也继承下来了,但是不是虚函数,所以不会放进虚表。 -
虚函数表本质上是一个存虚函数地址的函数指针数组,一般情况下这个数组最后面放了一个
nullptr
。 -
总结一下派生类虚表的生成:
-
先将基类中的虚表内容拷贝一份到派生类虚表中。
-
如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数。
-
派生类自己新增的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。(在 VS 监视窗口显示的虚表中是看不见的,下面将通过程序带大家来验证)
-
-
这里还有一个比较容易混淆的问题:虚函数存在哪?虚表存在哪?很多小伙伴会觉得:虚函数存在虚表,虚表存在对象中,注意这种回答是错的。这里再次强调:虚表存的是虚函数的地址,不是虚函数,虚函数和普通的成员函数一样,都是存在代码段的,只是它的地址又存到了虚表中。另外,对象中存的不是虚表,存的是虚表的地址。那虚表是存在哪儿呢?通过验证,在 VS 下虚表是存在代码段的。Linux g++ 下大家可以自己去验证。同一个程序中,同一类型的对象共用一个虚表。
4.2.1 编写程序去访问虚函数表
上面提到派生类自己新增的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。但是在 VS 的监视窗口中是看不到,以下面的代码为例:
class Person
{
public:virtual void func1() const { cout << "virtual void Person::fun1()" << endl; }virtual void func2() const { cout << "virtual void Person::fun2()" << endl; }virtual void func3() const { cout << "virtual void Person::fun3()" << endl; }//protected:int _a = 1;
};class Student : public Person
{
public:virtual void func1() const { cout << "virtual void Student::fun1()" << endl; }virtual void func3() const { cout << "virtual void Student::fun3()" << endl; }virtual void func4() const { cout << "virtual void Student::fun4()" << endl; }//protected:int _b = 2;
};int main(){Person Mike;Student Jack;return 0;
}
- 监视窗口中展现的派生类对象的虚函数表中并没有派生类自己的虚函数
func4
。但是我们从内存窗口可以看到第四个地址,我们可以大胆的猜测这个就是派生类自己的虚函数func4
的地址,但是口说无凭,下面我们来写一段代码验证一下我们的猜想。
typedef void (*FUNC_PTR)(); //定义了一个名为 FUNC_PTR 的类型,它是一个指向返回类型为 void 的函数的指针类型。typedef 用于给复杂类型定义一个别名,在这里,FUNC_PTR 表示一个指向无参数且返回 void 的函数的指针。void PrintVFT(FUNC_PTR* table){for (int i = 0; table[i] != nullptr; i++) {// 使用 printf 输出当前虚函数表中第 i 个函数指针的地址。printf("[%d]:%p->", i, &table[i]); // 将 table[i] 的值(即第 i 个函数指针)赋值给 f,f 是一个函数指针,可以像调用普通函数一样调用它。FUNC_PTR f = table[i]; f();}printf("\n");
}int main() {Person ps;Student st;// 取前四个字节int vft1 = *(int*)&ps; // 获取 ps 的虚表指针。int vft2 = *(int*)&st; // 获取 st 的虚表指针。// 将 vft1/vft2 强制转换为 VFPTR*(函数指针数组的类型),然后传递给 PrintVfptr 函数。// PrintVfptr 函数会输出对象的虚表中每个函数指针的地址并调用这些函数。PrintVFT((FUNC_PTR*)vft1);PrintVFT((FUNC_PTR*)vft2);return 0;
}
- 通过上图可以看出我们程序打印出来的地址和监视窗口中显示的地址是一样的,并且成功的调用了派生类中的虚函数
func4
,上图显示的结果完美的验证了我们的猜想。这里也说明了一个问题,VS 的监视窗口是存在 Bug 的,以后我们在调试代码过程中也不能完全相信监视窗口展现给我们的内容,比起监视窗口我们更应该相信内存窗口展现给我们的内容。这里也侧面反映了一个问题,只要我们能拿到函数的地址就能去调用该函数,正常情况下,我们只能通过派生类对象去调用虚函数func4
,这里我们直接拿到了这个函数的地址去调用,这里的问题在于函数的隐藏形参 this 指针接收不到实参,因为不是派生类对象去调用该函数。函数中如果去访问了成员变量,那么我们这种调用方式就会出问题。
4.2.2 虚表存储位置的验证
class Person{
public:virtual void func1() const { cout << "virtual void Person::fun1()" << endl; }//protected:int _a = 1;
};class Student : public Person{
public:virtual void func1() const { cout << "virtual void Student::fun1()" << endl; }//protected:int _b = 2;
};int main(){Person Mike;Student Jack;// 栈区int a = 10;printf("栈区:%p\n", &a);// 堆区int* pa = new int(9);printf("堆区:%p\n", pa);// 常量区(代码段)const char* c = "hello world!";printf("常量区(代码段):%p\n", c);// 静态区(数据段)static int b = 8;printf("静态区(数据段):%p\n", &b);// 虚表printf("基类的虚表:%p\n", (void*)*(int*)&Mike);printf("派生类的虚表:%p\n", (void*)*(int*)&Jack);}
-
*(int*)&Mike
:通过将Mike
对象的地址强制转换为int*
类型,并解引用该指针,获得Mike
的虚表指针vptr
。 -
(void*)
是为了将这个指针转换为void*
类型,以便printf
正确输出它的地址。
- 上面取虚表地址是通过强制类型转化来实现的,通过上面的监视窗口我们可以看出,虚表的地址永远是存储在对象的前四个字节,所以这里我们先取到对象的地址,然后将其强转为 int* 类型,为什么要强转为 int* 呢?因为,一个 int 型的大小就是四个字节,而指针的类型决定了该指针能够访问到内存空间的大小,一个 int* 的指针就能够访问到四个字节,再对 int* 解引用,这样就能访问到内存空间中前四个字节的数据,这样就能取到虚表的地址啦。通过打印结果我们可以看出,虚表的地址和常量区(代码段)的地址最为接近,因此我们可以大胆的猜测,虚表就是存储在常量区(代码段)的。
🎱五、 多态的静态绑定和动态绑定
在 C++ 中,静态绑定(Static Binding)和动态绑定(Dynamic Binding)涉及到对象方法的解析,即在调用一个对象的方法时,程序如何决定使用哪个具体的实现。这两种绑定机制是面向对象编程中多态性的核心概念,特别是在类继承和虚函数的场景下。
🔮5.1 静态绑定(Static Binding)
静态绑定也叫早期绑定(Early Binding),是在编译时决定函数调用的绑定方式。编译器在编译过程中根据对象的类型和函数的签名,直接将调用的目标地址确定下来。因此,静态绑定的函数调用在运行时没有额外的性能开销。
5.1.1 典型场景:
静态绑定通常出现在没有使用虚函数的场景下,即普通的成员函数调用时,编译器在编译期就能确定调用的是哪个函数。
#include <iostream>class Animal {
public:void speak() {std::cout << "Animal speaks" << std::endl;}
};class Dog : public Animal {
public:void speak() {std::cout << "Dog barks" << std::endl;}
};int main() {Animal a;Dog d;a.speak(); // 调用的是 Animal 的 speakd.speak(); // 调用的是 Dog 的 speak
}
在上述代码中,a.speak()
和 d.speak()
的调用在编译期已经被静态解析,分别调用了 Animal
和 Dog
的 speak()
方法。这就是静态绑定。
特点:
- 编译时决定:调用的函数在编译期决定,不依赖运行时的信息。
- 性能高:静态绑定不需要运行时开销,因此执行效率较高。
- 缺少灵活性:不能根据实际对象的类型在运行时做出决策。
🔮5.2 动态绑定(Dynamic Binding)
动态绑定也叫晚期绑定(Late Binding),是在运行时决定函数调用的绑定方式。这种方式依赖于对象的实际类型(而不是变量声明的类型)。C++ 中的动态绑定依赖于虚函数(virtual
关键字)实现。
5.2.1 典型场景:
动态绑定通常在类的继承结构中使用虚函数时出现。编译器生成一个虚函数表(vtable
),对象在运行时根据其实际类型从虚函数表中查找函数的具体实现。
class Animal {
public:virtual void speak() {cout << "Animal speaks" << endl;}
};class Dog : public Animal {
public:void speak() override {cout << "Dog barks" << endl;}
};int main() {Animal* a = new Dog();a->speak(); // 调用的是 Dog 的 speakdelete a;
}
在这个例子中,Animal* a = new Dog();
语句中,虽然 a
的类型是 Animal*
,但由于 speak()
是虚函数,调用时会根据对象的实际类型(Dog
),从虚函数表中动态地选择 Dog
类的 speak()
方法。
特点:
- 运行时决定:调用的函数在运行时根据对象的实际类型决定。
- 支持多态:可以实现基类指针或引用指向派生类对象,并在运行时调用派生类的函数。
- 有一定性能开销:因为需要通过虚函数表查找函数的实际实现,动态绑定相对于静态绑定有额外的开销。
5.2.2 C++ 中静态绑定与动态绑定的区别总结
静态绑定 | 动态绑定 |
---|---|
绑定发生在编译时 | 绑定发生在运行时 |
不需要虚函数表 | 依赖虚函数表(vtable ) |
调用的是编译时确定的类型的函数 | 调用的是运行时对象实际类型的函数 |
使用普通成员函数 | 使用虚函数(virtual ) |
执行效率高,没有运行时开销 | 有一定的运行时开销 |
不支持多态 | 支持多态 |
5.2.3 延伸到汇编底层的解释
在汇编层面,静态绑定和动态绑定的区别可以通过函数调用方式来理解:
-
静态绑定:编译器在生成机器码时,直接将函数的地址放入调用指令中,程序执行时直接跳转到这个地址,没有额外的查找开销。
- 汇编代码中,函数调用通常是通过直接的
call
指令跳转到固定的内存地址。
- 汇编代码中,函数调用通常是通过直接的
-
动态绑定:编译器为每个包含虚函数的类生成一个虚函数表(
vtable
),该表中存储了虚函数的地址。在运行时,对象通过虚函数表指针查找实际要调用的函数地址,然后跳转执行。- 汇编代码中,虚函数调用通常会先通过一个中间的表指针,间接跳转到实际的函数实现。
5.2.4 汇编中动态绑定的例子:
-
静态绑定的汇编实现可能会包含直接调用目标函数地址:
call Dog::speak
-
动态绑定的汇编实现需要通过虚表间接调用:
mov rax, [rdi] ; 从对象实例中加载虚表地址 call [rax + offset] ; 从虚表中取出实际函数的地址并调用
这种方式使得动态绑定的函数调用在运行时依赖对象的实际类型,而不是编译时的静态类型。
总结
- 静态绑定发生在编译时,依赖于编译时确定的类型,执行效率高但缺少灵活性。
- 动态绑定发生在运行时,通过虚函数表查找具体的实现,支持多态,但有一定的运行时开销。
🎱六、多态的常见面试题
● inline
函数可以是虚函数嘛?
答:可以,不过编译器会忽略 inline
属性,这个函数就不再是 inline
,因为虚函数要放进虚函数表中。
● 静态成员可以是虚函数嘛?
答:不能,因为静态成员函数没有 this 指针,使用“类型::成员函数”的调用方式无法访问虚函数表,所以静态成员函数无法放进虚函数表。
● 构造函数可以是虚函数嘛?
答:不能,因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始化的。
● 析构函数可以是虚函数嘛?什么场景下析构函数是虚函数?
答:可以,并且最好把基类的析构函数定义成虚函数。
● 对象访问普通函数快还是虚函数更快?
答:首先如果是普通对象,是一样快的。如果是指针对象或者是引用对象,则调用的普通函数更快。因为构成多态,运行时调用虚函数要到虚函数表中去查找。
● 虚函数表是在什么阶段生成的?存在哪?
答:虚函数表是在编译阶段就生成的,一般情况下存在代码段(常量区)。
结语
通过对C++多态性的深入了解,我们可以更好地编写具有高扩展性和灵活性的代码。多态不仅让代码变得更具适应性,还能够减少代码重复,提高维护效率。在未来的开发中,合理运用多态将为我们的项目带来显著的提升。希望本文的讲解能够帮助读者在实践中更好地掌握这一重要概念。
今天的分享到这里就结束啦!如果觉得文章还不错的话,可以三连支持一下,17的主页还有很多有趣的文章,欢迎小伙伴们前去点评,您的支持就是17前进的动力!