33人参与 • 2026-07-28 • C/C++
在上一篇文章中,我们已经知道,多态通常通过下面的方式使用:
base* ptr = new derived; ptr->func();
虽然 ptr 的类型是 base*,但实际调用的却可能是:
derived::func()
这背后有几个值得继续研究的问题:
dynamic_cast 应该在什么时候使用?本文主要从底层原理和实际工程使用两个角度,继续讲解 c++ 多态。
静态类型是编译器从代码中直接看到的类型。
class animal
{
public:
virtual void talk() const
{
}
virtual ~animal() = default;
};
class dog : public animal
{
public:
void talk() const override
{
}
};创建对象:
dog dog; animal* ptr = &dog;
这里:
dog 的静态类型:dog ptr 的静态类型:animal* *ptr 表达式的静态类型:animal
静态类型在编译阶段就已经确定。

动态类型是对象在程序运行时的实际类型。
animal* ptr = &dog;
虽然 ptr 的静态类型是 animal*,但它实际指向的是一个 dog 对象。
因此,它指向对象的动态类型是:
dog
对于非虚函数调用:
ptr->normalfunc();
调用版本主要由静态类型决定。
对于虚函数调用:
ptr->talk();
最终版本由对象的动态类型决定。
可以简单记成:
非虚函数看指针或引用声明成什么类型 虚函数看指针或引用实际指向什么对象
函数调用目标在编译阶段确定,称为静态绑定。
#include <iostream>
using namespace std;
class base
{
public:
void func()
{
cout << "base::func()" << '\n';
}
};
class derived : public base
{
public:
void func()
{
cout << "derived::func()" << '\n';
}
};
int main()
{
derived object;
base* ptr = &object;
ptr->func();
return 0;
}输出:
base::func()
因为 func() 不是虚函数。
编译器根据 ptr 的静态类型 base*,直接确定调用:
base::func()
把基类函数改成虚函数:
class base
{
public:
virtual void func()
{
cout << "base::func()" << '\n';
}
virtual ~base() = default;
};派生类完成重写:
class derived : public base
{
public:
void func() override
{
cout << "derived::func()" << '\n';
}
};再次调用:
derived object; base* ptr = &object; ptr->func();
输出变成:
derived::func()
函数调用目标需要根据实际对象类型,在运行时确定,这就是动态绑定。

先给出一个重要结论:
c++ 标准规定了虚函数调用应当表现出的行为,但没有强制编译器必须使用某种固定的底层结构。
主流编译器通常通过下面两个结构实现动态多态:
vptr:虚函数表指针 vtable:虚函数表
这是常见实现方式,但不是可以跨平台依赖的语言规则。
不同编译器、操作系统和 abi 可能采用不同的对象布局。
class normal
{
private:
int _value;
};
normal 对象通常只需要保存:
_value
以及必要的内存对齐空间。
class base
{
public:
virtual void func()
{
}
private:
int _value;
};主流实现通常会在对象中加入一个隐藏指针:
vptr
它指向当前对象所属类型的虚函数表。
可以抽象理解为:
base 对象 ├── vptr ─────→ base 虚函数表 └── _value
vptr 并不是我们在类中显式声明的成员:
class base
{
// 代码中并没有写 vptr
};
它通常由编译器自动添加并维护。
对象构造和析构过程中,编译器会根据当前阶段设置对应的虚表指针。
很多调试器中会看到 vptr 位于对象起始位置。
但这不是语言标准保证的。
下面这些因素都可能影响对象布局:
编译器 abi 平台位数 单继承 多继承 虚继承 成员对齐
因此,不能通过手工计算固定偏移访问 vptr。

虚函数表可以抽象理解为一个函数地址表。
class base
{
public:
virtual void func1()
{
}
virtual void func2()
{
}
};对应的虚表可以抽象为:
base vtable ├── &base::func1 └── &base::func2
对象中的 vptr 指向这张表。
base object1; base object2;
两个对象的数据成员相互独立:
object1._value object2._value
但它们通常指向同一张 base 虚表:
object1.vptr ─┐
├──→ base vtable
object2.vptr ─┘
也就是说:
每个对象保存自己的 vptr 同类型对象通常共享同一张 vtable
虚函数和普通函数一样,编译后都是机器指令,通常位于程序的代码区域。
虚表中保存的是:
虚函数地址 或者用于定位虚函数的相关信息
而不是完整的函数代码。

