15、编译期 JSON 解析(简介):设计思路、Token 解析、有限状态机

说实话,我第一次在编译期解析 JSON 时,心里是打鼓的。

运行时解析 JSON 已经够烦了——要考虑异常、类型、嵌套深度。编译期?那不是自找麻烦吗?

但后来我在一个嵌入式项目里遇到了硬需求:配置数据必须在启动前就确定,运行时不允许动态分配内存。嗯,编译期 JSON 解析就成了唯一出路。

今天我们就聊聊这个「编译期解析」到底怎么玩。先不搞太复杂,只讲核心思路:Token 解析 + 有限状态机

15.1 为什么要在编译期解析 JSON?

你可能会问:运行时解析不香吗?

香。但有些场景不行:

  • 嵌入式/裸机环境:没有标准库,没有动态内存,甚至没有异常机制
  • 性能极致要求:运行时解析有开销,编译期解析等于零运行时成本
  • 类型安全:编译期解析可以直接映射到 C++ 类型,运行时解析往往用 std::anynlohmann::json 那种动态结构

我在一个 IoT 项目里就吃过亏。运行时解析 JSON 配置,结果设备上电后解析失败,整个系统卡死。后来改成编译期解析,所有错误在编译阶段就暴露了——说白了,把运行时炸弹提前拆掉

15.2 整体设计思路

编译期 JSON 解析,本质上就是 constexpr 函数 + 模板递归

我们分三步走:

  1. Token 解析:把 JSON 字符串拆成一个个 Token(比如 {}"key"123
  2. 语法分析:用有限状态机判断 Token 序列是否符合 JSON 语法
  3. 值提取:把解析结果映射到 C++ 类型(比如 std::tuple 或自定义结构体)

今天只讲前两步。第三步涉及类型映射,我们后面单独开一章。

核心原则:编译期解析不允许抛出异常,所有错误必须通过 static_assertSFINAE 在编译期暴露。

15.3 Token 解析:从字符串到 Token 流

Token 解析说白了就是「分词」。把 JSON 字符串从左到右扫一遍,遇到什么字符就生成什么 Token。

我习惯用枚举表示 Token 类型:

enum class TokenType {
    ObjectBegin,    // {
    ObjectEnd,      // }
    ArrayBegin,     // [
    ArrayEnd,       // ]
    Colon,          // :
    Comma,          // ,
    String,         // "xxx"
    Number,         // 123 / 3.14
    True,           // true
    False,          // false
    Null,           // null
    Eof             // 结束
};

每个 Token 还附带一个值(如果是 String 或 Number)。编译期我们只能用 std::string_view 来引用原始字符串的片段,不能拷贝。

Token 解析函数长这样:

template<size_t N>
constexpr auto tokenize(const char (&str)[N]) {
    // 返回一个 std::array<Token, MaxTokens>
    // 实际 Token 数量在编译期确定
    // 遇到非法字符直接 static_assert
}

嗯,这里有个坑:数组大小 N 包含末尾的 '\0'。我刚开始写的时候忘了减 1,结果多解析了一个空字符。后来我习惯在函数内部用 N - 1 作为实际长度。

15.4 有限状态机:让解析有章可循

Token 解析只是第一步。真正的难点在于:如何判断 Token 序列是合法的 JSON?

答案就是有限状态机(FSM)。

JSON 的语法其实很简单。我画了一张状态图,你看一眼就明白了:

JSON 解析有限状态机(简化版) S 对象 键值对 数组 E 值/对象/数组 { "key" : 值 [ EOF , , } ] 图例 起始/结束状态 中间状态 转移条件

状态机其实不复杂。核心就几个状态:

  • 起始:期待一个值、对象或数组
  • 对象内部:期待键值对或 }
  • 键值对:期待 : 然后一个值
  • 数组内部:期待值或 ]
  • 结束:期待 EOF

