继上篇解锁C++多态的魔力:灵活与高效的编码艺术(上) 多态性是面向对象编程的重要特性之一,而C++通过虚函数、继承等机制实现了这一强大的功能。多态性使得代码更加灵活和可扩展,允许不同类型的对象以统一的方式进行操作。在本篇文章中,我们将深入探讨C++中多态的实现原理、使用场景及其优劣势,并通过具体代码示例展示如何利用多态来提升代码的可维护性和复用性。
C++ 中的 多态性(运行时多态)的底层实现依赖于 虚函数表(vtable
) 和 虚指针(vptr
)。要理解 C++ 中多态的底层原理,需要深入了解虚函数是如何通过这两者来实现的。下面是详细的解释。
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
本质上是一个指针,这个指针我们叫做虚函数表指针,一个含有虚函数的类中都至少有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中(虚函数本质上是存在代码段的),虚函数表也简称虚表。
上面我们看了一个普通类对象中的虚表,下面我们再来看看派生类中的虚表又是怎样的。
// 针对上面的代码我们做出以下改造
// 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;
}
通过监视窗口我们发现了以下几个问题:
func1
完成了重写,所以 d 的虚表中存的是重写后的 Derive::func1
,所以虚函数的重写也叫做覆盖,覆盖就是指虚表中虚函数的覆盖。重写是语法层面的叫法,覆盖是原理层面的叫法。
func2
继承下来后是虚函数,所以放进了虚表,func3
也继承下来了,但是不是虚函数,所以不会放进虚表。
nullptr
。
上面提到派生类自己新增的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。但是在 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 指针接收不到实参,因为不是派生类对象去调用该函数。函数中如果去访问了成员变量,那么我们这种调用方式就会出问题。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
正确输出它的地址。
在 C++ 中,静态绑定(Static Binding)和动态绑定(Dynamic Binding)涉及到对象方法的解析,即在调用一个对象的方法时,程序如何决定使用哪个具体的实现。这两种绑定机制是面向对象编程中多态性的核心概念,特别是在类继承和虚函数的场景下。
静态绑定也叫早期绑定(Early Binding),是在编译时决定函数调用的绑定方式。编译器在编译过程中根据对象的类型和函数的签名,直接将调用的目标地址确定下来。因此,静态绑定的函数调用在运行时没有额外的性能开销。
静态绑定通常出现在没有使用虚函数的场景下,即普通的成员函数调用时,编译器在编译期就能确定调用的是哪个函数。
#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 的 speak
d.speak(); // 调用的是 Dog 的 speak
}
在上述代码中,a.speak()
和 d.speak()
的调用在编译期已经被静态解析,分别调用了 Animal
和 Dog
的 speak()
方法。这就是静态绑定。
特点:
动态绑定也叫晚期绑定(Late Binding),是在运行时决定函数调用的绑定方式。这种方式依赖于对象的实际类型(而不是变量声明的类型)。C++ 中的动态绑定依赖于虚函数(virtual
关键字)实现。
动态绑定通常在类的继承结构中使用虚函数时出现。编译器生成一个虚函数表(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 的 speak
delete a;
}
在这个例子中,Animal* a = new Dog();
语句中,虽然 a
的类型是 Animal*
,但由于 speak()
是虚函数,调用时会根据对象的实际类型(Dog
),从虚函数表中动态地选择 Dog
类的 speak()
方法。
特点:
静态绑定 | 动态绑定 |
---|---|
绑定发生在编译时 | 绑定发生在运行时 |
不需要虚函数表 | 依赖虚函数表(vtable) |
调用的是编译时确定的类型的函数 | 调用的是运行时对象实际类型的函数 |
使用普通成员函数 | 使用虚函数(virtual) |
执行效率高,没有运行时开销 | 有一定的运行时开销 |
不支持多态 | 支持多态 |
在汇编层面,静态绑定和动态绑定的区别可以通过函数调用方式来理解:
call
指令跳转到固定的内存地址。vtable
),该表中存储了虚函数的地址。在运行时,对象通过虚函数表指针查找实际要调用的函数地址,然后跳转执行。
静态绑定的汇编实现可能会包含直接调用目标函数地址:
call Dog::speak
动态绑定的汇编实现需要通过虚表间接调用:
mov rax, [rdi] ; 从对象实例中加载虚表地址
call [rax + offset] ; 从虚表中取出实际函数的地址并调用
这种方式使得动态绑定的函数调用在运行时依赖对象的实际类型,而不是编译时的静态类型。
● inline
函数可以是虚函数嘛?
答:可以,不过编译器会忽略 inline
属性,这个函数就不再是 inline
,因为虚函数要放进虚函数表中。
● 静态成员可以是虚函数嘛? 答:不能,因为静态成员函数没有 this 指针,使用“类型::成员函数”的调用方式无法访问虚函数表,所以静态成员函数无法放进虚函数表。
● 构造函数可以是虚函数嘛? 答:不能,因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始化的。
● 析构函数可以是虚函数嘛?什么场景下析构函数是虚函数? 答:可以,并且最好把基类的析构函数定义成虚函数。
● 对象访问普通函数快还是虚函数更快? 答:首先如果是普通对象,是一样快的。如果是指针对象或者是引用对象,则调用的普通函数更快。因为构成多态,运行时调用虚函数要到虚函数表中去查找。
● 虚函数表是在什么阶段生成的?存在哪? 答:虚函数表是在编译阶段就生成的,一般情况下存在代码段(常量区)。
通过对C++多态性的深入了解,我们可以更好地编写具有高扩展性和灵活性的代码。多态不仅让代码变得更具适应性,还能够减少代码重复,提高维护效率。在未来的开发中,合理运用多态将为我们的项目带来显著的提升。希望本文的讲解能够帮助读者在实践中更好地掌握这一重要概念。