ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C++自定义字面量:把语义焊进类型系统,编译期守卫单位换算

C++自定义字面量:把语义焊进类型系统,编译期守卫单位换算 我最初接触自定义字面量其实是带着一种这语法真花哨的偏见去看的。直到有一次在项目里排查线上问题查到一个接口的单位换算出错——代码里所有时间相关的入参都是裸的int谁也没办法从sleep(1000)里看出这到底算毫秒还是微秒。从那一刻起我才认真开始研究operator的完整用法。折腾半年多之后我可以负责任地说自定义字面量User-Defined LiteralsUDL绝对不是语法糖它能把数值的语义直接焊进类型系统里让很多本该在运行期或者人肉 review 才能发现的错误直接在编译期暴露出来。这篇文章就是我对这个特性的全量复盘覆盖语法拆解、编译期物理量系统、字符串字面量的陷阱、以及如何用 UDL 搭建小型的编译期 DSL适合已经有一定 C 基础、想把代码写得更有表达力的读者。1. 从1000到1_kg内置字面量满足了可读性却满足不了语义1.1 数字分隔符和进制前缀解决的是看起来乱C14 引入的数字分隔符是我的第一个救命稻草1000000确实比1000000好认太多。C14 还顺带把0b1010这种二进制字面量转正配合0xFF、0八进制语言的数值表达力已经挺强了。但这些改进都有一个共同点它们只解决了字面量怎么书写的问题完全没有解决字面量代表什么含义的问题。你看这行代码void schedule(int timeout); schedule(300);作为一个阅读者你只能强行记住300 的单位是毫秒这个隐含约定。一旦某个调用方传进来的是微秒或者某次重构把单位的定义改了编译器完全无感这个锅只能靠代码评审和测试来背。内置字面量再怎么加糖都没办法把数字和单位绑成一个整体。1.2 自定义字面量的本质把后缀变成类型的一部分C11 引入的自定义字面量核心机制就是允许你给任何数值或字符串字面量追加后缀然后编译器在解析该字面量时会去调用你定义的operator函数。举个例子同样是 300 毫秒我可以写成auto timeout 300_ms;关键在于300_ms不是一个int而是你自己定义的类型比如DurationMilli之类。这样一来函数签名就可以从void schedule(int)改成void schedule(DurationMilli)传错单位直接编译失败。到了这个层面字面量承载的不再是一串数字而是一个带有物理意义和类型的值。1.3 这个特性的第一现场标准库自己就是最大用户很多人其实早就在用 UDL只是没意识到。C14 的std::chrono就提供了后缀是h、min、s、ms、us、ns的字面量运算符C17 的std::string和std::string_view又提供了s和sv后缀。如果你写过auto x 2s;或者auto str hellos;那你已经享受过 UDL 的红利了。标准库的这波操作给了我一个很明确的信号这个特性不是给玩具项目准备的而是官方认可、适合大规模使用的语言能力。写业务代码的人完全可以用同样的思路给日期、字节、坐标、百分比、金额这些高频概念设计自己的字面量。下文我就从语法层开始一步步拆解怎么设计出靠谱的 UDL。2. 语法骨架详解cooked 函数与 raw 模板的适用边界2.1 五种 cooked 签名一张表看完整自定义字面量的运算符函数主要分成两类一类叫 cooked已加工形式接收的是编译器解析完字面量之后的标准值另一类叫 raw原始形式接收的是字面量源代码的字符序列。先看 cooked 形式的完整签名集合字面量类型运算符签名示例整数operator_suffix(unsigned long long)42_kg浮点数operator_suffix(long double)3.14_pi普通字符串operator_suffix(const char*, size_t)abc_tag宽字符串operator_suffix(const wchar_t*, size_t)Labc_tagUTF-8 字符串 (C20)operator_suffix(const char8_t*, size_t)u8abc_tagUTF-16/32 字符串operator_suffix(const char16_t*, size_t)/(const char32_t*, size_t)uabc_tag,Uabc_tag注意几个细节。第一整数字面量的 cooked 参数一定是unsigned long long哪怕你写的是int i 5_x;字面量本身在进入后缀函数之前也会按unsigned long long处理。第二浮点字面量的 cooked 参数一定是long double。第三字符串的 cooked 形式必须同时接收指针和长度因为裸的const char*是无法确定长度的编译器替你算好长度再传进来是最合理的接口。2.2 raw 模板拿到的是字符包不是数值raw 形式长这样templatechar... Cs constexpr auto operator_bin() - unsigned long long;当编译器碰到1010_bin这种带后缀的整数字面量时如果选择调用 raw 模板Cs这个包里面装的就不是数值 1010而是字符序列1、0、1、0。你要自己把这些字符翻译成数值。这个能力非常有用因为你可以在解析的过程中塞入任何自定义逻辑比如按二进制解析、按自定义进制解析甚至对字符序列做合法性校验。写 raw 模板处理数字时我踩过最大的坑是数字前缀问题。对于1010_bin这种没有前缀的字面量字符包很干净。但是如果有人写出0b1010_bin前缀0b到底算不算进Cs标准演进和不同编译器实测的结果并不一致。所以我的建议是raw 模板场景下约定使用者不写进制前缀或者你自己在解析里把前缀字符显式跳过去。别让代码去赌编译器的处理方式。2.3 cooked 与 raw 该怎么选我的判断标准选型逻辑其实很朴素如果你的字面量本质上是数值的快捷写法后续操作只关心数值本身那就用 cooked。比如_kg、_ms、_kb核心是把数值和类型绑定没必要看到原始字符。如果你的字面量语义依赖数字长成什么样比如二进制、十六进制、自定义编码那就用 raw。cooked 形式拿到的是编译器已经按十进制转换好的数值你想恢复进制信息就晚了。字符串字面量没有 raw 模板形式这一点很多人会搞混。abc_tag只有 cooked 的指针长度签名可以选不存在templatechar...那种字符串版 raw 形态。这里还有一个实操经验同一后缀不要同时提供 cooked 和 raw 两个重载。同时存在不仅会让编译器做一次二选一的决议读者也会困惑到底哪个生效。我一般是一个后缀只提供一种形态要么 cooked要么 raw保持接口面清晰。3. 把单位写进类型编译期物理量系统的完整实现3.1 设计目标让编译器代替你做单位换算假设你想写一个物理计算库最理想的效果是这样的auto force 10_kg * 10_m / (2_s * 2_s); static_assert(std::is_same_vdecltype(force), QuantityDimMass, DimLength, DimTimePow-2);意思是10 千克乘以 10 米再除以2 秒乘以 2 秒得到的是一个质量×长度×时间⁻² 的量也就是力的量纲。整个过程必须在编译期完成运行期不能有任何开销。如果公式里混进了长度单位除以时间单位比如写成10_kg 3_m编译器就应该直接报错。3.2 维度用模板表达避免运行期存储开销我习惯用三个整数模板参数分别表示 质量、长度、时间 的指数templateint MassExp, int LengthExp, int TimeExp struct Dim { static constexpr int m MassExp; static constexpr int l LengthExp; static constexpr int t TimeExp; };然后用一个Quantity模板把维度和数值绑在一起templatetypename D, std::int64_t Scale 1 struct Quantity { static constexpr std::int64_t value Scale; using dim D; };这里我用int64_t存数值是为了在编译期整型运算中不出现浮点精度问题。如果你确实需要小数可以在运行时再除以定标系数。纯编译期常量场景整型是最稳的载体。3.3 运算符重载让量纲随运算自动变换再写同维度加法与跨维度乘法。加法要求两个量的维度完全一致乘法则是维度指数相加templatetypename D, std::int64_t V1, std::int64_t V2 constexpr auto operator(QuantityD, V1, QuantityD, V2) - QuantityD, V1 V2 { return {}; } templateint M1, int L1, int T1, int M2, int L2, int T2, std::int64_t V1, std::int64_t V2 constexpr auto operator*(QuantityDimM1, L1, T1, V1, QuantityDimM2, L2, T2, V2) - QuantityDimM1 M2, L1 L2, T1 T2, V1 * V2 { return {}; }模板签名已经把单位运算全部编码进类型了。10_kg * 10_m得到的类型是QuantityDim1,1,0, 100再除以(2_s * 2_s)即QuantityDim0,0,2, 4最终会走一个除法重载得到QuantityDim1,1,-2, 25。整个过程没有一句运行时代码也没有任何运行时计算——所有算术都在编译期常量表达式里完成了。3.4 UDL 入口给字面量挂上重量、长度、时间有了类型骨架接下来就是把10_kg这种书写形式映射到QuantityMass, 10using Mass Dim1, 0, 0; using Length Dim0, 1, 0; using Time Dim0, 0, 1; constexpr auto operator_kg(unsigned long long v) - QuantityMass, static_caststd::int64_t(v) { return {}; } constexpr auto operator_m(unsigned long long v) - QuantityLength, static_caststd::int64_t(v) { return {}; } constexpr auto operator_s(unsigned long long v) - QuantityTime, static_caststd::int64_t(v) { return {}; }注意这三个函数都声明为constexpr并且返回值类型是字面量类型LiteralType这样它们才有可能参与编译期常量表达式求值。10_kg本身在语法上优先按整数处理只有当你写了1.5_kg时编译器才会去寻找long double版本的重载。如果项目里两种写法都要支持就得两个重载都写constexpr auto operator_kg(long double v) - QuantityMass, 0; // 浮点进入后的处理策略见下我的建议是浮点 UDL 尽量别直接塞进整型Quantity可以在返回类型里加一个is_float标记或者干脆走定标整型。否则浮点常数进到整型模板里会静默截断这个坑会隐蔽到你怀疑人生。3.5 验证static_assert 是这类库的底线测试写完这套系统之后一定要写编译期断言否则编译期正确只是你一厢情愿static_assert(decltype(10_kg 10_kg)::value 20); static_assert(decltype(10_kg * 10_m / (2_s * 2_s))::value 25);如果某个单位写错了比如10_kg 3_m编译期就会提示operator找不到匹配的重载。你根本不需要写单元测试就知道这个库在编译层面是安全的。这种错误在编译期发生的爽快感是用标准数值类型写一万行防御性代码都换不来的。4. 字符串字面量 UDL 的幕后细节NUL 结尾、长度参数与 constexpr 边界4.1 字符串 UDL 没有 raw 模板形态别白费力气前面我提过一句字符串字面量只能走 cooked 的指针长度签名。我记得自己第一次尝试给字符串字面量写 raw 模板templatechar... Cs auto operator_tag() { ... } // 试图匹配 abc_tag结果编译器直接告诉我无法编译。后来查标准才确认raw 模板形式只对整数和浮点字面量开放字符串 UDL 的候选重载就只有指针长度的那几组。理解这个限制能帮你少走很多弯路处理字符串 UDL核心工作永远是围绕const char*和size_t两个参数展开的。4.2 NUL 终止符陷阱长度不包含\0但末尾可访问标准库和编译器会保证对于abc_tag这种形式传入的const char*指向一个以\0结尾的字符数组size_t参数是 3也就是说不包括结尾的\0。这个设计非常贴心和必要——如果你收到的字符串没有长度参数你根本不知道要在哪里停下来。但这里有一个非常经典的坑很多人在实现里下意识调用strlen(str)来拿长度以为和自己平时处理字符串一样。在编译期常量表达式里strlen这类运行时库函数是不能直接用的即使它对你人眼来说可以算编译器也不允许。正确做法是彻底信任传入的len参数循环范围[str, str len)。对于\0我自己的惯例是只在需要构造std::string_view时才利用str[len]一定等于\0这个事实其余解析逻辑一律以len为边界。4.3 写一个编译期解析函数C14 的循环在 constexpr 里终于可用了C11 的constexpr函数限制很多函数体基本只能是一条return语句写字符串解析简直是受刑。C14 放宽之后constexpr函数体内允许局部变量、循环、分支这让编译期解析器变得可以落地。下面是一个从字符串字面量解析 IPv4 地址的例子完整覆盖编译期解析会遇到的各种细节#include cstddef #include array struct IPv4 { std::arrayunsigned char, 4 octets{}; constexpr unsigned char operator[](std::size_t i) const { return octets[i]; } }; constexpr unsigned char parse_octet(const char* p, const char* end) { unsigned int v 0; while (p ! end *p ! .) { v v * 10 static_castunsigned int(*p - 0); p; } return static_castunsigned char(v); } constexpr IPv4 operator_ipv4(const char* str, std::size_t len) { IPv4 result{}; const char* p str; const char* end str len; bool ok true; for (std::size_t i 0; i 4; i) { if (p end) { ok false; break; } result.octets[i] parse_octet(p, end); if (i ! 3) { if (p end || *p ! .) { ok false; break; } p; } } if (p ! end) ok false; return ok ? result : IPv4{}; // 也可以在这里用一个 error flag 表达 }注意这里我没有在constexpr函数里直接throw因为 C20 之前throw 不属于常量表达式允许的操作。用返回一个空值/错误码的方式是跨标准和跨编译器最稳妥的错误处理方案。等 C20 全面铺开后你可以考虑用throw让非法输入产生编译错误但目前的代码还是要兼顾可移植性。4.4 C20 的 char8_tu8 字符串后缀的新增候选C20 把u8...字符串的类型从const char*变成了const char8_t*这意味着仅声明了operator_tag(const char*, size_t)的代码无法直接处理u8abc_tag。你的 UDL 如果预期接收 UTF-8 字面量就需要额外提供这个重载constexpr auto operator_tag(const char8_t*, std::size_t) - ...;这个新增的签名本质上是语言在 Unicode 道路上的一个补丁。我的经验是业务场景里能不用u8字符串尽量不用一旦用了就要把所有相关 UDL 的char版本同步补齐否则代码在 C20 模式编译时会无声地掉进不支持 UDL 的陷阱。5. 把 UDL 玩成小型 DSL编译期 IPv4、二进制解析与命名空间管理5.1 编译期 IPv4 的验证与使用上一节的_ipv4并不是纸上谈兵它可以这么用constexpr auto gateway 192.168.1.1_ipv4; static_assert(gateway[0] 192); static_assert(gateway[2] 1);此时 IP 地址解析发生在编译期运行期没有任何字符串处理。项目的配置模块里如果所有 IP 都是编译期常量那配置解析的开销就彻底消失了。我在实际项目中还把这种模式扩展到了 MAC 地址、版本号2.14.3_ver、以及简单的键值对字符串timeout3_cfg效果都很好。这种小 DSL 的精髓在于你不是在运行时解析字符串而是在编译期把字符串的语义固化成了类型。运行期代码里没有一个if分支没有一个动态分配这也是它比atoi一族函数优秀的地方。5.2 二进制字面量的 raw 模板实现与折叠表达式C14 有0b1010但这个语法是由编译器直接实现的你不能控制它的解析过程。用 raw 模板你可以自己定义一套解析规则。先看 C17 折叠表达式版本的二进制解析templatechar... Cs constexpr auto operator_bin() - unsigned long long { unsigned long long v 0; ((v (v 1) | static_castunsigned int(Cs - 0)), ...); return v; } static_assert(1010_bin 10);这里Cs - 0假设字符只有0和1。如果有人写出12_bin这个函数会把2算成 2 进去所以我通常还会加一个校验过程。折叠表达式让字符包的遍历变得一行搞定这是 C17 相对 C11 那个递归偏特化解析的巨大进步。如果你不能升级到 C17也可以用 C14 的局部数组加循环效果等价templatechar... Cs constexpr auto operator_bin() - unsigned long long { constexpr char chars[] {Cs..., \0}; unsigned long long v 0; for (char c : chars) { v (v 1) | static_castunsigned int(c - 0); } return v; }两种写法的共同要点是raw 模板输出的结果是无类型的纯数值所以它能无缝用到任何需要编译期常量的场景比如作为模板参数templateunsigned long long N struct MemoryBlock {}; MemoryBlock1_000_000_bin block; // 64 KB 的编译期常量5.3 命名空间隔离让后缀成为可选的插件UDL 有一个特殊性后缀是全局的一写出来就可能影响所有代码。为了避免污染标准库的惯例是放在inline namespace literals里用户需要时再using namespace引入。namespace mylib { class FileSize { /* ... */ }; namespace literals { inline namespace file_literals { constexpr FileSize operator_kb(unsigned long long v) { return FileSize{v * 1024}; } } // namespace file_literals } // namespace literals } // namespace mylib // 使用方 using namespace mylib::literals; auto max_file 1024_kb;inline namespace的作用是让using namespace mylib::literals;之后内部的所有后缀都直接可见不用你再using mylib::literals::operator_kb;一行行导入。这种设计强烈建议照抄因为它把启用 UDL变成了一个显式的行为代码的局部使用者不会被莫名其妙的后缀污染。5.4 后缀命名规范单下划线保留给用户标准库不带下划线最后说一个很容易被忽略的规则。标准库的所有 UDL 后缀比如h、min、s、ms、us、ns、s、sv、i、il、if都是不带下划线的。而用户自定义 UDL 的社区约定是必须以下划线开头比如_kg、_mb、_ipv4。这样一区分谁是谁的代码一目了然。两条硬性禁忌不能使用以双下划线开头的后缀也不能使用以单下划线后跟大写字母开头的后缀这类标识符是保留给实现的。我见过有人写_Foo后缀看起来没什么问题但严格来说这属于未定义行为风险不差这点字符改成_foo一劳永逸。5.5 如何确认你的 UDL 真的零开销很多人写 UDL 时最关心的问题是编译期计算到底靠不靠谱我的验证方法有三层第一层把 UDL 的结果作为static_assert的输入。这是最硬的标准如果能在static_assert里通过说明它一定是编译期常量。第二层用constexpr变量接收constexpr auto speed 10_kg * 10_m / (2_s * 2_s);只要这行能编译且没有触发任何运行时函数调用它就是零开销。第三层真的要看一眼汇编。在 Compiler Explorer 里把上面那段代码编译成汇编然后看有没有main之外的调用。如果你能看到movabs一个立即数那就是铁板钉钉的编译期求值。我这几年实践下来只要 UDL 函数声明了constexpr且返回类型是字面量类型、函数体满足常量表达式的要求编译器基本都能在常量上下文中完成求值。真正需要警惕的反而是看起来 constexpr实际偷偷走了运行时路径的场景比如返回了std::stringC20 之前 itu bukan 字面量类型这时候静默降级成运行期才算真正的坑。结尾自定义字面量这个特性看起来只是个小语法点但它对代码设计的影响远比想象中深。我现在写数值相关的接口已经养成了先问一句单位是什么的习惯能设计成_ms、_kg、_kb这类语义后缀的绝不裸开一个int参数。最后分享一个我很爱用的小技巧给版本号设计一个_ver后缀然后直接把它当作编译期比较器来用比如#if不方便做的检查可以用static_assert(CURRENT_VERSION 2.1.0_ver)一条语句搞定。这个特性真正迷人的地方不是省那几次字符串解析而是它把语义检查提前到了编译器眼皮底下让很多原本要等到测试阶段甚至线上才能发现的错误变成了一行编译错误提示。
返回列表