
写 C 代码有两种状态。第一种是知道语法点能干什么C20 出了 concept可以在模板参数前面写约束有了三路比较运算符可以用自动生成比较关系。第二种是知道这一行在编译器里发生了什么、失败发生在哪一层、会不会影响运行效率。这篇文章要聊的是第二种也就是标题里说的“底层基本功”。C20 真正值钱的地方不是又多了十几个关键词而是它让一些原本靠习惯维护的规则变成了语言级保证。模板约束从“文档建议”变成“编译期检查”大小比较从“手写判断逻辑”变成“编译器生成语义一致代码”编译期求值从“你能在 constexpr 里写多少”变成“哪些事必须发生在编译期”。如果只把这些特性当语法糖用那它们确实只是糖。但如果你理解它们背后的求值时机、重载规则和对象生命周期C20 是一套更现代的接口设计工具。本文不是“C20 新特性大全”因为那个清单太长了单独列出来反而没有价值。我会挑五个和底层功底强相关的方向展开concept、三路比较、consteval/constinit、std::span以及几个数值和位级工具。代码尽量保持最小可运行你只需要一个支持 C20 的编译器比如 GCC 11 以上、Clang 14 以上或 VS2022。适合的读者有两类一类是正在做 C 后端、客户端或嵌入式固件想升级到 C20 但担心模板报错和运行时开销的人另一类是读过 C20 特性清单但想知道这些特性“为什么这么设计”的读者。1. 先看结论本文要夯实的 C20 基本功清单先给一张速查表把每个特性的核心价值放在前面后面再展开细节。特性主要头文件 / 位置运行期成本底层价值concept 概念约束concepts编译期谓词不引入动态分发约束在模板实例化之前检查报错位置更靠近调用点三路比较compare编译器可按成员顺序生成比较代码自动补齐相等与全序比较避免手写逻辑不一致consteval / constinit语言内置consteval 强制编译期求值constinit 消除动态初始化顺序问题把“必须在编译期做的事”做进语言规则std::spanspan只保存指针和长度不拥有内存安全替代“裸指针 长度”的接口写法std::bit_cast / midpoint / lerpbitnumericcmath不引入额外运行时开销消除类型双关 UB处理数值边界问题这些特性彼此独立却都指向同一个目标让现代 C 在“性能不退化”的前提下把更多错误从运行期挪到编译期。本文后面每个章节都会给出代码示例建议你复制到自己的工程分支里单独验证不要在主干上直接改。2. concept 概念约束让编译器先拒绝而不是让模板实例化爆炸模板的最大问题从来不是“能写多花”而是“写错了在哪里报错”。在只用typename的 C17 时代约束只能写在注释里。调用方传错类型时编译器会一头扎进函数模板的实现内部实例化到某个表达式的第几层才发现operator不存在然后吐出一屏和用户代码无关的模板深度信息。concept 的价值就是把检查前移。你在模板“门口”声明清楚要求编译器先验证类型是否满足要求不满足就直接在这里停下不再一路实例化到函数体深处。下面是一个很小的概念例子#include concepts #include cstddef #include functional #include string_view template typename T concept Hashable requires(T value) { { std::hashT{}(value) } - std::convertible_tostd::size_t; }; template Hashable T std::size_t MakeKey(const T value) { return std::hashT{}(value); }代码里的Hashable是一个命名的编译期谓词。requires(T value)并不是在定义 lambda而是在定义一条约束规则给你一个T类型的临时对象调用std::hashT{}(value)是否合法返回类型是否能转换成std::size_t。如果合法说明该类型满足Hashable如果不合法说明它不能作为缓存键、哈希键或路由键。这种约束不只是“好看”。当不满足约束的类型被调用时编译错误会直接指出“没有匹配的 MakeKey因为约束不满足”而不是先进入模板内部的某个分支再报错。对大型项目来说这种差异直接影响排查成本。如果要在 C17 里达到类似效果通常要用std::enable_if_t和 SFINAEtemplate typename T, std::enable_if_tstd::is_convertible_v decltype(std::hashT{}(std::declvalT())), std::size_t, int 0 std::size_t MakeKeyOld(const T value) { return std::hashT{}(value); }两段代码功能相似但可读性差很多。C20 concept 把类型条件提炼成可命名对象还能复用、组合和求反。对一个成熟的现代 C 工程来说模板接口要能清楚表达“要求”这就是最基本的基本功。理解 concept 时还要注意一点它是编译期约束不是运行时多态机制。concept 不会生成虚表不会让对象跑到 heap 上去存 type_info更不会改变函数的 ABI。它只影响重载决议和模板实例化选择。编译器在一个满足约束的类型进入函数体前仍然会生成和普通模板一样的机器码。3. 三路比较运算符让编译器按照成员顺序替你写比较手写比较逻辑是 C 里最容易出错又最无聊的代码之一。为一个结构体写operator要先想成员顺序再想和是否一致后面加了字段忘了改排序和查找的行为就悄悄变了。C20 引入目的不是省几行代码而是把“按声明顺序逐成员比较”的语义标准化。一个典型的值对象可以写成这样#include compare #include algorithm #include vector struct Point { int x; int y; bool operator(const Point) const default; auto operator(const Point) const default; }; int main() { std::vectorPoint points {{1, 3}, {0, 5}, {2, 2}}; std::sort(points.begin(), points.end()); static_assert(Point{1, 2} Point{1, 2}); static_assert(Point{0, 9} Point{1, 0}); return points[0].x points[0].y; }这里有一个很容易忽略的细节默认的operator会把比较顺序绑定到成员声明顺序。也就是说Point先比x再比y。如果成员声明顺序不是“逻辑主键优先”结果可能与预期不符。要想清楚“先比谁、再比谁”而不是把这件事交给默认行为。的返回类型也不是只有一个。它可能返回std::strong_ordering、std::weak_ordering或std::partial_ordering。默认比较时会根据成员类型推导。如果结构体里只有整型、枚举这类全序成员通常得到std::strong_ordering。如果包含浮点成员由于浮点存在 NaN比较结果可能无法排序通常会得到std::partial_ordering。这一点需要结合你的业务语义理解不存在“独一无二的最优比较顺序”只有“满足你这套领域规则的比较顺序”。有些人会担心默认比较的性能。实际上编译器为 default的比较运算符生成的代码可以做到和手写逐成员比较一样紧凑。它不会为了“统一处理所有比较”而引入函数指针或虚调用。真正需要关注的是比较顺序与分支数量而不是“用了默认比较就变慢”。还有一个底层知识点C20 的和在重载决议时会自动生成重写候选。也就是说即使你只显式定义了和!、、、、也能通过重写机制找到可用版本。但这不代表你可以完全忽略它们。在模板代码中如果类型没有显式暴露某个比较运算符依赖默认重写的代码在某些编译器上仍可能因为调用深度而产生较难理解的错误。因此值对象建议保留 default的完整比较运算符让意图更明确。4. consteval 与 const