class base
{
public:
virtual void func1()
{
cout << "base::func1()" << '\n';
}
virtual void func2()
{
cout << "base::func2()" << '\n';
}
};可以抽象成:
base vtable ├── base::func1 └── base::func2
class derived : public base
{
public:
void func1() override
{
cout << "derived::func1()" << '\n';
}
virtual void func3()
{
cout << "derived::func3()" << '\n';
}
};
派生类虚表可以抽象成:
derived vtable ├── derived::func1 ├── base::func2 └── derived::func3
其中:
func1:派生类完成重写,槽位改为 derived::func1 func2:没有重写,继续使用 base::func2 func3:派生类新增虚函数
derived 重写 func1() 时,并不是直接修改 base 的虚表。
通常是:
base 对象 -> base vtable derived 对象 -> derived vtable
两种类型拥有各自对应的虚表。

假设:
base* ptr = new derived; ptr->func1();
常见实现中的调用过程可以抽象为:
1. 通过 ptr 找到实际对象 2. 从对象中读取 vptr 3. 通过 vptr 找到虚函数表 4. 找到 func1 对应的虚表槽位 5. 取得最终函数地址 6. 调用 derived::func1
示意:
base* ptr
|
v
derived 对象
├── vptr
│ |
│ v
│ derived vtable
│ ├── derived::func1
│ ├── base::func2
│ └── derived::func3
│
└── 数据成员
多态调用的关键不在于变量名叫 ptr,而在于:
ptr 实际指向的对象具有什么动态类型

class normal
{
private:
int _value;
};
在常见环境中,对象大小主要由数据成员和内存对齐决定。
class base
{
public:
virtual void func()
{
}
private:
int _value;
};常见实现中,对象还需要保存一个 vptr。
因此对象大小可能增加。
在 32 位环境中,指针通常占 4 字节。
在 64 位环境中,指针通常占 8 字节。
但最终对象大小还会受到:
成员排列 内存对齐 编译器 abi 多继承 虚继承
等因素影响。
更准确的说法是:
多态对象通常需要额外保存动态分派信息,常见形式是一个或多个虚表指针。
看下面的代码:
#include <iostream>
using namespace std;
class base
{
public:
base()
{
func();
}
virtual void func()
{
cout << "base::func()" << '\n';
}
virtual ~base() = default;
};
class derived : public base
{
public:
void func() override
{
cout << "derived::func()" << '\n';
}
};创建对象:
derived object;
在 base 构造函数中执行:
func();
调用的是:
base::func()
而不是:
derived::func()
构造 derived 时,顺序大致是:
先构造 base 部分 再构造 derived 部分
执行 base 构造函数时,derived 部分还没有完成初始化。
如果此时调用 derived::func(),它可能访问尚未初始化的派生类成员。
因此,构造期间的虚函数调用不会分派到更派生类版本。

