C++ 中文周刊 2026-04-17 第200期

周刊项目地址

公众号

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

qq群 753792291 答疑在这里

RSS

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

第200期了,感谢大家一路支持,我自己也没想到能写这么多期


资讯

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

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

性能周刊

听说 cppreference 活了,详见官方公告,只读了一年多终于有动静,embed赶紧来吧

C++26 is done! Herb Sutter 的伦敦会议 trip report,C++26 技术工作正式完成。四个核心大功能:反射(P2996,自模板发明以来最大升级)、内存安全(UB减少 + 标准库hardening,Google已在生产部署,降低segfault率30%)、合约(pre/post/contract_assert)、std::execution。这是C++11以来最重要的版本,吗。

文章

What reinterpret_cast doesn’t do

Andreas Fertig 在教嵌入式课程时发现一个普遍误解:很多人以为 reinterpret_cast 可以把 raw bytes 直接变成目标类型,实际上这是 UB。

常见写法(UB)

bool ProcessData(std::span<unsigned char> bytes) {
    // ⚠️ UB:reinterpret_cast 只转换了指针,没有创建 ConfigValues 对象
    ConfigValues* cfgValues = reinterpret_cast<ConfigValues*>(bytes.data());
    return HandleConfigValues(cfgValues);
}

标准怎么说[expr.reinterpret.cast] p7 说的是可以把指针转成另一种指针类型,然后转回来。它允许的是指针的类型别名,不是创建新对象。通俗说:reinterpret_cast 给了你一个”我说这块内存是 ConfigValues 的权利”,但没给你”在这块内存上真正存在一个 ConfigValues 对象的权利”。

用苹果橙子类比:如果 reinterpret_cast<Orange*>(apple) 真能创建 Orange,那它就必须销毁原来的 Apple,这样 std::any 这类 type-erasure 工具就没法实现了。

正确做法:C++23 的 std::start_lifetime_as

bool ProcessData(std::span<unsigned char> bytes) {
    // ✅ 正式开始 ConfigValues 的生命周期,不调用构造函数
    ConfigValues* cfgValues = std::start_lifetime_as<ConfigValues>(bytes.data());
    return HandleConfigValues(cfgValues);
}

start_lifetime_as 隐式在目标地址创建对象,同时结束源对象生命周期,不调用构造/析构函数,纯粹是 C++ 对象模型层面的操作。

值得一看,做嵌入式或者喜欢 type punning 的必看

Learning to read C++ compiler errors: Illegal use of -> when there is no -> in sight

Raymond Chen 的侦探故事。客户报告包含 ole2.h 时编译器报错:error C3927: '->': trailing return type is not allowed,但代码里根本没有 ->

结论:有个宏叫 AddError,展开后包含 -> token。客户一开始声称没有这样的宏,让他们生成预处理结果才发现确实有。

教训:编译器报说什么东西非法,那个东西大概率存在于某个宏展开里。当报错信息看起来和代码完全对不上时,先查宏。

A Critique Of The Two Trivial Relocatability Papers

一个 C++ 用户对两个 trivial relocatability 提案的业余批评,写得很到位。

背景:move 一个对象通常等价于 memcpy + 把旧位置设成 moved-from 状态(让析构变成 no-op)。对 vector 这类容器来说,resize 时可以直接 memcpy 数据再 free 旧内存,根本不需要调那些通常是 no-op 的析构函数——但前提是类型是 trivially relocatable 的。不是所有类型都行,比如 std::list 的尾节点指向 list 对象本身,std::string SSO 时数据指针也指向 string 对象本身,move 之后必须更新指针,不能直接 memcpy。

P1144(”sharp knife”方案):用属性标注 [[trivially_relocatable]],简单类型好用。但一旦成员包含非 trivially relocatable 的类型,属性就要写成带谓词的条件形式:

