cpp知识
thread_local 关键字
size_t write(cosnt StringViewRange auto&& buffers) {
static thread_local std::vector<iovec> iovecs;
// buffer -> iovecs
return write(iovecs, total_size);
}[线程 A 的内存视角]
+---------------------+
| 寄存器 fs | ---> 指向 TLS 区域
+---------------------+
| TLS 区域 (Thread Local Storage)
| [ iovecs_A (24字节) ] --------+
+---------------------+ | 指针指向
v
+--------------------------+
| 堆 (Heap) |
| [ iovec, iovec, ... ] | <--- 这里的内存被反复利用
+--------------------------+
-------------------------------------------------------
[线程 B 的内存视角]
+---------------------+
| 寄存器 fs | ---> 指向 线程 B 自己的 TLS
+---------------------+
| TLS 区域
| [ iovecs_B (24字节) ] --------+
+---------------------+ | 指针指向不同的堆地址
v
+--------------------------+
| 堆 (Heap) |
| [ iovec, iovec, ... ] |
+--------------------------+类(Class)没有线程,只有操作系统(OS)有线程。
错误理解:每一个 FileDescriptor 对象都有一个线程
正确理解:你的程序可能启动了 10 个线程,这 10 个线程可能会操作同一个 FileDescriptor 对象,也可能操作 1000 个不同的 FileDescriptor 对象。
static thread_local 的含义是:
不管你创建了多少个 FileDescriptor 对象(哪怕 1 万个),只要它们是在同一个线程(Thread A)里运行的 write 函数,它们就共享同一个 iovecs 变量。场景 1:线程 A 里的 socket1 调了 write,接着 socket2 也调了 write。
- 结果:socket2 会复用 socket1 刚刚用过的那块内存(前提是 socket1 用完后 vector 被 clear 了,但 capacity 还在)。效率极高!
场景 2:线程 A 里的 socket1 和线程 B 里的 socket1 同时调 write。
- 结果:线程 A 用的是 iovecs_A,线程 B 用的是 iovecs_B。互不干扰,不需要加锁。
出生(构造):
- 当某个线程第一次运行到 write 函数内部的那一行代码时,这个线程专属的 iovecs 被构造。
- 如果你有 10 个线程,但只有 3 个线程调用过 write,那就只有 3 个 iovecs 被创建。
存活:
- 只要这个线程还活着,这个变量就一直活着(保留着堆内存)。
- 它不随对象的销毁而销毁。即使你把所有的 FileDescriptor 对象都析构了,只要线程还在,这个 iovecs 依然占着内存等待下一次召唤。
死亡(析构):
- 当线程退出(Thread Exit) 时。
- C++ 运行时环境会自动遍历该线程所有的 thread_local 变量,调用它们的析构函数,从而释放那块堆内存,最后清理 TLS 里的头信息。
static 是什么意思?(在函数内部)
当 static 用于函数内部的局部变量时,它改变的是变量的 生命周期(Lifecycle) 和 存储位置。
- 普通局部变量 (int a = 0;)
- 存储位置:栈(Stack)。
- 生命周期:函数被调用时创建,函数返回时销毁。下次调用时是全新的。
- 比喻:便利贴。用完就撕了扔掉。
- 静态局部变量 (static int a = 0;):
- 存储位置:数据段(Data Segment / BSS)。
- 生命周期:整个程序运行期间。它在程序第一次运行到这行代码时初始化,直到程序结束才销毁。
- 比喻:墙上的白板。你写了字,离开房间再回来,字还在那里。
thread_local 是什么意思?
它改变的是变量的 实例数量 和 归属权。
- 没有 thread_local:
- 全局只有一份(如果是 static)。所有线程看到的都是同一个变量,改的也是同一个。
- 后果:多线程同时改会打架(Data Race),必须加锁。
- 有 thread_local:
- 每个线程都有一份独立的拷贝。
- 生命周期:与线程绑定。线程启动(或第一次使用)时创建,线程结束时销毁。
std::unique_ptr<> std::make_unique<>()
std::unique_ptr(霸道总裁):- 它是管理者。
- 它的座右铭是:“这块内存是我的,只能是我的,谁也别想复制,但我死了这块内存也别想活。”
- 它通过 RAII(资源获取即初始化)机制,保证出了作用域自动释放内存。
std::make_unique(专属工厂):- 它是生产者。
- 它的作用是:“别自己瞎折腾去 new 了,把要求告诉我,我帮你造好打包送给你。”
- 它是 C++14 引入的辅助函数,用来生成
unique_ptr。
独占所有权->不能拷贝 只能移动
零开销
- unique_ptr 内部只存了一个裸指针。
- 如果你不使用自定义删除器(Deleter),它的大小和 int* 完全一样(64位系统下就是 8 字节)。
- 它的解引用操作 *ptr 和 ptr-> 会被编译器优化成和裸指针一模一样的汇编指令
process(std::unique_ptr<A>(new A()), std::unique_ptr<B>(new B()));C++ 编译器在编译这行代码时,执行顺序是不确定的(Unspecified Evaluation Order)。它可能这样执行:
- new A() 分配成功。
- 执行 new B() —— 此时内存不足抛出异常!
std::unique_ptr<A>的构造函数还没来得及执行。- 结果:A 的指针丢失了,没人负责 delete 它,内存泄漏。
process(std::make_unique<A>(), std::make_unique<B>());make_unique 内部封装了 new 和 unique_ptr 的构造。它是一个完整的函数调用。
这意味着步骤变成了:
- 完整执行
make_unique<A>()(分配+包装)。成功后返回一个对象。 - 完整执行
make_unique<B>()。 - 如果 B 失败了,A 已经是一个智能指针对象了,它会自动析构释放内存。
- 结论:零泄漏风险。
使用工厂函数
// 方式1:不使用工厂函数(复杂)
class Value {
public:
Value(TypeId type, int32_t value) : type_(type), value_(value) {}
Value(TypeId type, const std::string& value) : type_(type) {
// 复杂的字符串处理逻辑
// 内存管理逻辑
// ...
}
Value(TypeId type, bool value) : type_(type), value_(static_cast<int8_t>(value)) {}
};
// 使用时需要记住复杂的构造方式
Value int_val(TypeId::INTEGER, 42);
Value bool_val(TypeId::BOOLEAN, static_cast<int8_t>(true)); // 需要手动转换
// 方式2:使用工厂函数(简洁)
class ValueFactory {
public:
static inline auto GetIntegerValue(int32_t value) -> Value {
return {TypeId::INTEGER, value};
}
static inline auto GetBooleanValue(bool value) -> Value {
return {TypeId::BOOLEAN, static_cast<int8_t>(value)};
}
};
// 使用时非常简单直观
Value int_val = ValueFactory::GetIntegerValue(42);
Value bool_val = ValueFactory::GetBooleanValue(true);工厂函数一般都使用static 因为这样函数只属于这个工厂类本身 不需要实例化一个ValueFactory对象 能够直接通过这个类名进行调用 而且可以在全局范围内进行访问
优点
- 能够统一接口
- 易于扩展 如果需要支持新的类别的话 直接在工厂中添加新方法即可
- 隐藏复杂度
class GameObject {
protected:
GameObject(const std::string& type, int x, int y) : type_(type), x_(x), y_(y) {}
public:
static std::unique_ptr<GameObject> CreatePlayer(int x, int y) {
auto player = std::unique_ptr<GameObject>(new GameObject("player", x, y));
player->setHealth(100);
player->setSpeed(5);
return player;
}
static std::unique_ptr<GameObject> CreateEnemy(int x, int y) {
auto enemy = std::unique_ptr<GameObject>(new GameObject("enemy", x, y));
enemy->setHealth(50);
enemy->setSpeed(3);
enemy->setAggressive(true);
return enemy;
}
static std::unique_ptr<GameObject> CreateItem(int x, int y, const std::string& itemType) {
auto item = std::unique_ptr<GameObject>(new GameObject("item", x, y));
item->setItemProperties(itemType);
return item;
}
};
// 使用
auto player = GameObject::CreatePlayer(0, 0);
auto enemy = GameObject::CreateEnemy(100, 100);
auto potion = GameObject::CreateItem(50, 50, "health_potion");例如这里都是创建游戏中的对象 可以使用工厂函数 更加简单直观地进行构造
TOP-K
1. 场景一:单机内存处理(Static Data, Fit in Memory)
假设给你一个包含 个整数的数组,内存放得下,找出最大的 个。
方法 A:全量排序 (Naive Approach)
- 做法:使用
std::sort(QuickSort/MergeSort) 将数组完全排序,然后取前 个。 - 复杂度:。
- 评价:最差。当 很大而 很小时(例如 ),做了大量无用功。
方法 B:最小堆 (Min-Heap) —— 工程首选
- 做法:
- 维护一个大小为 的小顶堆。
- 遍历数组,将元素压入堆。
- 如果堆的大小超过 ,弹出堆顶(即堆中最小的元素,也就是当前 Top K 里最弱的那个)。
- 最终堆里剩下的就是最大的 个。
- 复杂度:。
- 评价:最通用、最稳健。特别是当 时,效率极高。你优化后的代码用的就是这个。
方法 C:快速选择 (Quick Select) —— 平均最快
- 做法:基于快速排序(Quick Sort)的 Partition 思想。
- 随机选一个 Pivot,将数组分为“比 Pivot 大”和“比 Pivot 小”两部分。
- 看 Pivot 的位置:
- 如果 Pivot 正好在第 个位置,那么它左边的就是 Top K。
- 如果 Pivot 在 之后,递归处理左边。
- 如果 Pivot 在 之前,递归处理右边。
- 复杂度:平均 ,最坏 。
- 评价:理论最快,但修改了原数组,且不稳定。C++ 标准库中有
std::nth_element就是这个实现。
2. 场景二:海量数据/流式数据(Streaming Data)
假设数据是实时流进来的(像网络包、日志),或者数据在磁盘上,内存放不下 个元素。
- 限制:无法将所有数据加载到内存,无法使用 Quick Select。
- 唯一解法:最小堆 (Min-Heap)。
- 原理:不管 有多大(1TB 甚至无穷大),内存中只需要维护一个 大小的堆。
- 空间复杂度:。
- 应用:实时热搜榜、DDOS 攻击检测(流量最大的 K 个 IP)。
3. 场景三:分布式大数据(Distributed / Big Data)
假设有 10 亿行数据,分布在 1000 台机器上,要找全局 Top-K。
- 限制:单机算不动,网络带宽有限。
- 解法:分治法 (MapReduce 思想)
- Map 阶段:每台机器在本地数据上计算局部 Top-K(使用堆)。
- Reduce 阶段:每台机器将这 个元素发送给一台中心机器(或者下一层聚合节点)。
- Merge 阶段:中心机器收集到 个元素,再做一次 Top-K,得到全局 Top-K。
- 关键点:传输的数据量非常小(只有 ),而不是 。
4. 场景四:统计“频率”最高的 Top-K (Heavy Hitters)
上面的场景都是基于元素的值(Value)排序。如果问题是:“在一个 100GB 的日志文件中,找出出现次数最多的 10 个 IP 地址”。
这是一个难点,因为你不仅要排序,还要先统计计数。
方法 A:Hash Map + Heap (精确解)
- 做法:用 Hash Map 统计所有 IP 的出现次数,然后把 (IP, Count) 扔进堆里求 Top-K。
- 缺点:如果有 10 亿个不同的 IP,Hash Map 内存会爆炸。
方法 B:Hash 分片 (精确解,分布式)
- 做法:
- 把 IP 按照
hash(IP) % 1024分发到 1024 个小文件中。 - 这样相同的 IP 肯定在同一个文件里。
- 分别加载每个小文件到内存,用 Hash Map 统计并求局部 Top-K。
- 最后归并。
- 把 IP 按照
方法 C:Count-Min Sketch / Misra-Gries (近似解)
- 场景:允许一点点误差,但必须极省内存(比如路由器硬件)。
- 做法:使用概率数据结构(如 Count-Min Sketch)。
- 用多个 Hash 函数将元素映射到二维数组中进行计数。
- 不需要存储 IP 本身,只存储计数值。
- 优点:用极小的空间(几KB)就能统计海量数据。
5. 数据库中的 Top-K 优化
SELECT * FROM table ORDER BY col LIMIT K。
数据库优化器通常会按以下顺序尝试:
利用索引 (Index Scan):
- 如果在
col上有 B+ 树索引,索引本身就是有序的。 - 数据库只需要读索引的最左边(或最右边)的 个条目。
- 复杂度:。这是极速模式。
- 如果在
**Top-N Heap Sort:
- 如果没索引,必须全表扫描。
- 在扫描过程中维护一个大小为 的堆。
- 复杂度:。
全量排序 (External Sort):
- 如果 非常大(比如
LIMIT 1000000),堆太大内存放不下。 - 退化为外部归并排序。
- 如果 非常大(比如
总结表
| 场景 | 最佳策略 | 复杂度 | 备注 |
|---|---|---|---|
| 内存充足,静态数据 | Quick Select | 会修改原数组 | |
| 内存充足,一般通用 | Min-Heap | 不改原数组,稳定 | |
| 流式数据 (Streaming) | Min-Heap | 空间仅需 | |
| 海量数据 (分布式) | 分治 + 归并 | - | MapReduce 经典案例 |
| 高频词统计 (精确) | Hash分片 + Heap | - | 解决 Map 内存爆炸问题 |
| 高频词统计 (近似) | Count-Min Sketch | 空间 | 牺牲精度换空间 |
顶层const 与底层const
1. 基本定义
顶层 const (Top-level const)
顶层 const 表示对象本身是一个常量。 一旦初始化,该变量的值就不能再改变。
- 适用于任何对象类型(如
int、double、类对象、指针本身等)。
底层 const (Low-level const)
底层 const 与指针和引用等复合类型有关。 它表示所指的对象是一个常量,但变量本身(如果是指针)是可以指向其他地方的。
2. 指针中的区别(最容易混淆的地方)
指针既可以是顶层 const,也可以是底层 const,或者两者都是。
int i = 0;
// --- 顶层 const ---
int* const p1 = &i; // p1 是顶层 const。p1 的值(地址)不能变,但可以通过 p1 修改 i。
const int ci = 42; // ci 是顶层 const。ci 的值不能变。
// --- 底层 const ---
const int* p2 = &i; // p2 是底层 const。不能通过 p2 修改 i,但 p2 可以指向别处。
const int& r = ci; // 所有的引用 const 都是底层 const,因为引用本身不是对象,不可改变绑定。
// --- 两者兼有 ---
const int* const p3 = p2; // 左边的 const 是底层,右边的 const 是顶层。
// p3 既不能指向别处,也不能通过它修改所指的值。判断小技巧:
以星号 * 为分界线:
- 如果
const在*右边:是顶层 const(修饰指针变量本身)。 - 如果
const在*左边:是底层 const(修饰指针指向的数据)。
3. 核心区别与影响
这两者的区别主要体现在执行拷贝操作时:
(1) 顶层 const 的拷贝:不受影响
当执行拷贝操作时,顶层 const 会被忽略。
int i = 0;
const int ci = 42;
i = ci; // 正确:ci 是顶层 const,拷贝时忽略它的常量属性。
int* const p1 = &i;
int* p2 = p1; // 正确:p1 是顶层 const,拷贝 p1 的值(地址)没问题。(2) 底层 const 的拷贝:严格限制
当执行拷贝操作时,拷入和拷出的对象必须具有相同的底层 const 资格,或者能够进行类型转换(通常是 非 const 能够转化为 const,反之不行)。
const int* p2 = &i; // p2 是底层 const
int* p3 = p2; // 错误:p2 有底层 const,而 p3 没有。
// 如果允许,你就能通过 p3 修改 p2 本来保护的数据。
const int* p4 = p2; // 正确:两者都是底层 const。
int* p5 = &i;
p2 = p5; // 正确:int* 可以转化为 const int*。4. 为什么需要区分它们?
函数模板与
auto:auto关键字在推导类型时,通常会忽略顶层 const,但会保留底层 const。
const int ci = 42; auto a = ci; // a 是 int(顶层 const 被忽略) const int* p = &ci; auto b = p; // b 是 const int*(底层 const 被保留)函数重载:
- 对于顶层 const,编译器无法区分形参。
- 对于底层 const(指针或引用的指向对象是否为 const),编译器可以区分。
void func(int i) {} void func(const int i) {} // 错误:重复定义(顶层 const 不构成重载) void move(int* p) {} void move(const int* p) {} // 正确:底层 const 可以构成重载强制类型转换:
const_cast只能改变运算对象的底层 const 属性。
类型转换符
static_cast:
最常用。用于良性转换(如 int 转 float,找回存在虚继承关系的父子类指针等)。
注意:它在编译时完成,没有运行时类型检查(对于下行转换是不安全的)。
dynamic_cast:
专门用于含有虚函数的类层次结构中的安全转换(下行转换)。
特点:在运行时检查。如果转换失败,对于指针返回 nullptr,对于引用抛出异常。
reinterpret_cast:
最危险。它进行底层的位模式重新解释(如将一个 int* 强制转为 char*)。
没有逻辑转换,只是告诉编译器“把这块内存当成另一种类型看”。
const_cast:
- 唯一能去掉或加上 const 或 volatile 属性的转换符。
vptr vtable 与 多继承情况下的虚函数表
在C++中,运行时多态(Runtime Polymorphism)是通过虚函数(Virtual Function)、虚函数表(vtable) 和虚指针(vptr) 共同实现的。这种机制允许程序在运行时根据对象的实际类型(Dynamic Type)而非声明类型(Static Type)来决定调用哪个函数。
1. 虚函数表 (vtable):多态的地图
虚函数表是一个由编译器为每一个包含虚函数的类维护的静态数组(通常存储在只读数据段)。
- 结构:
vtable 本质上是一个函数指针数组。
数组的每个条目存储着该类虚函数的入口地址。
通常,vtable 的头部(索引为 -1 或 -2 的位置)还会包含 RTTI(运行时类型信息),如
type_info,用于dynamic_cast和typeid的识别。- 在单继承中,对象只有一个 vptr。但在多重继承中,为了兼容不同的基类指针,子类对象会有多个 vptr。
Child 对象的内存布局大致如下(64位系统):
[0-7 字节]:vptr_Mother(指向 Child 专门为 Mother 准备的虚表)
[8-11 字节]:Mother::m_data
[16-23 字节]:vptr_Father(指向 Child 专门为 Father 准备的虚表)
[24-27 字节]:Father::f_data
[28-31 字节]:Child::c_data
Child* c2 = dynamic_cast<Child*>(f);
运行时库拿到 f 时,它只知道 f 现在指向的是某个包含 vptr_Father 的内存块。如果它想知道这个对象的真实完整类型,它必须找到对象的最开头(也就是 Mother 开始的地方),因为只有在那里才能找到 Child 类的完整 RTTI(type_info)。
解决办法:offset-to-top
在 vptr_Father 指向的那个虚表中,索引为 -2 的位置存了一个值:-16。
dynamic_cast 访问 f 指向的 vptr_Father。
查阅虚表索引 -2 的位置,发现 offset-to-top 是 -16。
它将 f 的地址加上 -16,瞬间找回了对象的真正头部。
在头部获取 Child 的 type_info,从而确认这个对象确实是一个 Child。
在多重继承下,Child 类实际上拥有一个“组合虚表”,它可以被拆分成多个部分供不同的基类指针使用。
对于 Child : public Mother, public Father:
A. Mother 对应的虚表部分(主虚表 Primary Vtable):
索引 -2 (offset-to-top): 0 (因为 Mother 在 Child 的最开头,偏移量为 0)。
索引 -1 (typeinfo ptr): 指向 Child 的 type_info。
索引 0, 1...: Child::cook() 等虚函数地址。
B. Father 对应的虚表部分(次虚表 Secondary Vtable):
索引 -2 (offset-to-top): -16 (告诉程序:如果你想回对象头,请减 16 字节)。
索引 -1 (typeinfo ptr): 同样指向 Child 的 type_info。
索引 0, 1...: Child::drive() 等虚函数地址。
为什么都要存 type_info?
因为编译器无法预知你会从哪个基类指针发起 dynamic_cast。
如果你手持 Mother*,你会通过 Mother 的虚表看到它是 Child。
如果你手持 Father*,你会通过 Father 的虚表看到它是 Child。
所以,所有关联到这个类的虚表部分,其索引 -1 必须一致指向该类的真实类型信息。
- 生成时机:
- 编译期。编译器在编译每个类时,如果发现类中有虚函数,就会为该类生成一个唯一的 vtable。
- 存储位置:
- 存储在可执行文件的只读数据段(.rodata 或 .text)。它不占用对象的内存空间,而是所有该类的实例共用同一个 vtable。
- 类层次结构中的关系:
- 基类:拥有自己的 vtable,记录其虚函数地址。
- 派生类:也会拥有自己的 vtable。
- 如果派生类重写(Override) 了基类的虚函数,派生类 vtable 中对应的条目会被替换为派生类函数的地址。
- 如果派生类没有重写,则条目保留基类函数的地址。
- 如果派生类定义了新的虚函数,这些函数的地址会被追加到 vtable 的末尾。
2. 虚指针 (vptr):连接对象与地图的桥梁
虚指针是编译器隐式添加到对象实例中的一个指针。
- 存在方式:
- 当一个类拥有虚函数时,编译器会为该类的每个对象增加一个隐藏的指针成员(通常命名为
__vptr)。 - 为了提高效率,
vptr通常位于对象内存布局的最前面(Offset 0)。
- 当一个类拥有虚函数时,编译器会为该类的每个对象增加一个隐藏的指针成员(通常命名为
- 初始化过程:
- 构造函数执行时初始化。
- 当创建一个派生类对象时:
- 首先调用基类构造函数。此时
vptr指向基类的 vtable。 - 然后执行派生类构造函数。此时
vptr被更新,指向派生类的 vtable。
- 首先调用基类构造函数。此时
- 注意:这也是为什么在构造函数中调用虚函数无法实现多态的原因——此时对象尚未完全构造,
vptr仍指向当前构造层的 vtable。
- 作用:
- 它是对象实例与类 vtable 之间的纽带。通过
vptr,运行时系统能够找到该对象对应的 vtable,进而找到正确的函数地址。
- 它是对象实例与类 vtable 之间的纽带。通过
3. 动态绑定的查找过程:协同工作原理
当执行类似 base_ptr->virtual_func() 的代码时,编译器并不会生成一个直接跳转到某个函数地址的指令,而是生成一段查找代码。
查找步骤(汇编逻辑):
- 获取 vptr:程序访问
base_ptr所指向的对象,取出该对象起始位置存储的vptr。 - 定位 vtable:通过
vptr找到该对象所属类的虚函数表(vtable)。 - 索引偏移:编译器在编译阶段已经确定了
virtual_func在 vtable 中的偏移量(Index)。例如,如果virtual_func是类中定义的第一个虚函数,那么它就在索引 0 的位置。 - 间接跳转:程序取出 vtable 中对应索引处的函数指针,并跳转到该地址执行。
为什么能确保正确性?
- 静态与动态的分工:
- 编译器(静态):决定函数在 vtable 中的“槽位”(Index)。无论基类还是派生类,同一个虚函数在 vtable 中的索引是一致的。
- 运行时(动态):通过
vptr找到“具体的地图”(vtable)。如果是派生类对象,vptr指向派生类的表,表里索引 N 的位置存放的是派生类重写后的地址。
- 实例独立性:每个对象实例都有独立的内存空间。即使是两个不同的派生类对象(例如
Dog和Cat都继承自Animal),它们各自的vptr会分别指向Dog::vtable和Cat::vtable。
总结图示
对象内存布局 (Derived object) 派生类虚函数表 (Derived vtable)
+-----------------------+ +--------------------------+
| vptr (指向 vtable) ----|------> | [0]: RTTI / type_info |
+-----------------------+ +--------------------------+
| 成员变量 A | | [1]: Derived::func1() | (重写了基类)
+-----------------------+ +--------------------------+
| 成员变量 B | | [2]: Base::func2() | (继承自基类)
+-----------------------+ +--------------------------+结论:C++ 的多态性是以空间换时间的策略。它增加了一个指针的内存开销(vptr)和一张表(vtable)的存储开销,并通过两次解引用(一次找 vtable,一次找函数)的微小时间代价,实现了强大的运行时灵活性。
RTTI
简单一句话:RTTI 是一套由编译器生成的“类描述信息”结构体,它是一个实实在在存在的只读数据,存储在可执行文件的只读数据段(.rodata)中。
下面是详细的拆解:
1. RTTI 是什么?是一个额外的结构吗?
是的,它是一组结构体。
虽然 C++ 标准只规定了 std::type_info 类,但各大编译器(如 GCC/Clang 使用的 Itanium ABI)为了实现 dynamic_cast 在复杂的继承树里“导航”,实现了一套非常详细的结构体层次:
__class_type_info:最基础的类,不包含继承关系。__si_class_type_info:单继承类的 RTTI。它里面包含一个指向基类 RTTI 的指针。__vmi_class_type_info(Virtual Multiple Inheritance):最复杂的。它记录了:- 有多少个基类。
- 每个基类的 RTTI 指针。
- 每个基类相对于子类头部的偏移量。
- 基类是
public还是private继承,是否是virtual继承。
这就是为什么 dynamic_cast 能在运行时知道如何从 Father 跳回 Child: 它不是靠猜,而是靠读取这些像“家谱”一样的结构体。
2. RTTI 存在哪里?
在内存布局中,它属于静态只读数据。
- 物理位置:在 ELF 文件(Linux)或 PE 文件(Windows)的
.rodata(Read-Only Data)段,或者是.data.rel.ro(需要重定位的只读数据段)。 - 逻辑连接:虚表(VTable)中索引为
-1的位置(也就是vptr所指地址的前 8 个字节)存储了一个地址,这个地址指向了这个 RTTI 结构体。
直观图示:
[ 内存地址 ] [ 数据内容 ]
0x1000 [ offset-to-top ] (虚表开始)
0x1008 [ RTTI 指针 ] --------+
0x1010 (vptr->)[ 虚函数1 地址 ] |
|
0x2000 (RTTI) [ Child Type Info ] <------+ (位于 .rodata)
0x2010 [ "5Child" (类名) ]
0x2020 [ 基类 RTTI 指针 ] ----> [ Father Type Info ]3. 什么时候创建的?
在编译阶段(Compile Time)确定,在链接阶段(Link Time)合并。
- 编译时:当编译器发现一个类包含虚函数(即它是“多态类”)时,它会自动为该类生成两样东西:
- 虚表(VTable)。
- RTTI 结构体(包含类名字符串、基类指针等)。
- 链接时:由于一个类可能在多个
.cpp文件中被引用,编译器会在每个目标文件里都生成一份 RTTI。链接器(ld)负责把重复的 RTTI 合并,确保在整个程序运行期间,同一个类只有一个唯一的 RTTI 实例(这样才能保证typeid(a) == typeid(b)成立)。
4. 为什么要强调“多态类”才有 RTTI?
如果你定义一个普通的类:
class Simple { int x; };编译器不会为它生成虚表,也不会为它生成 RTTI 结构。
- 如果你对它调用
typeid,编译器会直接在编译时硬编码返回一个静态的结果。 - 如果你对它用
dynamic_cast,编译器会直接报错,因为它根本没地方去查“家谱”。
只有当你写了 virtual,编译器才会开启这套“魔法”支持。
- 物理本质:它是编译器在
.rodata段生成的一组描述类继承关系的常量结构体(如__vmi_class_type_info)。 - 连接方式:它通过虚表(VTable)中负索引位置的指针与对象实例相连。
- 核心作用:
- 身份识别:支持
typeid运算。 - 路径导航:为
dynamic_cast提供在多重继承和虚继承树中进行地址偏移计算的“地图”。
- 身份识别:支持
- 开销限制:它只针对多态类(含有虚函数的类)生成。虽然可以通过编译选项(如
-fno-rtti)关闭它以节省空间(嵌入式常用),但这样会导致dynamic_cast无法使用。
一句话:RTTI 就是 C++ 类的“运行时户口本”。
空类的大小
情况 A:真正的空类(无任何成员,无虚函数)
class Empty {};- 大小:1 字节。
- 原因:C++ 要求每个对象在内存中必须有唯一的地址。如果大小为 0,那么
Empty a[10]中所有元素的地址都一样,无法区分。因此编译器会插入一个“占位符”字节。 - 例外(空基类优化 EBCO):如果这个类被继承(例如
class Derived : public Empty { int x; };),派生类的大小通常是 4 字节,编译器会优化掉基类的那个 1 字节。
情况 B:带有虚函数的“空”类
class VirtualEmpty {
public:
virtual ~VirtualEmpty() {}
};- 大小:8 字节(在 64 位系统上)。
- 原因:一旦类里有了虚函数,编译器就会为它生成
vtable。为了让对象能找到这个表,每个对象实例必须包含一个vptr(虚函数指针)。在 64 位环境下,指针的大小是 8 字节。
图示
Memory Layout of a Virtual Class Object:
+-------------------+
| vptr | ----+ [vtable]
+-------------------+ | +-----------------------+
| member data | | | offset-to-top (-2) | <-- 用于找对象头
+-------------------+ | +-----------------------+
| | typeinfo ptr (-1) | <-- 用于 dynamic_cast
+-> +-----------------------+
| virtual_func_1 (0) | <-- 正常的虚函数调用
+-----------------------+
| virtual_func_2 (1) |
+-----------------------+“为什么析构函数通常要声明为 virtual?”
答案: 这样可以确保当通过基类指针删除派生类对象时,程序能通过 vtable 找到派生类的析构函数,从而正确释放派生类特有的资源,防止内存泄漏。
菱形继承与虚继承
虚继承(Virtual Inheritance) 是 C++ 中为了解决多重继承中著名的**“菱形继承”(Diamond Problem)**问题而引入的一种机制。
简单来说,它的核心作用是:确保在复杂的继承网络中,最顶层的基类在子类对象中只保留一份实例。
一、 虚继承解决的问题:菱形继承
想象这样一个继承关系:
- 类 A(基类):有一个成员变量
int a; - 类 B 继承自 A。
- 类 C 继承自 A。
- 类 D 同时继承自 B 和 C。
1. 如果不使用虚继承(普通继承):
在 D 类的对象内存布局中,会存在两份 A 的拷贝:
- 一份来自
D -> B -> A - 一份来自
D -> C -> A
这会导致两个严重问题:
- 数据冗余:对象
D内部存了两个a变量,白白浪费内存。 - 二义性(Ambiguity):当你通过
D的对象访问a时(例如d.a = 10;),编译器会报错。因为它不知道你是想改B路径下的a还是C路径下的a。你必须写成d.B::a这种丑陋的代码。
2. 如果使用虚继承:
class A { public: int a; };
class B : virtual public A { ... }; // 虚继承
class C : virtual public A { ... }; // 虚继承
class D : public B, public C { ... };此时,D 对象中只有一份 A 的成员。无论从 B 路径还是 C 路径去访问,操作的都是同一个 a。
二、 虚继承的“底层魔法”:它是如何实现的?
虚继承的实现比普通继承复杂得多,因为它打破了“基类必须排在派生类前面”的常规布局。
1. 内存布局的改变
在普通继承中,子类对象只是简单地把基类成员“贴”在自己成员的前面。
但在虚继承中,虚基类(即 A)的位置是不固定的。它通常被放在整个对象内存的最末尾。
2. 关键组件:VBase Offset(虚基类偏移量)
既然 A 的位置不固定,那么 B 和 C 在运行时怎么找到 A 呢?
还记得我们之前聊过的 虚表(VTable) 吗?在虚继承下,虚表里又多了一个重要的字段:VBase Offset。
- B 的虚表里会多出一项:记录“从 B 的起始地址到 A 的起始地址需要偏移多少字节”。
- 当你在代码里写
B* ptr = &d; ptr->a = 5;时,编译器会生成这样的代码:- 查
ptr指向的虚表。 - 找到 VBase Offset。
- 根据偏移量找到
A的真实位置,再修改a。
- 查
三、 虚继承下的对象布局示意图(64位系统)
假设 D 继承自虚基类 B 和 C:
[ Child D 对象的起始 ]
0-7 字节: vptr_B (指向 DasB 的虚表)
8-15 字节: B 的成员变量
16-23 字节: vptr_C (指向 DasC 的虚表)
24-31 字节: C 的成员变量
32-39 字节: D 的成员变量
40-47 字节: [ 虚基类 A 的成员 ] <-- 被挪到了最后,且全家共享这一份在 B 的虚表里:
offset-to-top: 0vbase_offset: 40 (告诉 B,A 在 40 字节后的位置)
在 C 的虚表里:
offset-to-top: -16vbase_offset: 24 (16+24=40,同样指向 A)
虚表是“类”的属性,而不是“基类”的属性
在内存中,class B、class C 和 class D 是三个完全不同的类。
- class B 的虚表:记录的是“当我是一个独立的 B 对象时,我该怎么活”。
- class C 的虚表:记录的是“当我是一个独立的 C 对象时,我该怎么活”。
- class D 的虚表组:记录的是“当我是 D 的时候,我的 B 部分、C 部分和 A 部分该怎么配合工作”。
所以,D 对象里的 vptr_B 指向的绝对不是 class B 定义的那个原始虚表,而是指向 class D 专门为自己的 B 分支定制的虚表。
所以有多个vptr的原因就是要能够在通过ABC (各个父类) 的指针访问的时候能够直接找到vptr 而且能够直接找到RTII 所以可以持有父类指针进行dycast
四、 虚继承的代价
虽然虚继承解决了菱形继承,但它不是免费的午餐:
- 性能开销:普通继承访问基类成员是“直接寻址”,虚继承是“间接寻址”(需要查表拿偏移量),速度稍慢。
- 内存开销:每个虚继承的子类对象都需要额外的
vptr(如果原本没有),且虚表体积变大。 - 初始化责任:在虚继承中,虚基类
A不再由直接派生类B或C初始化,而是由最终派生类D负责初始化。这意味着D的构造函数必须显式调用A的构造函数。(如果没有默认构造函数的话)
应用场景:最著名的例子是标准库中的 iostream。它继承自 istream 和 ostream,而这两者又共同虚继承自 ios。如果没有虚继承,cout 就会有两份文件状态信息。
虚基类的构造
为什么必须由 D 初始化?
如果由 B 和 C 各自负责初始化 A,那么在创建 D 的对象时,A 就会被初始化两次(一次由 B 的路径,一次由 C 的路径)。这违背了虚继承“在内存中只有一份 A 实例”的初衷。
因此,C++ 规定:虚基类由“最底层”的派生类(Most Derived Class)负责初始化,中间路径上的构造函数对虚基类的调用会被自动忽略。
代码示例
在这个例子中,基类 A 没有默认构造函数,必须传一个 int 参数。
#include <iostream>
using namespace std;
// 1. 虚基类
class A {
public:
int val;
A(int x) : val(x) {
cout << "A 构造函数被调用,val = " << val << endl;
}
};
// 2. 虚继承 A 的类 B
class B : virtual public A {
public:
// B 的构造函数试图把 x 传给 A
B(int x) : A(x) {
cout << "B 构造函数被调用" << endl;
}
};
// 3. 虚继承 A 的类 C
class C : virtual public A {
public:
// C 的构造函数也试图把 x 传给 A
C(int x) : A(x) {
cout << "C 构造函数被调用" << endl;
}
};
// 4. 最终派生类 D
class D : public B, public C {
public:
// 关键点:D 必须显式调用 A 的构造函数
// 即使 B 和 C 都写了 A(x),那些调用在创建 D 对象时都会被屏蔽
D(int x) : A(x), B(x), C(x) {
cout << "D 构造函数被调用" << endl;
}
};
int main() {
cout << "--- 开始创建 D 对象 ---" << endl;
D obj(100);
return 0;
}运行结果
--- 开始创建 D 对象 ---
A 构造函数被调用,val = 100
B 构造函数被调用
C 构造函数被调用
D 构造函数被调用深度解析
1. 如果 D 不显式调用 A(x) 会怎样?
如果 A 没有默认构造函数(无参构造函数),而 D 的构造函数里没写 A(x),编译器会直接报错:
error: no matching function for call to 'A::A()'
因为编译器认为 D 既然是“最终负责人”,它就必须负责把 A 盖起来。如果 D 没交代怎么盖 A,编译器不会去求助 B 或 C。
2. 如果 A 有默认构造函数,D 没写 A(x) 会怎样?
如果 A 有默认构造函数,而 D 没写 A(x),那么:
D会调用A的默认构造函数。B和C构造函数中对A(x)的调用依然会被忽略。- 结果就是
obj.val可能是个随机值或默认值,而不是你想要的 100。
3. 构造顺序是什么?
无论 D 的初始化列表里 A(x) 写在什么位置(即便写在 B 和 C 后面),虚基类 A 永远是第一个被构造的。
总结
“在虚继承中,为了保证虚基类在内存中只有唯一备份,C++ 规定虚基类的初始化责任由整个继承链中最底层的类承担。中间类的构造函数在初始化列表中对虚基类的调用会在运行时被屏蔽。这意味着,如果虚基类没有默认构造函数,最底层的类必须在初始化列表中显式调用虚基类的构造函数,否则无法通过编译。”
菱形继承 总结
1. 基础定义与核心矛盾
- 结构描述:类 A 为基类,B 和 C 分别虚拟继承自 A,最后 D 同时继承 B 和 C。
- 两大问题(不使用虚继承时):
- 数据冗余:D 对象内部包含两份 A 的成员,浪费内存。
- 访问二义性:通过 D 访问 A 的成员时,编译器无法确定是走 B 路径还是 C 路径。
2. 解决方案:虚拟继承(Virtual Inheritance)
- 语法:
class B : virtual public A。 - 核心作用:确保在整个继承体系中,虚基类 A 的子对象在最终派生类 D 中只有一份共享的实例。
3. 对象内存布局(核心底层逻辑)
在现代编译器(如 GCC/Clang)中,D 对象的内存布局大致如下:
- B 子对象部分:包含
vptr_B(D 的主虚指针,复用 B 的位置)和 B 的成员。 - C 子对象部分:包含
vptr_C(次虚指针)和 C 的成员。 - D 自己的成员:D 新增的变量。
- 共享虚基类 A 部分:被挪到了对象末尾,包含
vptr_A和 A 的成员。(g++中虚基类共享主虚表指针 会优化掉)
4. 虚表与虚指针(vptr & vtable)
- 虚表个数:D 对象通常有 3 个虚指针(vptr),分别指向 3 个为 D 定制的“子虚表”。
- 为什么不合并成一个 vptr?
- 指针兼容性:必须保证
B*、C*和A*指向 D 时,各自看到的内存布局与其基类原始布局一致(开头都是 vptr)。 - 索引一致性:不同基类的虚函数在虚表中的索引可能冲突,必须分开。
- 指针兼容性:必须保证
- Thunk 技术:在
D-as-C或D-as-A的虚表中,若调用了 D 重写的函数,虚表存的是 Thunk 地址。其作用是:修正this指针的偏移量,使其从子对象位置跳回 D 的起始位置,再跳转到真实的函数代码。
5. RTTI 与指针转换
- RTTI 归属:D 对象中所有的子虚表,其 RTTI 指针最终都指向 D 的类型信息。
- dynamic_cast:依赖 vptr 找到虚表,再通过虚表中的 RTTI 和
offset-to-top(到顶部的偏移量)信息,实现安全的上下行转换。 - 构造期切换:在构造 A、B、C 时,vptr 会经历从 A 到 B/C 再到 D 的“身份切换”,因此构造函数内调用
typeid返回的是当前构造类的类型。
6. 初始化规则(初始化列表)
- 唯一初始化权:虚基类 A 由最底层派生类 D 负责初始化。
- 屏蔽机制:即便 B 和 C 的初始化列表中写了 A 的构造函数,当构造 D 时,B 和 C 对 A 的调用会被忽略。
- 执行顺序:
- 最先执行虚基类 A 的构造。
- 按声明顺序执行 B、C 的构造。
- 最后执行 D。
(析构顺序与之完全相反)
7. 性能权衡(Trade-offs)
- 优点:彻底解决了菱形继承的冗余和二义性。
- 缺点:
- 对象变大:多了额外的 vptr 和虚表偏移信息。
- 访问变慢:访问虚基类成员需要通过虚表做一次间接寻址(Offset 查找)。
- 复杂性增加:内存布局复杂,调试难度高。
D 对象(大小:48 bytes)
┌──────────── offset 0x00
│ B 子对象(16 bytes)
│ [0x00..0x07] vptr_B-in-D ← 指向 D 的 vtable(B 视图)
│ [0x08..0x0B] int b = 2
│ [0x0C..0x0F] padding
├──────────── offset 0x10
│ C 子对象(16 bytes)
│ [0x10..0x17] vptr_C-in-D ← 指向 D 的 vtable(C 视图)
│ [0x18..0x1B] int c = 3
│ [0x1C..0x1F] int d = 4 ← D 的成员变量被紧贴在此处(复用 C 尾部空间)
├──────────── offset 0x20
│ A 虚基子对象(16 bytes)
│ [0x20..0x27] vptr_A-in-D ← 指向 D 的 vtable(A 视图)
│ [0x28..0x2B] int a = 1
│ [0x2C..0x2F] padding
└──────────── sizeof(D) = 48为什么每个子对象都保留vptr 什么叫不同视图
1. 为什么 A 还要有一个 vptr?
简单直接的原因是:多态的一致性。
当我们写出如下代码时:
D d;
A* ptr = &d;
ptr->foo(); // 调用 A 的虚函数(或者 D 重写的虚函数)对于编译器来说,它只知道 ptr 是一个 A*。为了支持多态,它必须能够通过 ptr 找到虚函数表。
- 如果
A子对象内部没有vptr,那么当它作为D的一部分时,我们就无法通过A*指针直接定位到虚函数表。 - 即使是虚继承,
A作为虚基类在内存中是共享的,但A依然是一个独立的类定义。任何包含虚函数的类,其对应的子对象内存块里必须有一个vptr,用来指向在该上下文下的虚函数地址。
所以,D 对象内部的 A 子对象里的 vptr,指向的是 “D 对象中 A 视图的虚表”。
2. 内存布局示意图
假设:A 有 vfA,B 虚继承 A 并有 vfB,C 虚继承 A 并有 vfC,D 继承 B, C。
[ D 对象的内存布局 ]
+-----------------------+
| vptr_for_B_in_D | <--- D 的起始地址 (也是 B 视图的起始)
| B 的成员变量 |
+-----------------------+
| vptr_for_C_in_D | <--- C 视图的起始地址
| C 的成员变量 |
+-----------------------+
| D 的成员变量 |
+-----------------------+
| vptr_for_A_in_D | <--- A 视图的起始地址 (虚基类在最后)
| A 的成员变量 |
+-----------------------+3. 每个视图的 vtable 分别有哪些内容?
D 实际上拥有一组虚表(vtable group),不同的 vptr 指向这个组内的不同位置。
(1) B-in-D 虚表视图 (Primary VTable)
这是 D 的主虚表。
- 内容:
- Virtual Base Offset (vbase_offset): 记录从当前位置(B)到虚基类
A的偏移量。 - Offset to Top: 从当前位置到整个
D对象顶部的偏移(此处为 0)。 - RTTI 指针: 指向
D的类型信息。 - 虚函数地址:
B引入的虚函数。D重写的B的虚函数。- 注意: 如果
D有新的虚函数(没在基类定义过),通常也会放在这里。
- Virtual Base Offset (vbase_offset): 记录从当前位置(B)到虚基类
(2) C-in-D 虚表视图 (Secondary VTable)
- 内容:
- Virtual Base Offset: 记录从当前位置(C)到虚基类
A的偏移量。 - Offset to Top: 负值,表示从 C 视图回到
D顶部的字节数。 - RTTI 指针: 指向
D的类型信息。 - 虚函数地址:
C引入的虚函数。- 如果
D重写了C的虚函数,这里存的是 Thunk 函数的地址(见下文)。
- Virtual Base Offset: 记录从当前位置(C)到虚基类
(3) A-in-D 虚表视图 (Virtual Base VTable)
- 内容:
- V-Call Offsets: 只有在虚继承中,虚基类的虚表才会有这个。用于在只知道
A*的情况下,调整this指针以找到D重写的函数。 - Offset to Top: 负值,表示从 A 视图回到
D顶部的距离。 - RTTI 指针: 指向
D的类型信息。 - 虚函数地址:
A定义的虚函数。- 如果
D重写了A的函数,这里存的也是 Thunk。
- V-Call Offsets: 只有在虚继承中,虚基类的虚表才会有这个。用于在只知道
4. 这些 vtable 的本质区别在哪里?
核心区别在于 this 指针的调整(Pointer Adjustment)。
区别一:解决“身处何方”的问题(vbase_offset)
在 B 和 C 的视图里,必须包含 vbase_offset。
因为在虚继承中,A 的位置是不固定的(由最底层的派生类 D 决定)。当 B 的成员函数想访问 A 的成员时,它不能硬编码偏移量,必须查表(通过 vptr_for_B 找到 vbase_offset),才能定位到 A。
区别二:Thunk 的存在
这是最关键的区别。假设 D 重写了 A 的 vfA:
- 如果你用
D* d; d->vfA(),this指针指向D的头部。 - 如果你用
A* a = &d; a->vfA(),此时this指针指向的是D内部A的子对象位置。
但是,D::vfA() 这个函数的代码是唯一的,它期望的 this 指针通常是 D 的头部。
- A 视图的 vtable 中对应的函数地址是一个 non-virtual adjustment thunk。
- 当你通过
A*调用时,它先执行一小段汇编代码(Thunk):this = this - offset_to_top,然后再跳转到真正的D::vfA()。
区别三:包含的函数集不同
- 主虚表(B 视图) 包含了
D对象最全的接口信息。 - 从属虚表(C 视图) 只包含跟
C相关的接口,且主要目的是为了处理通过C*访问对象时的指针调整。 - 虚基类虚表(A 视图) 它是被“剥离”出来的,因为它在内存中是独立的,它只包含
A自身的虚函数接口。
继承
记住一个“核心逻辑”
“取其更严者”。
在派生类中,成员的访问权限取决于两个权重的“取小值”:
- 基类成员本身的访问权限(Public > Protected > Private)
- 继承方式(Public > Protected > Private)
规则:
- 基类的
private成员: 无论怎么继承,在子类中都是**不可见(Inaccessible)**的。 - 基类的
public/protected成员: 它们在子类中的权限 =min(成员原有权限, 继承权限)。
例子: 基类是
public,继承方式是protected。min(public, protected) = protected。所以该成员在子类中是protected。
总结表
| 基类成员 \ 继承方式 | public 继承 | protected 继承 | private 继承 |
|---|---|---|---|
| public | public | protected | private |
| protected | protected | protected | private |
| private | 不可见 | 不可见 | 不可见 |
- 先给结论: “在 A 中,方法 f 会变成
private权限。” - 解释通用规则: “C++ 的继承权限控制有一个通用原则:基类的
public和protected成员在子类中的访问级别,是由‘成员自身的访问级别’和‘继承方式’中更严格的那一个决定的。” - 补充特殊情况: “但无论哪种继承方式,基类的
private成员在子类中都是不可直接访问的。” - 进阶说明(加分项): “虽然 f 在 A 内部变成了
private,意味着 A 的对象不能直接调用 f,但 A 的内部成员函数仍然可以调用它(因为它是 A 的私有成员)。”
常见的“坑”
(1) 默认继承方式是什么?
- 问题:
class A : B {}和struct A : B {}有什么区别? - 答案:
- 使用
class定义的类,默认继承方式是private。 - 使用
struct定义的类,默认继承方式是public。 - (同理,
class成员默认权限是private,struct是public)。
- 使用
(2) 既然 private 成员不可见,那它在子类内存中存在吗?
- 答案: 存在。基类的所有非静态成员变量都会占用子类对象的内存空间,只是编译器在语法层面限制了子类对其的直接访问。你可以通过指针偏移等非法手段访问,但这是破坏封装性的做法。
(3) 什么时候会用到 private 继承?
- 答案:
private继承表示 “根据某物实现 (Is-implemented-in-terms-of)”,而不是public继承的 “是一个 (Is-a)” 关系。 - 它类似于组合 (Composition),但当你需要访问基类的
protected成员或重写虚函数时,private继承比组合更有用。
(4) 向上转型(Upcasting)的限制
- 核心知识点: 只有
public继承 允许在外层代码中将Derived*安全地隐式转换为Base*。 - 如果是
private或protected继承,除非在派生类内部,否则编译器会报错(无法转换),因为这破坏了“接口对外不可见”的原则。
重载 重写和隐藏
| 特性 | 重载 (Overload) | 重写/覆盖 (Override) | 隐藏/重定义 (Hide/Overwrite) |
|---|---|---|---|
| 范围 | 同一个类中 | 基类与派生类之间 | 基类与派生类之间 |
| 函数名 | 相同 | 相同 | 相同 |
| 参数列表 | 必须不同 | 必须相同 | 不重要 (参数同或不同都会隐藏) |
| virtual | 无关 | 基类必须有 virtual | 基类必须没有 virtual |
| 返回类型 | 无关 | 必须相同 (协变除外) | 无关 |
特别强调“隐藏 (Hide)”:
只要子类定义了与父类同名的函数(无论参数是否相同),父类的同名函数在子类作用域内就被“遮蔽”了。除非使用 Base::func() 显式调用。
这里如果通过基类指针调用子类的 被隐藏的函数 实际上调用的是基类版本 因为这是编译器静态决定好的
关于“重载”的两个进阶细节
1. 只有返回值类型不同,能构成重载吗?
答案:不能。
- 原因: C++ 在进行函数调用匹配时,主要看函数签名(函数名 + 参数列表)。
- 深度解释: 假设有两个函数
int f()和void f()。当你调用f();时,编译器无法根据调用上下文确定你想要哪一个返回值,这会产生二义性。返回值不是函数签名的一部分。
2. const 和非 const 能构成重载吗?
答案:可以。
- 底层原理: 成员函数都有一个隐藏的
this指针参数。- 非
const成员函数的this指针类型是:T* const(指向类对象的常量指针)。 const成员函数的this指针类型是:const T* const(指向常量类对象的常量指针)。
- 非
- 匹配规则:
const对象会优先调用const版本的成员函数。- 非
const对象会优先调用非const版本的成员函数(若没有,则可以调用const版本)。
- 典型例子:
std::vector的operator[]就有两个版本,分别返回reference和const_reference。
Modern Cpp
第一模块:内存安全——智能指针 (Smart Pointers)
这是必考题,重点在于“所有权”和“引用计数原理”。
- 回答套路:
- unique_ptr (独占所有权):开销几乎为零(Zero-cost abstraction)。不能拷贝,只能
std::move。用于明确对象生命周期由单一对象管理的场景。 - shared_ptr (共享所有权):内部维护一个控制块(Control Block),包含一个原子操作的引用计数。
- 关键点:引用计数的增减是线程安全的,但指向的对象访问不是。
- weak_ptr (弱引用):不控制对象生命周期,不增加引用计数。
- 核心用途:解决
shared_ptr的循环引用导致内存泄漏的问题;用于缓存场景,访问前需调用lock()检查对象是否存活。
- 核心用途:解决
- unique_ptr (独占所有权):开销几乎为零(Zero-cost abstraction)。不能拷贝,只能
第二模块:性能飞跃——移动语义与右值引用 (Move Semantics)
这是 C++11 的灵魂,重点在于“减少无谓的拷贝”。
- 核心概念:
- 左值 vs 右值:左值有名字、有持久地址;右值通常是临时对象。
- std::move:本质是一个
static_cast,将左值强制转换为右值引用,从而触发移动构造函数。 - 移动构造函数:通过“窃取”原对象的资源(指针置空、交换数据)而不是分配并拷贝,将效率提升几个数量级。
- 陷阱:
- 有名字的右值引用是左值。
- 完美转发 (std::forward):结合模板和万能引用(Universal Reference),保持参数的左/右值属性原样传递。
第三模块:编译优化与语法糖 (Efficiency & Syntax)
展现你对代码整洁度和执行效率的追求。
- auto & decltype:自动类型推导。减少冗长的迭代器声明,但注意不要滥用,以免降低代码可读性。
- nullptr:取代
NULL。NULL本质是0,容易产生重载歧义;nullptr是强类型指针类型。 - constexpr:编译时常量表达式。将计算推迟到编译期,提高运行时性能。
- noexcept:明确函数不抛出异常。有助于编译器生成更高效的代码(特别是
vector扩容时的移动优化)。 - emplace_back vs push_back:
push_back接收对象,可能涉及拷贝/移动。emplace_back接收构造参数,在容器内部原地构造,省去临时对象创建。
第四模块:函数式编程——Lambda 表达式
现代 C++ 异步和回调编程的核心。
- 基本组成:
[捕获列表](参数列表) -> 返回类型 { 函数体 }。 - 捕获方式:
[&]:按引用捕获(小心悬挂指针/生命周期问题)。[=]:按值捕获。[this]:捕获当前类的指针。
- 应用场景:配合
std::sort、std::find_if等算法,或作为异步回调函数。
进阶
- RAII (资源获取即初始化):智能指针是 RAII 思想的完美实践,它将资源的生命周期与局部变量绑定。
- 内存对齐与缓存友好:Modern C++ 配合
std::vector等连续内存容器,比 Java/Python 等依赖 GC 的语言在 Cache Locality 上更有优势。 - 类型安全 (Type Safety):强类型枚举 (
enum class) 解决了普通enum容易隐式转换为整数和作用域污染的问题。
互斥量 与 锁对象
1. 互斥量 (Mutex Types)
互斥量是提供互斥访问的底层机制。它们是实际的“锁”对象。
a. std::mutex (普通互斥量)
- 特性:最简单、最基础的互斥量。
- 行为:
- 一个线程成功锁定后,其他线程尝试锁定它都会被阻塞,直到持有锁的线程解锁。
- 不可重入(Non-reentrant):同一个线程不能在已经持有锁的情况下再次尝试锁定它。如果同一个线程尝试两次锁定
std::mutex而没有中间解锁,会导致未定义行为(通常是死锁)。
- 适用场景:绝大多数需要互斥访问共享资源的场景,例如保护全局变量、共享数据结构等。这是你最常用的互斥量类型。
- 优点:性能开销相对较小。
- 缺点:不支持递归锁定。
b. std::recursive_mutex (递归互斥量)
- 特性:允许同一个线程多次锁定它而不会造成死锁。
- 行为:
- 一个线程可以多次调用
lock(),每次调用都会增加锁的计数器。 - 只有当
unlock()被调用了相同次数后,锁才真正被释放,其他线程才能获取到它。
- 一个线程可以多次调用
- 适用场景:
- 当一个函数内部可能调用另一个也需要该互斥量的函数时。
- 在面向对象设计中,一个对象的方法需要锁定互斥量,并且该方法又调用了该对象的其他方法,而这些其他方法也需要锁定同一个互斥量时。
- 优点:解决了
std::mutex的不可重入问题,代码编写更灵活。 - 缺点:性能开销通常比
std::mutex大;过度使用可能隐藏设计缺陷,因为递归锁定的需求有时表明代码结构可以优化。
c. std::timed_mutex (定时互斥量)
- 特性:在
std::mutex的基础上,增加了尝试在指定时间段内获取锁的能力。 - 行为:
- 提供
try_lock_for()(尝试锁定一段时间) 和try_lock_until()(尝试锁定直到某个时间点) 方法。 - 如果能在规定时间内获得锁,则返回
true,否则返回false。
- 提供
- 适用场景:
- 需要避免长时间阻塞,例如在某些实时性要求较高的系统中,线程不能无限期等待。
- 实现一些复杂的死锁避免策略,可以在获取锁失败后尝试其他操作。
- 优点:提供了非阻塞或限时阻塞的锁获取方式。
- 缺点:性能开销比
std::mutex稍大。
d. std::recursive_timed_mutex (递归定时互斥量)
- 特性:结合了
std::recursive_mutex和std::timed_mutex的特性。 - 行为:既可重入,又支持定时尝试锁定。
- 适用场景:同时需要递归锁定和定时锁定功能的场景,相对较少。
- 优点:功能最全面。
- 缺点:性能开销最大,且复杂性最高。
e. std::shared_mutex (共享互斥量 / 读写锁)
- 特性:允许共享访问(读锁)和独占访问(写锁)。
- 行为:
- 共享锁(读锁):多个线程可以同时获取共享锁。
- 独占锁(写锁):只有一个线程可以获取独占锁,并且在持有独占锁时,任何其他线程(无论是读还是写)都不能获取锁。
- 适用场景:典型的“读多写少”场景,如缓存、配置数据等。在读取操作远多于写入操作时,可以显著提高并发性能。
- 优点:提高了读操作的并发性。
- 缺点:相对于
std::mutex实现更复杂,开销也稍大。
2. 锁守卫 (Lock Guards)
锁守卫(Lock Guards)是C++中利用**RAII(Resource Acquisition Is Initialization)**原则来管理互斥量生命周期的机制。它们是类,在其构造函数中锁定互斥量,在其析构函数中自动解锁互斥量,从而防止忘记解锁导致死锁。
a. std::lock_guard<MutexType> (简单锁守卫)
- 特性:最简单的RAII锁守卫。
- 行为:
- 构造时调用
mutex.lock()。 - 析构时调用
mutex.unlock()。 - 不可移动,不可复制。
- 不能手动解锁:一旦构造,它就持有锁直到离开作用域。
- 构造时调用
- 适用场景:几乎所有需要互斥量的地方,用于简单的代码块保护。
- 优点:简单、安全、开销最小。
- 缺点:不提供灵活的锁管理(例如,不能提前解锁,不能尝试非阻塞锁定)。
b. std::unique_lock<MutexType> (独占锁守卫)
- 特性:比
std::lock_guard更灵活的RAII锁守卫,用于管理独占性互斥量(如std::mutex,std::recursive_mutex,std::timed_mutex,std::shared_mutex的写锁)。 - 行为:
- 构造时可以立即锁定,也可以延迟锁定(
std::defer_lock)。 - 可以通过
lock()/unlock()手动控制锁的生命周期。 - 可以通过
try_lock()/try_lock_for()/try_lock_until()尝试非阻塞或定时锁定。 - 可移动,不可复制。
- 可以与
std::condition_variable配合使用。
- 构造时可以立即锁定,也可以延迟锁定(
- 适用场景:
- 需要更精细地控制锁的生命周期(例如,在某个条件满足后立即释放锁)。
- 需要尝试非阻塞或定时获取锁。
- 与条件变量一起使用时(必须使用
unique_lock)。 - 管理
std::shared_mutex的写锁。
- 优点:灵活性高,功能强大。
- 缺点:相对于
std::lock_guard有轻微的性能开销。
c. std::shared_lock<MutexType> (共享锁守卫)
- 特性:用于管理
std::shared_mutex的共享(读)锁。 - 行为:
- 构造时调用
shared_mutex.lock_shared()。 - 析构时调用
shared_mutex.unlock_shared()。 - 可以延迟锁定,也可以手动
lock()/unlock()。 - 支持
try_lock_shared()/try_lock_shared_for()/try_lock_shared_until()。 - 可移动,不可复制。
- 构造时调用
- 适用场景:当使用
std::shared_mutex来实现读写锁时,std::shared_lock用于管理读者的锁。 - 优点:以RAII方式安全地管理读锁,支持灵活的读锁操作。
- 缺点:不能用于独占性互斥量。
d. std::scoped_lock (C++17,多互斥量锁守卫)
- 特性:C++17引入,用于同时锁定一个或多个互斥量,并自动处理死锁问题。
- 行为:
- 构造时可以接受一个或多个互斥量作为参数。
- 它内部使用一种死锁避免算法(例如,通常是按照地址排序来锁定),保证所有互斥量都能被安全地锁定。
- 析构时自动解锁所有持有的互斥量。
- 适用场景:当一个操作需要同时访问多个共享资源,且每个资源都由自己的互斥量保护时。
- 优点:方便、安全地锁定多个互斥量,无需手动处理死锁。
- 缺点:不支持延迟锁定或非阻塞锁定。
总结比较表格
| 特性 / 锁类型 | std::mutex | std::recursive_mutex | std::timed_mutex | std::shared_mutex |
|---|---|---|---|---|
| 可重入性 | 否 | 是 | 否 | 否 (仅写锁) |
| 定时锁定 | 否 | 否 | 是 | 是 |
| 共享/独占 | 独占 | 独占 | 独占 | 共享 + 独占 |
| 性能开销 | 低 | 中 | 中 | 中等 |
| 使用守卫 | lock_guard, unique_lock, scoped_lock | lock_guard, unique_lock, scoped_lock | unique_lock | unique_lock (写), shared_lock (读), scoped_lock |
| 典型用途 | 多数互斥场景 | 递归调用场景 | 避免无限期等待 | 读多写少场景 |
如何选择合适的锁?
- 首先考虑
std::mutex和std::lock_guard:这是最简单、最安全、开销最小的组合,适用于绝大多数简单的互斥场景。 - 如果需要高级功能(如条件变量、提前解锁、非阻塞尝试锁定):使用
std::unique_lock配合std::mutex。 - 如果需要读写分离,且读操作远多于写操作:使用
std::shared_mutex,读操作用std::shared_lock,写操作用std::unique_lock。 - 如果一个函数可能递归调用自身或调用其他也需要同一锁的函数:考虑
std::recursive_mutex,但也要反思设计是否可以避免递归锁。 - 如果需要避免长时间阻塞,或者实现更复杂的死锁避免策略:考虑
std::timed_mutex或std::unique_lock配合try_lock_for/until。 - 如果需要同时锁定多个互斥量,并且希望自动处理死锁:使用
std::scoped_lock(C++17)。