C++ 中文周刊 2026-05-15 第201期

周刊项目地址

公众号

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

qq群 753792291 答疑在这里

RSS

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


资讯

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

性能周刊

GCC 16.1 发布

GCC 16.1 正式发布,几个大改动:

isocpp 的摘要 写得更清楚一些,感兴趣直接看那个。

C++20 终于成默认了,embed 赶紧来吧。

文章

ARM 处理器上字符匹配最快的方式?

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: 结构化绑定可以引入参数包了

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 只能在模板里用,这个提案是在慢慢放开这个限制。非模板上下文的支持因为实现复杂度被砍掉了,下次再说。


xor eax, eax 和 sub eax, eax 清零,为什么大家都用 xor?

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 历史考古,我谢谢你。不过挺有意思,就是那种”为什么大家都这么写”的问题往往答案都是”历史惯性”。


给 C++ 异常加上 Stack Trace

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_accessstd::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 是刚需。值得复现一下。


C++20 Modules:工具链的空白地带

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,链接进新的模块对象,运行时内存corruptionmake 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 工具链这一关……

GCC 16 改进了 C++ 错误信息和 SARIF 输出

Red Hat 的 David Malcolm(GCC 诊断系统主要贡献者)写的文章,讲他在 GCC 16 里做的几个改进:

C++ 错误信息可读性终于有人认真搞了,之前那个模板错误展开五屏的体验确实不是人过的。


C++26: string 和 string_view 的改进

三个小改动,都是”为什么这么基础的东西以前没有”系列:

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,什么都可能发生。但更具体地:

所以:就算函数”看起来不用那个参数”,也可能用了对应的内存作暂存,省不得。


WIL scope_exit 的异常处理边界

讲 WIL 的 wil::scope_exit 在三个地方可能抛异常:

auto cleanup = wil::scope_exit([captures] { action; });
  1. lambda 构造时(capture 初始化):scope_exit 还没创建,异常直接传出,action 不执行
  2. scope_exit 移动 lambda 时:WIL 的处理是 UB,C++ 标准的 std::scope_exit(实验阶段)则会在传出异常前先执行 action
  3. 析构时:析构默认 noexcept,直接 std::terminate;如果显式允许抛出,还要看是正常退出还是 unwind 中

这是微妙的异常安全细节,实际上你的 cleanup lambda 里要尽量不抛异常。了解一下就行。


手写 std::variant

从零实现 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 链接。


向量搜索引擎性能优化:不改算法,快了 16 倍

作者在用 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;
};

额外优化:

汇编对比很直观:vtable 版有 mov rax, [r12] + jne + call rax + sqrtss;flat array 版直接跑紧凑的 AVX SIMD 循环,没有间接寻址。

这个优化是经典的”数据导向设计”(Data-Oriented Design)思路,指针追逐是 cache miss 的罪魁祸首,改成连续内存布局效果立竿见影。值得复现一下。


用 C++ 各版本实现 Parse, don’t validate

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);
};

后面各版本进化路线:

了解一下还是可以的。这个模式在 Rust/Haskell 社区早就是共识,C++ 这边因为历史包袱一直比较难推广,但 std::expected 进来之后其实顺手多了。

从 UB 到合法:std::launder 的用法

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

因为 nconst,编译器有权认为它永远是 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 或者搞内存池的时候会碰到。


15 种过滤容器的方法

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


Rule of Five 的几个好问题

作者在 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)会退回到拷贝,如果拷贝也不行就挂。

值得复现一下,接地气,不绕弯子。


游戏 Buff 系统的性能优化:换容器就快了 4 倍

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),但实际只用几个,全是浪费。

关键洞察:

关于 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全家桶。不过崩溃恢复这块确实是跨进程同步绕不过去的坑。


用优先队列删除目录里除最新 10 个文件以外的所有文件

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: 标准库加固(Library Hardening)

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

覆盖的容器:spanstring_viewvectordequelistarraystringoptionalexpectedmdspanbitsetvalarray。迭代器相关、关联容器暂时不在范围内,留到后续提案。

怎么开启: 标准没规定激活方式,各实现自己定,类似 -fhardened-D_LIBCPP_HARDENING_MODE=...。GCC 15 和 MSVC 19.44 已经部分实现了。

值得关注的方向。0.3% 的开销换 30% segfault 下降,这买卖明显划算,应该默认开。


Win32 句柄继承的反向问题及用”傀儡进程”解决

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);

实际操作流程:

  1. 各组件的句柄保持 non-inheritable
  2. 要创建子进程时,把想传的句柄 DuplicateHandle 复制到 helper process 里,设为 inheritable
  3. PROC_THREAD_ATTRIBUTE_HANDLE_LIST + PROC_THREAD_ATTRIBUTE_PARENT_PROCESS(指定 helper 为父进程)创建子进程
  4. 创建完毕后 DuplicateHandle + DUPLICATE_CLOSE_SOURCE 关掉 helper 里的副本

好处:主进程里的句柄始终 non-inheritable,其他线程的 CreateProcess 不会意外继承;多个线程可以同时操作同一个 helper process,不需要每次都新建。

又是WinAPI,我看不懂不多逼逼。


C++26 Reflection 入门:^^[: :]

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 已经可以跑了。


C++26 Reflection enum-to-string 编译耗时对比

Vittorio Romeo 接着他之前那篇 reflection 编译开销的文章,这次用 enum-to-string 这个”reflection hello world”做了三种方案的编译时间基准测试(GCC 16.1 正式版):

按枚举成员数量(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。

开源项目介绍


上一期

本期

下一期

看到这里或许你有建议或者疑问或者指出错误,请留言评论! 多谢! 你的评论非常重要!也可以帮忙点赞收藏转发!多谢支持! 觉得写的不错那就给点吧, 在线乞讨 微信转账