析构顺序和构造相反:
先析构 derived 再析构 base
当程序进入 base 析构函数时,derived 部分已经被销毁。
因此,在基类析构阶段调用虚函数,也不会再分派到 derived 的重写版本。
不要在构造函数和析构函数中依赖运行时多态。
尤其不要在基类构造函数中调用需要访问派生类状态的虚函数。
更合适的设计包括:
构造完成后显式调用初始化函数 使用工厂函数创建对象 通过构造参数传递必要信息 把公共初始化放入非虚函数
访问权限不会阻止虚函数重写。
class base
{
public:
virtual void func()
{
cout << "base::func()" << '\n';
}
};
class derived : public base
{
private:
void func() override
{
cout << "derived::func()" << '\n';
}
};调用:
derived object; base& ref = object; ref.func();
这段代码可以通过编译,并调用:
derived::func()
因为:
是否允许通过 base 接口调用:检查 base::func 的访问权限 最终执行哪个版本:根据动态类型进行虚函数分派
但直接写:
object.func();
会编译失败,因为 derived::func() 是 private。
可以简单理解为:
是否构成重写:看虚函数规则 能否直接调用:看访问权限
简单单继承中,一个多态对象通常表现为一个虚表指针。
但在多继承中,一个对象可能包含多个多态基类子对象。
class scanner
{
public:
virtual void scan()
{
}
virtual ~scanner() = default;
};
class printer
{
public:
virtual void print()
{
}
virtual ~printer() = default;
};
class allinone : public scanner, public printer
{
};allinone 对象可以抽象成:
allinone ├── scanner 子对象 │ └── scanner 对应的 vptr ├── printer 子对象 │ └── printer 对应的 vptr └── allinone 自己的成员
因此,不能简单认为:
每个多态对象一定只有一个 vptr
在多继承、虚继承等复杂场景中,完整对象可能包含多个虚表指针。
具体布局属于编译器和 abi 的实现细节。
假设:
class shape
{
public:
virtual void draw() const
{
}
virtual ~shape() = default;
};
class circle : public shape
{
public:
void draw() const override
{
cout << "circle" << '\n';
}
};如果写:
vector<shape> shapes; circle circle; shapes.push_back(circle);
会把 circle 按值转换成 shape,发生对象切片。
派生类部分会被舍弃。
#include <memory> #include <vector> using namespace std; vector<unique_ptr<shape>> shapes; shapes.push_back(make_unique<circle>());
遍历:
for (const auto& shape : shapes)
{
shape->draw();
}
容器中统一保存:
unique_ptr<shape>
但每个指针可以指向不同的实际对象:
circle rectangle triangle 其他 shape 派生类
如果每个对象只有一个明确所有者,通常优先使用:
unique_ptr<base>
它能够表达独占所有权,而且不需要手动 delete。
只有在确实存在共享所有权时,才考虑:
shared_ptr<base>

#include <iostream>
#include <memory>
#include <string>
#include <utility>
#include <vector>
using namespace std;
class shape
{
public:
explicit shape(string name)
: _name(std::move(name))
{
}
virtual double area() const = 0;
virtual void draw() const = 0;
const string& name() const
{
return _name;
}
virtual ~shape() = default;
private:
string _name;
};
class circle : public shape
{
public:
explicit circle(double radius)
: shape("圆形")
, _radius(radius)
{
}
double area() const override
{
return 3.1415926 * _radius * _radius;
}
void draw() const override
{
cout << "绘制圆形" << '\n';
}
private:
double _radius;
};
class rectangle : public shape
{
public:
rectangle(double width, double height)
: shape("矩形")
, _width(width)
, _height(height)
{
}
double area() const override
{
return _width * _height;
}
void draw() const override
{
cout << "绘制矩形" << '\n';
}
private:
double _width;
double _height;
};
int main()
{
vector<unique_ptr<shape>> shapes;
shapes.push_back(make_unique<circle>(3.0));
shapes.push_back(make_unique<rectangle>(4.0, 5.0));
for (const auto& shape : shapes)
{
cout << "名称:" << shape->name() << '\n';
cout << "面积:" << shape->area() << '\n';
shape->draw();
cout << '\n';
}
return 0;
}新增图形时,只需要增加一个新的派生类:
class triangle : public shape
{
};
原来的遍历逻辑通常不需要修改。
rtti 是运行时类型识别:
run-time type information
多态类型可以使用 dynamic_cast 检查实际类型。
void process(shape* shape)
{
if (auto* circle = dynamic_cast<circle*>(shape))
{
cout << "当前对象是 circle" << '\n';
}
}如果转换成功,返回合法指针。
如果失败,返回:
nullptr
try
{
circle& circle = dynamic_cast<circle&>(shape);
}
catch (const bad_cast&)
{
cout << "转换失败" << '\n';
}
引用形式转换失败会抛出:
std::bad_cast
如果程序中大量出现:
if 是 circle else if 是 rectangle else if 是 triangle
通常说明基类接口设计得不够完整。
优先考虑把不同类型的行为设计成虚函数:
shape.draw(); shape.area();
dynamic_cast 更适合确实需要访问某个派生类专有能力的场景。