每个状态根据当前 Token 决定下一步怎么走。如果遇到不期望的 Token,直接 static_assert 报错。

小技巧:我习惯用 switch 语句实现状态转移。每个 case 对应一个状态,内部再 switch 当前 Token。这样代码结构清晰,编译期也能展开。

15.5 编译期实现的关键点

编译期解析和运行时解析最大的区别是什么?

没有循环变量,没有动态分配,没有异常。

具体来说:

  • constexpr 函数 + 递归代替循环
  • std::array 代替 std::vector
  • static_assert 代替 throw
  • std::string_view 代替 std::string

我举个例子。运行时解析你会这么写:

// 运行时:循环 + 动态数组
std::vector<Token> tokens;
for (size_t i = 0; i < str.size(); ++i) {
    if (str[i] == '{') tokens.push_back({TokenType::ObjectBegin});
    // ...
}

编译期你得这么写:

// 编译期:递归 + 固定数组
template <size_t N>
constexpr auto tokenize(const char (&str)[N], size_t pos = 0) {
    if constexpr (pos >= N - 1) {
        return std::array<Token, 1>{{TokenType::Eof}};
    } else {
        if (str[pos] == '{') {
            auto rest = tokenize(str, pos + 1);
            return prepend(Token{TokenType::ObjectBegin}, rest);
        }
        // ...
    }
}

看到了吗?递归代替循环,数组代替 vector。这是编译期编程的基本功。

注意:递归深度有限制。大多数编译器默认递归深度 512 层。如果你的 JSON 嵌套太深,可能触发编译错误。我建议嵌套不超过 100 层。

15.6 一个完整的 Token 解析示例

我们拿一个简单的 JSON 来演示:

constexpr char json[] = R"({"name":"Alice","age":30})";

Token 解析后得到:

序号 Token 类型
0 ObjectBegin {
1 String "name"
2 Colon :
3 String "Alice"
4 Comma ,
5 String "age"
6 Colon :
7 Number 30
8 ObjectEnd }
9 Eof

然后有限状态机从起始状态开始,依次消费这些 Token:

  1. 遇到 ObjectBegin → 进入「对象」状态
  2. 遇到 String → 进入「键值对」状态
  3. 遇到 Colon → 等待值
  4. 遇到 String → 回到「键值对」状态(值已就绪)
  5. 遇到 Comma → 继续下一个键值对
  6. ... 重复直到 ObjectEnd
  7. 遇到 Eof → 结束

如果中间出现非法 Token(比如在对象内部突然出现 ]),状态机直接报编译错误。

15.7 避坑指南

我踩过几个坑,分享给你:

  • 字符串转义:JSON 字符串里可能有 \"\\\n 等转义序列。编译期解析时,你得手动处理这些转义。我建议先做一次「转义展开」,再进入 Token 解析。
  • 数字精度:编译期浮点数解析很麻烦。我一般只支持整数,浮点数用 long double 硬扛。如果你需要高精度,考虑用 boost::multiprecision
  • Unicode\uXXXX 转义在编译期处理起来很痛苦。我个人的做法是:不支持。如果 JSON 里有 Unicode,让用户预处理。

我的建议:编译期 JSON 解析不要追求「完整支持所有 JSON 特性」。够用就好。你想想看,编译期解析本来就是为了特定场景服务的,没必要做成通用库。

15.8 小结

今天我们聊了编译期 JSON 解析的入门思路:

  • Token 解析:把字符串拆成 Token 流,用递归代替循环
  • 有限状态机:用状态转移判断语法合法性
  • 编译期约束:constexpr、递归、static_assert、std::array

说实话,编译期解析的门槛不在于技术,而在于思维转换。你习惯了运行时那种「动态、灵活、容错」的写法,切换到编译期「静态、严格、零容忍」的风格,确实需要适应一段时间。

但一旦上手,你会发现——编译期能做很多你以前想都不敢想的事


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