struct [[trivially_relocatable(
    std::is_trivially_relocatable_v<std::string>
    && std::is_trivially_relocatable_v<std::list<int>>
    && std::is_trivially_relocatable_v<library::sbo_container>
    && std::is_trivially_relocatable_v<std::unordered_map<std::string, int>>
)]] Example2 {
    std::string str;
    std::list<int> list;
    library::sbo_container other;
    std::unordered_map<std::string, int> map;
    Example2(); Example2(Example2&&); ~Example2();
};

漏掉一个成员 → 数据损坏、野指针,而且很难 debug。另外,这等于剥夺了库作者以后改成 non-trivially relocatable 的权利(Qt 为了 ABI 稳定性会故意声明空的构造/析构函数,留后路)。

作者建议:把属性改成自动条件版,叫 memberwise_trivially_relocatable

P2786(”弯曲定义”方案):换了关键字 trivially_relocatable_if_eligible replaceable_if_eligible(是的,两个关键字),但更大的问题是重新定义了”trivial”的含义——为了支持 ARM64e 平台的 signed vtable pointer(Apple 的需求),P2786 的”trivial”不再意味着”可以 memcpy”,而是”编译器知道怎么做”。

后果一连串:

  1. 不能用 memcpy,必须用 std::relocate/std::uninitialized_relocate,强制依赖标准库头文件
  2. 不能用 realloc——P1144 至少开了这扇门,P2786 直接锁死
  3. 没有谓词版本(context-sensitive keyword 引入新的 most-vexing-parse 问题),SBO 类型要绕路写 SFINAE dummy struct
  4. 没有任何实际 benchmark 证明支持 polymorphic 类型有收益
  5. 对 union 内含 polymorphic 类型的情况:implementation-defined(这其实是个逃生舱,但不应该是)

作者总结:P2786 感觉是委员会为标准库自己加的特性,普通 C++ 用户几乎捞不到任何好处。

结论:应该标准化 P1144 的定义(trivially relocatable = 可以 memcpy),把属性拆成 [[trivially_relocatable_if_eligible]][[trivially_relocatable_if(predicate)]];P2786 在没有实际收益证明之前不该推进。

P2786 差点进了 C++26,最后一刻撤回,P1144 从2018年起卡在委员会里已经8年了。这两个提案的故事是 C++ 委员会里最典型的互相否决死锁案例之一

Hashing in C++26

系列第二篇。作者之前用 tied() 返回 std::tuple of references 来做泛型哈希(每个类都得自己写这个函数)。C++26 反射出来之后可以直接干掉这个要求。

C++26 反射基础

反射操作符 ^^ 获取实体的编译期表示(std::meta::info 类型),配合 splicer 语法 [: r :] 生成代码:

struct Data { int id; std::string name; };
std::meta::info r_id = ^^Data::id;
Data d{42, "hello"};
d.[: r_id :] = 100;  // 等价于 d.id = 100

反射遍历所有成员,自动计算哈希

template <typename T>
    requires std::is_class_v<T>
size_t calculate_hash(const T& obj, size_t seed = 0)
{
    constexpr auto ctx = std::meta::access_context::unchecked();

    // subobjects_of 同时包含基类子对象 + 非静态数据成员
    static constexpr auto r_subobjects = std::define_static_array(
        std::meta::subobjects_of(^^T, ctx));

    // template for:编译期展开,每个成员生成一条 hash_combine
    template for (constexpr auto r_sub : r_subobjects)
    {
        using Subobject_t = typename[:std::meta::type_of(r_sub):];
        static_assert(Hashable<Subobject_t>, "Subobject must be hashable");
        Utility::hash_combine(seed, obj.[:r_sub:]);  // splicer 访问成员值
    }
    return seed;
}

access_context::unchecked() 让 private 成员也能访问(不然 private member 的 hash 会被漏掉)。

opt-in 机制:变量模板特化一行搞定:

template <typename T>
inline constexpr bool enabled_for_hashing_v = false;

template <EnabledForHashing T>
struct std::hash<T> {
    size_t operator()(const T& obj) const { return calculate_hash(obj); }
};

