Skip to content

Latest commit

 

History

History
3003 lines (2379 loc) · 99 KB

File metadata and controls

3003 lines (2379 loc) · 99 KB
layout post
title 第196期

C++ 中文周刊 2026-02-20 第196期

周刊项目地址

公众号

点击「查看原文」跳转到 GitHub 上对应文件,链接就可以点击了

qq群 753792291 答疑在这里

RSS

欢迎投稿,推荐或自荐文章/软件/资源等,评论区留言

本期文章由 机械工业出版社 赞助 ,他们送了我好多书,在此表示感谢


资讯

标准委员会动态/ide/编译器信息放在这里

编译器信息最新动态推荐关注hellogcc公众号 本周更新 2025-01-08 第288期

性能周刊

文章

Predrag Gruevski写的经典文章(PVS-Studio转载),列了43条程序员对UB的"错觉",逐条打脸

首先搞清三个概念:

程序行为分三个桶,不是两个:

  • Specification-defined:语言标准定义了会发生什么,占绝大部分
  • Implementation-defined:由编译器/OS/硬件定义,比如char到底几位
  • Undefined behavior:什么都可能发生,没有任何保证

关于"什么时候触发UB"的错觉:

  1. UB只在-O2/O3才会触发 — 错
  2. 关掉优化用-O0就没UB了 — 错
  3. 加调试符号就安全 — 错
  4. 在调试器下跑就没UB了 — 错
  5. 好吧有UB,但代码还是会"做正确的事" — 错
  6. 最多崩溃(SIGSEGV)— 错
  7. 最多崩溃或死循环 — 错

关于"UB会不会执行奇怪的代码":

  1. 至少不会跑到程序中其他不相干的代码 — 错
  2. 至少不会执行程序中理论上不可达的代码 — 错

关于"UB的影响范围"的错觉:

  1. 之前"正常工作"的UB代码,下次还能正常工作 — 不保证
  2. UB的影响至少局限于使用了UB值的代码 — 错
  3. 至少局限于同一个编译单元 — 错
  4. 至少只影响UB之后的代码 — 大错特错! UB可以"时间旅行",编译器可以基于"不存在UB"的假设优化UB之前的代码

关于"可能后果"的错觉:

17-24. 至少不会损坏内存/堆/栈/栈帧/CPU状态 — 都不保证 28. 至少不会把硬盘擦了 — 不保证(虽然不太可能) 29. 至少不会损坏硬件 — 不保证

"之前好好的"系列:

31-36. 不改代码重新编译还能好好工作吗?用同样编译器?同一台机器?同一时间编译?在月食期间献祭一根新内存条?— 统统不保证

关于"自我一致性"的错觉:

37-40. 相同二进制 + 相同输入重复跑,行为一样吗?即使程序是确定性的?即使是单线程?即使不读任何外部数据?— 统统不保证

社区贡献的错觉:

  1. 调试器里看到的程序状态跟源码是对应的 — 错。UB可以时间旅行,导致调试器里的变量值和代码逻辑对不上
  2. 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中连续排列,天然适合列扫描

优化路径(每步都有火焰图验证):

  1. Transaction Put → SST Writer(180s → 19.5s):Transaction Put每次插入都要锁key、排序,120列的场景下开销爆炸。改用SST Writer直接写SST文件,每列一个SST,后续compaction时合并
  2. 关掉过滤器和压缩(19.5s → 14.3s):火焰图发现Standard128RibbonBitsBuilder(类Bloom filter)吃了20%CPU,LZ4压缩也在热路径上。这两者在导入阶段不需要,compaction时会重建
  3. 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);
  1. 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));
}
  1. 去掉热路径的运行期检查(10.6s → 8.7s,18%提速):rocksdb::SstFileWriter::Rep::AddImpl里一堆key有序性检查和虚函数调用的status方法(只读一个atomic_bool但用了memory_order_relaxed),火焰图显示这些检查吃了20%CPU。改成debug-only assert,虚函数改成编译期static_cast
  2. 消除key的隐藏拷贝(8.7s → 7.8s,10%提速):每行每列都要构建key并调用ikey.Set(key, sn, vt),暗含一次字符串拷贝。120列 × 100万行 = 1.2亿次分配。改成预创建key复用

Key takeaways:

  1. 避免热路径中的虚函数
  2. 别不必要地拷贝字符串
  3. 运行期检查能改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提议用算术版本替换查表版本

三种方案:

  1. 查表法 - Node.js目前使用的方法,16字符查找表
  2. 算术nibble法 - Skovoroda提议的纯算术运算,无查表
  3. 手写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%的纯算法+实现改进

转换的两个步骤:

  1. 数字计算:把浮点数拆成有效数字和10的幂(比如π → 31415927-7
  2. 字符串生成:把数字写成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:

  1. 短而简单的函数在性能关键路径上一定要内联,收益可以很惊人
  2. 对于可快可慢的函数(如字符串处理),是否内联取决于输入规模。字符串长度可能决定了内联是否必要

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分支被删除
  • Part 2

    用随机测试工具对 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:-VV = INT64_MIN时溢出,1u << 631 << 32移位超出位宽等

  • Part 3

    讨论副作用与 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&&参数类型可能和你预期完全相反——intfloatfloatint

    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_viewconst& 的变种——不会延长临时对象生命周期:

    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_viewsubstr() 的临时拷贝:

    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_tuplereference_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-moveUniquePtr 的 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::arrayoperator[] 打破了这个规则(返回引用而非子对象直接访问):

    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!指针被隐式转为 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_ifenable_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_storagestd::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::string0 隐式构造导致 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::dropbegin() 会缓存,修改容器后行为诡异:

    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::terminatetry-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 12vector::reserveresize混淆、一元负号与无符号、未对齐引用、所有权与异常、协程生命周期、总结

    reserveresizereserve 只分配内存但 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); // 回退到保存点
  • Part 3: Stack Allocator

    在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;
    };
  • Part 4: Pool Allocator

    将内存切分成等大小的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;
    }
  • Part 5: Free List Allocator

    通用分配器,支持任意大小和任意顺序的分配/释放。核心数据结构:

    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);
        }
    }
  • Part 6: Buddy Allocator

    伙伴分配器:要求后备内存大小为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和作者对线

上一期

本期

下一期