拓冰建站拓冰建站
首页 / 资讯中心 / 正文

C++编译期字符串拼接:从constexpr到模板元编程的完整解法

前几天技术群里有人贴了一道题编号是2601问的是C能不能在编译时连接两个串指针。乍一听很平常字符串拼接谁没写过可当你真的动手会发现一连串麻烦要么是长度信息在指针退化时丢了要么是模板参数推导不出来要么是能跑但只支持字面量不支持指针。这个问题看着小实际把C模板元编程和constexpr机制里不少核心东西都牵扯出来了。我花了点时间把几种可行写法、背后的原理、以及容易踩的坑整理了一遍分享给正在折腾编译期字符串处理的同学。先说清楚文章解决什么问题给定两个const char*指针通常指向字符串字面量要求在不运行代码的情况下编译期就得到拼接后的字符串并且拼接结果能参与static_assert、能作为模板参数、能继续参与后续编译期计算。适合的读者是C模板/元编程的进阶学习者或者做反射、代码生成、注册表这类需要编译期字符串的工程场景的人。1. 串指针背后的隐藏问题编译期拼接到底难在哪1.1 字符串字面量、字符数组与指针退化先从一个最容易被忽略的事实说起字符串字面量Hello的类型其实不是const char*而是const char[6]末尾带一个\0。只有当你把它赋值给指针时才发生数组到指针的退化长度信息就此丢失。const char* p Hello; // p 的类型是 const char*长度 6 这个信息没了 const char(ref)[6] Hello; // 数组引用保留长度N 6这一点是整道题的题眼。运行时你拿strlen(p)数一下就知道多长但编译期不行——编译期的所有计算必须基于不依赖运行时状态的常量表达式。如果长度丢失你就没法在编译期确定拼接结果要占多大空间。所以所有能工作的方案第一步都在做同一件事把字符串长度重新带回类型系统或模板参数中。模板推导可以做到这一点前提是参数类型写成数组引用templatestd::size_t N constexpr std::size_t str_size(const char ()[N]) { return N; } constexpr std::size_t len str_size(Hello); // len 6包含 \01.2 为什么不能在编译期直接调 strcat 或 std::string你可能想运行时拼接不是有strcat吗编译期能不能直接调不行原因有三个第一strcat会修改第一个参数指向的缓冲区属于副作用操作不满足常量表达式的纯函数要求。第二strcat的返回值是char*指向的缓冲区生命周期和大小都无法在编译期静态验证。第三std::string的拼接在C20之前涉及动态内存分配标准不允许在constexpr函数里使用。即便到了C20std::string的编译期拼接也只是“能算”并不代表你能顺手拿到一个可做模板参数的编译期字符串。这些边界后面我会专门讲。现在先给出最基础、最干净的方案。2. 方案一数组引用模板参数 constexpr 函数2.1 用模板参数把长度编码进类型系统最直接的写法是让两个参数都以数组引用形式进入模板让编译器自动推导出各自长度然后按长度构造一个固定大小的std::array作为返回类型。#include array #include cstddef templatestd::size_t N1, std::size_t N2 constexpr auto join(const char (lhs)[N1], const char (rhs)[N2]) - std::arraychar, N1 N2 - 1 { std::arraychar, N1 N2 - 1 out{}; // 先把左串内容拷进去跳过末尾的 \0 for (std::size_t i 0; i N1 - 1; i) out[i] lhs[i]; // 再把右串连字符一起拷进去这里 i N2 会把右串的 \0 也拷进去 // 于是 out 恰好以 \0 结尾 for (std::size_t i 0; i N2; i) out[N1 - 1 i] rhs[i]; return out; }调用方式和平时的字符串拼接非常接近constexpr auto combined join(Hello, , World!); static_assert(combined.size() 13); static_assert(combined[12] !); // C17 起 std::string_view 的构造函数和比较都是 constexpr可以这样验证内容 static_assert(std::string_view(combined.data(), combined.size()) Hello, World!);这里最关键的是返回类型大小的推导N1包含左串结尾的\0N2包含右串结尾的\0两串拼接之后只需要一个终止符所以结果数组长度是N1 N2 - 1。第二个循环里i N2会把右串连同它的\0整体搬进结果正好让结果以\0收尾。2.2 数组引用方案能做什么、不能做什么这套写法在C14下就能跑因为C14放宽了constexpr函数的限制函数体内允许循环、局部变量和多个返回值。实测用GCC和Clang的C14模式都能通过。它天然支持连续拼接constexpr auto combine3 join(join(A, B), C);内层返回std::array但外层join的参数类型是const char()[N]没法接一个std::array。所以连续拼接得换一个接受std::array参数的变体。这就是我接下来要处理的“指针场景”问题的前奏当你持有的不是数组引用而是一个真正的const char*指针时模板参数推导会直接失败。constexpr const char* a Hello, ; constexpr const char* b World!; // 编译错误无法从 const char* 推导 N1 和 N2 // constexpr auto bad join(a, b);3. 方案二constexpr 指针取长度再交给模板函数3.1 用 std::char_traits ::length 在编译期取长度回到题目本身——两个“串指针”。如果这两个指针本身是constexpr变量那么它们指向的字符串在编译期就是可解析的长度也是可计算的。C17标准把std::char_traitschar::length标记为constexpr可以这样用#include string constexpr const char* a Hello, ; constexpr const char* b World!; constexpr std::size_t len_a std::char_traitschar::length(a); constexpr std::size_t len_b std::char_traitschar::length(b);注意这里必须保证a和b是constexpr变量且在计算len_a之前就已经是常量。如果指针是从某个函数参数传进来的哪怕调用处传的是字面量函数内部也无法把它当作编译期常量来操作。这个限制后面讲坑的时候还会展开。3.2 把指针指向的内容搬进 std::array有了长度下一步就是把指针内容复制到编译期可持有的容器里。我习惯用一个简单的copy_chars工具templatestd::size_t N constexpr std::arraychar, N 1 copy_chars(const char* src) { std::arraychar, N 1 out{}; for (std::size_t i 0; i N; i) out[i] src[i]; // 强制把 \0 也拷进去方便后续拼接 return out; }这里N是字符串长度N 1是为了给\0留位置。紧接着把两个std::array按长度拼接templatestd::size_t N1, std::size_t N2 constexpr auto join_arrays(const std::arraychar, N1 lhs, const std::arraychar, N2 rhs) - std::arraychar, N1 N2 - 1 { std::arraychar, N1 N2 - 1 out{}; for (std::size_t i 0; i N1 - 1; i) out[i] lhs[i]; for (std::size_t i 0; i N2; i) out[N1 - 1 i] rhs[i]; return out; }3.3 完整演示两个 const char* 的编译期连接把上面几个工具组合起来就能对真正的“串指针”完成编译期拼接#include array #include cstddef #include string #include string_view constexpr const char* a Hello, ; constexpr const char* b World!; templatestd::size_t N constexpr std::arraychar, N 1 copy_chars(const char* src) { std::arraychar, N 1 out{}; for (std::size_t i 0; i N; i) out[i] src[i]; return out; } templatestd::size_t N1, std::size_t N2 constexpr auto join_arrays(const std::arraychar, N1 lhs, const std::arraychar, N2 rhs) - std::arraychar, N1 N2 - 1 { std::arraychar, N1 N2 - 1 out{}; for (std::size_t i 0; i N1 - 1; i) out[i] lhs[i]; for (std::size_t i 0; i N2; i) out[N1 - 1 i] rhs[i]; return out; } int main() { constexpr auto result join_arrays(copy_charsstd::char_traitschar::length(a)(a), copy_charsstd::char_traitschar::length(b)(b)); static_assert(result.size() 13); static_assert(std::string_view(result.data(), result.size()) Hello, World!); return result.size() 13 ? 0 : 1; }整体流程就是constexpr指针 - 编译期取长度 - 复制进std::array- 按模板参数拼接。长度信息始终没有离开编译期可计算的范围。这段代码在C17模式下用GCC和Clang实测都能通过MSVC的C17也对std::char_traitschar::length提供了constexpr支持但标准真正把它固定为constexpr是在C17建议按C17起步。4. 方案三把拼接结果封装成编译期字符串类型4.1 为什么 std::array 还不够用std::array虽然能保存编译期字符序列但它毕竟不是字符串类型没有c_str()也没有字符串的长度语义。如果拼接结果还要参与后续编译期运算比如继续拼一段、提取子串、或者作为模板参数传入函数用裸std::array会非常别扭。更好的做法是封装一个MetaString类型把字符串长度和\0终止符一起管理起来。这类类型在模板元编程里很常见很多反射库和序列化库内部都有类似实现。templatestd::size_t N struct MetaString { char data[N]{}; constexpr MetaString() default; constexpr MetaString(const char (str)[N]) { for (std::size_t i 0; i N; i) data[i] str[i]; } constexpr const char* c_str() const { return data; } constexpr std::size_t size() const { return N - 1; } }; templatestd::size_t N constexpr MetaStringN make_meta(const char (str)[N]) { return MetaStringN(str); }这里的N包含\0所以size()返回N - 1。构造时逐个字符复制保证data数组首先是一个完整的C字符串同时长度信息直接编码在类型参数中。4.2 operator让连续拼接顺理成章给MetaString重载operator拼接结果依然是MetaString长度类型自动推导templatestd::size_t N1, std::size_t N2 constexpr auto operator(const MetaStringN1 lhs, const MetaStringN2 rhs) - MetaStringN1 N2 - 1 { MetaStringN1 N2 - 1 out; for (std::size_t i 0; i N1 - 1; i) out.data[i] lhs.data[i]; for (std::size_t i 0; i N2; i) out.data[N1 - 1 i] rhs.data[i]; return out; }有了这个重载连续拼接的写法就和普通字符串一样自然constexpr auto key make_meta(plugin.) make_meta(math.) make_meta(add); static_assert(key.size() 17); static_assert(std::string_view(key.c_str(), key.size()) plugin.math.add);为什么这个方案更适合工程因为MetaString的字符串长度被编码在类型中编译器在每次operator调用时都能推导出新的类型长度拼接多少次都不会丢失信息。而且C20开始字面量类型可以作为非类型模板参数MetaString满足这个条件可以直接出现在模板实参中比如templateMetaString Key struct Registration { /* ... */ }; Registrationmake_meta(player.) make_meta(attack) reg;这就在C20环境下实现了真正意义上的“编译期字符串即类型参数”是做注册表和反射系统时比较实用的技巧。4.3 与 C20 constexpr std::string 对比C20把std::string的构造、析构和很多操作开放给了constexpr所以还能这样写consteval std::string combine(const char* a, const char* b) { return std::string(a) b; } constexpr std::string s combine(Hello, , World!); static_assert(s Hello, World!);这段代码确实简洁得多甚至可以直接处理真正的const char*不需要手动取长度。但它的短板也很明显std::string不是字面量类型不能作为非类型模板参数它在编译期计算时依赖内部的动态分配虽然标准允许了但在深度模板递归和连续拼接的场景下编译速度和内存消耗都不如纯数组方案。我把三种方案的适用场景梳理成一个表格方案输入形态能否作为非类型模板参数连续拼接易用性最低C标准数组引用std::array字符串字面量/数组引用间接经封装后可以中等C14constexpr指针copy_charsconstexpr const char*间接经封装后可以一般C17MetaStringoperator字面量/指针均可直接支持C20好C14封装C20模板参数constexpr std::string字面量/指针均可不行好C20我的建议是只做一次性的编译期常量校验直接std::array方案就够要做注册表、反射、编译期字符串运算封装MetaString才是正道。C20的constexpr std::string适合快速出结果不适合需要把字符串本身当作类型的一部分的场景。5. 实战中必须避开的坑和边界条件5.1 尺寸计算N1 N2 - 1 与 N1 N2拼接结果数组大小的公式必须想清楚。N1和N2都包含各自的\0结果只需要一个\0所以是N1 N2 - 1。如果误写成N1 N2结果末尾会多出一个未初始化的元素string_view构造时会把长度算错c_str()打印也可能出现不可控的越界读。还有个容易被忽视的点空字符串。当N1 1时第一个循环不执行当N2 1时第二个循环只复制一个\0。这两个分支在新式constexpr函数里都是合法的但要求实现时对循环边界非常敏感。我见过有人在空串场景下把i N1 - 1写成i N1结果把左串的\0也复制了拼接结果中间插了个终止符字符串直接断成两截。5.2 空指针不能当空串处理字符串函数里空串和空指针是两回事。std::char_traitschar::length(nullptr)会直接触发未定义行为编译器在常量求值阶段会拒绝而不是给出一个0。所以在写通用工具时最好对指针先做断言constexpr bool valid (a ! nullptr) (b ! nullptr); static_assert(valid, input pointers must be non-null);如果你希望工具函数能接收空指针并安全返回空串需要单独加分支。但要注意我说的是“工具函数”不是“两个指针都是constexpr变量的场景”——后者在编译期就能确认非空完全没有运行时兼容的负担。5.3 指针本身必须是编译期常量这个坑非常隐蔽。很多初学者把题目理解成“在运行到某个函数时把传入的两个指针在编译期连接”这是不可能的。一旦指针作为函数参数进入普通代码它就是一个运行时值。只有两种情况可以拿到编译期字符串长度一种是指针本身就是constexpr变量且初始化自字符串字面量另一种是字符串字面量直接以数组引用形式传给模板。换句话说“编译时连接两个串指针”要求两个指针的来源是编译期可见的。如果你在运行时把用户输入传给一个普通函数再期望里面做编译期拼接方向就错了。constexpr const char* a First; // OK这是编译期可见的 const char* runtime_p argv[1]; // 运行时指针无法在编译期取长度5.4 存储生命周期与编译器版本constexpr变量的值在编译期求值后通常存放在程序镜像的只读数据区生命周期是整个程序运行期。这一点保证了编译期字符串可以安全地全局使用不会出现局部数组悬垂的问题。但如果你试图“编译期拼接”一个指向局部缓冲区的指针编译期求值阶段就会失败因为局部地址不是编译期常量。编译器支持方面实测下来GCC 9以上、Clang 10以上对C17的constexpr std::char_traitschar::length支持稳定MSVC需要较新的版本并且要确保/std:c17或更高。如果你还在用C14std::char_traitschar::length不是标准的constexpr虽然libstdc和libc在实现上多数已支持但跨编译器移植时不要赌这个。5.5 模板递归深度和编译期性能深度拼接字符串时如果走的是递归模板实例化比如老式的push_back式字符序列模板模板深度会随字符串长度线性增长很容易触达编译器默认模板递归上限。用我前面写的循环式constexpr函数因为所有循环都是常量求值不会产生大量模板实例化编译耗时要好很多。如果是把几百个字符拼在一起MetaString方案基本无压力如果硬上字符级模板递归几百字符就够喝一壶了。6. 一个工程示例编译期生成注册表键名方案讲完我拿一个实际场景串一遍插件系统里每个模块要注册若干函数注册键的格式是“模块名.函数名”。如果这个键在编译期就能确定注册表可以在静态初始化阶段填充查找时不需要任何运行时字符串拼接。先用MetaString定义好编译期字符串运算constexpr auto module_name make_meta(plugin.math); constexpr auto function_name make_meta(add); constexpr auto full_key module_name make_meta(.) function_name;然后在注册函数模板中把MetaString作为非类型模板参数C20templateMetaString Key, auto Fn struct RegistryEntry { static constexpr std::string_view key() { return {Key.data, Key.size()}; } // ... 查找、排序、调用等逻辑 }; constexpr RegistryEntryfull_key, add math_add_entry;这个full_key在编译期就已经是plugin.math.add存储在只读数据区。注册表构建时直接取full_key.data就能得到完整的、带\0结尾的C字符串不需要再拷贝、拼接或分配内存。如果你还在C17环境非类型模板参数还不支持MetaString类型可以退一步把full_key.data[0]作为auto参数传入或者干脆用constexpr auto持有全局对象。这些写法都是为了同一个目的让字符串的编译期计算结果直接进入类型系统从而获得零运行时成本的静态表。我自己实际用这套东西写过一个简单的函数注册表几百个入口编译期拼接出来的注册键放在一个std::array里查找时二分性能和直接用枚举常量没有本质区别。这个方向后续可以继续扩展比如把__PRETTY_FUNCTION__里的类型名在编译期提取出来做类型注册或者配合std::source_location生成日志标签。核心思路都一样先把字符串变成编译期一等公民再谈编译期元编程的玩法。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门