template <>
inline constexpr bool enabled_for_hashing_v<Person> = true; // 启用

以前写 std::hash<T> 特化要手写 N 行,新加成员还容易漏。现在加一行特化就行,新成员自动进哈希计算。(Godbolt

Search optimization journey 3: Optimize norm gathering

SereneDB 系列的第三篇。背景:BM25 评分要读每个文档的 norm 值,128个文档一个块,每次需要从 columnar storage 按偏移量 gather 128 个值:

for (scores_size_t i = 0; i != kPostingBlock; ++i) {
    values[i] = ReadValue(origin, docs[i] - doc_base);
}

这是 gather pattern,每次迭代访问不同内存地址,编译器拒绝自动向量化(就算加 #pragma clang loop vectorize(enable) 也不行)。

AVX2 手写 gather intrinsics:

auto indices = _mm256_loadu_si256(reinterpret_cast<const __m256i*>(docs + i));
indices = _mm256_sub_epi32(indices, base);
auto gathered = _mm256_i32gather_epi32(
    reinterpret_cast<const int*>(origin), indices, std::to_underlying(Encoding));
gathered = _mm256_and_si256(gathered, mask);
_mm256_storeu_si256(reinterpret_cast<__m256i*>(values + i), gathered);

x86 能跑,但 ARM NEON 没有 gather 指令(只有 SVE 才有)。于是做了 benchmark,结果出乎意料:

关键洞察:sorted posting list 里有大量 contiguous block([1100, 1101, …, 1227])。连续 block 可以用 sequential read,编译器能完全向量化。

加一个简单 check:

if (docs[kPostingBlock-1] - docs[0] == kPostingBlock-1) {
    // contiguous: 编译器自动 vmovdqu + vpmovzxwd,完美 SIMD
} else {
    // sparse: 逐元素 gather
#pragma clang loop unroll(full)
    for (...) values[i] = ReadValue(origin, docs[i] - base);
}

Benchmark 结果(AMD Ryzen 9 9950X,clang 21)

方案 Dense Sparse Mixed
Scalar 25.7 ns 25.7 ns 26.6 ns
Gather (AVX2) 23.5 ns 23.4 ns 24.1 ns
Hybrid(无 unroll) 2.7 ns 26.0 ns 17.8 ns
HybridUS(sparse unroll) 2.7 ns 18.9 ns 13.8 ns

Dense 路径:2.7 ns vs 25 ns,快 8x

vpgatherdd 看起来是一条指令,但内部仍然是 8 个单独 micro-load 串行执行,它打不过编译器自动生成的 vpmovzxwd(一次 load 8 个 short + zero-extend 成 32-bit)。

:把 dense 路径也 unroll(full) 会变成 5x 更慢(13.5 ns vs 2.7 ns)——编译器展开后看到 128 个独立的常量偏移访问,反而不认识连续 load 模式了,退化成 128 个 scalar load。

最终方案:zero intrinsics,portable,每个路径都赢。反直觉的地方在于 AVX2 gather 根本没想象中快

Top 20 C++ multithreading mistakes and how to avoid them

2026年更新版,加入了 C++20/23 的新内容。挑几个典型的:

坑1:忘记 join()

// 崩溃
std::thread t1(LaunchRocket);
// 忘了 t1.join(),析构时 terminate()

// C++20 解法:jthread 自动 join
std::jthread t1(LaunchRocket); // 析构时自动 join,没事

坑8:加锁顺序不一致导致死锁

std::mutex muA, muB;

void CallHome_AB(const std::string& msg) {
    muA.lock();  // 先锁A
    std::this_thread::sleep_for(100ms);
    muB.lock();  // 再锁B
    // ...
    muB.unlock(); muA.unlock();
}

void CallHome_BA(const std::string& msg) {
    muB.lock();  // 先锁B
    std::this_thread::sleep_for(100ms);
    muA.lock();  // 再锁A — 死锁!
    // ...
}

t1 持有 A 等 B,t2 持有 B 等 A,经典哲学家就餐。

C++17 解法:std::scoped_lock 原子性获取多锁

void CallHome(const std::string& msg) {
    std::scoped_lock lock(muA, muB); // 原子性获取两把锁,内部用死锁规避算法
    std::cout << msg << "\n";
} // 自动释放

坑9:mutex 双重加锁 UB

异常路径最容易触发:正常路径加锁+解锁没问题,但异常路径触发第二次 lock 导致 UB(debug 实现通常是 crash)。解法是用 std::recursive_mutex 或者提取 RAII 包装。

坑6/7:不用 RAII 锁

直接调 mu.lock()/unlock() 一旦中间抛异常就死锁。lock_guard 解决单锁,scoped_lock 解决多锁,现代 C++ 根本不应该手动 lock/unlock。

老文经典,新人必看,老人也有拿来复盘的价值

You’re absolutely right, no one can tell if C++ is AI generated

Mathieu Ropert 分析了一条推文:两段实现同一功能的代码,一段 AI 生成,一段人工写,猜哪个是哪个。

// Option 1
Node* get_or_create(Nodes& nodes, std::string_view name) {
    auto it = nodes.data.find(name);
    if (it != nodes.data.end()) {
        return it->second.get();
    }
    auto node = std::make_unique<Node>();
    node->name = name;
    Node* node_ptr = node.get();
    nodes.data.try_emplace(name, std::move(node));
    return node_ptr;
}

// Option 2
Node* get_or_create(const string& name) {
    if (!nodes.count(name)) {
        nodes[name] = make_unique<Node>();
        nodes[name]->name = name;
    }
    return nodes[name].get();
}

Option 2 用了 3-4 次 operator[]/count 查找,性能是 Option 1 的一半。按常理判断 Option 2 是 AI 写的——非惯用 C++,像从 Python 转过来的写法。

然后他去问 ChatGPT,ChatGPT 判断 Option 1 是 AI 写的——因为”繁琐过度工程”。AI 以为人类喜欢写低效但可读的代码。

ChatGPT 自己提供的版本:

Node* get_or_create(const std::string& name) {
    auto [it, inserted] = nodes.try_emplace(name, nullptr);
    if (inserted) {
        it->second = std::make_unique<Node>();
        it->second->name = name;
    }
    return it->second.get();
}

这才是干净的 C++17 写法,一次查找,比两个原版都好。但用了全局变量,没用 string_view,还是有瑕疵。

结论:用 AI 检测 AI 生成代码是浪费时间,它们的训练数据里充满了人写的烂代码。两段都烂,最好的版本反而是 AI 给出的

C++20 changes to std::chrono you might not be aware of: clocks and more

C++20 除了加日历和时区,还悄悄加了 5 个新 clock,大多数人不知道。

5 个新 clock

Clock 起点 闰秒 说明
utc_clock 1970-01-01 ✅ 计入 UTC,比 system_clock 多计闰秒
tai_clock 1958-01-01 国际原子时,当前 = UTC + 37s
gps_clock 1980-01-06 GPS时间,当前 = UTC + 18s
file_clock 实现定义 实现定义 std::filesystem::file_time_type 的底层
local_t 伪时钟,只是类型标签,表示”本地时区”

clock_cast:在不同 clock 间转换 time_point

#include <chrono>

auto sys_now = std::chrono::system_clock::now();

// system_clock → utc_clock(会加上累积的闰秒偏移)
auto utc_now = std::chrono::clock_cast<std::chrono::utc_clock>(sys_now);

// system_clock → tai_clock(UTC + 37秒)
auto tai_now = std::chrono::clock_cast<std::chrono::tai_clock>(sys_now);

// system_clock → gps_clock(UTC + 18秒)
auto gps_now = std::chrono::clock_cast<std::chrono::gps_clock>(sys_now);

// file_clock ↔ system_clock(读文件时间戳后转 system_clock 比较常见)
auto file_now = std::chrono::file_clock::now();
auto sys_from_file = std::chrono::clock_cast<std::chrono::system_clock>(file_now);

is_clock trait:检查类型是否满足 Clock concept

static_assert(std::chrono::is_clock_v<std::chrono::system_clock>);   // true
static_assert(std::chrono::is_clock_v<std::chrono::utc_clock>);      // true
static_assert(std::chrono::is_clock_v<std::chrono::local_t>);        // false!local_t 不是真正的 clock
static_assert(!std::chrono::is_clock_v<int>);                        // false

为什么要区分 system_clockutc_clock

system_clock 走的是 Unix 时间(不计闰秒),而 utc_clock 计入闰秒。两者差距随时间累积(截至2024年差37秒)。对精确时间戳(GPS定位、金融交易、天文)来说这个差别很重要;普通业务用 system_clock 就够了。

local_t 的用途

local_t 不是 clock,没有 now(),只是一个类型标签,专门配合 zoned_time 表示”不带时区信息的本地时间”:

// local_t 配合 time_zone 使用
auto tz = std::chrono::locate_zone("Asia/Shanghai");
std::chrono::local_time<std::chrono::seconds> local_now{...};  // local_t 的 time_point
auto zoned = std::chrono::zoned_time{tz, local_now};           // 加上时区变成绝对时间

做金融、日志、文件系统相关业务的值得了解一下,clock_cast 很实用

What happens when a destructor throws

面试经典题,很多人答不上来。

情况一:正常情况下析构函数抛异常Godbolt

struct A {
    ~A() {
        throw std::runtime_error("boom");
    }
};

int main() {
    try {
        A a;
    } catch (const std::exception& e) {
        std::cout << "Caught: " << e.what() << "\n";
    }
}

这个能 catch 到,因为没有其他异常在飞。

如果想明确允许析构抛异常,需要加 noexcept(false)(默认析构是 noexcept,里面 throw 会直接 terminate):(Godbolt

struct A {
    ~A() noexcept(false) {
        throw std::runtime_error("boom");
    }
};

情况二:栈展开时析构函数再抛Godbolt

struct A {
    ~A() noexcept(false) { throw std::runtime_error("destructor boom"); }
};

void foo() {
    A a;
    throw std::runtime_error("first exception");
}

int main() {
    try { foo(); }
    catch (...) {}
}

foo() 抛异常,开始 unwind,~A() 被调用,~A() 又抛,此时已经有一个活跃异常,std::terminate 直接调用,程序挂。

结论:析构函数里的异常只有在没有其他活跃异常时才能传播,否则 terminate。所以析构函数应该 noexcept,顶多 catch 然后记日志,绝对不要往外抛。

省流:析构抛异常等于自杀

Rust to C++: Implementing The Question Mark Operator

Rust 的 ? 运算符:函数返回 Result/Option 时,遇到错误直接提前返回,否则解包值。C++ 里对应的是 std::expected/std::optional,但没有语法糖,要写:

// 没有?操作符的写法,每次都这样...
const auto maybe_value{some_func()};
if (!maybe_value.has_value()) {
    return std::unexpected(maybe_value.error());
}
auto value{maybe_value.value()};

这篇文章实现了一个叫 maybe 的宏(作者不用大写是因为想当原语用)。

为什么 IIFE 不行:lambda 里的 return 只返回 lambda 本身,不能从外层函数返回。

为什么 goto 不行:需要在函数顶部设 label,副作用很难管理。

解法:GNU statement expressions(GCC 和 Clang 都支持):

({ stmt1; stmt2; final_value; })  // 最后一条语句的值就是整个表达式的值
                                   // 内部的 return 会真正从父函数返回!

宏重载(macro routing)——让 maybe 支持 1 个或 2 个参数:

#define GET_maybe_MACRO(_1, _2, NAME, ...) NAME
#define maybe(...) GET_maybe_MACRO(__VA_ARGS__, maybe_2, maybe_1)(__VA_ARGS__)

原理:参数向后移位,NAME 自然选到对应的宏名。1个参数时选 maybe_1,2个选 maybe_2

maybe_2 的实现(带 fallback 的版本):

#define maybe_2(expr, fallback)                                     \
  ({                                                                \
    auto&& _result{(expr)};                                         \
    if (!_result) {                                                 \
      [[maybe_unused]] auto&& _maybe_error{::_get_error(_result)};  \
      using _fallback_type = std::decay_t<decltype(fallback)>;      \
      return std::unexpected<_fallback_type>(fallback);             \
    }                                                               \
    *std::move(_result);                                            \
  })

_get_errorrequires 表达式区分 std::optionalstd::expected

template <typename T>
auto _get_error(T&& container) -> decltype(auto) {
    if constexpr (requires { container.error(); }) {
        return std::forward<T>(container).error();
    } else {
        return nullptr;
    }
}

用起来像这样:

const auto binary{maybe(search<binary_pattern>(line),
                         std::unexpected(missing_component("binary")))};
// 如果 search 返回空,自动提前返回 unexpected(missing_component(...))
// 否则 binary 就是解包后的值

相关提案是 P2561(2022年提的,至今还在讨论中)。文章实现过程本身比结果更有价值,展示了宏路由 + GNU statement expressions 的组合,以及为什么 IIFE/goto 都行不通。

embed赶紧落地,? 赶紧来吧

Two studies in compiler optimisations

两个案例研究 LLVM 内部优化过程,用 LLVM 22.1.0 作为参考实现。

Case 1:取模递增

round-robin 场景:

unsigned next_naive(unsigned cur, unsigned count) {
    return (cur + 1) % count;
}

生成 div 指令,6 cycles latency。知道 cur < count 的前提,可以改成:

unsigned next_cmov(unsigned cur, unsigned count) {
    unsigned next = cur + 1;
    return next < count ? next : 0;
}

生成 cmov(conditional move),快很多。文章深入分析了 LLVM 的 peephole 优化、loop invariant 提升等 pass 是如何处理这两种写法的(LLVM IR 演变 Godbolt),很有启发性。

Case 2:字节序转换

标准写法是手工拼 bytes:

uint32_t load_le32(const uint8_t* data) {
    return data[0] | (data[1] << 8) | (data[2] << 16) | (data[3] << 24);
}
uint32_t load_be32(const uint8_t* data) {
    return data[3] | (data[2] << 8) | (data[1] << 16) | (data[0] << 24);
}

x86 上 -O1 就能优化成 mov eax, [rdi] + 可选的 bswap eax,最优了。

有人不想重复写,改成泛型版本:

static uint64_t load(const uint8_t* data, size_t sz, bool be) {
    uint64_t val = 0;
    for (size_t i = 0; i < sz; ++i)
        val |= static_cast<uint64_t>(data[be ? sz-i-1 : i]) << (8 * i);
    return val;
}
uint32_t load_le32(const uint8_t* data) { return load(data, 4, false); }
uint32_t load_be32(const uint8_t* data) { return load(data, 4, true); }

编译结果一样(Godbolt)!但优化路径不同:这次要等到 ISel 阶段的 DAGCombiner 才能识别出”多个 byte load + 移位 + or = 一个宽 load”的模式。DAGCombiner 里的 MatchLoadCombine() 递归追踪每个字节的来源,最多追 10 层,写法特别的可能超限。

如果改成模板版:

template <size_t sz, bool be>
static uint64_t load(const uint8_t* data) { /* 同上 */ }

小端版本提前在 AggressiveInstCombine 就被优化了(-O2 才跑,只跑一次),更早优化 = 更多后续优化机会(Godbolt)。大端版本 AggressiveInstCombine 不处理(中端 pass 不想引入 target-specific bswap intrinsic),还是留给 DAGCombiner。

搬起石头砸自己脚

uint32_t load_add_le32(const uint8_t* data) {
    return load_le32(data) + data[0];
}

看起来应该是一次 32-bit load + 一次 byte load + add。实际汇编(Godbolt):

movzx ecx, byte [rdi]
movzx edx, word [rdi+1]
shl   edx, 8
movzx eax, byte [rdi+3]
shl   eax, 24
or    eax, edx
or    eax, ecx
add   eax, ecx

4 个单独 byte load!因为 DAGCombiner 在追踪 byte 来源时,故意排除了已在其他地方用过的 bytedata[0]+ data[0] 也用了),防止引入重复 load。这个逻辑在通常情况下合理,这里反而坏了。

另一个坑:-Os 优化大小时,编译器拒绝展开循环,DAGCombiner/AggressiveInstCombine 就没法识别模式,需要手动加 #pragma unrollGodbolt)。

了解一下还是可以的,做性能优化的强烈推荐看

Evolving a Translation System with Reflection in C++

C++26 反射的实际用例:一个多语言翻译系统。

现有的设计每次添加语言都要改 enum、struct 成员、switch 三个地方:

enum class language : std::uint8_t { en_US, en_GB, de_DE, de_CH };

template<typename... Args>
struct translation_set {
    std::format_string<const Args&...> en_US;
    std::format_string<const Args&...> en_GB;
    std::format_string<const Args&...> de_DE;
    std::format_string<const Args&...> de_CH;

    constexpr auto string_for_language(lang::language lang) const {
        switch (lang) {
            case language::en_US: return en_US;
            case language::de_DE: return de_DE;
            // ...
        }
    }
};

用 C++26 反射后,可以让 translation_set 的成员名和 language enum 的枚举值通过反射自动关联,添加新语言只改 enum 就够了:

// 用反射:自动从enum成员名找对应的translation_set成员
template<language L, typename... Args>
constexpr auto get_string(const translation_set<Args...>& ts) {
    // 编译期根据 L 的 identifier 找 ts 的对应成员
    return [:find_member_by_name(^translation_set<Args...>,
                                 identifier_of(^L)):](ts);
}

文章探索了多种反射方案,比较各自的权衡,不是说”这个方案最好”,而是帮你建立对反射能干什么、值不值得的感觉。

反射赶紧来吧,这类样板代码真的太烦了

auto{x} != auto(x)

Arthur O’Dwyer 解释了一个细节:auto(x)auto{x} 不一样。

auto(x):P0849 引入的 decay-copy,等价于把 auto 替换成推导出的类型 T,然后做 T(x),通常就是复制构造。

auto{x}:等价于 T{x},这是带花括号的初始化,可能触发 initializer_list 构造函数!

auto paren() {
    std::vector<std::any> v;
    return auto(v);  // 返回一个空vector的拷贝(0个元素)
}

auto curly() {
    std::vector<std::any> v;
    return auto{v};  // 返回一个包含v的vector(1个元素!)
}

MSVC 目前把两者都当成 copy constructor,是个 bug。

结论:写泛型代码需要 decay-copy 时用 auto(x),别用 auto{x}Godbolt

The Global API Injection Pattern

如何测试使用全局 API(日志、文件 IO、分配器等)的代码?常见答案是 DI,但传统做法(虚函数 singleton、到处传 logger 对象)都有代价。

这篇介绍了一种用变量模板特化实现零开销 DI 的方式:

// 定义API接口
struct logger {
    static auto log(auto&&...) -> void;
};

// 变量模板 + 函数模板包装
template <typename...>
constexpr inline auto log_api = logger{};

template <typename... DummyArgs, typename... Args>
auto log(Args&&... args) -> void {
    auto& api = log_api<DummyArgs...>;
    api.log(std::forward<Args>(args)...);
}

// 生产代码特化
template <>
constexpr inline auto log_api<> = my_logger{};

// 测试代码特化(或者不特化,用默认的null logger)
template <>
constexpr inline auto log_api<> = test_logger{};

好处:

DummyArgs 那个 trick 有点绕,但理解之后确实挺优雅

Finding a duplicated item in an array of N integers in the range 1 to N − 1

Raymond Chen 分享的算法题:N 个整数,值域 1 到 N-1,找一个重复的。由鸽巢原理必有重复,可能不止一个。

O(N log N) 显然(排序),但 O(N) 空间 O(N) 时间?

Raymond 的方法:把数组看成 1-based 链表,借符号位做访问标记,沿链追踪直到发现已标记节点(即重复项)。修改了数组,但最后能还原。

面试者的方法(更优):把数组看成图,值是出边。N 不在 1 到 N-1 范围内,所以从 index N 出发的链必然走进某个环,环的入口就是重复项。用 Floyd’s cycle detector(龟兔赛跑算法):

找到相遇点 → 重置一个指针到起点 → 同速前进 → 相遇处即环入口(即重复项)

O(N) 时间,O(1) 额外空间,不修改数组。

面试者把面试官教了一手,这个故事本身就值得记住

simd distances

上面说过了,SereneDB 用 std::abs 加两个编译器 flag(-fassociative-math -fno-signed-zeros)让 Clang 自动生成 4 路并行的 AVX2 代码,L1 距离比 Faiss 手写的 AVX2 intrinsics 还快。-fassociative-math 允许重排浮点运算顺序,让编译器创建 4 个独立累加器,一次处理 16 个 float,而 Faiss 手写版本只处理 8 个。

让编译器来做,这点很值得复现一下

Annotations for C++26 Hashing

系列第三篇,在上一篇反射哈希的基础上,用 C++26 annotations(注解)让 opt-in 更优雅。

上一篇 opt-in 需要写模板特化:template<> inline constexpr bool enabled_for_hashing_v<Person> = true;,有点啰嗦。用 C++26 annotations 可以改成:

struct [[=hashable]] Person {
    int id;
    std::string name;
    [[=skipped_for_hash]] std::optional<std::string> cached_full_name; // 不参与哈希
};

C++26 annotations 语法[[=value]],value 可以是任意 structural type 的常量值:

struct CompositeAnnotation { int id; int value; };

struct Data {
    [[=42]] int x;
    [[=CompositeAnnotation{.id=7, .value=3}]] int y;
};

查询注解用三个 meta-function:

实现 [[=hashable]] opt-in

struct Hashable_t {};
inline constexpr Hashable_t hashable;

// 检查类型是否有 hashable 注解
template <typename T>
concept EnabledForHashing =
    std::meta::annotations_of_with_type(^^T, ^^Hashable_t).size() > 0;

template <EnabledForHashing T>
struct std::hash<T> {
    size_t operator()(const T& obj) const { return calculate_hash(obj); }
};

实现 [[=skipped_for_hash]] 跳过成员

struct SkippedForHash_t {};
inline constexpr SkippedForHash_t skipped_for_hash;

// calculate_hash 里加 if constexpr 过滤
auto included_for_hashing = [](std::meta::info r_entity) consteval -> bool {
    return std::meta::annotations_of_with_type(r_entity, ^^SkippedForHash_t).size() == 0;
};

template for (constexpr auto r_sub : r_subobjects)
{
    if constexpr (included_for_hashing(r_sub))
    {
        Utility::hash_combine(seed, obj.[:r_sub:]);
    }
}

用起来非常干净:

struct [[=hashable]] User : Id, Person, [[=skipped_for_hash]] ScoreMixIn<int> {
    std::chrono::year_month_day registration_date;
    [[=skipped_for_hash]] std::chrono::year_month_day last_login; // 不参与哈希
};

基类、成员各自都能标注 skip,intent 直接写在代码里,比藏在模板特化里清晰多了。

这两篇合起来是 C++26 反射实际应用的很好示例,值得一看。Annotations 篇 Godbolt,GCC 16 已经可以跑

开源项目介绍


上一期

本期

下一期

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