公众号

点击「查看原文」跳转到 GitHub 上对应文件,链接就可以点击了
qq群 753792291 答疑在这里
欢迎投稿,推荐或自荐文章/软件/资源等,评论区留言
本期文章由 机械工业出版社 赞助 ,他们送了我好多书,在此表示感谢
标准委员会动态/ide/编译器信息放在这里
编译器信息最新动态推荐关注hellogcc公众号 本周更新 2025-01-08 第288期
Predrag Gruevski写的经典文章(PVS-Studio转载),列了43条程序员对UB的”错觉”,逐条打脸
首先搞清三个概念:
程序行为分三个桶,不是两个:
char到底几位关于”什么时候触发UB”的错觉:
关于”UB会不会执行奇怪的代码”:
关于”UB的影响范围”的错觉:
关于”可能后果”的错觉:
17-24. 至少不会损坏内存/堆/栈/栈帧/CPU状态 — 都不保证
“之前好好的”系列:
31-36. 不改代码重新编译还能好好工作吗?用同样编译器?同一台机器?同一时间编译?在月食期间献祭一根新内存条?— 统统不保证
关于”自我一致性”的错觉:
37-40. 相同二进制 + 相同输入重复跑,行为一样吗?即使程序是确定性的?即使是单线程?即使不读任何外部数据?— 统统不保证
社区贡献的错觉:
最后一条特别假设:
“如果程序编译没报错就没有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每次插入都要锁key、排序,120列的场景下开销爆炸。改用SST Writer直接写SST文件,每列一个SST,后续compaction时合并Standard128RibbonBitsBuilder(类Bloom filter)吃了20%CPU,LZ4压缩也在热路径上。这两者在导入阶段不需要,compaction时会重建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);
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));
}
rocksdb::SstFileWriter::Rep::AddImpl里一堆key有序性检查和虚函数调用的status方法(只读一个atomic_bool但用了memory_order_relaxed),火焰图显示这些检查吃了20%CPU。改成debug-only assert,虚函数改成编译期static_castikey.Set(key, sn, vt),暗含一次字符串拷贝。120列 × 100万行 = 1.2亿次分配。改成预创建key复用Key takeaways:
结论:火焰图定位 + 每步小改,总共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提议用算术版本替换查表版本
三种方案:
在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%的纯算法+实现改进
转换的两个步骤:
31415927 和 -7)当前状况:
std::to_chars(Linux)用了多达2倍的指令,{fmt}也略多一些,仍有改进空间有趣的发现:
没有任何现有实现能总是生成最短字符串。比如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 — Most Vexing Parse、非常量的常量、move语义、enable_if_t vs void_t、遗忘return
Most Vexing Parse:C++ 允许在函数内部前向声明函数,导致构造调用和函数声明的语法冲突:
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!指针被隐式转为 bool
Move 语义: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&);
// → 重复定义同一实体 → UB
std::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的幂约束),但实现复杂度更高