13、编译期字符串:字符序列封装、编译期字符串拼接、编译期字符串查找
说实话,编译期字符串这个话题,我当年刚接触模板元编程时觉得挺鸡肋的。字符串嘛,运行时搞搞不就行了?直到我在一个嵌入式项目里,需要在编译期生成设备标识符,运行时根本没有字符串处理库可用……嗯,从那以后,我再也不敢小看编译期字符串了。
说白了,编译期字符串就是把字符串变成类型系统能理解的东西。C++ 标准里没有直接支持,但模板元编程给了我们足够的工具来自己造轮子。
13.1 字符序列封装:从字符串到类型
我们先解决第一个问题:怎么把一个字符串塞进类型系统里?
我个人习惯用变长模板参数包,把每个字符拆开。你想想看,"hello" 本质上就是 'h', 'e', 'l', 'l', 'o' 这五个字符的序列。那我们就可以这样封装:
template<char... Chars>
struct String {
static constexpr char value[] = {Chars..., '\0'};
static constexpr std::size_t size = sizeof...(Chars);
};
// 使用示例
using Hello = String<'h', 'e', 'l', 'l', 'o'>;
这样,Hello 就是一个类型了。它的 value 是编译期常量数组,size 是编译期常量。完美。
但每次写 String<'h', 'e', 'l', 'l', 'o'> 太痛苦了。我们需要一个宏来转换字面量字符串:
#define STRING(str) \
[]<std::size_t... Is>(std::index_sequence<Is...>) { \
return String<str[Is]...>{}; \
}(std::make_index_sequence<sizeof(str) - 1>{})
// 现在可以这样用
using Hello = decltype(STRING("hello"));
这里用了 C++17 的泛型 lambda 和 index_sequence。核心思路是把字符串的每个字符索引展开成模板参数包。我在项目中第一次看到这种写法时,觉得这简直是魔法……后来自己写多了,也就习惯了。
13.2 编译期字符串拼接:类型层面的加法
有了字符序列,拼接就顺理成章了。说白了就是把两个参数包合并成一个:
template<typename L, typename R>
struct Concat;
template<char... Ls, char... Rs>
struct Concat<String<Ls...>, String<Rs...>> {
using type = String<Ls..., Rs...>;
};
template<typename L, typename R>
using Concat_t = typename Concat<L, R>::type;
// 使用示例
using Hello = decltype(STRING("hello"));
using World = decltype(STRING(" world"));
using HelloWorld = Concat_t<Hello, World>;
// HelloWorld::value == "hello world"
这里有个细节:String<Ls..., Rs...> 直接把两个参数包展开在一起。C++ 模板参数包展开就是这么直接,没有运行时循环,没有内存分配,纯编译期操作。
我曾经在一个日志系统里用这个技术,在编译期就把日志前缀(时间戳、模块名、级别)拼好了。运行时只需要输出最终字符串,性能提升很明显。
13.3 编译期字符串查找:在类型序列中搜索
查找就更有意思了。我们需要在编译期判断一个字符是否在字符串里,或者找到某个子串的位置。
先来个简单的:判断字符是否存在。
template<char Target, char... Chars>
struct Contains;
template<char Target, char First, char... Rest>
struct Contains<Target, First, Rest...> {
static constexpr bool value =
(Target == First) || Contains<Target, Rest...>::value;
};
template<char Target>
struct Contains<Target> {
static constexpr bool value = false;
};
// 使用
static_assert(Contains<'e', 'h', 'e', 'l', 'l', 'o'>::value); // true
static_assert(!Contains<'x', 'h', 'e', 'l', 'l', 'o'>::value); // false
这个实现是递归的。每次取第一个字符比较,剩下的递归处理。模板元编程里递归是家常便饭,因为编译期没有循环。
再进阶一点:查找子串位置。这个稍微复杂些,需要逐位匹配:
template<typename Haystack, typename Needle, std::size_t Pos = 0>
struct Find;
// 匹配成功
template<char... Hs, char... Ns, std::size_t Pos>
requires (Pos + sizeof...(Ns) <= sizeof...(Hs))
struct Find<String<Hs...>, String<Ns...>, Pos> {
private:
// 检查从 Pos 开始的子串是否匹配
template<std::size_t... Is>
static constexpr bool match(std::index_sequence<Is...>) {
return ((getChar<Pos + Is, Hs...>() == getChar<Is, Ns...>()) && ...);
}
public:
static constexpr std::size_t value =
match(std::make_index_sequence<sizeof...(Ns)>{})
? Pos
: Find<String<Hs...>, String<Ns...>, Pos + 1>::value;
};
// 没找到
template<typename Haystack, typename Needle, std::size_t Pos>
requires (Pos + sizeof...(Needle::value) > sizeof...(Haystack::value))
struct Find<Haystack, Needle, Pos> {
static constexpr std::size_t value = std::string_view::npos;
};
getChar 辅助函数需要自己实现,用来从参数包中按索引取字符。另外,C++17 的折叠表达式让匹配逻辑简洁了很多。如果是 C++11,你得写递归模板,代码量翻倍。
这个 Find 模板会从位置 0 开始,逐个位置尝试匹配。一旦匹配成功就返回当前位置,否则递归到下一个位置。如果剩余字符不够了,返回 npos。
我在一个代码生成工具里用过这个技术。工具需要在编译期解析模板字符串中的占位符,比如 "{{name}}",然后替换成实际值。编译期查找和替换让生成代码零运行时开销,最终二进制体积也小了很多。
13.4 知识体系总览
下面这张图总结了编译期字符串的核心脉络:
13.5 避坑指南与经验总结
讲几个我踩过的坑:
- 递归深度限制: 编译器对模板递归深度有限制(通常 256 或 512 层)。如果字符串很长,查找或拼接可能触发编译错误。我遇到过 300 个字符的字符串查找直接让 GCC 崩溃……后来改用二分法或分治策略来降低递归深度。
- 编译时间爆炸: 模板实例化太多,编译时间会显著增加。我记得有个项目,编译期字符串处理让编译时间从 30 秒涨到 5 分钟。优化方案是减少不必要的模板实例化,比如用
if constexpr提前剪枝。 - 宏的局限性:
STRING宏依赖 lambda,而 lambda 不能用在所有上下文中(比如类成员默认值)。这时候需要退而求其次,手动写String<'h','e','l','l','o'>。
std::string。别为了炫技把项目搞复杂了。
好了,编译期字符串的核心内容就这些。字符序列封装是基础,拼接和查找是常见操作。掌握了这些,你就能在编译期做很多有趣的事情了。