运行时多态并不是完全没有成本。
多态对象在常见实现中需要保存一个或多个虚表指针。
普通函数可以直接调用确定地址。
虚函数通常需要:
读取 vptr 查询虚表 取得函数地址 进行间接调用
如果编译器无法确定实际对象类型,虚函数可能难以直接内联。
现代编译器有时能够推断对象的真实类型。
例如:
derived object; object.func();
编译器明确知道对象是 derived,可能直接调用或内联:
derived::func()
这种优化称为:
devirtualization 去虚拟化
大多数普通业务代码中,虚函数的一次间接调用通常不是主要性能瓶颈。
是否使用多态,更应该根据设计需求决定。
在下面这些场景中才需要重点测量:
高频调用的底层循环 游戏引擎核心路径 高性能计算 实时系统 缓存敏感的数据结构
不要在没有性能测试的情况下,为了避免一次虚调用而破坏代码结构。
c++ 中除了虚函数多态,还有基于模板的静态多态。
void drawshape(const shape& shape)
{
shape.draw();
}
特点:
运行时决定类型 支持统一存储不同派生对象 接口稳定 存在虚调用和对象布局开销
template<class t>
void drawshape(const t& shape)
{
shape.draw();
}
特点:
编译期确定类型 容易内联 通常没有虚表开销 不同类型会生成不同模板实例 难以直接放进同一个值类型容器
适合运行时多态:
对象类型需要在运行时决定 需要插件式扩展 需要统一保存不同类型对象 调用者只依赖抽象接口
适合模板多态:
类型在编译期已知 性能和内联非常重要 不需要运行时替换对象类型 希望使用泛型算法
两种方式并不是谁替代谁,而是适用于不同场景。

void draw() const override;
可以提前发现:
函数名写错 参数类型写错 const 遗漏 引用限定符不一致
virtual ~base() = default;
前提是允许通过基类指针销毁派生对象。
避免:
void process(base object); vector<base> objects;
优先:
void process(const base& object); vector<unique_ptr<base>> objects;
优先:
auto ptr = make_unique<derived>();
而不是:
base* ptr = new derived; // ... delete ptr;
不要为了某个派生类方便,就把大量特殊功能塞进基类。
基类应该描述整个类型体系真正共有的行为。
构造和析构期间不会向更派生类版本分派。
下面这些都属于实现细节:
vptr 位于对象什么位置 虚表位于哪个内存区域 虚表末尾有没有空指针 一个槽位的具体顺序
可以通过调试器观察,但不能写成可移植代码的依赖。
虚函数代码通常存放在代码区域。
虚表中存放的是虚函数地址或相关调度信息。
通常不是。
常见情况是:
每个对象保存 vptr 同类型对象共享 vtable
不一定。
简单单继承中通常表现为一个。
多继承和虚继承中可能存在多个多态基类子对象,因此可能有多个虚表指针。
不一定。
对象布局属于编译器和 abi 的实现细节。
不一定。
不同编译器实现可能不同。
通常不会。
只有参与虚函数机制的成员函数才需要虚表槽位。
语法上可以。
但构造期间不会调用更派生类版本,因此不要依赖这种调用实现多态初始化。
向下转换通常要求源类型是多态类型,也就是至少含有一个虚函数。
不一定。
虚调用存在间接调用开销,但是否成为瓶颈需要通过实际测量判断。
现代编译器还可能进行去虚拟化优化。
c++ 多态的常见底层实现可以概括成:
对象保存虚表指针 虚表保存虚函数地址 基类指针或引用调用虚函数 运行时根据对象虚表选择最终函数
理解虚函数表的目的,不是让我们在项目中手工修改虚表,而是帮助我们理解:
为什么同一个基类指针,可以在运行时调用不同派生类的函数。
真正写代码时,仍然应该依赖标准想深入了解c++多态的底层原理吗?本文从虚函数表、虚表指针到动态绑定,带你剖析编译器如何实现运行时多态,你将掌握对象大小变化的原因,学会在构造和析构中避免陷阱,并提升多态设计性能,点击阅读,解锁高效多态编程的核心技巧的虚函数、智能指针和类型系统,而不是依赖某个编译器的内部对象布局。
到此这篇关于c++ 多态底层原理解析:静态绑定、动态绑定、虚函数表与工程实践的文章就介绍到这了,更多相关c++ 静态绑定和动态绑定内容请搜索代码网以前的文章或继续浏览下面的相关文章希望大家以后多多支持代码网!
您想发表意见!!点此发布评论
版权声明:本文内容由互联网用户贡献,该文观点仅代表作者本人。本站仅提供信息存储服务,不拥有所有权,不承担相关法律责任。 如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 2386932994@qq.com 举报,一经查实将立刻删除。
发表评论