本文是 C++ 面向对象系列的第三篇,前两篇分别讲了继承的构造析构顺序和虚继承的菱形问题。多态是面向对象三大特性里最有"魔法感"的一个,但魔法背后都有代价------本篇从语法讲到底层虚表,把这个代价和机制讲清楚。
目录
[2.1 虚函数](#2.1 虚函数)
[2.2 虚函数的重写(覆盖)](#2.2 虚函数的重写(覆盖))
[2.3 为什么必须是指针或引用](#2.3 为什么必须是指针或引用)
[3.1 协变(了解即可)](#3.1 协变(了解即可))
[3.2 析构函数的重写(重要,面试高频)](#3.2 析构函数的重写(重要,面试高频))
[3.3 override 和 final(C++11,实际工程常用)](#3.3 override 和 final(C++11,实际工程常用))
[6.1 虚函数表指针(vfptr)](#6.1 虚函数表指针(vfptr))
[6.2 派生类的虚表是怎么建立的](#6.2 派生类的虚表是怎么建立的)
[6.3 多态调用的底层执行过程](#6.3 多态调用的底层执行过程)
[6.4 一道经典面试题解析](#6.4 一道经典面试题解析)
一、多态是什么
多态(polymorphism)字面意思是"多种形态"。C++ 里分两类:
编译时多态(静态多态):函数重载、函数模板。参数不同,调用不同的函数,绑定发生在编译期。
运行时多态(动态多态):同一个函数调用,根据对象的实际类型,在运行时决定调用哪个版本。这是本篇重点。
用买票举例最直观:同样是 BuyTicket() 这个动作,传进来的是普通人就全价,是学生就打折,是军人就优先。行为随对象类型变化,这就是运行时多态。
cpp
namespace Jianyi
{
class Person
{
public:
virtual void BuyTicket()
{
std::cout << "Person::买票 - 全价" << std::endl;
}
};
class Student : public Person
{
public:
virtual void BuyTicket() override
{
std::cout << "Student::买票 - 打折" << std::endl;
}
};
class Soldier : public Person
{
public:
virtual void BuyTicket() override
{
std::cout << "Soldier::买票 - 优先" << std::endl;
}
};
// 注意:参数类型是基类指针,不是具体类型
void Func(Person* ptr)
{
ptr->BuyTicket(); // 调用谁的版本,取决于 ptr 指向谁
}
}
int main()
{
Jianyi::Person ps;
Jianyi::Student st;
Jianyi::Soldier sr;
Jianyi::Func(&ps); // Person::买票 - 全价
Jianyi::Func(&st); // Student::买票 - 打折
Jianyi::Func(&sr); // Soldier::买票 - 优先
return 0;
}
输出:
Person::买票 - 全价
Student::买票 - 打折
Soldier::买票 - 优先
同一个 Func,同一行 ptr->BuyTicket(),三次调用结果不同。这就是多态。
二、多态的构成条件
运行时多态要成立,必须同时满足两个条件,缺一不可:
- 通过基类的指针或引用调用函数
- 被调用的函数是虚函数,且派生类完成了重写(覆盖)
这两个条件不是随意规定的,后面讲虚表原理时会看到,这两条正好对应底层的两个机制。
2.1 虚函数
在类的成员函数前加 virtual,它就成了虚函数:
cpp
class Person
{
public:
virtual void BuyTicket() { std::cout << "全价" << std::endl; }
};
几个注意点:
virtual只能修饰成员函数,普通函数、静态函数不能加- 构造函数不能是虚函数(构造时对象还没建好,虚表机制还没生效)
- 析构函数建议 加
virtual,后面专门讲原因
2.2 虚函数的重写(覆盖)
派生类中存在一个与基类虚函数返回值类型、函数名、参数列表完全相同的函数,就构成重写:
cpp
namespace Jianyi
{
class Base
{
public:
virtual void func(int x) { std::cout << "Base::func " << x << std::endl; }
};
class Derive : public Base
{
public:
// 构成重写:三要素完全相同
virtual void func(int x) override { std::cout << "Derive::func " << x << std::endl; }
};
}
有一个容易踩的坑:派生类重写时可以不写 virtual ,因为基类的虚函数被继承下来后,在派生类里依然保持虚函数属性。但这样写不规范,而且考试选择题里经常用这个设坑,让你判断是否构成多态。建议养成习惯,重写时显式加上 virtual。
2.3 为什么必须是指针或引用
这是很多人没想清楚的点。直接用对象调用会怎样?
cpp
namespace Jianyi
{
void FuncByValue(Person p) // 传值
{
p.BuyTicket(); // 永远调用 Person 的版本,多态失效
}
void FuncByRef(Person& p) // 引用,多态生效
{
p.BuyTicket();
}
void FuncByPtr(Person* p) // 指针,多态生效
{
p->BuyTicket();
}
}
传值的问题是对象切片(object slicing) :把 Student 传给 Person 形参时,多出来的 Student 部分被切掉了,剩下的只是一个 Person 对象,虚表指针也变成了 Person 的,自然调不到 Student 的版本。
指针和引用不会拷贝对象本身,它们只是"指向"那个对象,对象的实际类型不变,虚表也不变,多态才能发挥作用。
三、虚函数重写的几个细节
3.1 协变(了解即可)
重写有一个例外:协变 。派生类重写基类虚函数时,如果返回值是基类指针/引用 → 派生类指针/引用,也算合法重写:
cpp
namespace Jianyi
{
class A {};
class B : public A {};
class Base
{
public:
virtual A* clone() { return new A; }
};
class Derive : public Base
{
public:
virtual B* clone() override { return new B; } // 返回值不同,但构成协变,合法
};
}
协变实际使用场景很少,了解概念即可,面试偶尔会问"重写的例外情况是什么"。
3.2 析构函数的重写(重要,面试高频)
析构函数是个特殊情况。基类和派生类的析构函数名字不同(~Base vs ~Derive),按理不满足重写的三要素,但编译器对析构函数做了特殊处理:编译后所有析构函数统一处理为 destructor 这个名字。
所以只要基类析构函数加了 virtual,派生类析构函数不管写不写 virtual,都自动构成重写。
为什么基类析构函数一定要加 virtual? 看这个例子:
cpp
namespace Jianyi
{
class Base
{
public:
// 如果不加 virtual,用基类指针 delete 派生类对象时,只会调用 Base 的析构
virtual ~Base()
{
std::cout << "~Base()" << std::endl;
}
};
class Derive : public Base
{
public:
~Derive()
{
std::cout << "~Derive() 释放资源" << std::endl;
delete[] _buf;
}
private:
char* _buf = new char[64];
};
}
int main()
{
Jianyi::Base* p = new Jianyi::Derive; // 基类指针指向派生类对象
delete p; // 如果 ~Base 不是虚函数,这里只调 ~Base(),_buf 没被释放 → 内存泄漏
// 加了 virtual,多态生效,先调 ~Derive(),再调 ~Base(),正确释放
return 0;
}
如果 ~Base 不是虚函数,delete p 是静态绑定,直接根据指针类型(Base*)调 ~Base(),派生类的 _buf 就永远泄漏了。
结论:只要类有可能被继承,析构函数就加 virtual,这是工程习惯。
面试角度:这道题几乎是必考题。答题时一定要说清楚:不加 virtual → 静态绑定 → 只调基类析构 → 派生类资源泄漏。结合代码例子才能说完整。
3.3 override 和 final(C++11,实际工程常用)
C++ 对重写的要求很严格,函数名、参数、返回值三要素有一个写错,就不是重写了,变成了隐藏。更糟糕的是编译器不会报错,运行时才会发现行为不对。
C++11 为此提供了两个关键字:
override:告诉编译器,这个函数我打算重写基类的虚函数,帮我检查一下。
cpp
namespace Jianyi
{
class Car
{
public:
virtual void Drive() {}
};
class Benz : public Car
{
public:
// 注意:故意把 Drive 写成了 drive(大小写错误)
// 加了 override,编译器立刻报错:没有找到可重写的基类虚函数
virtual void drive() override {} // error!
};
}
没有 override 的话,drive() 会悄悄变成一个新函数,把 Car::Drive() 隐藏掉,多态失效,而且不报任何错误。override 就是把这个问题从运行时提前到编译时暴露。
final:禁止派生类重写这个虚函数,或者禁止类被继承。
cpp
namespace Jianyi
{
// 用在函数上:禁止重写
class Car
{
public:
virtual void Drive() final {}
};
class Benz : public Car
{
public:
virtual void Drive() {} // error:Drive 被声明为 final,不能重写
};
// 用在类上:禁止继承
class SingletonBase final {};
class Derived : public SingletonBase {}; // error:SingletonBase 不能被继承
}
final 的实际用途:单例模式、不希望被扩展的工具类,或者明确某个虚函数的语义在这一层就已经固定了。
四、重载、重写、隐藏的区别(经常考)
这三个概念放在一起很容易混,整理对比如下:
| 重载(Overload) | 重写/覆盖(Override) | 隐藏(Hide) | |
|---|---|---|---|
| 作用域 | 同一作用域 | 父子类不同作用域 | 父子类不同作用域 |
| 函数名 | 相同 | 相同 | 相同 |
| 参数 | 必须不同 | 必须相同 | 可以不同 |
| 返回值 | 可以不同 | 必须相同(协变例外) | 可以不同 |
| virtual | 无要求 | 两个都必须是虚函数 | 只要不构成重写就是隐藏 |
| 多态 | 静态多态 | 动态多态 | 不产生多态 |
隐藏是最容易踩的坑:派生类里写了和基类同名的函数,但不满足重写条件(比如基类没加 virtual,或者参数不同),就会把基类的版本隐藏掉。用基类指针调用时,不会触发多态,也不会调到派生类的版本,行为和预期完全不同。
cpp
namespace Jianyi
{
class Base
{
public:
void func(int x) { std::cout << "Base::func(int)" << std::endl; } // 普通函数,非虚
};
class Derive : public Base
{
public:
void func(double x) { std::cout << "Derive::func(double)" << std::endl; } // 参数不同
// Base::func(int) 被隐藏了!
};
}
int main()
{
Jianyi::Derive d;
d.func(1); // 调的是 Derive::func(double),Base 版本被隐藏
d.Base::func(1); // 要显式指定才能调到 Base 版本
return 0;
}
五、纯虚函数和抽象类
在虚函数后面加 = 0,就是纯虚函数:
cpp
class Shape
{
public:
virtual double area() = 0; // 纯虚函数,不需要实现(语法上可以,但没意义)
virtual void print() = 0;
};
包含纯虚函数的类叫抽象类,抽象类有两个特点:
- 不能实例化(不能创建对象)
- 派生类继承后,如果没有重写所有纯虚函数,派生类也是抽象类,同样不能实例化
纯虚函数的作用是强制派生类重写,它定义了一套接口规范,具体实现交给派生类:
cpp
namespace Jianyi
{
// 抽象基类,定义"形状"的接口
class Shape
{
public:
virtual double area() const = 0;
virtual void print() const = 0;
virtual ~Shape() {} // 析构也要是虚函数
};
class Circle : public Shape
{
public:
explicit Circle(double r) : _r(r) {}
double area() const override
{
return 3.14159 * _r * _r;
}
void print() const override
{
std::cout << "Circle, r=" << _r << ", area=" << area() << std::endl;
}
private:
double _r;
};
class Rectangle : public Shape
{
public:
Rectangle(double w, double h) : _w(w), _h(h) {}
double area() const override
{
return _w * _h;
}
void print() const override
{
std::cout << "Rectangle, " << _w << "x" << _h << ", area=" << area() << std::endl;
}
private:
double _w, _h;
};
}
int main()
{
// Jianyi::Shape s; // error:抽象类不能实例化
// 用基类指针管理不同的派生类对象
Jianyi::Shape* shapes[] = {
new Jianyi::Circle(5.0),
new Jianyi::Rectangle(3.0, 4.0),
};
for (auto* s : shapes)
{
s->print(); // 多态:根据实际类型调用对应的 print
delete s;
}
return 0;
}
输出:
cpp
Circle, r=5, area=78.5397
Rectangle, 3x4, area=12
这种"用基类指针/引用统一操作不同派生类"的模式,是多态最核心的使用场景,工程里非常常见。
面试角度:抽象类的意义不只是"不能实例化",更重要的是它定义了一套接口约定,强制派生类去实现。这是面向对象里"开闭原则"的体现:对扩展开放(新增派生类),对修改关闭(不动基类接口)。
六、多态的底层原理:虚函数表
现在来看多态是怎么实现的。这部分是理解多态的关键,也是面试经常深问的地方。
6.1 虚函数表指针(vfptr)
先看一个问题:含虚函数的类,对象大小是多少?
cpp
namespace Jianyi
{
class Base
{
public:
virtual void func1() { std::cout << "Base::func1" << std::endl; }
virtual void func2() { std::cout << "Base::func2" << std::endl; }
void func3() { std::cout << "Base::func3" << std::endl; } // 普通函数
protected:
int _a = 1;
char _ch = 'x';
};
}
int main()
{
// 32位程序下:int(4) + char(1) + 对齐(3) + vfptr(4) = 12 字节
// 64位程序下:int(4) + char(1) + 对齐(3) + vfptr(8) = 16 字节
std::cout << sizeof(Jianyi::Base) << std::endl;
return 0;
}
比只有数据成员时多了一个指针的大小。这个多出来的就是虚函数表指针(__vfptr,vfptr = virtual function pointer)。
每个含虚函数的类的对象里,都有一个 vfptr,它指向这个类的虚函数表(vtable / 虚表)。虚表是一个函数指针数组,存着这个类所有虚函数的地址:

几个关键结论:
- 同类型的对象共用同一张虚表(虚表是类级别的,不是对象级别的)
func3是普通函数,不在虚表里,地址直接编译时确定- 虚表存在代码段(常量区),不在堆也不在栈
6.2 派生类的虚表是怎么建立的
派生类继承基类时,会把基类的虚表复制一份作为自己虚表的起点,然后:
- 如果派生类重写了某个虚函数,就把虚表里对应位置的地址覆盖成派生类自己的函数地址
- 派生类新增的虚函数追加到虚表末尾
cpp
namespace Jianyi
{
class Base
{
public:
virtual void func1() { std::cout << "Base::func1" << std::endl; }
virtual void func2() { std::cout << "Base::func2" << std::endl; }
protected:
int _a = 1;
};
class Derive : public Base
{
public:
virtual void func1() override { std::cout << "Derive::func1" << std::endl; } // 重写
virtual void func3() { std::cout << "Derive::func3" << std::endl; } // 新增
protected:
int _b = 2;
};
}
虚表对比:

Derive 对象的内存布局:

注意:Derive 对象里只有一个 vfptr,它指向的是 Derive 自己的虚表(不是 Base 的虚表),这张虚表里已经把重写的函数地址换掉了。
6.3 多态调用的底层执行过程
现在可以解释多态是怎么发生的了:
cpp
void Func(Jianyi::Base* ptr)
{
ptr->func1(); // 这里发生了什么?
}
不满足多态条件时(静态绑定) :编译器在编译时就确定调用地址,直接 call Base::func1,地址写死在指令里。
满足多态条件时(动态绑定):编译器生成的汇编大致如下:
cpp
mov eax, dword ptr [ptr] ; 取出 ptr 指向的对象的首地址(即 vfptr 的地址)
mov edx, dword ptr [eax] ; 取出 vfptr,即虚表地址
mov eax, dword ptr [edx] ; 取出虚表第[0]项,即 func1 的地址
call eax ; 调用
三步:找对象 → 找虚表 → 找函数地址 → 调用。这就是为什么多态有一点点额外开销(相比普通函数调用多了两次内存间接寻址),但在大多数场景下这个开销完全可以忽略。
动态绑定也解释了多态的两个条件为什么是那两个:
- 必须是指针或引用 :只有指针/引用才不会切片,保证
vfptr还是派生类的那个 - 必须是虚函数:只有虚函数才会走虚表查找这条路,普通函数编译时直接绑定地址
6.4 一道经典面试题解析
cpp
class A
{
public:
virtual void func(int val = 1) { std::cout << "A->" << val << std::endl; }
virtual void test() { func(); }
};
class B : public A
{
public:
void func(int val = 0) { std::cout << "B->" << val << std::endl; }
};
int main()
{
B* p = new B;
p->test(); // 输出什么?
return 0;
}
答案:B->1
分析:
p->test():p是B*,test是虚函数,但B没有重写test,所以调用的是A::test()A::test()里调func():此时this指针指向的是B对象,满足多态条件(虚函数 + 指针),所以调的是B::func()- 但是默认参数是静态绑定的 :编译器在编译
A::test()时,看到的是func()没传参,就用A::func的默认值1,不会在运行时查虚表找默认参数 - 结果:调用
B::func,但参数是A的默认值1,输出B->1
结论:虚函数的函数体由虚表决定(运行时),但默认参数由声明时的静态类型决定(编译时)。不要在重写的虚函数里改默认参数值,行为会让人困惑。
七、总结
多态
├── 编译时多态:函数重载、函数模板(静态绑定)
└── 运行时多态:虚函数 + 基类指针/引用(动态绑定)
├── 构成条件
│ ├── 基类的指针或引用调用
│ └── 被调函数是虚函数且完成重写
├── 虚函数重写细节
│ ├── 三要素完全相同(返回值、函数名、参数)
│ ├── 协变:返回值可以是基类→派生类指针/引用
│ ├── 析构函数:基类析构必须加 virtual,防止资源泄漏
│ ├── override:编译期检查是否真正构成重写
│ └── final:禁止重写或禁止继承
├── 纯虚函数 = 0:强制派生类实现,类变为抽象类
└── 底层原理
├── 每个含虚函数的对象有 vfptr(虚函数表指针)
├── vfptr 指向该类的虚表(函数指针数组,存于代码段)
├── 派生类虚表 = 复制基类虚表 + 覆盖重写项 + 追加新虚函数
└── 调用过程:对象 → vfptr → 虚表 → 函数地址 → call
面试高频考点汇总:
- 多态的两个必要条件是什么,为什么
- 析构函数为什么要加 virtual(结合内存泄漏例子说清楚)
- override 和 final 的作用,为什么工程里要用 override
- 重载/重写/隐藏的区别
- 虚函数表的结构,vfptr 存在哪,vtable 存在哪
- 动态绑定的汇编级别执行步骤
- 默认参数和虚函数的经典陷阱(B->1 那道题)
