| layout | post |
|---|---|
| title | 第196期 |
公众号
点击「查看原文」跳转到 GitHub 上对应文件,链接就可以点击了
qq群 753792291 答疑在这里
欢迎投稿,推荐或自荐文章/软件/资源等,评论区留言
本期文章由 机械工业出版社 赞助 ,他们送了我好多书,在此表示感谢
标准委员会动态/ide/编译器信息放在这里
编译器信息最新动态推荐关注hellogcc公众号 本周更新 2025-01-08 第288期
Predrag Gruevski写的经典文章(PVS-Studio转载),列了43条程序员对UB的"错觉",逐条打脸
首先搞清三个概念:
程序行为分三个桶,不是两个:
- Specification-defined:语言标准定义了会发生什么,占绝大部分
- Implementation-defined:由编译器/OS/硬件定义,比如
char到底几位 - Undefined behavior:什么都可能发生,没有任何保证
关于"什么时候触发UB"的错觉:
- UB只在-O2/O3才会触发 — 错
- 关掉优化用-O0就没UB了 — 错
- 加调试符号就安全 — 错
- 在调试器下跑就没UB了 — 错
- 好吧有UB,但代码还是会"做正确的事" — 错
- 最多崩溃(SIGSEGV)— 错
- 最多崩溃或死循环 — 错
关于"UB会不会执行奇怪的代码":
- 至少不会跑到程序中其他不相干的代码 — 错
- 至少不会执行程序中理论上不可达的代码 — 错
关于"UB的影响范围"的错觉:
- 之前"正常工作"的UB代码,下次还能正常工作 — 不保证
- UB的影响至少局限于使用了UB值的代码 — 错
- 至少局限于同一个编译单元 — 错
- 至少只影响UB之后的代码 — 大错特错! UB可以"时间旅行",编译器可以基于"不存在UB"的假设优化UB之前的代码
关于"可能后果"的错觉:
17-24. 至少不会损坏内存/堆/栈/栈帧/CPU状态 — 都不保证 28. 至少不会把硬盘擦了 — 不保证(虽然不太可能) 29. 至少不会损坏硬件 — 不保证
"之前好好的"系列:
31-36. 不改代码重新编译还能好好工作吗?用同样编译器?同一台机器?同一时间编译?在月食期间献祭一根新内存条?— 统统不保证
关于"自我一致性"的错觉:
37-40. 相同二进制 + 相同输入重复跑,行为一样吗?即使程序是确定性的?即使是单线程?即使不读任何外部数据?— 统统不保证
社区贡献的错觉:
- 调试器里看到的程序状态跟源码是对应的 — 错。UB可以时间旅行,导致调试器里的变量值和代码逻辑对不上
- UB纯粹是运行时现象 — 错。C++的ODR(One Definition Rule)违规就是编译期/链接期UB,编译器甚至不需要报错就能造成混乱
最后一条特别假设:
"如果程序编译没报错就没有UB" — 在C/C++中100%是错的。编译器没有义务检测UB。在Rust中,只要不用unsafe,编译通过基本就没有UB — 这是Rust社区付出巨大努力的成果
**核心观点:**编译器的保证列表是空的。一旦有UB,所有行为都是合规的。不管你觉得多离谱
Rhidian De Wit用最小实现讲清楚C++20协程的完整组成,从回调地狱到协程的优雅转变
背景设定:
假设你有个嵌入式系统要和10个硬件板通信,每个板操作耗时约1秒。同步阻塞?启动要10秒,用户会暴动。用线程?线程创建开销大(Linux默认每个线程2MB栈内存),还有竞态条件和死锁。用Promise的.then()回调?代码一嵌套起来就地狱了:
MySocketType socket{};
socket.connect("MyServer:1234").then(
[&socket]() {
socket.send("FirstPartOfData").then(
[]() {
socket.send("SecondPartOfData").then(
); // and many more ...
}
).catch(
[](std::runtime_error const & ex) {
std::cerr << "Error sending data to server: " << ex.what() << "\n";
}
);
}
).catch(
[](std::runtime_error const & ex) {
std::cerr << "Creating connection failed: " << ex.what() << "\n";
}
);用协程重写,彻底消除嵌套,写出来的异步代码看起来像同步的:
MySocketType socket{};
try {
co_await socket.connect("MyServer:1234");
} catch (std::runtime_error const & ex) {
std::cerr << "Creating connection failed: " << ex.what() << "\n";
}
try {
co_await socket.send("FirstPartOfData);
co_await socket.send("SecondPartOfData");
// and more!
} catch (std::runtime_error const & ex) {
std::cerr << "Error sending data to server: " << ex.what() << "\n";
}最小Promise/Awaiter实现:
Promise代表异步工作的状态 — 存一个布尔标志和回调函数。Awaiter负责检查是否就绪(await_ready)、挂起协程(await_suspend中注册回调,Promise完成时调用handle.resume()恢复协程)、以及恢复后的处理(await_resume)。operator co_await把两者连接起来:
class Promise {
private:
bool m_isReady;
std::function<void()> m_callback;
public:
Promise() = default;
bool IsReady() const {
return m_isReady;
}
void AddCallback(std::function<void()> cb) {
m_callback = std::move(cb);
}
void Set() {
// we can only execute a Promise once
if (m_isReady) return;
m_isReady = true;
m_callback();
}
Awaiter operator co_await() {
return Awaiter{ *this };
}
};
class Awaiter {
private:
Promise & m_promise;
public:
Awaiter(Promise & promise) : m_promise(promise) {}
bool await_ready() {
return m_promise.IsReady();
}
void await_suspend(std::coroutine_handle<> handle) {
m_promise.AddCallback([handle]() {
handle.resume();
});
}
void await_resume() {}
};promise_type挂载协程语义:
通过特化coroutine_traits,告诉编译器"返回Promise类型的函数就是协程"。initial_suspend/final_suspend返回suspend_never表示eager模式(立即执行),返回suspend_always就是lazy模式。return_void()在协程结束时触发Promise的Set(通知等待者),unhandled_exception()重新抛出未捕获的异常:
template<typename ... Args>
struct std::coroutine_traits<Promise, Args...> {
struct promise_type {
Promise promise; // The Promise object associated with our coroutine
Promise get_return_object() {
return promise;
}
std::suspend_never initial_suspend() noexcept { return {}; }
std::suspend_never final_suspend() noexcept { return {}; }
void return_void() {
promise.Set();
}
void unhandled_exception() {
std::rethrow_exception(std::current_exception());
}
};
};**最后的建议:**实际做异步工作需要事件循环或线程池,作者推荐Boost.Asio(功能完整但不太友好)和cppcoro(更易用),不要自己造轮子
SereneDB用RocksDB做存储引擎,在ClickBench数据集(120列、650MB、约100万行的裁剪版)上从180秒优化到7.8秒的完整路径
SereneDB的列式存储方案:
RocksDB本身只是KV存储,SereneDB通过复合key (table_id, column_id, primary_key) 实现列式存储。同一列的数据自然在RocksDB中连续排列,天然适合列扫描
优化路径(每步都有火焰图验证):
- Transaction Put → SST Writer(180s → 19.5s):
Transaction Put每次插入都要锁key、排序,120列的场景下开销爆炸。改用SST Writer直接写SST文件,每列一个SST,后续compaction时合并 - 关掉过滤器和压缩(19.5s → 14.3s):火焰图发现
Standard128RibbonBitsBuilder(类Bloom filter)吃了20%CPU,LZ4压缩也在热路径上。这两者在导入阶段不需要,compaction时会重建 - fast_float替换sscanf(14.3s → 12s,16%提速):
fast_float::parse_options options{
fast_float::chars_format::general |
fast_float::chars_format::skip_white_space
};
auto [parseEnd, ec] = fast_float::from_chars_advanced(ptr, end, v, options);- std::string → vector<char>(12s → 10.6s,12%提速):热路径中频繁的单字节
append调用,std::string每次都要维护null terminator,换成vector<char>直接把字符写入次数减半:
while (true) {
auto v = th.getByteOptimized(delim);
if (!th.isNone(delim)) {
break;
}
th.ownedString_.append(1, static_cast<char>(v));
}- 去掉热路径的运行期检查(10.6s → 8.7s,18%提速):
rocksdb::SstFileWriter::Rep::AddImpl里一堆key有序性检查和虚函数调用的status方法(只读一个atomic_bool但用了memory_order_relaxed),火焰图显示这些检查吃了20%CPU。改成debug-only assert,虚函数改成编译期static_cast - 消除key的隐藏拷贝(8.7s → 7.8s,10%提速):每行每列都要构建key并调用
ikey.Set(key, sn, vt),暗含一次字符串拷贝。120列 × 100万行 = 1.2亿次分配。改成预创建key复用
Key takeaways:
- 避免热路径中的虚函数
- 别不必要地拷贝字符串
- 运行期检查能改assert就改assert
**结论:**火焰图定位 + 每步小改,总共23倍加速(180s → 7.8s)。不要害怕改成熟项目的代码(包括RocksDB这种),仔细测量+精准修改就能带来巨大收益
Andreas Fertig上个月写了篇Singleton done right in C++,收到大量评论质疑:为什么把拷贝/移动构造放到private里并用=default?直接=delete不好吗?这篇是回应
原始代码(引发争论):
先看引起争议的Logger单例:
class Logger {
Logger() = default;
Logger(const Logger&) = default;
Logger(Logger&&) = default;
Logger& operator=(const Logger&) = default;
Logger& operator=(Logger&&) = default;
public:
static Logger& Instance() {
static Logger theOneAndOnlyLogger{};
return theOneAndOnlyLogger;
}
};作者承认,出于"best by default"的精神,现在他会改成=delete + public。但接下来解释了为什么有人需要private + default
真正的原因 - ConfigManager的Reset():
看这个有Reset()方法的ConfigManager,Reset()创建一个新的默认构造对象然后move到this,这样对象就回到了默认状态。要做到这一点,move操作必须在类内部可用:
class ConfigManager {
std::unordered_map<std::string, std::string> mConfig{};
ConfigManager() = default;
ConfigManager(const ConfigManager&) = default;
ConfigManager(ConfigManager&&) = default;
ConfigManager& operator=(const ConfigManager&) = default;
ConfigManager& operator=(ConfigManager&&) = default;
public:
void Reset() {
ConfigManager fresh; // 创建默认构造对象
*this = std::move(fresh); // move赋值到this
}
// Get(), Set() 等其他方法...
};**为什么不直接=delete?**因为Reset()通过move赋值一个新构造的对象来重置状态。用move而不是手动清理每个成员,可以保证Reset后对象一定处于默认构造态,不需要碰析构逻辑,也不会漏掉新增的成员变量
用swap还是move取决于你对异常的态度:move失败程序终止(简单粗暴),swap留了恢复的余地
总结:=delete确实是更好的默认选择。但当类内部需要借用拷贝/移动语义(比如Reset、swap)时,private + default是合理的
Daniel Lemire对比三种hex编码方案的性能,起因是Skovoroda给Node.js提议用算术版本替换查表版本
三种方案:
- 查表法 - Node.js目前使用的方法,16字符查找表
- 算术nibble法 - Skovoroda提议的纯算术运算,无查表
- 手写SIMD(NEON) - 用ARM NEON指令手写向量化
在10000随机字节上的基准测试:
| 方案 | 吞吐量 | 每字节指令数 |
|---|---|---|
| 查表 | 3.1 GB/s | 9 |
| 算术nibble | 23 GB/s | 0.75 |
| NEON手写 | 42 GB/s | 0.69 |
算术版本为什么比查表快近8倍?因为查表有内存依赖,阻碍了编译器的自动向量化。纯算术操作没有这个问题,编译器可以轻松用SIMD指令一次处理多个字节
查表版本(Node.js当前用的):
static const char hex[] = "0123456789abcdef";
for (size_t i = 0, k = 0; k < dlen; i += 1, k += 2) {
uint8_t val = src[i];
dst[k + 0] = hex[val >> 4];
dst[k + 1] = hex[val & 15];
}算术nibble版本(Skovoroda提议的):
关键trick:x + '0'处理0-9的情况,(x > 9) * 39再加39跳到'a'-'f'的ASCII区间。纯算术,无分支,编译器一看就能向量化:
char nibble(uint8_t x) { return x + '0' + ((x > 9) * 39); }
for (size_t i = 0, k = 0; k < dlen; i += 1, k += 2) {
uint8_t val = src[i];
dst[k + 0] = nibble(val >> 4);
dst[k + 1] = nibble(val & 15);
}NEON手写向量化:
一次处理32字节,用vqtbl1q_u8做NEON表查找+vst2q_u8做交织写入(评论区有人建议用ST2替代ZIP,代码更干净且性能不变):
size_t maxv = (slen - (slen%32));
for (; i < maxv; i += 32) {
uint8x16_t val1 = vld1q_u8((uint8_t*)src + i);
uint8x16_t val2 = vld1q_u8((uint8_t*)src + i + 16);
uint8x16_t high1 = vshrq_n_u8(val1, 4);
uint8x16_t low1 = vandq_u8(val1, vdupq_n_u8(15));
uint8x16_t high2 = vshrq_n_u8(val2, 4);
uint8x16_t low2 = vandq_u8(val2, vdupq_n_u8(15));
uint8x16_t high_chars1 = vqtbl1q_u8(table, high1);
uint8x16_t low_chars1 = vqtbl1q_u8(table, low1);
uint8x16_t high_chars2 = vqtbl1q_u8(table, high2);
uint8x16_t low_chars2 = vqtbl1q_u8(table, low2);
uint8x16x2_t zipped1 = {high_chars1, low_chars1};
uint8x16x2_t zipped2 = {high_chars2, low_chars2};
vst2q_u8((uint8_t*)dst + i*2, zipped1);
vst2q_u8((uint8_t*)dst + i*2 + 32, zipped2);
}**结论:**直觉是很差的性能指导。查表看起来应该更快(O(1)嘛),但实际上纯算术版本对编译器更友好,自动向量化后快了一个数量级。如果用x64的AVX-512手写向量化,性能还能更高
Daniel Lemire介绍他们最新发表的论文Converting Binary Floating-Point Numbers to Shortest Decimal Strings的研究成果
算法演进:
从1990年Steele和White的Dragon4算法开始,到Grisu3、Ryū(2018)、Schubfach、Grisu-Exact、Dragonbox,三十年间性能提升约10倍 — 相当于每年8%的纯算法+实现改进
转换的两个步骤:
- 数字计算:把浮点数拆成有效数字和10的幂(比如π →
31415927和-7) - 字符串生成:把数字写成ASCII字符串,放小数点,必要时切换科学计数法
当前状况:
- 最快的实现:Dragonbox(Jeon)和Schubfach(Giulietti)并列,Ryū(Adams)紧随其后
- 指令效率:高效算法每次转换约200-350条指令。
std::to_chars(Linux)用了多达2倍的指令,{fmt}也略多一些,仍有改进空间 - 字符串生成阶段现在占20%-35%的总成本 — 数字计算已经很快了,瓶颈转移到了"写ASCII"这步
有趣的发现:
没有任何现有实现能总是生成最短字符串。比如std::to_chars把0.00011渲染成0.00011(7个字符),但科学计数法1.1e-4更短。不过按惯例指数要补到两位(1.1e-04),这个trick就不总管用了
评论区Victor Zverovich({fmt}作者)提到了zmij新算法即将集成到{fmt},替代当前的Dragonbox with compact cache
**结论:**浮点转字符串这个看似简单的问题,算法和实现细节仍能带来数量级提升
Sandor Dargo在Meeting C++ 2025上和人讨论的一个问题:有个控制硬件的类,需要一个map<int, string>存硬件模块ID和名称。这个map运行时不会变,理论上应该是const。但由于硬约束,不能在构造时初始化,只能后续通过init()设置
**问题:**怎么在类型系统中表达"初始化前可写,初始化后只读"?
方案1 - 私有非const成员:
最简单的做法。_available_modules是private的,不通过getter暴露,初始化后不再修改 — 但这靠的是纪律和约定,类型系统没有强制保证:
std::map<int, std::string> list_available_modules() {
return { {1, "widget"}, {2, "gadget"}, {42, "bar"} };
}
class MyHardwareController {
public:
void init() { _available_modules = list_available_modules(); }
std::optional<std::string> get_module_name(int id) const {
if (_available_modules.contains(id)) {
return _available_modules.at(id);
}
return std::nullopt;
}
private:
std::map<int, std::string> _available_modules;
};方案2 - optional<const map>:
语义非常清晰:可能还没有map,但一旦有了就不可变。optional::emplace()设置值,但底层数据不可修改。加_already_initialized标志防止重复初始化:
class MyHardwareController {
public:
void init() {
if (_already_initialized) {
throw std::logic_error{"Object already initialized"};
}
_available_modules.emplace(list_available_modules());
_already_initialized = true;
}
std::optional<std::string> get_module_name(int id) const {
if (_available_modules->contains(id)) {
return _available_modules->at(id);
}
return std::nullopt;
}
private:
bool _already_initialized {false};
std::optional<std::map<int, std::string>> _available_modules;
};方案3 - 专用Registry类:
把"只初始化一次"的语义完全封装到ModuleRegistry中。调用方不可能部分修改或误用。还可以delete赋值运算符让替换变得不可能(但别忘了rule of five):
class ModuleRegistry {
public:
void set_once(std::map<int, std::string> m) {
if (modules) {
throw std::logic_error("Modules already initialized");
}
modules = std::move(m);
}
bool is_initialized() const { return modules.has_value(); }
const std::map<int, std::string>& get_modules() const {
if (!modules) {
throw std::logic_error("Modules not initialized yet");
}
return modules.value();
}
private:
std::optional<std::map<int, std::string>> modules;
};
class MyHardwareController {
public:
void init() {
if (_module_registry.is_initialized()) {
throw std::logic_error{"Module registry already initialized"};
}
_module_registry.set_once(list_available_modules());
}
std::optional<std::string> get_module_name(int id) const {
if (_module_registry.is_initialized() && _module_registry.get_modules().contains(id)) {
return _module_registry.get_modules().at(id);
}
return std::nullopt;
}
private:
ModuleRegistry _module_registry;
};**总结:**即使const不能在构造时设置,也能通过类型设计表达不可变意图。核心思想 — 通过类型表达意图 — 是现代C++最强大的工具之一
Daniel Lemire用实际基准测试量化函数调用的开销
核心观点:函数调用本身不贵(跳转+保存寄存器),真正的成本在于阻止编译器做跨函数优化,特别是自动向量化
场景1 - 极小函数:内联带来20倍加速
一个简单的加法函数,不内联时编译器无法向量化循环,内联后可以用SIMD:
int add(int x, int y) {
return x + y;
}
int add3(int x, int y, int z) {
return add(add(x, y), z);
}内联后等价于:
int add3(int x, int y, int z) {
return x + y + z;
}在循环中用add对数组求和,差异巨大:
for (int x : numbers) {
sum = add(sum, x);
}不内联时的汇编(M4/LLVM)—— 每次加法6条指令,约3个周期:
ldr w1, [x19], #0x4
bl 0x100021740 ; add(int, int)
cmp x19, x20
b.ne 0x100001368 ; <+28>add函数本身只有两条指令:
add w0, w1, w0
ret内联后 —— 编译器自动向量化,SIMD一次处理16个整数,只需8条指令:
ldp q4, q5, [x12, #-0x20]
ldp q6, q7, [x12], #0x40
add.4s v0, v4, v0
add.4s v1, v5, v1
add.4s v2, v6, v2
add.4s v3, v7, v3
subs x13, x13, #0x10
b.ne 0x1000013fc ; <+104>每个整数从6条指令降到0.5条指令,加速超过20倍
| 版本 | 每元素纳秒 |
|---|---|
| 不内联 | 0.7 |
| 内联 | 0.03 |
| 内联(禁SIMD) | 0.07 |
即使禁用SIMD,内联也快10倍
场景2 - 较重的函数:内联收益取决于输入规模
一个统计空格数的函数:
size_t count_spaces(std::string_view sv) {
size_t count = 0;
for (char c : sv) {
if (c == ' ') ++count;
}
return count;
}长字符串(1000个字符):内联反而略慢(可能因为指令缓存压力)
| 版本 | 每次调用纳秒 |
|---|---|
| 不内联 | 111 |
| 内联 | 115 |
短字符串(0-6个字符):内联快了60%
| 版本 | 每次调用纳秒 |
|---|---|
| 不内联 | 1.6 |
| 内联 | 1.0 |
Takeaways:
- 短而简单的函数在性能关键路径上一定要内联,收益可以很惊人
- 对于可快可慢的函数(如字符串处理),是否内联取决于输入规模。字符串长度可能决定了内联是否必要
UB系列:
John Regehr 的经典三部曲,深入讲解 C/C++ 中未定义行为的方方面面
-
A Guide to Undefined Behavior in C and C++, Part 1
从抽象机模型出发,解释 UB 存在的原因。将函数分为三类:Type 1(对所有输入都有定义行为)、Type 2(对部分输入有 UB 但不依赖 UB 实现)、Type 3(依赖 UB 实现的"功能")
Type 2函数的经典例子——看起来对但其实有UB的除法:
int32_t unsafe_div_int32_t(int32_t a, int32_t b) { return a / b; // UB when b==0 or (a==INT_MIN && b==-1) }
编译器会利用"UB不会发生"的假设做激进推导,比如这个函数永远返回1:
int stupid(int a) { return (a+1) > a; // 编译器推导:有符号溢出是UB -> 不会发生 -> (a+1) > a 恒为true -> 返回1 }
真实案例——Linux内核
tun驱动被GCC优化掉空指针检查:struct sock *sk = tun->sk; // (1)解引用tun if (!tun) // (2)检查tun是否为NULL return POLLERR; // 编译器推导:(1)已经解引用了tun,说明tun != NULL(否则是UB) // 所以(2)的检查永远为false -> 整个if分支被删除
-
用随机测试工具对 LLVM/Clang 中间表示进行测试,找出整数相关的 UB。经典例子——
negate.c演示编译器的"双重思想":#include <limits.h> #include <stdio.h> int main(void) { int x = INT_MIN; int y = -x; // UB: -INT_MIN溢出 if (y > 0) printf("positive\n"); else printf("nonpositive\n"); }
GCC
-O1输出positive,-O2输出nonpositive——同一个二进制的同一个变量y,编译器在不同优化级别下做出了相反的判断。这就是UB的"时间旅行"效应还找到了LLVM中的真实bug:
-V当V = INT64_MIN时溢出,1u << 63和1 << 32移位超出位宽等 -
讨论副作用与 UB 的交互。经典例子——
volatile写入和除零的重排序:int foo(int x) { volatile int r = x; // volatile写入(副作用) return 1 / (x - 1234); }
当
x == 1234时,GCC/Clang/Intel CC生成的汇编都把divl(除法指令)排在volatile写入之前:foo: movl %edi, %ecx movl $1, %eax subl $1234, %ecx cltd idivl %ecx # 除法在前 → 先崩溃 movl %edi, -4(%rsp) # volatile写入在后 → 永远执行不到 ret
更糟糕的——连
printf调试都看不到:void bar() { printf("hello!\n"); } void foo3(int x) { bar(); // 你期望至少能看到这行输出 int y = 1 / (x - 1234); // 但编译器在-O1下把崩溃移到了bar()之前 }
CompCert(经过形式化验证的C编译器)对此的处理方式是保证副作用的顺序不被重排,即使存在UB
PVS-Studio 的 12 篇系列文章,从 C++ 程序员实战角度全面梳理各类 UB 陷阱
-
Part 1 — 什么是 UB、窄化转换、隐式类型转换
隐式类型转换:看起来人畜无害的
average函数,结果却大错特错:int average(const std::vector<int>& v) { if (v.empty()) return 0; return std::accumulate(v.begin(), v.end(), 0) / v.size(); // v.size()是unsigned, -3被隐式转换成巨大的无符号数 } average({-1,-1,-1}); // 期望-1,实际输出1431655764
C 和 C++ 的
abs是不同函数——隐式转换把double截断了:#include <cmath> std::cout << abs(3.5) << "\n"; // C库函数,参数是long → 输出3 std::cout << std::abs(3.5); // C++重载版本 → 输出3.5
std::string的+=接受char,整数被隐式转换——编译通过但结果完全不对:std::string s; s += 48; // 隐式转成char('0'),OK s += 1000; // signed char平台上溢出 → UB s += 49.5; // double → char 隐式截断
更隐蔽的:泛型
accumulate意外接受了string做累加器,浮点数被隐式转char累加——编译通过,结果是空串:template <class Range, class Acc> auto accumulate(Range&& r, Acc acc) requires(requires(){ {acc += *std::begin(r)}; }) { for (auto&& x : r) acc += x; return acc; } std::vector<double> v{0.5, 0.7, 0.1}; auto res = accumulate(v, std::string{}); // 编译通过!结果是 ""
隐式转换链:
auto&&参数类型可能和你预期完全相反——int变float、float变int:void f(float&& x) { std::cout << "float " << x << "\n"; } void f(int&& x) { std::cout << "int " << x << "\n"; } void g(auto&& v) { f(v); } // C++20 g(2); // 输出 "float 2" —— int& 不能绑到 int&&,走 int→float→float&& 链 g(1.f); // 输出 "int 1" —— 同理 float& → float → int → int&&
自定义类型也一样——
operator bool拦截了本该走MyMovableStruct&&的重载:struct MyMovableStruct { operator bool() { return !data.empty(); } std::string data; }; void consume(MyMovableStruct&& x) { std::cout << "MyStruct\n"; } void consume(bool x) { std::cout << "bool " << x << "\n"; } void g(auto&& v) { consume(v); } g(MyMovableStruct{"hello"}); // 输出 "bool 1" 而不是 "MyStruct"
防御手段:用模板
=delete禁止隐式转换:int only_ints(int x) { return x; } template <class T> auto only_ints(T x) = delete; only_ints(2); // OK only_ints('1'); // 编译错误 only_ints(2.5); // 编译错误: explicitly deleted
-
Part 2 — 有符号整数溢出、浮点数陷阱、整数提升、char 的符号问题
有符号溢出:编译器认为"有符号数不会溢出",所以你的溢出检查可以被直接删除:
if (x > 0 && a > 0 && x + a <= 0) { // handle overflow } // 编译器推导:正数+正数不可能<=0(因为溢出是UB,不会发生) // 整个if被删除。汇编中cin>>y后直接 xor eax,eax; ret
hash_code函数永不返回负数——编译器的推导链:h初始为正 → 没有溢出(UB不发生)→ h永远为正 →if(h<0)永远为false→ 检查被删除:int hash_code(std::string s) { int h = 13; for (char c : s) { h += h * 27752 + c; } if (h < 0) h += std::numeric_limits<int>::max(); return h; // 实际运行时溢出后返回负数 }
有限循环变无限循环——编译器把乘法优化成递增后改变了循环条件:
char buf[50] = "y"; for (int j = 0; j < 9; ++j) { std::cout << (j * 0x20000001) << std::endl; if (buf[0] == 'x') break; } // 优化后等价于 for(j=0; j < 9*0x20000001; j += 0x20000001) // 9*0x20000001 > INT_MAX → 条件恒为true → 无限循环
反向案例——本该溢出崩溃却"正常"运行。编译器用64位寄存器存32位
index,溢出永远不会发生:size_t Count = size_t(5) * 1024 * 1024 * 1024; // 5 GB char *array = (char *)malloc(Count); memset(array, 0, Count); int index = 0; for (size_t i = 0; i != Count; i++) array[index++] = char(i) | 1; // 32位index溢出是UB,编译器用64位寄存器 → 碰巧全部填完
有符号和无符号的求和优化差异巨大——signed版编译器直接用公式
$N(N+1)(2N+1)/6$ ,unsigned版只能老老实实循环:// signed: 编译器直接展开成公式,没有循环 int64_t summate_squares(int64_t n) { int64_t sum = 0; for (int64_t i = 1; i <= n; ++i) sum += i * i; return sum; } // unsigned: 编译器无法优化(溢出有定义,必须处理) uint64_t usummate_squares(uint64_t n) { uint64_t sum = 0; for (uint64_t i = 1; i <= n; ++i) sum += i * i; return sum; }
整数提升:
uint16_t的运算结果不是unsigned而是int,因为整数提升:uint16_t x = 1, y = 2; auto a = x - y; // a 是 int,不是 uint16_t! auto b = x + y; // b 也是 int
-1 < v.size()返回false——signed 和 unsigned 比较时隐式转换:std::vector<int> v = {1}; auto idx = -1; if (idx < v.size()) std::cout << "less!\n"; else std::cout << "oops!\n"; // 输出oops! (-1转成巨大正数)
unsigned short * unsigned short溢出——整数提升把两个uint16_t变成int做乘法:constexpr std::uint16_t IntegerPromotionUB(std::uint16_t x) { x *= x; // 提升为int做乘法,65535*65535溢出int → UB! return x; } static_assert(IntegerPromotionUB(65535) == 1); // 编译失败
char 的符号陷阱:
char在 x86 上通常是signed,128 变成 -128,做数组下标时符号扩展为$2^{64}-128$ :struct CharTable { std::array<bool, 256> _is_whitespace{}; bool is_whitespace(char c) const { return _is_whitespace[c]; // c=128 → c=-128(signed) → size_t(18446744073709551488) → 越界! } };
符号扩展在不同宽度转换时表现不同——直接转和两步转结果不一样:
int8_t c = -5; uint16_t c_direct = c; // 65531(符号扩展) uint16_t c_two = static_cast<uint8_t>(c); // 251(先截断再零扩展) std::cout << c_direct << " != " << c_two; // "65531 != 251"
浮点数陷阱:浮点数做模板特化参数时
+0.0和-0.0可能匹配不同特化,但static_assert认为它们相等:template <double x> struct X { static constexpr double val = x; }; template <> struct X<+0.> { static constexpr double val = 1.0; }; template <> struct X<-0.> { static constexpr double val = -1.0; }; constexpr double a = -3.0, b = 3.0; std::cout << X<a + b>::val << "\n"; // +1 std::cout << X<-1.0 * (a + b)>::val << "\n"; // -1 static_assert(a + b == -1.0 * (a + b)); // OK! 但上面输出不同
大整数转
float丢精度——相邻整数映射到同一个浮点值:static_assert( static_cast<float>(std::numeric_limits<int>::max()) == static_cast<float>(static_cast<long long>( std::numeric_limits<int>::max()) + 1) // OK! 精度丢了 );
-
Part 3 — 悬垂引用、
string_view、range-for 陷阱、自引用、vector迭代器失效悬垂引用:
std::min返回const T&,临时对象出了函数就死了:const int x = 11; auto&& y = std::min(x, 10); // 10是临时对象,出了min就死了 std::cout << y << "\n"; // UB! GCC -O3 输出0
传引用参数 + vector 扩容 = 悬垂引用:
template <class T> void append_n_copies(std::vector<T>* elements, const T& x, int N) { for (int i = 0; i < N; ++i) elements->push_back(x); // push_back可能realloc → x悬空! } std::vector<int> v; v.push_back(10); append_n_copies(&v, v.front(), 5); // UB!
Builder 链式调用返回引用——临时对象死了,引用悬空:
class VectorBuilder { std::vector<int> v; public: VectorBuilder& Append(int x) { v.push_back(x); return *this; } const std::vector<int>& GetVector() { return v; } }; auto&& v = VectorBuilder{}.Append(1).Append(2).GetVector(); // 悬垂引用!
operator+=在临时对象上的坑——编译器直接返回0:struct Min { int x; Min& operator+=(const Min& other) { x = std::min(x, other.x); return *this; } }; auto&& m = (Min{5} += Min{10}); return m.x; // 悬垂引用,编译器生成 xor eax,eax; ret
string_view是const&的变种——不会延长临时对象生命周期:auto GetString = []() -> std::string { return "hello"; }; std::string_view sv = GetString(); // 临时string死了 std::cout << sv << "\n"; // 悬垂引用!
common_prefix返回string_view到临时string:std::string_view common_prefix(std::string_view a, std::string_view b) { auto len = std::min(a.size(), b.size()); size_t common_count = 0; for (; common_count < len; ++common_count) if (a[common_count] != b[common_count]) break; return a.substr(0, common_count); } auto common = common_prefix("hello"s + "World111...", "helloW"); std::cout << common << "\n"; // 悬垂引用!第一个参数是临时string
Initials()返回string_view到substr()的临时拷贝:struct Person { std::string name; std::string_view Initials() const { if (name.length() <= 2) return name; return name.substr(0, 2); // substr返回临时string → 悬垂! } };
range-for 的甜蜜陷阱——从直接访问成员改成调方法就炸了:
// 直接访问成员没问题(临时对象生命周期被延长) for (auto v : MakeShape().vertexes) { ... } // OK // 通过方法返回引用就炸了 for (auto v : MakeShape().Vertexes()) { ... } // UB! 悬垂引用 // 展开后:auto&& container_ = MakeShape().Vertexes(); // MakeShape()的临时对象在分号处就死了
自引用:变量在自己的初始化表达式中已经可见——rename 工具可能制造 UB:
int x = x + 5; // UB! x未初始化 const int max_v = 10; void fun(int y) { const int max_v = [&]{ // 局部max_v遮蔽了全局max_v return std::min(max_v, y); // 引用的是局部(尚未初始化的)max_v → UB }(); }
自引用的合法用途——对象在构造时引用自己:
struct Impl : Iface { explicit Impl(const Iface* other_ = nullptr) : other(other_) {} int method(int x) const override { if (x == 0) return 1; if (other) return x * other->method(x - 1); return 0; } const Iface* other = nullptr; }; Impl impl{&impl}; // 合法!取地址不需要读取值 std::cout << impl.method(5); // 120(阶乘)
vector迭代器失效:在遍历中push_back→ reallocation → 所有引用/迭代器失效:void run_actions(std::vector<Action> actions) { for (auto&& act : actions) { // UB! if (auto new_act = evaluate(act)) actions.push_back(std::move(*new_act)); // realloc破坏迭代器 } }
即使改用下标也要小心——logging 时的引用在
push_back后悬空:for (size_t idx = 0; idx < actions.size(); ++idx) { const auto& act = actions[idx]; if (auto new_act = evaluate(act)) actions.push_back(std::move(*new_act)); std::cerr << act.Id() << "\n"; // UB! push_back可能使act悬空 } // 修复:每次用 actions[idx] 重新访问
-
Part 4 — Lambda 捕获陷阱、
tuple与引用、代理对象(vector<bool>)、use-after-move、生命周期延长Lambda 捕获:按引用捕获 + 逃逸 = 悬垂引用:
auto make_add_n(int n) { return [&](int x) { return x + n; }; // n是悬垂引用! } auto add5 = make_add_n(5); std::cout << add5(5); // UB! GCC输出5, Clang输出1711411576
改成
const int& n参数后编译器不再警告,但问题依然存在:auto make_add_n(const int& n) { return [&](int x) { return x + n; }; // 同样悬垂!但编译器不警告了 } // GCC输出5, Clang输出10 → UB的不同表现
成员函数中捕获
this——临时对象销毁后this悬空:struct Task { int id; std::function<void()> GetNotifier() { return [this]{ std::cout << "notify " << id << "\n"; }; } }; auto notify = Task{5}.GetNotifier(); notify(); // UB! GCC输出"notify 0", Clang输出"notify 29863"
C++20 之前
[=]隐式捕获this指针而不是成员值拷贝:std::function<void()> GetNotifier() { return [=]{ // C++20前:= 捕获this指针,不是id的拷贝! std::cout << "notify " << id << "\n"; }; }
防御手段——用 ref-qualifier 阻止临时对象调用:
struct Task { int id; std::function<void()> GetNotifier() && = delete; // 禁止右值调用 std::function<void()> GetNotifier() & { return [this]{ std::cout << "notify " << id << "\n"; }; } };
tuple的陷阱:make_tuple对reference_wrapper有特殊处理:int x = 5; float y = 6; auto t = std::make_tuple(std::ref(x), std::cref(y), "hello"); // t 的类型是 tuple<int&, const float&, const char*>
forward_as_tuple创建引用元组——临时对象死了引用悬空:int x = 5; auto t = std::forward_as_tuple(x, 6.f, std::move("hello")); // t = tuple<int&, float&&, const char(&&)[6]> std::get<1>(t); // UB! 6.f 临时对象已死
tie返回值逃逸时同样悬空:template <class... T> auto tie_consts(const T&... args) { return std::tie(args...); } auto t = tie_consts(1, 1.f, "hello"); std::cout << std::get<1>(t) << "\n"; // UB! 临时对象已死
Python 风格交换变量——在 C++ 中是 unspecified behavior:
int x = 5, y = 3; std::tie(x, y) = std::tie(y, x); std::cout << x << " " << y; // 5 5 或 3 3,未指定行为
代理对象(
vector<bool>):auto推导出代理类型而不是bool:std::vector<bool> v; v.push_back(false); std::cout << v[0] << " "; // 0 const auto b = v[0]; // b不是bool,是vector<bool>::reference auto c = b; c = true; std::cout << c << " " << b; // "0 1 1",const b的值变了!
pop_last+vector<bool>= 代理对象引用已销毁的内存:template <class T> auto pop_last(std::vector<T>& v) { auto last = std::move(v.back()); // vector<bool>时是代理对象 v.pop_back(); // 删掉最后一个字节,代理对象悬空 return last; // UB!(碰巧因为~bool啥也不做而没崩) }
泛型函数意外接收
vector<bool>::reference,没有默认构造函数 → 崩溃:template <class T> T sum(T a, T b) { T res; // vector<bool>::reference 没有默认构造 → 未定义 res = a + b; // 两个代理对象通过 operator bool → int 求和 → bool return res; } std::vector<bool> v{true, false}; std::cout << sum(v[0], v[1]); // GCC/Clang可能 crash
use-after-move:
UniquePtr的 move-assignment 如果只做 swap 而不清零,原对象可能持有新对象的旧指针:UniquePtr& operator=(UniquePtr&& other) noexcept { std::swap(this->_ptr, other._ptr); // other 现在持有 this 的旧指针 return *this; // other 析构时 delete 旧指针 → OK // 但语义上other不是"空"的! }
vector的 move 行为取决于 allocator 的propagate_on_container_move_assignment——不同标准库实现结果不同:using VectorString = std::vector<std::string, MyAlloc<std::string>>; VectorString v = {"hello", "world", "my"}; VectorString vv; vv = std::move(v); std::cout << v.size() << "\n"; // clang -stdlib=libc++: 输出3(元素被逐个move,v不清空) // clang -stdlib=libstdc++: 输出0(v被清空)
move 后的
string状态是 unspecified——老版本 libc++ 中 SSO 字符串 move 后不清空:void f() { std::string s; for (unsigned i = 0; i < 10; ++i) { s.append(1, static_cast<char>('0' + i)); g(std::move(s)); // s 的状态是 unspecified // 现代实现中 s 会为空,但标准不保证 } }
生命周期延长:子对象的引用延长整个父对象的生命周期:
struct User { std::string name; std::vector<int> tokens; }; User get_user() { return {"Dmitry", {1,2,3,4,5}/*fuck jekyll render*/}; } std::string&& name = get_user().name; // 整个User对象还活着!可以通过地址算术访问tokens(合法但丑陋)
但
std::array的operator[]打破了这个规则(返回引用而非子对象直接访问):struct User { std::array<Name, 2> name; std::vector<int> tokens; }; const std::string& name = get_user().name[1].name; std::cout << name << "\n"; // stack-use-after-scope! 崩溃
C++23 修复了 range-for 中临时对象生命周期问题,但引入了新的死锁可能——Mutex 的 guard 跨循环体存活:
template <class T> struct Mutex { T data; std::mutex _mutex; ... }; Mutex<User> m{\ {\ {1,2,3,4,5}/*fuck jekyll render*/}\
}; for (auto token : m.lock().get().tokens()) { std::cout << token << "\n"; m.lock(); // 第二次lock → 死锁(C++23中guard生命周期被延长) }
- [Part 5](https://pvs-studio.com/en/blog/posts/cpp/1160/) — Most Vexing Parse、非常量的常量、move语义、`enable_if_t` vs `void_t`、遗忘return
**Most Vexing Parse**:C++ 允许在函数内部前向声明函数,导致构造调用和函数声明的语法冲突:
```cpp
Worker w(Timer()); // 不是构造调用!是函数声明:
// 返回Worker、接受"返回Timer的无参函数"的前向声明
std::cout << w; // 函数名隐式转bool,输出1
带参数的版本更难发现——Timer(time_to_work) 中的括号被解释为参数名的括号:
const int time_to_work = 10;
Worker w(Timer(time_to_work)); // 仍然是函数声明!
// time_to_work 是 Timer 类型的参数名
std::cout << w; // 输出 1修复:用 auto w = Worker(Timer()) 或大括号初始化 Worker w{Timer{}/*fuck jekyll render*/}。
非常量的常量:const 对优化的帮助远不如想象。以 const vector& 为例,编译器无法将 size() 提出循环:
using predicate = bool (*)(int);
int count_if(const std::vector<int>& v, predicate p) {
int res = 0;
for (size_t i = 0; i < v.size(); ++i) // 每次迭代都要重新计算 size()!
{
if (p(v[i])) { ++res; } // 因为 p 可能通过全局引用修改 v
}
return res;
}虽然看起来不合理,但确实可以构造出反例:
std::vector<int> global_v = {1};
bool pred(int x) {
if (x == global_v.size()) { global_v.push_back(x); return true; }
else { return false; }
}
int main() { return count_if(global_v, pred); }const 字段 + placement new 交互特别危险。结构体含 const 成员时,复用同一块内存可能读到旧的缓存值:
struct Unit { const int id; int health; };
std::vector<Unit> units;
unit.emplace_back(Unit{1, 2});
std::cout << unit.back().id << " "; // 1
unit.pop_back();
unit.emplace_back(Unit{2, 3});
std::cout << unit.back().id << " "; // 可能输出 1(编译器缓存了 const 值)C++17 引入 std::launder 来解决从"错误来源"指针访问含 const 成员对象的 UB:
using storage = std::aligned_storage_t<sizeof(Unit), alignof(Unit)>;
storage s;
new (&s) Unit{1,2};
// 错误:reinterpret_cast<Unit*>(&s)->id → UB
// 正确:
std::cout << std::launder(reinterpret_cast<Unit*>(&s))->id << "\n";const_cast 消除 const/non-const 成员函数重复代码的常用模式(注意 std::as_const 不能忘):
int& get_for_val_or_abs_val(int val) {
return const_cast<int&>(
std::as_const(*this) // 添加 const 以调用 const 版本
.get_for_val_or_abs_val(val));
}重载的标准库构造函数:同一个容器的不同构造函数行为差异巨大:
using namespace std::string_literals;
std::string s1 { "Modern C++", 3 }; // 从指针取前3个字符 → "Mod"
std::string s2 { "Modern C++"s, 3 }; // 从 string 取下标3开始的子串 → "ern C++"std::string s1 {'H', 3}; // initializer_list → "H\3"(2个字符)
std::string s2 {3, 'H'}; // initializer_list → "\3H"(2个字符)
std::string s3 (3, 'H'); // 重复3次 → "HHH"(3个字符)vector 同理,大括号 vs 小括号完全不同:
std::vector<int> v1 {3, 2}; // v1 == {3, 2},两个元素
std::vector<int> v2 (3, 2); // v2 == {2, 2, 2},三个元素指针隐式转 bool 的坑:
bool array[5] = {true, false, true, false, true};
std::vector<bool> vector {array, array + 5};
std::cout << vector.size() << "\n"; // 输出 2,不是 5!指针被隐式转为 boolMove 语义:std::move 什么都不做,只是类型转换。按值传 unique_ptr 有额外析构开销,但按右值引用传则不会真正 move:
void consume_v1(std::unique_ptr<int> p) {} // 真正 move
void consume_v2(std::unique_ptr<int>&& p) {} // 不 move!
void test_v1() {
auto x = std::make_unique<int>(5);
consume_v1(std::move(x));
assert(!x); // OK,x 已被 move
}
void test_v2() {
auto x = std::make_unique<int>(5);
consume_v2(std::move(x));
assert(!x); // 断言失败!x 没有被 move
}构造函数中 use-after-move 最常见——参数名和成员名相近时容易出错:
struct Person {
Person(std::string first_name, std::string last_name)
: first_name_(std::move(first_name)),
last_name_(std::move(last_name)) {
std::cerr << first_name; // 错误!use-after-move,输出空串
}
std::string first_name_, last_name_;
};自赋值 move 可能导致数据消失——朴素的 remove_if 实现:
template <class T, class P>
void remove_if(std::vector<T>& v, P&& predicate) {
size_t new_size = 0;
for (auto&& x : v) {
if (!predicate(x)) {
v[new_size] = std::move(x); // 当 new_size == 当前索引时,self-move-assignment!
++new_size;
}
}
v.resize(new_size);
}
// std::string 的 self-move 后变为空串 → 所有 name 消失防护措施——检查自赋值:
MyType& operator=(MyType&& other) noexcept {
if (this == std::addressof(other)) return *this;
....
}enable_if_t vs void_t:SFINAE 基础——模板参数代换失败时跳过该模板而非报错:
template <class T>
std::enable_if_t<sizeof(T) <= 8> process(T) {
std::cout << "by value";
}
template <class T>
std::enable_if_t<(sizeof(T) > 8)> process(const T&) {
std::cout << "by ref";
}enable_if 和 enable_if_t 差一个 _t 后缀,混淆后 SFINAE 失效变成 ODR 违规:
// 错误!std::enable_if<false> 是合法类型,SFINAE 不触发
template<class T>
std::enable_if<sizeof(T) <= 8> process(T);
template<class T>
std::enable_if<sizeof(T) > 8> process(const T&);
// → 重复定义同一实体 → UBstd::void_t 看起来能简化 SFINAE 谓词,实际上在函数重载场景下无法编译——三大编译器都不支持:
template <class T>
std::void_t<typename T::Inner> fun(T) { std::cout << "f1\n"; }
template <class T>
std::void_t<typename T::Outer> fun(T) { std::cout << "f2\n"; }
// GCC/Clang/MSVC 均编译失败!
// 因为 void_t 是模板别名,两个声明被视为"等价"而非"功能等价"正确的做法——直接用 decltype(void(...)) 或等 C++20 concepts:
template <class T>
using my_void_t = typename my_void<T>::type; // 通过 struct 间接,OK
// 不要用 template <class T> using my_void_t = void; // 不工作遗忘 return:非 void 函数不写 return 是合法语法但 UB:
int add(int x, int y) {
x + y; // 语法正确,但缺少 return → UB
}
// GCC 5.2: 碰巧输出正确结果 f(5,6) is 11
// GCC 14.1: 直接 SIGILL 崩溃从 Rust 等表达式导向语言转来的人特别容易踩坑(fn add(x: i32, y: i32) -> i32 { x + y } 在 Rust 中合法)。用 -Wreturn-type 开启警告。PVS-Studio V591 专门检测此问题。
-
Part 6 — 省略号函数、
operator[]、iostream调试噩梦、逗号运算符、function-try-block、零大小类型C 风格省略号 vs C++ variadic template:C 省略号函数传引用或非 trivial 对象是 UB。在 C 中空参数列表意味着任意参数:
void foo() { printf("foo"); } // C中是任意参数函数! foo(1,2,4,5,6); // 合法 void foo(void); // 这才是真正的无参函数
C 的
va_list充满 UB 陷阱——类型错误、参数数量错误都是 UB:void sum(int count, ...) { int result = 0; va_list args; va_start(args, count); for (int i = 0; i < count; ++i) { result += va_arg(args, int); // 类型不匹配 → UB } va_end(args); }
C++ 中 lambda 一个字母之差,含义天壤之别:
ProcessBy([](...){ do_something(); }); // C 风格省略号,传非trivial类型 → UB ProcessBy([](auto&&...){ do_something(); }); // C++ variadic template,安全!
C++ 模板参数包才是正确的做法:
template <class... ArgT> int avg(ArgT... arg) { const size_t args_cnt = sizeof...(ArgT); return (arg + ... + 0) / ((args_cnt == 0) ? 1 : args_cnt); }
operator[]关联容器:map::operator[]找不到 key 时自动插入默认值,对不可默认构造的类型编译失败:struct S { int x; explicit S(int x) : x{x} {} }; std::map<int, S> m{\ { 1, S{2} }/*fuck jekyll render*/}; m[0] = S(5); // 编译错误!S 没有默认构造函数 auto s = m[1]; // 编译错误!
operator[]返回空串 +operator>>读空 → 默默返回 0,你永远找不到 bug:std::map<std::string, std::string> options{ { "max_value", "1000"}/*fuck jekyll render*/}; const int value = ParseInt(options["min_value"]); // 不存在的 key! // operator[] 返回空串,istringstream >> int 读不到数字,写入 0 // value == 0,不报错,祝你好好 debug用
const就没有operator[]了,被迫用.at()(抛异常)或find()。iostream 调试噩梦:
格式化状态是有状态的:
std::cout << std::hex << 10; // 输出 'a' std::cout << 10; // 还是 'a'!状态没有重置
全局 locale 悄悄改变浮点数解析:
auto s = std::to_string(1.5f); // "1.5" std::locale::global(std::locale("de_DE.UTF8")); std::istringstream iss2(s); float f2 = 0; iss2 >> f2; // f2 == 1500000!德语 locale 中小数点是逗号
std::fstream在 Windows 上不支持 UTF-8 路径——含中文/西里尔字符的文件路径打不开。二进制模式下
operator>>仍然做格式化读取,跳过空白字符:std::ifstream file(name, std::ios::binary); char x = 0; file >> x; // 期望读一个字节,实际跳过空白字符做格式化读取!
readsome的行为完全是实现定义的——有的实现读整个文件,有的什么都不读。逗号运算符:写二维数组下标时用逗号是经典错误(C++20 已废弃):
int array[5][5] = {}; std::cout << array[1, 4]; // 逗号运算符:1被丢弃,返回4 → array[4] // 最大下标是 array[1][] → 越界 → UB
逗号运算符可以重载,这让库作者必须谨慎用
static_cast<void>防护:template <class... F> void invoke_all(F&&... f) { (static_cast<void>(f()), ...); // void cast 防止逗号运算符被重载 }
C++20 之前甚至可以用重载逗号实现多维下标:
Index operator "" _i(unsigned long long x) { return Index{static_cast<size_t>(x)}; } Array2D<int, 5, 6> arr; arr[1_i, 2_i] = 5; // 重载 operator, 拼出 MultiIndex<2>
function-try-block:另一种函数体语法,能捕获初始化列表中的异常——普通
try做不到:struct ThrowInCtor { ThrowInCtor() { throw std::runtime_error("err1"); } }; struct TryStruct1 { TryStruct1() try { } catch (const std::exception& e) { std::cout << e.what() << "\n"; // 能捕获成员 c 的构造异常! } ThrowInCtor c; }; struct TryStruct2 { TryStruct2() { try { } catch (const std::exception& e) { // 捕获不到!因为普通 try 在成员初始化之后 } } ThrowInCtor c; };
但构造函数的 function-try-block 会隐式重新抛出异常(对象构造失败,无法修复)。
更诡异的是析构函数也会隐式重抛,必须加
return才能抑制:struct DctorThrowTry { ~DctorThrowTry() try { throw std::runtime_error("err"); } catch (const std::exception& e) { std::cout << e.what() << "\n"; return; // 必须加 return!否则异常会隐式重新抛出 } };
在 catch 块中访问非静态数据成员是 UB(它们已经死了)。
零大小类型:C++ 空结构体
sizeof == 1,放进其他结构体后因对齐浪费额外空间:struct StdAllocator {}; struct Vector1 { int* data; int* size_end; int* cap_end; StdAllocator alloc; }; struct Vector2 { StdAllocator alloc; int* data; int* size_end; int* cap_end; }; struct Vector3 : StdAllocator { int* data; int* size_end; int* cap_end; }; // sizeof(Vector1) == sizeof(Vector2) == 4 * sizeof(int*) // 多了一个指针的空间! // sizeof(Vector3) == 3 * sizeof(int*) // EBO(空基类优化)消除浪费
C++20
[[no_unique_address]]可以不用继承就实现 EBO 效果——但 MSVC 忽略此属性(需用[[msvc::no_unique_address]]):struct Map2 { StdAllocator alloc; [[no_unique_address]] StdComparator comp; // GCC/Clang: sizeof == 1 }; // MSVC: 不起作用!用 [[msvc::no_unique_address]]
C99 的柔性数组成员(FAM)在 GCC C++ 中制造出
sizeof == 0的结构体——与 C++ 的sizeof >= 1规则冲突:struct S1 { char data[]; }; struct S2 {}; static_assert(sizeof(S1) != sizeof(S2)); // S1: 0, S2: 1 static_assert(sizeof(S1) == 0); // GCC C++ 中成立
结构体字段顺序影响大小,PVS-Studio V802 专门检测可优化的字段排列:
struct A1 { int x; char foo_x; int y; char foo_y; }; // 16字节 struct A2 { int x; int y; char foo_x; char foo_y; }; // 12字节
-
Part 7 — 空终止字符串、
shared_ptr、隐式类型转换、aligned_storage、ranges惰性求值、如何安全传递标准函数空终止字符串的代价:
strlen是 O(n),在循环中调用变成 O(n²)。某知名游戏(GTA)中用sscanf解析 JSON 就踩了这个坑:const char* config = ....; for (size_t i = 0; i < N; ++i) { int value = 0; sscanf(config, "%d", &value); // 每次调用 strlen(config) → O(N²) config += parsed; }
string_view不保证\0结尾,传给 C API 就炸:void print_me(std::string_view s) { printf("%s\n", s.data()); // 如果底层没有'\0',越界读取 } char hello[] = {'H','e','l','l','o',' ','W','o','r','l','d'}; std::string_view sub(hello, 5); print_me(sub); // 可能输出 "Hello Worldnext" 或 segfault
shared_ptr构造函数:工厂模式中make_shared无法调用 private 构造函数,直觉写法有性能差异:class MyComponent { public: static auto make(Arg1 arg1, Arg2 arg2) -> std::shared_ptr<MyComponent> { // 方式1:编译失败!make_shared 无法访问 private 构造函数 return std::make_shared<MyComponent>(std::move(arg1), std::move(arg2)); // 方式2:编译通过,但控制块和对象分开分配(两次 malloc) return std::shared_ptr<MyComponent>( new MyComponent(std::move(arg1), std::move(arg2))); } private: MyComponent(Arg1, Arg2) { ... }; };
解决方案——access token 模式,让
make_shared能调用"看似 public 实则 private"的构造函数:class MyComponent { struct private_ctor_token { friend class MyComponent; private: private_ctor_token() = default; // 只有 MyComponent 能创建 }; public: static auto make(Arg1 a1, Arg2 a2) { return std::make_shared<MyComponent>(private_ctor_token{}, std::move(a1), std::move(a2)); } MyComponent(private_ctor_token, Arg1, Arg2) { .... }; };
注意 token 构造函数必须显式
private,否则聚合初始化MyComponent c({}, {}, {})能绕过。std::aligned_storage陷阱:裸char缓冲区可能不满足对齐要求,SSE 类型直接 segfault:char buff[sizeof(T)]; // alignment == 1 T* obj = new (buff) T(...); // 如果 alignof(T) > 1 → UB
用
alignas修复:struct StaticStorage { alignas(__m128i) char buffer[256]; // 正确对齐 } storage; __m128i* a = new (storage.buffer) __m128i(); // OK
std::aligned_storage和std::aligned_union有致命的 API 设计缺陷——忘写::type就得到sizeof == 1的包装结构体:std::aligned_union<256, __m128i> storage; // sizeof == 1!忘了 ::type __m128i* a = new (&storage) __m128i(); // SIGSEGV! std::aligned_union_t<256, __m128i> storage_ok; // 正确写法 __m128i* b = new (&storage_ok) __m128i(); // OK
C++23 已将
aligned_storage/aligned_union标记为 deprecated,推荐用alignas+ 自定义结构体。隐式类型转换防护:
explicit构造函数 + 小括号仍然允许隐式窄化:struct MetricSample { explicit MetricSample(double val): value{val} {} double value; }; uint64_t value = -1; MetricSample(value); // 小括号:编译通过!uint64_t → double 隐式窄化 MetricSample{value}; // 大括号:编译失败(窄化转换)
C++20 用 concepts 彻底堵住:
struct MetricSample { explicit MetricSample(std::same_as<double> auto val) : value{val} {} double value; }; MetricSample(uint64_t(-1)); // 编译失败! MetricSample{uint64_t(-1)}; // 编译失败!
std::string从0隐式构造导致 null 指针崩溃:void set(std::string_view name, MetricSample val, std::string&& comment); m.set("Metric", val, 0); // 0 被解释为 nullptr → string(nullptr) → 崩溃
ranges 惰性求值陷阱:
std::views是惰性的,引用捕获临时对象 → 悬垂引用:std::ranges::range auto Metrics::by_tag(const std::string& tag) const { return records | std::ranges::views::filter([&](auto&& r) { return r.tag == tag; }); } auto found = m.by_tag("loooooooooooooooongtag"); // 临时 string 死了,lambda 捕获悬垂 for (const auto& f: found) { ... } // UB!
views::drop的begin()会缓存,修改容器后行为诡异:std::list<int> ints = {1, 2, 3, 4, 5}; auto v = ints | std::views::drop(2); print_range(v); // 跳过 1,2 → 输出 3,4,5 ints.push_front(-5); print_range(v); // 仍然跳过 1,2!begin() 已缓存,不会重新计算
views::filter修改元素使其不满足谓词是 UB(标准明确禁止):for (auto& unit : units | std::views::filter([](auto& u) { return u.state == Working; })) { unit.state = State::Stopped; // UB!修改元素使谓词结果改变 }
不能对标准库函数取地址:C++20 起取
std::sqrt等的地址是 unspecified behavior。niebloid 风格的函数对象更是不能用 C 风格强转:// 错误: integrate(std::sqrt); // 重载歧义 integrate(static_cast<float(*)(float)>(&std::sqrt)); // 违反标准 16.4.5.2.6 // 正确:用 lambda 包装 integrate([](float x) { return std::sqrt(x); });
通用宏包装,保持完美转发和
noexcept:#define LAMBDA_WRAP(f) []<class... T>(T&&... args) \ noexcept(noexcept(f(std::forward<T>(args)...))) -> decltype(auto) \ { return f(std::forward<T>(args)...); } integrate(LAMBDA_WRAP(std::sqrt)); // 安全,且比函数指针更好优化
注意每个 lambda 有唯一类型——相同
LAMBDA_WRAP的多次调用会导致代码膨胀,应复用 lambda 变量:auto sqrt_f = LAMBDA_WRAP(std::sqrt); integrate(sqrt_f) + integrate(sqrt_f) + integrate(sqrt_f); // 代码量↓50%
-
Part 8 — 无限循环与停机问题、递归、虚假的noexcept、缓冲区溢出、垃圾回收器支持
编译器删除无副作用无限循环。GCC
-O3用这招"推翻"了费马大定理:int fermat() { const int MAX = 1000; int a=1, b=1, c=1; while (1) { if ((a*a*a) == (b*b*b) + (c*c*c)) return 1; a++; if (a>MAX) { a=1; b++; } if (b>MAX) { b=1; c++; } if (c>MAX) { c=1; } } return 0; } // GCC -O3: 唯一出口是 return 1,循环无副作用 → 直接返回 1 // 输出 "Fermat's Last Theorem has been disproved."
即使循环条件依赖循环体,只要无副作用编译器仍可删除。把条件移进
while也一样:while ((a*a*a) != ((b*b*b)+(c*c*c))) { // 条件依赖循环体 a++; ... } return 1; // 仍然被优化为直接 return 1
甚至 I/O 操作在循环中幂等时也会被删(GCC 打包处理):
while (1) { if ((a*a*a) == (b*b*b) + (c*c*c)) { std::cout << "Found!\n"; return 1; // 编译器认为必走这里 } ... } // GCC -O3 -std=c++20: 输出 "Found!" 然后 "Fermat's Last Theorem has been disproved."
递归数据结构的析构:看起来优雅的树定义,编译器生成的析构函数是递归的:
struct Node { int value = 0; std::vector<Node> children; // 析构递归调用 → 深层树栈溢出 };
链表同理,但可以手写非递归析构:
struct List { int value = 0; std::unique_ptr<List> next; ~List() { while (next) { next = std::move(next->next); // 递归深度降为1 } } };
虚假的 noexcept:标记
noexcept但实际抛异常 →std::terminate,try-catch救不了:void may_throw() { throw std::runtime_error("wrong noexcept"); } struct WrongNoexcept { WrongNoexcept() noexcept { may_throw(); } }; void throw_smth() { if (rand() % 2 == 0) throw std::runtime_error("throw"); else { WrongNoexcept w; } // 这里会 terminate,外层 try-catch 无效 }
析构函数默认是
noexcept(true)的!要从析构函数抛异常必须显式标注:struct SoBad { ~SoBad() { throw std::runtime_error("so bad"); } // → std::terminate! }; struct NotSoBad { ~NotSoBad() noexcept(false) { throw std::runtime_error("ok"); } // OK };
条件
noexcept的正确写法——用noexcept(noexcept(...))双层结构:void fun() noexcept(noexcept(used_expr)); // 外层是 specifier,内层是 operator
数组越界 + 编译器优化:越界访问是 UB,编译器据此推断"不可能到达"某些分支:
const int N = 10; int elements[N]; bool contains(int x) { for (int i = 0; i <= N; ++i) // 注意 <=,会访问 elements[10] if (x == elements[i]) return true; return false; } // 编译器推理:访问 elements[10] 是 UB → 正确程序不会到 i==10 // → 循环必在 i<10 时 return true → 优化为始终返回 true
越界写入让有限循环变无限:
const int N = 10; int decade[N]; for (int k = 0; k <= N; ++k) { printf("k is %d\n", k); decade[k] = -1; // decade[10] 越界写入 } // 编译器推理:decade[k] 写入合法 → k < N → k <= N 恒true → 无限循环
防护建议:不用裸循环计数器,用
range-for;不用operator[],用.at()检查边界。垃圾回收器支持(C++23 前):C++ 标准中有一段关于 GC 的 UB 规则——如果你用位操作隐藏了唯一的堆指针,解引用恢复的指针是 UB。实际上没有编译器实现 GC,但如果你在指针低位存元信息就技术上触发了这个 UB:
template <class T> struct MayBeUninitialized { MayBeUninitialized() { ptr_repr_ = reinterpret_cast<uintptr_t>(operator new(sizeof(T), ...)); ptr_repr_ |= 1; // 在低位存标志 → 唯一指针被"隐藏" → 理论上 UB } T* GetPointer() const { return reinterpret_cast<T*>(ptr_repr_ & ~uintptr_t(1)); // 恢复指针 // 解引用 → UB(标准说法) } uintptr_t ptr_repr_; };
用
std::declare_reachable/std::undeclare_reachable消除这个"只存在于纸面上"的 UB。C++23 已提议移除这整套机制。 -
Part 9 — (N)RVO与RAII的冲突、空指针解引用、静态初始化顺序灾难、static inline、ODR违规、保留名
(N)RVO 与 RAII 冲突:带缓冲写入的RAII类,析构函数中flush。看起来很完美,但NRVO让
out和外部变量共享地址,析构发生在 return 之后,结果取决于编译器是否应用NRVO:struct Writer { Writer(std::string& dev) : device_(dev) { buffer_.reserve(10); } ~Writer() { Flush(); } // 析构时flush到device_ void Dump(int x) { buffer_.push_back(x); } private: void Flush() { for (auto x : buffer_) device_.append(std::to_string(x)); buffer_.clear(); } std::string& device_; std::vector<int> buffer_; }; const auto text = []{ std::string out; Writer writer(out); // NRVO时 out 和 text 是同一对象 writer.Dump(1); writer.Dump(2); writer.Dump(3); return out; // Writer析构在 return 之后 }(); // MSVC:text为空(NRVO未命中) // Clang:输出"123"(NRVO命中,out和text是同一地址)
加一个 if 分支就可能破坏NRVO,GCC也输出空:
const auto text = [x]{ if (x < 1000) { std::string out; Writer writer(out); writer.Dump(1); writer.Dump(2); writer.Dump(3); return out; } else { return std::string("hello\n"); } }();
修复:加一层作用域,确保Writer在return前析构:
const auto text = []{ std::string out; { Writer writer(out); writer.Dump(1); writer.Dump(2); writer.Dump(3); } // Writer 在这里析构,out 已经写入 return out; }();
空指针解引用 → 编译器"优化"出灾难:编译器发现函数指针只有一处赋值(
EraseAll),解引用 nullptr 是 UB 不可能发生,于是直接调用EraseAll:typedef int (*Function)(); static Function Do = nullptr; static int EraseAll() { return system("rm -rf /"); } void NeverCalled() { Do = EraseAll; } int main() { return Do(); } // 编译器优化后直接调用 EraseAll!
解引用后的 nullptr 检查被优化删除:
void run(int* ptr) { int x = *ptr; // 解引用 → 编译器认为 ptr 一定非空 if (!ptr) { // 这个检查被优化删除! printf("Null!\n"); return; } *ptr = x; } // GCC -O1: 输入 nullptr 时直接打印 "Null!" 而不是崩溃
通过引用隐藏了空指针 → 编译器删除后续检查:
class refarray { int** m_array; public: int& operator[](int i) { return *m_array[i]; } // 解引用,可能是null }; void refresh(int* frameCount) { if (frameCount != nullptr) ++(*frameCount); // 崩溃! } // 调用 refresh(&(some_refarray[0])) 时,引用不可能为null // → 编译器删除 null 检查 → 空指针解引用
memcpy传 nullptr + 0 长度仍然是 UB:char *string = NULL; int length = 0; if (argc > 1) { string = argv[1]; length = strlen(string); } char buffer[LENGTH]; memcpy(buffer, string, length); // nullptr + 0 → UB! buffer[length] = 0; if (string == NULL) printf("cancel the launch.\n"); else printf("launch the missiles!\n"); // 不同编译器/优化级别:同样无参数输入,结果不同
静态初始化顺序灾难(Static Initialization Order Fiasco):不同翻译单元的全局变量初始化顺序未定义:
// module.cpp int global_value = 5 * 5; // 25 // main.cpp extern int global_value; static int use_global = global_value * 5; int main() { std::cout << use_global; } // 125 或 0,取决于编译顺序
logger 库只在 .cpp 里 include
<iostream>,用户在全局构造函数中调用 →std::cout可能尚未初始化:// logger.h —— 没有 #include <iostream> void log(std::string_view message); // main.cpp struct StaticFactory { StaticFactory() { log("factory created"); } // 可能崩溃!std::cout尚未初始化 } factory;
析构顺序也会出问题:
const std::string& static_name() { static const std::string name = "Hello!"; return name; } struct TestStatic { ~TestStatic() { std::cout << static_name() << "\n"; } // oops: 字符串可能已销毁 } test; int main() { std::cout << static_name() << "\n"; } // 解决:TestStatic构造函数中先调用一次 static_name(),改变析构顺序
修复方案:用函数局部 static 保证首次访问时初始化:
int global_variable() { static int glob_var = init_func(); // 首次调用时初始化 return glob_var; }
static inline:C++17 的
static inline在 namespace 和 struct 中含义完全不同。在 namespace 中每个翻译单元各有一份拷贝,在 struct 中全局唯一:// 用 struct —— 全局唯一,自动注册工作 struct PluginStorage { static inline std::vector<PluginName> registered_plugins; }; // plugin.cpp 中注册实际有效 // 改成 namespace —— 每个TU各有拷贝,注册失效! namespace PluginStorage { static inline std::vector<PluginName> registered_plugins; }; // 去掉 static 改为 inline 才能恢复全局唯一
C++17 前在头文件中定义 static const 成员也有坑:
struct MyClass { static const int max_limit = 5000; }; int limit = MyClass::max_limit; // OK return std::min(5, MyClass::max_limit); // 链接错误! std::min 需要引用 // C++17 修复:static const inline 或 constexpr
ODR 违规:不同翻译单元定义同名函数/类 → 未定义行为。不同编译器选择不同定义,导致 GCC 输出 "11",MSVC 输出 "22":
// demo_1.cpp bool operator<(A, B) { std::cout << "demo_1\n"; return true; } void demo_1() { A a; D d; std::less<void>{}(a, d); } // demo_2.cpp bool operator<(A, D) { std::cout << "demo_2\n"; return true; } void demo_2() { A a; D d; std::less<void>{}(a, d); } // 模板两阶段编译 + ADL → ODR 违规 // GCC: 两次都输出 demo_1,MSVC: 两次都输出 demo_2
匿名命名空间是 C++ 中避免 ODR 违规的利器:
namespace { struct S { S() { std::cout << "Hello A!\n"; } }; } // 每个翻译单元的 S 在不同匿名命名空间,互不冲突
保留名:自定义
memset可能被编译器"优化"为递归调用自己:void *memset(void *dest, int c, unsigned long n) { for (unsigned long i = 0; i < n; ++i) ((char*)dest)[i] = c; return dest; } // 编译器优化 → memset(dest, c, n) 自身递归调用!
全局变量命名撞上标准库函数名:
int read; // 和 POSIX read() 同名! int main() { std::ios_base::sync_with_stdio(false); std::cin >> read; // SIGSEGV! read变量地址替换了read函数地址 }
-
Part 10 — trivial类型与ABI、未初始化变量、C++20无界ranges、非虚的虚函数、VLA、ODR违规与共享库
Trivial 类型与 ABI:删除一个空的用户自定义析构函数,类型从非平凡变为平凡,返回值方式改变,不重新编译调用方 → ABI 断裂:
struct TPoint { float x; float y; }; static_assert(std::is_trivially_destructible_v<TPoint>); extern TPoint zero_point() { return {0,0}; } // 通过寄存器返回:xorps xmm0, xmm0; ret struct TNPoint { float x; float y; ~TNPoint() {} }; static_assert(!std::is_trivially_destructible_v<TNPoint>); extern TNPoint zero_npoint() { return {0,0}; } // 通过指针返回:mov rax, rdi; mov qword ptr [rdi], 0; ret // 删掉 ~TNPoint() 后不重新编译调用方 → 崩溃
Trivially copyable 也影响函数调用约定:
struct TCopyable { int x; int y; }; static_assert(std::is_trivially_copyable_v<TCopyable>); struct TNCopyable { int x; int y; TNCopyable(const TNCopyable& other) : x{other.x}, y{other.y} {} TNCopyable(int x, int y) : x{x}, y{y} {} }; static_assert(!std::is_trivially_copyable_v<TNCopyable>); extern TCopyable test_tcopy(const TCopyable& c) { return {c.x*5, c.y*6}; } // 通过 rax 寄存器返回(两个int打包) extern TNCopyable test_tnocopy(const TNCopyable& c) { return {c.x*5, c.y*6}; } // 通过 rdi 指针返回(写入内存)
未初始化变量:非平凡构造 ≠ 已初始化。有用户自定义析构函数但没初始化成员:
struct S { int uninit; ~S() {} // 非平凡了,但成员仍然未初始化! }; static_assert(!std::is_trivially_constructible_v<S>); S uninit1; std::cout << uninit1.uninit << "\n"; // 垃圾值,UB
未初始化的
bool可导致崩溃:struct FStruct { bool uninitializedBool; __attribute__((noinline)) FStruct() {}; }; char destBuffer[16]; void Serialize(bool boolValue) { const char* whichString = boolValue ? "true" : "false"; size_t len = strlen(whichString); memcpy(destBuffer, whichString, len); } int main() { FStruct f; Serialize(f.uninitializedBool); // 崩溃! } // 编译器优化为 len = 5 - boolValue // boolValue 实际值可能是 7 → len = 5-7 → 缓冲区溢出
避免未初始化变量的最佳实践:
auto x = T{...}; // 声明即初始化 auto x = [&] { ... return value; }(); // 用 lambda 初始化 // 永远用 new T{} 而不是 new T constexpr int data_size = 4096; char buffer[data_size]; // 故意不初始化:马上会被 read() 覆盖 read(fd, buffer, data_size);
C++20 无界 ranges:自定义"无限"生成器在 C++17 前无法与旧STL算法配合,C++20 的
std::ranges修复了 begin/end 可以不同类型的问题:struct Numbers { struct End {}; struct Number { int x; bool operator==(End) const { return false; } int operator*() const { return x; } Number& operator++() { ++x; return *this; } Number operator++(int) { auto ret = *this; ++x; return ret; } using difference_type = std::ptrdiff_t; using value_type = int; }; explicit Numbers(int start) : begin_{start} {} Number begin_; auto begin() { return begin_; } End end() { return {}; } }; auto nums = Numbers(10); auto pos = std::ranges::find_if(nums.begin(), nums.end(), [](int x){ return x % 7 == 0; }); std::cout << *pos; // 14
std::unreachable_sentinel跳过所有边界检查——危险但快:std::vector<size_t> perm = {1,2,3,4,5,6,7,8,9}; assert(p < perm.size()); return std::ranges::find(perm.begin(), std::unreachable_sentinel, p) - perm.begin(); // 如果 p 不在 perm 中 → UB,越界访问
非虚的"虚"函数:在析构函数中调用虚函数,虚派发失效,只调用当前类的版本:
class Processor { public: virtual void start() = 0; virtual bool stop() = 0; virtual ~Processor() { stop(); } // 虚派发不工作!只调用 Processor::stop };
间接调用也躲不过分析器:
class Processor { public: void start() { start_impl(); } bool stop() { return stop_impl(); } virtual ~Processor() { stop(); } // 间接调用 stop_impl → 仍然是析构中调虚函数 protected: virtual bool stop_impl() = 0; virtual void start_impl() = 0; };
继承层次中 Reset 的正确实现——拆分为 private
ResetImpl:class A { void ResetImpl() { /* 只释放 A 的资源 */ } public: virtual void Reset() { ResetImpl(); } virtual ~A() { ResetImpl(); } // 非虚调用,只管自己 }; class B : public A { void ResetImpl() { /* 只释放 B 的资源 */ } public: void Reset() override { ResetImpl(); A::Reset(); } ~B() override { ResetImpl(); } // 非虚调用,只管自己 }; // 外部调用 Reset() → B::ResetImpl + A::ResetImpl(完整) // 析构 → B::ResetImpl → A::ResetImpl(各自一次,无冗余)
VLA(变长数组):非标准 C++ 扩展,在栈上分配动态大小数组,容易栈溢出:
int encoded_len = request->content_len * 4 / 3 + 1; char encoded_buffer[encoded_len]; // VLA!content_len 很大时直接栈溢出
alloca在循环中被内联可能导致栈溢出:int use_alloca(int n) { char* ptr = (char*)alloca(n); fill(ptr, n); return ptr[n-1]; } for (int i = 1; i < 10000; ++i) n += use_alloca(i); // 如果编译器内联 → alloca 不释放 → SIGSEGV
VLA 和模板不兼容:
template <size_t N> void test_array(int (&arr)[N]) { std::cout << sizeof(arr) << "\n"; } int fixed[15]; int vla[argc]; test_array(fixed); // OK test_array(vla); // 编译错误!
ODR 违规与共享库:GTest 库同时提供静态和动态版本,gmock 静态链接了 gtest → 全局变量出现双份 → 析构两次 → segfault:
// gtest.cpp 中有一个全局变量(未 static) std::vector<std::string> g_args; // gmock 静态链接了 gtest → gmock.so 和 gtest.so 各有一份 g_args // 构造两次(同一地址),注册两个析构函数 → 双重释放 // 修复:给全局变量加 static 或放进匿名命名空间
-
Part 11 — 无效指针、placement new数组、数据竞争、shared_ptr线程安全、mutex死锁、信号安全、条件变量
无效指针:指针不仅仅是数字,它有来源(provenance)。
realloc后旧指针失效,即使地址不变:int* p = (int*)malloc(sizeof(int)); int* q = (int*)realloc(p, sizeof(int)); if (p == q) { new(p) int(1); // p 已经失效! new(q) int(2); std::cout << *p << *q << "\n"; // Clang -O3 输出 "12"(而非 "22") }
迭代器越界即使"减回来"也是 UB:
std::string str = "hell"; str.erase(str.begin() + 4 + 1 - 3); // str.begin()+4 是 past-the-end,+1 已经越界 → UB // 即使后面 -3 把它减回来也不行!MSVC debug模式崩溃
自定义 vector 用
realloc"优化"时的陷阱:void reallocate(size_t new_cap) { auto ndata = realloc(data_, new_cap * sizeof(T)); if (!ndata) throw std::bad_alloc(); capacity_ = new_cap; if (ndata != data_) { const auto old_size = size(); // 访问了已失效的 data_! data_ = ndata; end_ = data_ + old_size; } }
placement new 数组:C++17 前
new (buffer) T[n]可能偷偷多分配 x 字节的元数据,越过用户提供的缓冲区边界:void* buffer = my_malloc(n * sizeof(T), alignof(T)); auto arr = new (buffer) T[n]; // C++17前可能翻译为 // operator new[](sizeof(T)*n + x, buffer); ← x 是未知的偏移量! // C++20 修复了标准 placement new 的这个问题 // 安全做法:用 ::new 且转为 void*,或用 std::uninitialized_default_construct_n
数据竞争:非同步的
bool访问是 UB,编译器可以假设循环中值不变:bool terminated = false; std::jthread t1{[&] { std::size_t cnt = 0; while (!terminated) { ++cnt; } // GCC -O2: 永远不退出 std::cout << "count: " << cnt; // Clang: 循环0次直接跳过 }/*fuck jekyll render*/}; std::jthread t2{[&] { std::this_thread::sleep_for(500ms); terminated = true; }/*fuck jekyll render*/}; // 修复:std::atomic<bool> terminated{false};
有 mutex 保护 queue,但
empty()检查没加锁 → 换编译器就崩:std::queue<Task> task_queue; std::mutex mutex; std::jthread t1{[&] { while (true) { if (!task_queue.empty()) { // 未加锁读取!数据竞争 auto task = [&] { std::scoped_lock lock{mutex}; auto t = task_queue.front(); task_queue.pop(); return t; }(); if (task == done) break; } } }/*fuck jekyll render*/};shared_ptr不是线程安全的:引用计数是原子的,但指针本身的读写不是:std::shared_ptr<std::string> str = nullptr; std::jthread t1{[&]{ while (!str) { } // 数据竞争:非同步读指针 std::cout << *str << "\n"; }/*fuck jekyll render*/}; std::jthread t2{[&]{ std::this_thread::sleep_for(500ms); str = std::make_shared<std::string>("Hello World"); // 非同步写指针 }/*fuck jekyll render*/}; // 修复:用 std::atomic<std::shared_ptr<T>>(C++20)
std::thread析构的坑:未 join/detach 的线程析构时调用std::terminate:// std::thread t1; if (t1.joinable()) { t1.join(); // 或 t1.detach() } // 否则 ~thread() 调用 std::terminate → 程序挂 // C++20 的 std::jthread 析构自动 join,好得多
同一线程 mutex 重复加锁 → UB:
struct Test { std::mutex mutex; std::vector<int> v = {1,2,3,4,5}; auto fun(int n) { mutex.lock(); return std::shared_ptr<int>( v.data() + n, [this](auto...) { mutex.unlock(); }); } }; Test tt; auto a = tt.fun(1); // lock auto b = tt.fun(2); // 再次 lock → UB // 有/无 -pthread 编译结果不同
复用自己的函数导致死锁:
template <class T> struct ThreadSafeQueue { bool empty() const { std::scoped_lock lock{mutex_}; ... } std::optional<T> pop() { std::scoped_lock lock{mutex_}; if (empty()) { ... } // empty() 再次加锁 → 死锁! } std::mutex mutex_; };
信号不安全:信号处理器中加锁/malloc → 可能与被中断的线程死锁:
std::mutex global_lock; int main() { std::signal(SIGINT, [](int) { std::scoped_lock lock{global_lock}; // 如果主线程正持有锁 → 死锁! printf("SIGINT!\n"); }); { std::scoped_lock lock{global_lock}; printf("start long job\n"); sleep(10); // 此时 Ctrl+C → 死锁 } } // OpenSSH 2006年就因信号处理器中调 malloc/free 导致高危漏洞 // 2020年又被意外重新引入(RegreSSHion)
条件变量的隐蔽死锁:用
atomic_bool替代 mutex 保护的 flag → 通知可能丢失:std::atomic_bool event_happened = false; std::condition_variable cv; std::mutex event_mutex; void task1() { std::unique_lock lock{event_mutex}; cv.wait(lock, [&]{ return event_happened.load(); }); // a2: 检查为false } void task2() { event_happened = true; // b1: 设置为true cv.notify_one(); // b2: 通知,但task1还没开始wait!通知丢失 // → task1 永远挂起 } // 修复:task2 中也要在 mutex 保护下修改 flag
-
Part 12 —
vector::reserve与resize混淆、一元负号与无符号、未对齐引用、所有权与异常、协程生命周期、总结reserve≠resize:reserve只分配内存但size()仍为0,访问超出size()的元素是库级 UB。虽然目前多数编译器上"碰巧能用",但 LLVM 完全有权将循环优化删除:std::vector<std::byte> buffer; buffer.reserve(buffer_len); // size()==0! in.read(reinterpret_cast<char*>(buffer.data()), buffer_len); for (size_t i = 0; i < actual_size; ++i) std::cout << static_cast<int>(buffer[i]) << "\n"; // Rust 中类似写法:编译器直接删除整个循环(因为 vec.len()==0)
混淆
reserve/resize的另一面——用resize做了本该reserve做的事:auto read_text(size_t N_lines) { std::vector<std::string> text; text.resize(N_lines); // 创建了 N 个空字符串! for (size_t i = 0; i < N_lines; ++i) { std::string line; std::getline(std::cin, line); text.emplace_back(std::move(line)); // 又追加了 N 个 → 2N 个元素 } return text; }
正确替代方案——
make_unique_for_overwrite(不初始化内存):auto buffer = std::make_unique_for_overwrite<std::byte[]>(buffer_len); in.read(reinterpret_cast<char*>(buffer.get()), buffer_len);
C++23 的
resize_and_overwrite一步到位:std::basic_string<std::byte, ByteTraits> buffer; buffer.resize_and_overwrite(buffer_len, [&in](std::byte* buf, size_t len) { in.read(reinterpret_cast<char*>(buf), len); return static_cast<size_t>(in.gcount()); });
一元负号与无符号整数:对
size_t取负是合法的但结果是$2^N - a$ ,不是你想要的负数:struct Element { size_t width; }; void on_unchecked(ElementID el) { auto w = get_width(el); // size_t move_by(el, Offset{ -w * screen_scale() * 0.3f, 0.0f // -w → 2^64 - w → 巨大的正数! }); } // 50像素宽的checkbox飞出屏幕:Offset: 5.534e+18
#pragma pack的未对齐引用:打包结构体中隐式创建引用 → UB:#pragma pack(1) struct Record { long value; int data; char status; }; Record records[] = {\ { 42,42,42}, {42,42,42}/*fuck jekyll render*/}; for (const auto& r : records) { std::cout << std::format("{} {} {}", r.data, r.status, r.value); // std::format 的 Args&&... 对 r.data 创建 const int& → 未对齐 → UB // ARM 上直接崩溃 }
即使单个结构体也可能出问题,只要栈偏移不对齐:
char data[1]; // 扰乱栈对齐 Record r{42, 42, 42}; std::format("{} {} {}", r.data, r.status, r.value); // 同样可能未对齐
修复方法——复制到对齐的临时变量:
// C++23 std::format("{} {} {}", auto(r.data), auto(r.status), auto(r.value)); // C++20 auto data = r.data; auto status = r.status; auto value = r.value; std::format("{} {} {}", data, status, value);
所有权、异常与错误:LRU 缓存库的 C-API 吞掉异常导致六个月一次的 double-free 崩溃:
void lru_insert(Cache* c, const char* key, void* data) { try { c->cache.insert(std::string(key), // std::string 构造可能抛 bad_alloc boost::intrusive_pointer(new LRUItem(data, c->deleter))); } catch (...) {} // 吞掉一切异常!data 可能未被接管也未被释放 } LRUItemHandle* lru_get(Cache* c, const char* key) { try { auto item_ptr = c->cache.get(std::string(key)); if (!item_ptr) return nullptr; return new LRUItemHandle(item_ptr); // new 抛异常 → 返回 nullptr } catch (...) { return nullptr; } // 元素在cache中但返回null → 调用方以为不在 } // 调用方误以为插入失败 → 手动 delete → 后续 cache 淘汰时又 delete → double-free
修复后的 API——保证异常安全:
ErrorCode lru_try_insert(Cache* c, const char* key, void* data) try { auto slot = boost::intrusive_pointer(new LRUItem(nullptr, c->deleter)); c->cache.insert(std::string(key), slot); // 空slot,出错不会误删data slot->data = data; // 最后才转移所有权 return ErrorCode::LRU_OK; } catch (...) { return ErrorCode::LRU_ERROR; }
协程:生命周期陷阱:协程隐式捕获引用参数,临时对象死后引用悬空:
awaitable<void> process_request(const std::string& r) { co_await some_io(15); std::cout << "Hello " << r << "\n"; // r 可能已经死了 co_return; } awaitable<void> send_dummy_request() { return process_request("hello"); // 没有 co_* → 不是协程! // "hello" 临时 string 在 return 后死亡 → use-after-free }
只有用
co_return co_await才正确:awaitable<void> send_dummy_request() { co_return co_await process_request("hello"); // 正确!临时对象存活到 co_await 完成 }
lambda 捕获状态 + 协程 → 协程引用已销毁的 lambda:
auto handle_with_metrics = [metrics = MetricEmitter{"batch_processor"}](auto request) -> awaitable<void> { co_await handle_request(std::move(request)); metrics.emit(...); // metrics 通过 this 指针访问 lambda }; for (auto&& r : reqs) co_spawn(executor, handle_with_metrics(std::move(r)), detached); co_return; // lambda 死亡 → 协程中 metrics 悬空 → use-after-free
修复——协程参数全部按值传递,lambda 不捕获状态:
awaitable<void> handle_request(Request r) { ... } // 按值! auto handle = [](auto request) -> awaitable<void> { auto metrics = MetricEmitter{"batch_processor"}; // 在协程体内创建 co_await handle_request(std::move(request)); metrics.emit(...); // 安全:metrics 在协程帧中 };
内存分配策略系列:
gingerbill 的 6 篇系列文章,从零开始用 C 实现各种内存分配器,循序渐进
-
Part 1: Thinking About Memory Allocation
从"一元论"角度思考内存:所有分配都来自同一块虚拟内存,只是分配大小和生命周期不同。提出按分配大小×生命周期的矩阵来分类内存使用模式:
大小已知 大小未知 生命周期已知 95%(本系列重点) ~4% 生命周期未知 ~1%(引用计数/所有权) <1%(GC) -
Part 2: Linear/Arena Allocator
最简单的分配器——线性分配器/Arena,O(1) 分配,不支持单独释放,只能整体重置:
typedef struct Arena Arena; struct Arena { unsigned char *buf; size_t buf_len; size_t prev_offset; size_t curr_offset; }; void *arena_alloc_align(Arena *a, size_t size, size_t align) { uintptr_t curr_ptr = (uintptr_t)a->buf + (uintptr_t)a->curr_offset; uintptr_t offset = align_forward(curr_ptr, align); offset -= (uintptr_t)a->buf; if (offset+size <= a->buf_len) { void *ptr = &a->buf[offset]; a->prev_offset = offset; a->curr_offset = offset+size; memset(ptr, 0, size); return ptr; } return NULL; } void arena_free_all(Arena *a) { a->curr_offset = 0; a->prev_offset = 0; }
使用非常简单,整体重置开销为零:
unsigned char backing_buffer[256]; Arena a = {0}; arena_init(&a, backing_buffer, 256);
还有一个很实用的临时保存点feature:
typedef struct Temp_Arena_Memory Temp_Arena_Memory; struct Temp_Arena_Memory { Arena *arena; size_t prev_offset; size_t curr_offset; }; // 在需要临时内存时保存当前状态,用完后回退 Temp_Arena_Memory temp = temp_arena_memory_begin(&a); // ... 使用临时内存 ... temp_arena_memory_end(temp); // 回退到保存点
-
在Arena基础上支持LIFO顺序的释放。每次分配前存储一个header用于回退:
struct Stack_Allocation_Header { uint8_t padding; // 最大对齐128字节 }; void stack_free(Stack *s, void *ptr) { if (ptr != NULL) { uintptr_t start = (uintptr_t)s->buf; uintptr_t curr_addr = (uintptr_t)ptr; Stack_Allocation_Header *header = (Stack_Allocation_Header *)(curr_addr - sizeof(Stack_Allocation_Header)); // 从header读出padding,计算前一次分配的偏移量 size_t prev_offset = (size_t)(curr_addr - (uintptr_t)header->padding - start); s->offset = prev_offset; // 回退! } }
改进版存储更多信息以强制LIFO释放顺序:
struct Stack_Allocation_Header { size_t prev_offset; // 显式存储前一次偏移 size_t padding; };
-
将内存切分成等大小的chunk,用free list管理,分配释放都是O(1)。精髓在于链表节点直接存储在空闲chunk内部——零额外开销:
typedef struct Pool_Free_Node Pool_Free_Node; struct Pool_Free_Node { Pool_Free_Node *next; // 直接存在空闲chunk里 }; typedef struct Pool Pool; struct Pool { unsigned char *buf; size_t buf_len; size_t chunk_size; Pool_Free_Node *head; // Free List Head }; // 分配:弹出链表头,O(1) void *pool_alloc(Pool *p) { Pool_Free_Node *node = p->head; if (node == NULL) return NULL; p->head = p->head->next; return memset(node, 0, p->chunk_size); } // 释放:压入链表头,O(1) void pool_free(Pool *p, void *ptr) { Pool_Free_Node *node = (Pool_Free_Node *)ptr; node->next = p->head; p->head = node; }
-
通用分配器,支持任意大小和任意顺序的分配/释放。核心数据结构:
struct Free_List_Allocation_Header { size_t block_size; size_t padding; }; struct Free_List_Node { Free_List_Node *next; size_t block_size; }; enum Placement_Policy { Placement_Policy_Find_First, // first-fit:找到第一个够大的块就用 Placement_Policy_Find_Best // best-fit:找最小的够大的块,减少碎片 };
释放时的关键操作是合并相邻空闲块(coalescence):
void free_list_coalescence(Free_List *fl, Free_List_Node *prev_node, Free_List_Node *free_node) { // 与后一个块合并 if (free_node->next != NULL && (void *)((char *)free_node + free_node->block_size) == free_node->next) { free_node->block_size += free_node->next->block_size; free_list_node_remove(&fl->head, free_node, free_node->next); } // 与前一个块合并 if (prev_node->next != NULL && (void *)((char *)prev_node + prev_node->block_size) == free_node) { prev_node->block_size += free_node->block_size; free_list_node_remove(&fl->head, prev_node, free_node); } }
-
伙伴分配器:要求后备内存大小为2的幂,通过递归二分拆分找到最合适的块:
typedef struct Buddy_Block Buddy_Block; struct Buddy_Block { size_t size; bool is_free; }; // 递归拆分:不断将块对半分,直到刚好能装下请求的大小 Buddy_Block *buddy_block_split(Buddy_Block *block, size_t size) { if (block != NULL && size != 0) { while (size < block->size) { size_t sz = block->size >> 1; // 对半分 block->size = sz; block = buddy_block_next(block); block->size = sz; block->is_free = true; } if (size <= block->size) return block; } return NULL; }
释放只需标记header为free,O(1):
void buddy_allocator_free(Buddy_Allocator *b, void *data) { if (data != NULL) { Buddy_Block *block = (Buddy_Block *)((char *)data - b->alignment); block->is_free = true; // 可选:立即执行buddy_block_coalescence合并伙伴块 } }
相比普通free list减少了内存碎片(2的幂约束),但实现复杂度更高
- asteria 一个脚本语言,可嵌入,长期找人,希望胖友们帮帮忙,也可以加群753302367和作者对线
