公众号

点击「查看原文」跳转到 GitHub 上对应文件,链接就可以点击了
qq群 753792291 答疑在这里
欢迎投稿,推荐或自荐文章/软件/资源等,评论区留言
第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以来最重要的版本,吗。
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 的必看
Raymond Chen 的侦探故事。客户报告包含 ole2.h 时编译器报错:error C3927: '->': trailing return type is not allowed,但代码里根本没有 ->。
结论:有个宏叫 AddError,展开后包含 -> token。客户一开始声称没有这样的宏,让他们生成预处理结果才发现确实有。
教训:编译器报说什么东西非法,那个东西大概率存在于某个宏展开里。当报错信息看起来和代码完全对不上时,先查宏。
一个 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”,而是”编译器知道怎么做”。
后果一连串:
memcpy,必须用 std::relocate/std::uninitialized_relocate,强制依赖标准库头文件realloc——P1144 至少开了这扇门,P2786 直接锁死作者总结:P2786 感觉是委员会为标准库自己加的特性,普通 C++ 用户几乎捞不到任何好处。
结论:应该标准化 P1144 的定义(trivially relocatable = 可以 memcpy),把属性拆成 [[trivially_relocatable_if_eligible]] 和 [[trivially_relocatable_if(predicate)]];P2786 在没有实际收益证明之前不该推进。
P2786 差点进了 C++26,最后一刻撤回,P1144 从2018年起卡在委员会里已经8年了。这两个提案的故事是 C++ 委员会里最典型的互相否决死锁案例之一
系列第二篇。作者之前用 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)
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 根本没想象中快
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。
老文经典,新人必看,老人也有拿来复盘的价值
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 除了加日历和时区,还悄悄加了 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_clock 和 utc_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 很实用
面试经典题,很多人答不上来。
情况一:正常情况下析构函数抛异常(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 的 ? 运算符:函数返回 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_error 用 requires 表达式区分 std::optional 和 std::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赶紧落地,? 赶紧来吧
两个案例研究 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 来源时,故意排除了已在其他地方用过的 byte(data[0] 被 + data[0] 也用了),防止引入重复 load。这个逻辑在通常情况下合理,这里反而坏了。
另一个坑:-Os 优化大小时,编译器拒绝展开循环,DAGCombiner/AggressiveInstCombine 就没法识别模式,需要手动加 #pragma unroll(Godbolt)。
了解一下还是可以的,做性能优化的强烈推荐看
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);
}
文章探索了多种反射方案,比较各自的权衡,不是说”这个方案最好”,而是帮你建立对反射能干什么、值不值得的感觉。
反射赶紧来吧,这类样板代码真的太烦了
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)
如何测试使用全局 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 有点绕,但理解之后确实挺优雅
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) 额外空间,不修改数组。
面试者把面试官教了一手,这个故事本身就值得记住
上面说过了,SereneDB 用 std::abs 加两个编译器 flag(-fassociative-math -fno-signed-zeros)让 Clang 自动生成 4 路并行的 AVX2 代码,L1 距离比 Faiss 手写的 AVX2 intrinsics 还快。-fassociative-math 允许重排浮点运算顺序,让编译器创建 4 个独立累加器,一次处理 16 个 float,而 Faiss 手写版本只处理 8 个。
让编译器来做,这点很值得复现一下
系列第三篇,在上一篇反射哈希的基础上,用 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:
annotations_of(r) — 返回所有注解annotations_of_with_type(r, r_t) — 返回指定类型的注解std::meta::extract<T>(r) — 提取注解值实现 [[=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 已经可以跑