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 是编译期常量。完美。

小技巧: 记得末尾加 '\0',这样 value 可以直接当 C 风格字符串用。我早期没加,调试时 printf 打印出一堆乱码,排查了半天。

但每次写 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 知识体系总览

下面这张图总结了编译期字符串的核心脉络:

编译期字符串知识体系 编译期字符串 字符序列封装 变长模板参数包 index_sequence 展开 宏辅助转换 编译期字符串拼接 参数包合并 类型组合 零运行时开销 编译期字符串查找 字符存在判断 子串位置查找 递归模板匹配 应用:编译期日志前缀、代码生成、设备标识符

13.5 避坑指南与经验总结

讲几个我踩过的坑:

  • 递归深度限制: 编译器对模板递归深度有限制(通常 256 或 512 层)。如果字符串很长,查找或拼接可能触发编译错误。我遇到过 300 个字符的字符串查找直接让 GCC 崩溃……后来改用二分法或分治策略来降低递归深度。
  • 编译时间爆炸: 模板实例化太多,编译时间会显著增加。我记得有个项目,编译期字符串处理让编译时间从 30 秒涨到 5 分钟。优化方案是减少不必要的模板实例化,比如用 if constexpr 提前剪枝。
  • 宏的局限性: STRING 宏依赖 lambda,而 lambda 不能用在所有上下文中(比如类成员默认值)。这时候需要退而求其次,手动写 String<'h','e','l','l','o'>
我的建议: 编译期字符串适合处理短字符串(几十个字符以内)和固定模式。如果字符串长度不确定或内容动态变化,还是老老实实用运行时 std::string。别为了炫技把项目搞复杂了。

好了,编译期字符串的核心内容就这些。字符序列封装是基础,拼接和查找是常见操作。掌握了这些,你就能在编译期做很多有趣的事情了。


公众号:蓝海资料掘金营,微信deep3321