文章目录
- 一、多态是什么
- 二、虚函数重写:多态的语法前提
-
- [2.1 两个必要条件](#2.1 两个必要条件)
- [2.2 协变:返回值可以不同(了解即可)](#2.2 协变:返回值可以不同(了解即可))
- [2.3 析构函数的重写:面试必考](#2.3 析构函数的重写:面试必考)
- [2.4 override 和 final:编译期的保险](#2.4 override 和 final:编译期的保险)
- 三、纯虚函数和抽象类
- 四、多态的原理:虚函数表
-
- [4.1 对象里多了个指针](#4.1 对象里多了个指针)
- [4.2 虚表是怎么工作的](#4.2 虚表是怎么工作的)
- [4.3 动态绑定 vs 静态绑定:汇编见真章](#4.3 动态绑定 vs 静态绑定:汇编见真章)
- [4.4 虚表存在哪](#4.4 虚表存在哪)
- 五、重载、重写、隐藏:三个概念一次分清
- 总结
多态是C++面向对象三大特性里最「值钱」的一个------它是设计模式、框架、运行时多态的基础。但多态也是最容易被学浅的:很多人背得出「虚函数 + 指针/引用调用」,却说不清虚函数表到底在哪、动态绑定和静态绑定在汇编层面差在哪。这篇从虚函数重写一路拆到虚表的内存布局,把多态的原理讲透。
一、多态是什么
先看一个经典例子:
cpp
class Person
{
public:
virtual void BuyTicket()
{
cout << "买票-全价" << endl;
}
};
class Student : public Person
{
public:
virtual void BuyTicket()
{
cout << "买票-半价" << endl;
}
};
class Soldier : public Person
{
public:
virtual void BuyTicket()
{
cout << "买票-优先" << endl;
}
};
void Func(Person* ptr)
{
ptr->BuyTicket(); // 同一个调用,行为由 ptr 指向的对象决定
}
int main()
{
Person ps;
Student st;
Soldier sr;
Func(&ps); // 买票-全价
Func(&st); // 买票-半价
Func(&sr); // 买票-优先
return 0;
}
Func 里的 ptr->BuyTicket() 是同一行代码,但传入不同对象就调用不同版本的 BuyTicket。多态的本质:同一个函数调用,在运行时根据对象的实际类型,决定执行哪个版本的函数。
没有多态的话,Func 里 ptr 是 Person*,编译器只会调用 Person::BuyTicket------不管你传进来的是 Student 还是 Soldier。
二、虚函数重写:多态的语法前提
2.1 两个必要条件
多态的发生必须同时满足:
- 虚函数重写:派生类重写基类的虚函数(函数名、参数、返回值都相同)。
- 基类指针或引用调用虚函数。
cpp
class Person
{
public:
virtual void BuyTicket() { /* 全价 */ } // 基类虚函数
};
class Student : public Person
{
public:
virtual void BuyTicket() { /* 半价 */ } // 重写基类虚函数
};
两个条件缺一不可。很多人写出多态 bug,就是因为只满足了第一个------用对象调用而不是指针/引用:
cpp
Person p = Student(); // 切片!p 是纯 Person 对象
p.BuyTicket(); // 调的是 Person::BuyTicket,不是多态
对象调用不构成多态,因为对象在编译期类型就确定了。必须是指针或引用。
2.2 协变:返回值可以不同(了解即可)
重写虚函数时,返回值类型通常要求完全一致。唯一的例外是协变------基类虚函数返回基类指针/引用,派生类返回派生类指针/引用:
cpp
class A {};
class B : public A {};
class Person
{
public:
virtual A* BuyTicket() { return nullptr; }
};
class Student : public Person
{
public:
virtual B* BuyTicket() { return nullptr; } // 协变:B* 是 A* 的派生
};
协变实际意义不大,知道有这个特例就行。
2.3 析构函数的重写:面试必考
基类的析构函数应该设计成虚函数。原因看这段代码:
cpp
class A
{
public:
~A() { cout << "~A()" << endl; } // 注意:没加 virtual
};
class B : public A
{
public:
~B()
{
cout << "~B()" << endl;
delete[] _p; // 释放资源
}
private:
int* _p = new int[10];
};
int main()
{
A* p = new B; // 基类指针指向派生类对象
delete p; // 只调用了 ~A(),没调用 ~B() ------ _p 泄漏了!
return 0;
}
delete p 时,p 是 A*,编译器静态绑定到 ~A()。~B() 没被调用,_p 指向的内存永远泄漏。
修复:给基类析构函数加 virtual:
cpp
class A
{
public:
virtual ~A() { cout << "~A()" << endl; }
};
加了 virtual 之后,派生类的析构函数自动和基类析构构成重写(编译器把析构函数统一处理成 destructor,所以名字不同也能重写)。delete p 就会走虚表,正确调用 ~B() 再 ~A()。
判断标准:只要一个类会被继承、且可能通过基类指针删除派生类对象,析构函数就必须是虚函数。
2.4 override 和 final:编译期的保险
虚函数重写要求严格,手一抖函数名写错或参数漏了,就「静默地」没构成重写------编译不报错,运行时结果不对,debug 半天。
cpp
class Car
{
public:
virtual void Dirve() {} // 基类拼错了:Dirve
};
class Benz : public Car
{
public:
virtual void Drive() override {} // override 检测:报错!
// error C3668: 没有重写任何基类方法
};
override 告诉编译器「我要重写基类的虚函数」,如果没重写上,直接编译报错。所有重写虚函数的场景都建议加 override。
反过来,final 禁止派生类继续重写:
cpp
class Car
{
public:
virtual void Drive() final {} // 到此为止,不允许再重写
};
class Benz : public Car
{
public:
virtual void Drive() override {} // 编译错误:final 禁止重写
};
三、纯虚函数和抽象类
cpp
class Car
{
public:
virtual void Drive() = 0; // 纯虚函数:= 0,不需要实现
};
class Benz : public Car
{
public:
virtual void Drive() override { cout << "Benz-舒适" << endl; }
};
class BMW : public Car
{
public:
virtual void Drive() override { cout << "BMW-操控" << endl; }
};
- 纯虚函数:在虚函数后面加
= 0,只有声明没有实现(语法上可以实现,但没意义,反正要被重写)。 - 抽象类 :包含纯虚函数的类。不能实例化。
- 派生类如果不重写纯虚函数,那它自己也是抽象类,同样不能实例化。
cpp
// Car car; // 编译错误:抽象类不能实例化
Car* p = new Benz; // 可以:指针指向派生类对象
p->Drive(); // Benz-舒适
纯虚函数的本质是「强制派生类实现某个接口」。它定义了一种契约:凡是 Car,就必须会 Drive。这是设计模式里「接口」概念的C++实现。
四、多态的原理:虚函数表
4.1 对象里多了个指针
cpp
class Base
{
public:
virtual void Func1() { cout << "Func1()" << endl; }
protected:
int _b = 1;
char _ch = 'x';
};
int main()
{
Base b;
cout << sizeof(b) << endl; // 输出 12(32位下)
return 0;
}
_b 是 4 字节,_ch 是 1 字节,加上内存对齐应该是 8。但输出 12------多了 4 字节,这 4 字节就是虚函数表指针(_vfptr)。
只要一个类含虚函数,它的对象里就有一个虚函数表指针,指向这个类所有虚函数地址组成的「虚函数表(虚表)」。
Base b 的内存布局:
┌──────────────┐
│ _vfptr │ → 虚函数表 → [ Base::Func1 ]
├──────────────┤
│ _b (4字节) │
│ _ch(1字节) │ + 对齐填充
└──────────────┘
4.2 虚表是怎么工作的
看多态在底层怎么实现:
cpp
class Person
{
public:
virtual void BuyTicket() { cout << "全价" << endl; }
};
class Student : public Person
{
public:
virtual void BuyTicket() { cout << "半价" << endl; } // 重写
virtual void Study() { cout << "学习" << endl; } // 新增虚函数
};
void Func(Person* ptr)
{
ptr->BuyTicket(); // 运行时到 ptr 指向对象的虚表里查 BuyTicket 的地址
}
内存模型:
Person 对象的虚表: Student 对象的虚表:
┌──────────────────┐ ┌──────────────────┐
│ Person::BuyTicket │ │ Student::BuyTicket│ ← 重写后被覆盖
└──────────────────┘ │ Student::Study │ ← 新增的虚函数
└──────────────────┘
关键三点:
- 派生类重写基类虚函数,派生类虚表中对应位置被覆盖成派生类版本。
- 派生类新增的虚函数,追加在虚表后面。
- 派生类继承的虚表指针和基类对象的虚表指针不是同一个------各自有独立的虚表。
所以 Func(&st) 时,ptr 指向 Student 对象,运行时从 Student 对象的虚表里查到 Student::BuyTicket 的地址并调用。这就是「运行时绑定」的来源------函数地址在运行期才确定,而不是编译期。
4.3 动态绑定 vs 静态绑定:汇编见真章
不满足多态条件的调用,编译器在编译期就确定了函数地址,叫静态绑定 ;满足多态的,运行期才确定,叫动态绑定。看汇编:
asm
; 动态绑定(ptr->BuyTicket(),BuyTicket 是虚函数)
mov eax, dword ptr [ptr] ; 取对象的虚表指针
mov edx, dword ptr [eax] ; 取虚表第一个函数地址
call eax ; 间接调用------地址运行期才知道
; 静态绑定(BuyTicket 不是虚函数)
mov ecx, dword ptr [ptr]
call Student::BuyTicket (0EA153Ch) ; 直接调用------地址编译期就固定
动态绑定多了两次内存解引用(先取虚表指针,再取函数地址),这就是多态的运行时开销------虽然极小,但确实存在。这也是为什么C++坚持「不写 virtual 就零开销」:虚函数表只在需要多态时才引入。
4.4 虚表存在哪
虚函数本身和普通函数一样,编译后是代码段的指令。虚函数表存在常量区(代码段附近)------可以用下面的代码验证:
cpp
int main()
{
Base b;
const char* p = "xxxx"; // 常量区
Base* p3 = &b;
printf("常量区:%p\n", p);
printf("虚表地址:%p\n", *(int*)p3); // 和常量区地址接近
return 0;
}
虚表地址和常量区地址在同一个量级,说明虚表确实在常量区(只读数据段)。注意:虚表到底放哪,C++标准没有规定,不同编译器可能不同,VS 是放在常量区。
五、重载、重写、隐藏:三个概念一次分清
这是面试的固定考点,用一张表终结:
| 对比项 | 重载(overload) | 重写(override) | 隐藏(hide) |
|---|---|---|---|
| 作用域 | 同一个类内 | 基类和派生类之间 | 基类和派生类之间 |
| 函数名 | 相同 | 相同 | 相同 |
| 参数列表 | 必须不同 | 必须相同 | 任意 |
| 返回值 | 任意 | 相同(协变除外) | 任意 |
| 是否有 virtual | 无所谓 | 基类必须是 virtual | 无所谓 |
| 本质 | 编译期多态 | 运行期多态 | 名字屏蔽 |
最容易混的是重写和隐藏:
cpp
class Base
{
public:
virtual void func(int) {} // 虚函数
};
class Derived : public Base
{
public:
void func(int) {} // 参数相同、基类是virtual → 重写(构成多态)
void func(double) {} // 参数不同 → 隐藏(不构成多态,屏蔽基类所有func)
};
判断顺序:先看作用域(同类是重载,异类是重写或隐藏),再看参数列表和 virtual。
总结
四条核心认知:
- 多态的两个条件:虚函数重写 + 基类指针/引用调用。缺一个都不算多态,对象调用永远不是多态。
- 虚表是运行期多态的引擎:含虚函数的类对象里藏一个虚表指针,重写就是把派生类虚表里的对应项覆盖掉。调用时通过虚表间接跳转,这就是动态绑定的本质。
- 析构函数要设成虚函数:否则通过基类指针 delete 派生类对象时,派生类析构不会执行,资源泄漏。
- 重载/重写/隐藏:同类同名不同参是重载;异类同名同参且基类virtual是重写(多态);异类同名但参数不同是隐藏(屏蔽基类版本)。