公众号

点击「查看原文」跳转到 GitHub 上对应文件,链接就可以点击了
qq群 753792291 答疑在这里
欢迎投稿,推荐或自荐文章/软件/资源等,评论区留言
编译器信息最新动态推荐关注hellogcc公众号 本周更新 2025-01-08 第288期
GCC 16.1 正式发布,几个大改动:
-freflection)、Contracts、expansion statements、std::simdga68(……嗯)isocpp 的摘要 写得更清楚一些,感兴趣直接看那个。
C++20 终于成默认了,embed 赶紧来吧。
Daniel Lemire 老熟人,这次讲的是在 ARM 上用 SVE2 指令集加速 JSON 字符分类的问题。
背景: simdjson 在索引 JSON 文档时,需要对每个字节判断它是不是结构字符(: , [ ] { })或者空白字符(\t \n \r ' ')。这叫 vectorized classification,是解析速度的关键路径。
传统 NEON 做法: Langdale & Lemire 2019 年的论文给出的方案是 table-driven 的 nibble lookup,对每个字节拆成高低 nibble 分别查表取 bitmask 再合并,没有分支,挺好用。
SVE2 新做法: SVE2 有个指令叫 svmatch_u8,直接一条指令就能做”这个字节在不在这个集合里”的判断——a 向量里每个位置,和 b 向量里所有字节比较,只要有一个相等就命中。这比 NEON 的 nibble lookup 清晰多了:
// 结构字符集合
uint8_t op_chars_data[16] = {
0x3a, 0x2c, 0x5b, 0x5d, 0x7b, 0x7d, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0
};
// 空白字符集合
uint8_t ws_chars_data[16] = {
0x09, 0x0a, 0x0d, 0x20, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0
};
svuint8_t op_chars = svld1_u8(svptrue_b8(), op_chars_data);
svuint8_t ws_chars = svld1_u8(svptrue_b8(), ws_chars_data);
svbool_t pg = svptrue_pat_b8(SV_VL16);
svuint8_t d = svld1_u8(pg, input);
svbool_t op = svmatch_u8(pg, d, op_chars); // 一条指令搞定
svbool_t ws = svmatch_u8(pg, d, ws_chars);
SVE/SVE2 现状碎碎念: 设计上寄存器宽度可变(像 RISC-V 那套),结果现在所有商用芯片还是 128-bit,不如 AVX-512 的 512-bit 宽。但优点是和 NEON 可以互操作,能直接在 NEON 代码里插 SVE2 指令。Graviton4/Cobalt 100/Google Axion 都支持了,就是 Apple 一直没跟进 SVE2,理由不明,可能是他们有自己的路线。
值得关注的方向,SVE2 在字符处理场景确实有优势。
C++26 的 P1061R10 让结构化绑定可以引入参数包,简单来说就是 Python 那套 *rest 解包语法进 C++ 了。
std::tuple<X, Y, Z> f();
auto [x, y, z] = f(); // 今天就能用
auto [... xs] = f(); // C++26: xs 是长度为3的包 (X, Y, Z)
auto [x, ... rest] = f(); // C++26: x是X,rest是包(Y, Z)
auto [x, ... rest, z] = f(); // C++26: x是X,rest是包(Y),z是Z
auto [... a, ... b] = f(); // ill-formed,多个包不行
实际用处看 dot product: 以前用 tuple 做点积,得嵌两层 std::apply,丑死了:
// C++26 前:
template<class P, class Q>
auto dot_product(P p, Q q) {
return std::apply([&](auto... p_elems){
return std::apply([&](auto... q_elems){
return (... + (p_elems * q_elems));
}, q)
}, p);
}
// C++26:
template<class P, class Q>
auto dot_product(P p, Q q) {
auto && [... p_elems] = p;
auto && [... q_elems] = q;
return (... + (p_elems * q_elems));
}
省流:可以,写起来清爽很多。历史上 pack 只能在模板里用,这个提案是在慢慢放开这个限制。非模板上下文的支持因为实现复杂度被砍掉了,下次再说。
Raymond Chen 的历史八卦。Compiler Explorer 作者 Matt Godbolt 写了篇文章解释为什么编译器用 xor eax, eax 清零——因为比 mov eax, 0 省几个字节(不用编码4字节立即数)。但 Raymond 说,那 sub eax, eax 也是同样字节数,执行周期也一样,而且 flag 行为还更好:
| 指令 | OF | SF | ZF | AF | PF | CF |
|---|---|---|---|---|---|---|
xor eax, eax |
clear | clear | set | undefined | set | clear |
sub eax, eax |
clear | clear | set | clear | set | clear |
xor 会让 AF 标志位变成 undefined,sub 则是干净地 clear。理论上 sub 更好。
但 xor 赢了,Raymond 的猜测是纯粹的历史惯性——早期有人用了 xor,感觉”聪明”,编译器跟进,大家看见编译器用 xor 就以为 xor 有什么特别的,然后全倒向 xor。Intel 后来给两个指令都加了 zero-register detection(识别为清零,bypass 执行),但其他 CPU 厂商可能只识别了 xor 没识别 sub,所以用 xor 保险。
他的一个前同事就习惯用 sub r, r,Raymond 说读汇编的时候一眼就能认出是他写的。
另一个 bonus:Itanium 的 xor r, r 清零不管用,因为数学运算不会 reset NaT bit,但 Itanium 有专用零寄存器,所以也不需要这个 trick。
又是 WinAPI/x86 历史考古,我谢谢你。不过挺有意思,就是那种”为什么大家都这么写”的问题往往答案都是”历史惯性”。
Python/Java 抛异常会给你完整 stack trace,C++ 就给你个 what(): bad optional access,然后你看着 crash log 一脸懵。这篇文章讲怎么优雅地补上这个能力。
简单方案: 自定义异常类,构造的时候 std::stacktrace::current() 抓栈:
class ExceptionWithStackTrace : public std::exception {
public:
ExceptionWithStackTrace(std::string what) {
m_what = std::format("what(): {}\n{}", what,
std::stacktrace::current(1)); // skip自身frame
}
auto what() const noexcept -> const char* override {
return m_what.c_str();
}
private:
std::string m_what;
};
问题是标准库的异常(std::bad_optional_access、std::out_of_range 等)你改不了。
更好的方案:hook __cxa_throw。 Itanium ABI 下所有 throw 都会调用 __cxa_throw,用链接器的 --wrap 功能劫持它:
-Wl,--wrap=__cxa_throw
然后实现 __wrap___cxa_throw,在里面抓 stacktrace 存到 thread_local 变量,再转发给原始函数:
thread_local std::array<std::stacktrace, 5> s_stacktraces;
extern "C" auto __real___cxa_throw(
void* thrown_object, std::type_info* tinfo,
void (*dest)(void*)) -> void;
extern "C" auto __wrap___cxa_throw(
void* thrown_object, std::type_info* tinfo,
void (*dest)(void*)) -> void
{
auto exception_count = std::uncaught_exceptions();
if (exception_count < ssize_t(s_stacktraces.size())) {
s_stacktraces[exception_count] = std::stacktrace::current(1);
}
__real___cxa_throw(thrown_object, tinfo, dest);
}
catch 块里调 print_stacktrace() 就能拿到抛出点的完整栈了。包括标准库异常也生效。
限制: 只适用于 GCC/Clang + Itanium ABI(Linux/macOS/MinGW),MSVC 要 hook 不同的函数。
这个 trick 挺实用的,线上排查 crash 的时候 stack trace 是刚需。值得复现一下。
Memgraph 从 2025 年底开始在生产代码里逐步引入 C++20 modules,编译器这边还好,工具链这边——一言难尽。作者踩了 6 个坑:
问题一:clangd 不会自己构建 PCM 文件
clangd 需要预编译的 .pcm 文件才能理解 import,但它不会触发 build。解决方法:开编辑器前先手动 build 一遍模块:
ninja -C build -t inputs \
| grep -E '\.cppm\.o$|\.o\.modmap$' \
| xargs -r ninja -C build
问题二:头文件 flag 推断出错
compile_commands.json 只有 .cpp/.cppm 的条目,没有头文件。clangd 用启发式找”最近的同名 .cpp”来借 flag,但每个 TU 的 -fmodule-file= 参数可能不一样,推断错了就到处报找不到模块的红线,而 clang 编译完全正常。
问题三:import through #include 不传播(clangd bug,已有 fix)
A.cppm export 一个类型,B.h 里 import A,C.cpp 里 #include "B.h" 用这个类型,clangd 报错说没 import,但 clang 编译正常。原因是 preamble PCH 和 C++20 modules 互相打架,#include 进来的 import 丢了。workaround 是在 C.cpp 里再加一行多余的 import A;。
问题四:ccache 不追踪 PCM 文件内容
ccache 只 hash 编译参数字符串(包括 -fmodule-file=mod.pcm 路径),不 hash PCM 文件的内容。改了模块接口重新生成 PCM,ccache 还是返回旧的缓存,结果是 stale object file 里用的是旧的 struct layout,链接进新的模块对象,运行时内存corruption。make clean 没用,因为旧缓存在 ccache 里不在 build 目录里。典型的”我这里能跑”bug。
问题五:clang-tidy 也需要 artifact 先存在
和 clangd 一样,要先 build 出 .bmi 等文件才能跑。
问题六:clang-tidy fix 冲突
同一个头文件可能同时被普通 TU 和 module global fragment 看到,clang-tidy 如果要 apply fix 会搞出冲突。
总结就是:modules 编译器支持已经基本到位,但clangd / ccache / clang-tidy 这一整套工具链还在追赶中,生产环境引入要做好踩坑准备。embed 赶紧来吧,modules 这一关过完还有 modules 工具链这一关……
Red Hat 的 David Malcolm(GCC 诊断系统主要贡献者)写的文章,讲他在 GCC 16 里做的几个改进:
-fdiagnostics-format=html,输出带语法高亮和行号链接的 HTML,适合在 CI 报告里展示-fanalyzer)开始支持小型 C++ 代码C++ 错误信息可读性终于有人认真搞了,之前那个模板错误展开五屏的体验确实不是人过的。
三个小改动,都是”为什么这么基础的东西以前没有”系列:
P2495R3:stringstream 支持 string_view 构造
以前 stringstream 只能从 string 构造,传 string_view 要先转一次 string。C++26 直接加了 string_view 构造函数。Clang 19 已经有了。
P2697R1:bitset 支持 string_view 构造
同一个作者,同一个问题,bitset 从字符串构造也要转 string。C++26 修了。Clang 18 已经有了。
P2591R5:string + string_view 终于能拼接了
这个才是长期的痛:
std::string s = "hello ";
std::string_view sv = "world";
return s + sv; // C++26 之前:编译错误!
以前 operator+ 两边必须都是 string,加 string_view 不行,只能 s + std::string(sv) 或者 s += sv。C++26 加了 operator+ 重载,GCC 15/Clang 19 已经有了。
这设计问题被某个永远没实现的”string builder 特性”卡了好多年。省流:可以了。
Raymond Chen 这周连出三篇,讲怎么用 Windows 信号量搭跨进程读写锁,是一个演进系列:
第一篇:基本思路
SRWLOCK 不跨进程,但可以用有名信号量模拟。核心想法:设置最大读者数 N,信号量初始 token 数为 N:
#define MAX_SHARED 100
void AcquireShared() { WaitForSingleObject(sharedSemaphore, INFINITE); }
void ReleaseShared() { ReleaseSemaphore(sharedSemaphore, 1, nullptr); }
void AcquireExclusive() {
for (unsigned i = 0; i < MAX_SHARED; i++)
WaitForSingleObject(sharedSemaphore, INFINITE);
}
void ReleaseExclusive() { ReleaseSemaphore(sharedSemaphore, MAX_SHARED, nullptr); }
第二篇:修死锁
两个线程同时拿写锁会死锁——各拿了一半 token,互相等对方放。解法:加一个 mutex 序列化写锁请求,同时只有一个线程贪婪地抢 token:
HANDLE sharedMutex;
void AcquireExclusive() {
WaitForSingleObject(sharedMutex, INFINITE);
for (unsigned i = 0; i < MAX_SHARED; i++)
WaitForSingleObject(sharedSemaphore, INFINITE);
ReleaseMutex(sharedMutex);
}
第三篇:写锁吞吐量差的问题
修完死锁后,写锁吞吐还是很差——写锁在一个一个抢 token 的过程中,后来的读者会插队拿到 token,写锁一直被饿着。文章分析了这个场景,解法留到下一篇。
又是WinAPI,代码我看懂了但真的不会有机会用。
Raymond Chen 的另一篇。假设你知道某个函数在第一个参数为正时会忽略第二个参数,那能不能只传一个参数?
C/C++ 说:参数个数错了就是 UB,什么都可能发生。但更具体地:
b 是 dead variable,可能把 c 的存储复用到 b 的位置——你没传 b,那里就是调用方栈帧的数据,写坏了所以:就算函数”看起来不用那个参数”,也可能用了对应的内存作暂存,省不得。
讲 WIL 的 wil::scope_exit 在三个地方可能抛异常:
auto cleanup = wil::scope_exit([captures] { action; });
std::scope_exit(实验阶段)则会在传出异常前先执行 actionstd::terminate;如果显式允许抛出,还要看是正常退出还是 unwind 中这是微妙的异常安全细节,实际上你的 cleanup lambda 里要尽量不抛异常。了解一下就行。
从零实现 std::variant 的教学文章,讲解很系统。
为什么要有 variant: C 的 union 不记录当前存的是什么类型,析构器也不知道该调谁的。std::variant 就是加了类型索引的 type-safe union。
先看用法: visitor pattern 三种写法——
// 传统 struct visitor
struct MyVisitor {
void operator()(int i) const { std::cout << "int: " << i; }
void operator()(std::string s) const { std::cout << "string: " << s; }
void operator()(double d) const { std::cout << "double: " << d; }
};
std::visit(MyVisitor(), var);
// generic lambda
std::visit([](const auto& v){ std::cout << v; }, var);
// overload trick(最好用)
template<typename... Ts> struct overload : Ts... { using Ts::operator()...; };
template<typename... Ts> overload(Ts...) -> overload<Ts...>;
std::visit(overload{
[](int i) { std::cout << "int: " << i; },
[](std::string s) { std::cout << "string: " << s; },
[](double d) { std::cout << "double: " << d; }
}, var);
多重分派: std::visit 可以同时 dispatch 多个 variant,经典场景是 Cell 类型(int/string)的运算:
struct Cell {
std::variant<int, std::string> data;
Cell& operator+=(Cell& other) {
std::visit(overloaded{
[](int a, int b) { a += b; },
[](std::string a, int b) { a += std::to_string(b); },
[](int a, std::string b) { throw std::runtime_error("Invalid!"); },
[](std::string a, std::string b) { a += b; }
}, data, other.data);
return *this;
}
};
实现部分——TMP 工具:
find_index_of_v:找类型在包里的下标,用 constexpr for 循环遍历 {std::is_same_v<T,Ts>...}:
template<typename T, typename... Ts>
constexpr size_t find_index_of_impl() {
size_t i{0uz};
for (bool is_same : {std::is_same_v<T, Ts>...}) {
if (is_same) break;
++i;
}
return i;
}
template<typename T, typename... Ts>
constexpr size_t find_index_of_v = find_index_of_impl<T, Ts...>();
uint_atleast_t:用最小够用的无符号整数类型存 index(省空间):
template<size_t max>
constexpr auto uint_atleast_impl() {
if constexpr (max <= std::numeric_limits<uint8_t>::max()) return uint8_t{};
else if constexpr (max <= std::numeric_limits<uint16_t>::max()) return uint16_t{};
else if constexpr (max <= std::numeric_limits<uint32_t>::max()) return uint32_t{};
else return uint64_t{};
}
template<size_t max>
using uint_atleast_t = decltype(uint_atleast_impl<max>());
get_nth_type_t:用展开 10 个 if constexpr 的查表法,每 10 个类型只实例化一次,比逐个递归快:
template<size_t n, typename T0=void, typename T1=void, /*...*/ typename T9=void, typename... Ts>
constexpr auto get_nth_type_impl() {
/**/ if constexpr (n == 0) return std::type_identity<T0>{};
else if constexpr (n == 1) return std::type_identity<T1>{};
// ...
else if constexpr (n == 9) return std::type_identity<T9>{};
else return get_nth_type_impl<n-10, Ts...>();
}
template<size_t n, typename... Ts>
using get_nth_type_t = typename decltype(get_nth_type_impl<n, Ts...>())::type;
核心数据结构——递归 union_:
template<typename First, typename... Rest>
struct union_ {
union {
First head;
union_<Rest...> tail;
};
constexpr union_() = default;
constexpr union_(std::in_place_index_t<0u>, auto&&... args) : head{FWD(args)...} {}
template<size_t I>
constexpr union_(std::in_place_index_t<I> idx, auto&&... args)
: tail{std::in_place_index<I-1>{}, FWD(args)...} {}
};
template<typename T>
struct union_<T> { T head; /*...*/ }; // base case
// get:index==0 取 head,否则递归取 tail
template<size_t i, typename Self>
constexpr auto&& get(Self&& self) {
if constexpr (i == 0) return FWD(self).head;
else return get<i-1>(FWD(self).tail);
}
variant 类完整实现:
namespace dev {
template<typename... Ts>
class variant {
using index_type = tools::uint_atleast_t<sizeof...(Ts)>;
tools::union_<Ts...> m_data;
index_type m_idx;
public:
// 默认构造:初始化第0个类型
constexpr variant() {
tools::construct_at<0>(m_data, tools::get_nth_type_t<0, Ts...>());
m_idx = 0;
}
// 转换构造
template<tools::exists<Ts...> T>
constexpr explicit variant(T&& x) {
tools::construct_at<tools::find_index_of_v<T, Ts...>>(m_data, FWD(x));
m_idx = tools::find_index_of_v<T, Ts...>;
}
// 拷贝构造:用 index_sequence + fold expression 生成 if 链
constexpr variant(const variant& other) {
index_type idx = other.m_idx;
[&]<auto... Indices>(std::index_sequence<Indices...>) {
(((idx == Indices)
? (tools::construct_at<Indices>(m_data, tools::get<Indices>(other.m_data)), 0)
: 0), ...);
}(std::make_index_sequence<sizeof...(Ts)>());
m_idx = idx;
}
// 移动构造(同理)
constexpr variant(variant&& other) noexcept {
index_type idx = other.m_idx;
[&]<auto... Indices>(std::index_sequence<Indices...>) {
(((idx == Indices)
? (tools::construct_at<Indices>(m_data, tools::get<Indices>(std::move(other.m_data))), 0)
: 0), ...);
}(std::make_index_sequence<sizeof...(Ts)>());
m_idx = idx;
}
// 析构:同样展开 index_sequence,运行时 index 匹配后调正确的 destroy_at
constexpr ~variant() {
[&]<auto... Indices>(std::index_sequence<Indices...>) {
(((m_idx == Indices) ? (tools::destroy_at<Indices>(m_data), 0) : 0), ...);
}(std::make_index_sequence<sizeof...(Ts)>());
}
// swap:同 index 直接 swap 值,不同 index 用临时变量交换
void swap(variant<Ts...>& other) noexcept {
using std::swap;
if (m_idx == other.m_idx) {
[&]<size_t... Idxs>(std::index_sequence<Idxs...>) {
(((m_idx == Idxs) ? (swap(get<Idxs>(*this), get<Idxs>(other)), 0) : 0), ...);
}(std::make_index_sequence<sizeof...(Ts)>());
} else {
auto p = std::move(*this);
std::construct_at(this, std::move(other));
std::construct_at(&other, std::move(p));
}
}
// 拷贝/移动赋值:copy-and-swap
variant<Ts...>& operator=(const variant<Ts...>& other) {
variant(other).swap(*this); return *this;
}
variant<Ts...>& operator=(variant<Ts...>&& other) noexcept {
variant(std::move(other)).swap(*this); return *this;
}
// 转换赋值
template<tools::exists<Ts...> T>
constexpr variant<Ts...>& operator=(T&& element) {
this->~variant();
constexpr size_t i = tools::find_index_of_v<T, Ts...>;
tools::construct_at<i>(m_data, FWD(element));
m_idx = i;
return *this;
}
constexpr std::size_t index() const noexcept { return m_idx; }
// get<I> / get<T> 友元
template<size_t I, typename... Us>
constexpr friend decltype(auto) get(const variant<Us...>& v);
template<typename T, typename... Us>
constexpr friend T& get(const variant<Us...>& v);
template<size_t I, typename... Us>
constexpr friend decltype(auto) get(variant<Us...>& v);
template<typename T, typename... Us>
constexpr friend T& get(variant<Us...>& v);
};
template<size_t I, typename... Ts>
constexpr decltype(auto) get(const variant<Ts...>& v) {
return tools::get<I>(v.m_data);
}
template<typename T, typename... Ts>
constexpr T& get(const variant<Ts...>& v) {
return tools::get<tools::find_index_of_v<T, Ts...>>(v.m_data);
}
template<size_t I, typename... Ts>
constexpr decltype(auto) get(variant<Ts...>& v) {
return tools::get<I>(v.m_data);
}
template<typename T, typename... Ts>
constexpr T& get(variant<Ts...>& v) {
return tools::get<tools::find_index_of_v<T, Ts...>>(v.m_data);
}
} // namespace dev
析构那段用 lambda + index_sequence 在编译期展开 if 链是这篇文章最有意思的地方,因为 destroy_at<I> 的 I 必须是编译期常量,但活跃 index 是运行期的,只能这样绕。了解 variant 内部机制不错,感兴趣的可以点进去看完整的 Compiler Explorer 链接。
作者在用 C++ 实现一个基于 Vamana 图算法的向量搜索引擎 sembed-engine,做了一次纯数据结构层面的优化,算法没变,结果:
| 场景 | 前 | 后 | 提升 |
|---|---|---|---|
| gvec query p50 | 4.094ms | 0.631ms | 6.5x |
| w2v query p50 | 25.15ms | 1.524ms | 16.5x |
| w2v build time | 17.91s | 1.889s | 9.5x |
Recall 保持 1.0,访问节点数不变。
问题所在: 原来的数据结构是 Record { int64_t id; shared_ptr<Vector> vector; },每次距离计算要:load record → 解 shared_ptr → 跳转到 Vector 对象 → 虚函数调用 → 计算 → 开方。
优化方案: 把所有向量的浮点数压到一个大 flat array 里,用 FloatVectorView(就是个 const float* + size_t)做轻量引用:
// 第 i 个向量从这里开始
FloatVectorView view { values.data() + i * dimensions, dimensions };
struct FloatVectorView {
const float* data;
size_t dimensions;
};
额外优化:
sqrt,相对大小不变)汇编对比很直观:vtable 版有 mov rax, [r12] + jne + call rax + sqrtss;flat array 版直接跑紧凑的 AVX SIMD 循环,没有间接寻址。
这个优化是经典的”数据导向设计”(Data-Oriented Design)思路,指针追逐是 cache miss 的罪魁祸首,改成连续内存布局效果立竿见影。值得复现一下。
Alexis King 那篇经典的 Parse, don’t validate 很多人看过,但是 Haskell 例子。这篇用日期解析这个具体场景,从 C++98 一路到 C++23 演示怎么落地。
核心思想:用类型系统在 parse 阶段保证数据合法,而不是到处加 if 判断。成功构造出来的对象就是合法的,后续代码不用再验证。
起点(不好的写法):
struct Birthdate { int year, month, day; };
Birthdate make_birthdate(const char* user_input) {
Birthdate b = {0, 0, 0};
std::sscanf(user_input, "%d-%d-%d", &b.year, &b.month, &b.day);
return b; // 传入"2O26-04-I7"也不会报错,直接返回 {2, 0, 0}
}
C++98 版: 私有构造 + 静态工厂 + 返回 ParseStatus 错误码,嵌入式环境友好:
enum ParseStatus { PARSE_OK = 0, PARSE_NULL_INPUT, PARSE_BAD_FORMAT, ... };
class Birthdate {
static ParseStatus parse_iso_yyyy_mm_dd(const char* s, size_t s_len, Birthdate& out);
private:
Birthdate(unsigned short y, unsigned char m, unsigned char d);
};
后面各版本进化路线:
optional 或 unique_ptr 代替 out-paramstd::optional<Birthdate>,失败返回 nulloptstd::expected<Birthdate, ParseError>,错误也能携带信息了解一下还是可以的。这个模式在 Rust/Haskell 社区早就是共识,C++ 这边因为历史包袱一直比较难推广,但 std::expected 进来之后其实顺手多了。
Andreas Fertig 讲 std::launder 到底什么时候用、和 reinterpret_cast/std::start_lifetime_as 有什么区别。
问题场景: placement new 在已有对象的内存上重新构造,但没有更新指针——
struct X { const int n; double d; };
X* p = new X{7, 8.8};
new (p) X{42, 9.9}; // placement new,重新构造
int i = p->n; // UB!编译器可以假设 p 指向的对象未变,n 是 const
因为 n 是 const,编译器有权认为它永远是 7,直接优化掉后面的读。你拿到的 i 是 UB。
最简单的修法: 更新指针——p = new (p) X{42, 9.9},让编译器知道对象变了。能这样做就这样做,忘了 launder。
必须用 launder 的场景: 自定义 allocator,你的 Get() 没有存着最新的指针:
template<size_t SIZE, size_t ALIGNMENT>
class Buffer {
alignas(ALIGNMENT) std::byte mBuffer[SIZE];
public:
template<typename T, typename... Ts>
T* Construct(Ts... vals) {
return new (mBuffer) T{std::forward<Ts>(vals)...}; // 返回 new 的结果,OK
}
template<typename T>
[[nodiscard]] T* Get() {
return std::launder(reinterpret_cast<T*>(mBuffer)); // launder!
}
};
Get() 里的 reinterpret_cast 只是类型转换,不告诉编译器这块内存已经有新对象了。加上 std::launder 相当于加了一道”反优化”屏障,告诉编译器:这个指针指向的是一个有效的在生命期内的对象,别再假设它没变过。
总结表格:
| 工具 | 用途 | 引入版本 |
|---|---|---|
reinterpret_cast |
指针类型转换 | C++98 |
std::bit_cast |
获取对象的位表示 | C++20 |
std::start_lifetime_as |
在内存块上开启对象生命期 | C++23 |
std::launder |
更新对已存活对象的指针视图 | C++17 |
了解一下还是可以的,写自定义 allocator 或者搞内存池的时候会碰到。
cppstories 的老文章,2025 年底更新加入了 C++23 内容,发布到了 ACCU Overload。从最朴素的写法一路卷到 C++23 generator,当个 STL 算法速查表用不错。
问题定义:Filter(container, predicate) → 返回满足条件的元素新容器。
// v1: raw loop
template<typename T, typename Pred>
auto FilterRaw(const std::vector<T>& vec, Pred p) {
std::vector<T> out;
for (auto&& elem : vec)
if (p(elem)) out.push_back(elem);
return out;
}
// v2: copy_if(最自然)
std::copy_if(begin(vec), end(vec), std::back_inserter(out), p);
// v3: remove_copy_if(反向谓词)
std::remove_copy_if(begin(vec), end(vec), std::back_inserter(out), std::not_fn(p));
// v4: remove_erase idiom(注意要先拷贝)
out.erase(std::remove_if(begin(out), end(out), std::not_fn(p)), end(out));
// v5: C++20 erase_if(更简洁)
std::erase_if(out, std::not_fn(p));
// v6: C++20 ranges::copy_if
std::ranges::copy_if(vec, std::back_inserter(out), p);
// 泛型版(支持任意容器)
template<typename TCont, typename Pred>
auto FilterEraseIfGen(const TCont& cont, Pred p) {
auto out = cont;
std::erase_if(out, std::not_fn(p));
return out;
}
// C++23: filter_view + ranges::to(惰性求值再收集)
auto result = vec | std::views::filter(p) | std::ranges::to<std::vector>();
// C++23: std::generator(协程懒过滤)
std::generator<T> LazyFilter(std::vector<T> vec, Pred p) {
for (auto&& elem : vec)
if (p(elem)) co_yield elem;
}
花里胡哨,但 copy_if + back_inserter 写 15 年了,老老实实用就行。
Lemire 的小数学帖。给定无符号类型变量 x 和常量 a,判断 a * x 是否溢出,最直接的方法是比较 x > (2^L - 1) / a(L 是位宽)。
但他还推导了另一个等价结论:
定理: 若 x 是无符号类型,a 是非零常量,则 a * x 溢出 当且仅当 (a * x) / a != x。
证明思路:设溢出时绕了 k 圈(k ≥ 1),机器实际算出来的值是 r = a*x - k*2^L,把 r 除以 a 得到的是 x + (-k*2^L / a),因为第二项是负整数,所以 != x。不溢出时 k=0,r/a = x,成立。
实际上直接用 x > MAX/a 更高效,编译器也更容易优化。作为数学性质有点意思,但 Go 编译器(举例用的)没能把 (a*x)/a != x 优化成简单比较,所以实践中还是老老实实写 x > MAX/a。
作者在 LinkedIn 看到几个关于 rule of five 的问题打算回答,结果 app 崩了,内容全丢了,一气之下写成了博客。Q&A 格式,几个问题挺值得记一下:
必须全部实现五个吗? 不一定,但不实现就要想清楚为什么。作者建议用 = default 而不是不写,让意图显式。
什么时候 delete 拷贝但允许移动? 有 move-only 语义的时候。但建议优先用 = default(让 unique_ptr 成员隐式 delete 拷贝),而不是显式 = delete——因为后者在改变 ownership 模型时容易忘了改回来:
// 优先这样(default 让成员类型说话):
struct A {
A(const A&) = default; // unique_ptr 会让它隐式 deleted
A& operator=(const A&) = default;
A(A&&) = default;
A& operator=(A&&) = default;
~A() = default;
private:
std::unique_ptr<B> b_;
};
virtual 析构器不能用 Rule of Zero:
struct A {
// ...
virtual ~A() = default; // 不是纯 default,声明了 virtual,得写完整 rule of five
};
为什么 STL 容器要求 move constructor 是 noexcept? 移动一半抛异常了,没法保证 strong exception guarantee,所以强制 noexcept,出问题直接 terminate。
move constructor 抛异常会怎样? std::vector 等容器在需要 move 时(比如 resize)会退回到拷贝,如果拷贝也不行就挂。
值得复现一下,接地气,不绕弯子。
Mathieu Ropert 给一个大型策略游戏(Grand Strategy 类型,480 个 stat)做 buff/modifier 系统的性能优化。没改算法,只换了底层容器,最终从 163.7us → 42.4us。
基准实现:
using StatID = uint32_t;
using BuffSource = uint64_t;
struct Stats {
struct Entry {
double value;
std::unordered_map<BuffSource, double> sources; // 每个 stat 的来源
};
std::unordered_map<StatID, Entry> entries;
};
用采样 profiler 一看,绝大部分时间都在 new/delete——每次 buff 加/移除都触发 unordered_map 内部的节点分配。
各版本优化数据:
| 版本 | 外层容器 | 内层(sources) | 耗时 | 内存 |
|---|---|---|---|---|
| V1 基准 | unordered_map | unordered_map | 163.7us | 17918 bytes |
| V2 | unordered_map | vector | 91.8us | 11629 bytes |
| V3 | unordered_map | Unreal TArray | 86.6us | - |
| V4 | unordered_map | TInlineAllocator<4> (小对象优化) | 69us | - |
| V5 | array<Entry, 480> (固定数组) | vector | 1142.8us | 灾难 |
| V6 | variant<map, vector> (按对象类型选) | TInlineAllocator | 45.1us | - |
| V7 | variant + bytell_hash_map | TInlineAllocator | 42.4us | - |
V5 崩溃的原因:计算里用了大量临时 Stats 变量,每个都分配了 ~100KB(480 个 stat 的 array),但实际只用几个,全是浪费。
关键洞察:
vector 立刻砍掉一半时间:most entries 只有几个来源,linear search 比 hash lookup 还快std::variant<map, vector> 让大对象(country)走 dense,小对象走 sparse关于 TInlineAllocator(SBO / Small Buffer Optimization):
std::vector 的经典问题:容量从 0 增长时会连续触发多次 realloc——0→1→2→3→4,每次都是一次 heap 分配。大多数 buff 来源只有几个,每次加/移除都在做这件事。
TInlineAllocator<N> 是 Unreal 的 small vector,原理和 std::string 的 SSO 一样:在对象内部内联存放 N 个元素的栈上空间,元素数量 ≤ N 时完全不上堆,超出才退回普通 heap 分配:
┌──────────────────────────────────────┐
│ TArray<T, TInlineAllocator<4>> │
│ size: 2 │
│ capacity: 4 (inline, no malloc) │
│ inline: [elem0][elem1][ ][ ] │ ← 直接在对象里
└──────────────────────────────────────┘
vs std::vector<T>:
┌──────────────────────┐ heap
│ vector │ ┌──────────────┐
│ size: 2 │→ │ elem0 elem1 │
│ capacity: 2 │ └──────────────┘
│ ptr: ──────────────►│
└──────────────────────┘
对于”绝大多数 entry 最多 4 个 buff 来源”这个场景,inline 直接把分配次数降到零。STL 标准库没有内置 small_vector,但标准外的实现很多:
// Abseil
#include "absl/container/inlined_vector.h"
absl::InlinedVector<std::pair<BuffSource, double>, 4> sources;
// Boost
#include <boost/container/small_vector.hpp>
boost::container::small_vector<std::pair<BuffSource, double>, 4> sources;
// folly
folly::small_vector<std::pair<BuffSource, double>, 4> sources;
接口和 std::vector 完全一样,drop-in 替换。inline 容量选多少看实际分布,4 是这里的经验值(最坏情况只有一两打来源)。超出 inline 容量后几何增长照常,不会比普通 vector 更差。
感兴趣的可以点进去看看,unordered_map 嵌套 unordered_map 是游戏代码里常见的坑,改成小 vector 的收益往往超出预期。
前三篇的信号量方案有个致命问题:进程崩溃时持有的 token 永远丢失。信号量没有 owner 概念,线程死了 token 也不会归还,写锁就永远拿不到了。
解决方案:把信号量换成 mutex 数组。mutex 有 owner,进程崩溃时系统会把它标为 abandoned 状态,等待方会收到 WAIT_ABANDONED 而不是永久阻塞:
HANDLE sharedMutex;
HANDLE tokenMutexes[MAX_SHARED];
// 读锁:抢任意一个 token mutex
int AcquireShared() {
// WaitForMultipleObjects(..., FALSE, ...) 任一到手即可
}
// 写锁:把所有 token mutex 全拿走(序列化由 sharedMutex 保证)
void AcquireExclusive() {
WaitForSingleObject(sharedMutex, INFINITE);
for (int i = 0; i < MAX_SHARED; i++)
WaitForSingleObject(tokenMutexes[i], INFINITE);
ReleaseMutex(sharedMutex);
}
文章还封装了一个 TimeoutTracker 辅助类,跟踪已经过去的时间,防止多次 Wait 累积超时时间计算错误。代码量比之前的版本大了不少,但正确性好多了。
又是WinAPI全家桶。不过崩溃恢复这块确实是跨进程同步绕不过去的坑。
Raymond Chen 的算法小题。FindFirstFile 枚举出来的顺序是文件系统决定的,没法让它按日期排序。朴素做法是把所有文件读进来排序再删,O(n) 空间 O(n log n) 时间。
但可以更好:用一个大小上限为 10 的最小堆(min-priority queue),按修改时间排序。枚举时每来一个文件就 push 进堆,堆超过 10 个就把最老的(堆顶)弹出来删掉。因为堆的大小固定最多 10,所有 push/pop 都是 O(1)(常数很小),整体 O(n):
constexpr int files_to_keep = 10;
auto dateAscending = [](const WIN32_FIND_DATA& a, const WIN32_FIND_DATA& b) {
return CompareFileTime(&a.ftLastWriteTime, &b.ftLastWriteTime) > 0;
};
std::priority_queue<WIN32_FIND_DATA,
std::vector<WIN32_FIND_DATA>,
decltype(dateAscending)> names(dateAscending);
WIN32_FIND_DATA wfd;
wil::unique_hfind findHandle(FindFirstFileW(L"*.*", &wfd));
if (findHandle.is_valid()) {
do {
if (wfd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) continue;
names.push(wfd);
if (names.size() > files_to_keep) {
DeleteFileW(names.top().cFileName);
names.pop();
}
} while (FindNextFileW(findHandle.get(), &wfd));
}
注意 dateAscending 的比较是反的(大的排前面),这样 top() 返回的是日期最早的文件,恰好是要删的那个。
算法题包装成 Windows API 实战,priority_queue 经典用法,值得记住。
C++26 通过 P3471R4 把标准库里一大类 UB 变成了运行时可检测的 contract violation。简单说就是:越界访问、空容器 front()、nullopt 解引用……这些以前是 UB,现在加固后会在实际发生副作用前 abort/terminate。
Google 的生产数据: 在数亿行代码里启用 libc++ hardening,发现了 1000+ 个 bug(包括安全相关的),平均性能开销仅 0.30%,生产环境 segfault 率下降了 30%。这数据很惊人。
具体加固了什么:
std::vector<int> v = {1, 2, 3};
int x = v[5]; // contract violation: 5 >= 3
v.pop_back();
v.pop_back();
v.pop_back();
v.pop_back(); // contract violation: !empty() is false
std::string_view sv("hello");
char c = sv[10]; // contract violation: 10 >= 5
sv.remove_prefix(10); // contract violation: 10 > 5
std::optional<int> opt;
int x = *opt; // contract violation: has_value() is false
std::span<int, 5> sp(data, 3); // contract violation: extent mismatch
覆盖的容器:span、string_view、vector、deque、list、array、string、optional、expected、mdspan、bitset、valarray。迭代器相关、关联容器暂时不在范围内,留到后续提案。
怎么开启: 标准没规定激活方式,各实现自己定,类似 -fhardened 或 -D_LIBCPP_HARDENING_MODE=...。GCC 15 和 MSVC 19.44 已经部分实现了。
值得关注的方向。0.3% 的开销换 30% segfault 下降,这买卖明显划算,应该默认开。
Raymond Chen 的 Win32 深水区。之前有篇文章讲了用 PROC_THREAD_ATTRIBUTE_HANDLE_LIST 精确控制哪些句柄被子进程继承,避免乱继承。但这个方案有个反向问题:
PROC_THREAD_ATTRIBUTE_HANDLE_LIST 只能列出标记为 inheritable 的句柄,句柄必须先设为 inheritable 才能放进去。但一旦设成 inheritable,任何其他线程如果直接调 CreateProcess(bInheritHandles=TRUE) 不带 attribute list,就会意外把你的句柄继承进去。
解决方案:弄一个傀儡辅助进程(helper process),它什么都不干,就等主进程退出:
// helper process 的全部逻辑
WaitForSingleObject(hMainProcess, INFINITE);
ExitProcess(0);
实际操作流程:
DuplicateHandle 复制到 helper process 里,设为 inheritablePROC_THREAD_ATTRIBUTE_HANDLE_LIST + PROC_THREAD_ATTRIBUTE_PARENT_PROCESS(指定 helper 为父进程)创建子进程DuplicateHandle + DUPLICATE_CLOSE_SOURCE 关掉 helper 里的副本好处:主进程里的句柄始终 non-inheritable,其他线程的 CreateProcess 不会意外继承;多个线程可以同时操作同一个 helper process,不需要每次都新建。
又是WinAPI,我看不懂不多逼逼。
^^ 和 [: :]GCC 16 正式支持 C++26 reflection(-freflection),是时候看看语法了。两个新操作符:
^^(reflection operator): 把任意 C++ 实体变成 std::meta::info,一个编译期 handle:
constexpr std::meta::info r1 = ^^int; // 类型
constexpr std::meta::info r2 = ^^x; // 变量
constexpr std::meta::info r3 = ^^std::vector; // 类模板
constexpr std::meta::info r4 = ^^mylib; // namespace
[: :](splicer): 反过来,把 std::meta::info 变回 C++ 实体:
constexpr auto r1 = ^^int;
typename[:r1:] x = 5; // int x = 5;
typename[:^^double:] y = 20.2; // double y = 20.2;
实际用途——enum to string,再也不用写 switch:
template<typename T>
requires std::is_enum_v<T>
constexpr std::string_view to_enum_string(T val)
{
template for (constexpr auto e :
std::define_static_array(std::meta::enumerators_of(^^T)))
{
if (val == [:e:])
return std::meta::identifier_of(e);
}
return "<unknown>";
}
enum class Colors { Red, Yellow, Green };
// 用法:
to_enum_string(Colors::Red); // "Red"
to_enum_string(Colors::Yellow); // "Yellow"
std::meta::enumerators_of(^^T) 拿到枚举的所有成员列表,template for 是 C++26 的编译期循环,[:e:] 把每个反射成员还原成枚举值,std::meta::identifier_of(e) 拿名字字符串。GCC 16 + -std=c++26 -freflection 已经可以跑了。
Vittorio Romeo 接着他之前那篇 reflection 编译开销的文章,这次用 enum-to-string 这个”reflection hello world”做了三种方案的编译时间基准测试(GCC 16.1 正式版):
<string_view>__PRETTY_FUNCTION__ 字符串解析,无宏无 reflection<meta>按枚举成员数量(4/16/64/256/1024)测编译耗时,关键数据:
只 include 头文件的开销(单 TU):
| 方案 | header include 耗时 |
|---|---|
| X-macro (const char*) | ~26ms(几乎为零) |
| X-macro (string_view) | 136ms |
| enchantum | 147ms |
| Reflection | 181ms |
Reflection 的 <meta> 头文件本身就比 <string_view> 贵 ~45ms,这是每个用到它的 TU 都要付的固定税。随着枚举成员数量增长,reflection 的编译时间比 X-macro 涨得更快,但比 enchantum 在大枚举上反而更稳定(enchantum 的默认扫描范围 [-256, 256] 会拖慢所有枚举)。
结论:reflection 比 X-macro 编译慢,但写起来干净得多,没有宏污染,不用同步维护两份列表。enchantum 是 C++17 的折衷选择,能用就用,不能用就等 reflection。