15、编译期 JSON 解析(简介):设计思路、Token 解析、有限状态机
说实话,我第一次在编译期解析 JSON 时,心里是打鼓的。
运行时解析 JSON 已经够烦了——要考虑异常、类型、嵌套深度。编译期?那不是自找麻烦吗?
但后来我在一个嵌入式项目里遇到了硬需求:配置数据必须在启动前就确定,运行时不允许动态分配内存。嗯,编译期 JSON 解析就成了唯一出路。
今天我们就聊聊这个「编译期解析」到底怎么玩。先不搞太复杂,只讲核心思路:Token 解析 + 有限状态机。
15.1 为什么要在编译期解析 JSON?
你可能会问:运行时解析不香吗?
香。但有些场景不行:
- 嵌入式/裸机环境:没有标准库,没有动态内存,甚至没有异常机制
- 性能极致要求:运行时解析有开销,编译期解析等于零运行时成本
- 类型安全:编译期解析可以直接映射到 C++ 类型,运行时解析往往用
std::any或nlohmann::json那种动态结构
我在一个 IoT 项目里就吃过亏。运行时解析 JSON 配置,结果设备上电后解析失败,整个系统卡死。后来改成编译期解析,所有错误在编译阶段就暴露了——说白了,把运行时炸弹提前拆掉。
15.2 整体设计思路
编译期 JSON 解析,本质上就是 constexpr 函数 + 模板递归。
我们分三步走:
- Token 解析:把 JSON 字符串拆成一个个 Token(比如
{、}、"key"、123) - 语法分析:用有限状态机判断 Token 序列是否符合 JSON 语法
- 值提取:把解析结果映射到 C++ 类型(比如
std::tuple或自定义结构体)
今天只讲前两步。第三步涉及类型映射,我们后面单独开一章。
核心原则:编译期解析不允许抛出异常,所有错误必须通过 static_assert 或 SFINAE 在编译期暴露。
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 的语法其实很简单。我画了一张状态图,你看一眼就明白了:
状态机其实不复杂。核心就几个状态:
- 起始:期待一个值、对象或数组
- 对象内部:期待键值对或
} - 键值对:期待
:然后一个值 - 数组内部:期待值或
] - 结束:期待 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:
- 遇到
ObjectBegin→ 进入「对象」状态 - 遇到
String→ 进入「键值对」状态 - 遇到
Colon→ 等待值 - 遇到
String→ 回到「键值对」状态(值已就绪) - 遇到
Comma→ 继续下一个键值对 - ... 重复直到
ObjectEnd - 遇到
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