前言
手写一个解析器是什么体验?先写一个按字符切换的switch,再补一层回退逻辑,然后处理「已经吃掉的字符要吐回来」的问题,最后在嵌套语法面前彻底崩掉。绝大多数人写第一个配置解析器或表达式求值器时都走过这条路,结果是几百行的状态机加一堆难以测试的边界分支。
Boost.Spirit 想解决的就是这件事:把语法直接写成 C++ 表达式。你写qi::int_ >> ',' >> qi::double_,它的含义就是「先一个整数,再一个逗号,再一个浮点数」,同时自动把结果填进你指定的变量里。这属于解析器组合子(parser combinator)风格,语法规则由「终结符 + 运算符」组合而成,不需要另写文法文件,也不需要外部代码生成器。
但要说清一件常被误解的事:Spirit 不是「更快的手写解析器」。它用模板元编程把你的表达式在编译期展开成解析器类型,运行期确实没有虚函数调度,但从工程角度看,它的代价是编译时间和报错可读性——一条属性类型不匹配的错误,模板展开后的报错可能有几十行。所以本文的重点不是吹它的性能,而是把最容易卡住新手的几件事(属性传递、skipper、完整消费检查)讲清楚。
先交代环境:本文示例使用 Boost.Spirit Qi(Spirit V2),即boost::spirit::qi命名空间,需要 Boost 库(头文件即可,Spirit 是 header-only)。示例以C++17为基准,用-std=c++17编译,GCC 13 / Clang 17 / MSVC 19.3x 都适用。另外需要说明:较新的 Boost 发行版已经把 Spirit V2(Qi 与 Karma)标记为 deprecated,推荐迁移到 Spirit X3;Qi 仍然随发行版提供并可正常使用,本文最后会给出 X3 的最小对照。
一、Spirit 的组成:Qi、Karma、Lex 与 X3
Spirit 其实是一族库,按方向分工:
| 子库 | 命名空间 / 头文件 | 职责 |
|---|
| Spirit.Qi | boost::spirit::qi | 解析:字符流 → 结构化数据 |
| Spirit.Karma | boost::spirit::karma | 生成:结构化数据 → 字符流 |
| Spirit.Lex | boost::spirit::lex | 词法分析器,可与 Qi 配合 |
| Spirit.X3 | boost::spirit::x3 | Qi 的下一代,基于 C++14,编译更快 |
Qi 的核心思想是:每个解析器都是一个模板类,它有「属性类型」(attribute type,即它能产出什么)和「是否成功」两个概念。组合子运算符把小的解析器拼成大的:
| 运算符 | 含义 | 属性合并方式 |
|---|
>> | 顺序,全成功才成功 | 属性依次拼接 |
| ` | ` | 择一,左优先 |
*p | 零次或多次 | 容器 |
+p | 一次或多次 | 容器 |
-p | 至多一次 | boost::optional或可默认构造的类型 |
p % d | p 被 d 分隔的列表 | 容器 |
!p | 零宽否定断言 | 无 |
「属性」是 Qi 里最关键的概念:解析器一边匹配输入,一边往属性里写数据。表达式写对了,属性会自动传播到你的变量;写错了,就是一条难读的静态断言。
二、最小的 Qi 示例:解析逗号分隔的整数列表
先看一个不涉及自定义类型的完整例子,它足够短,可以逐行读。
#include <boost/spirit/include/qi.hpp>
#include <cstddef>
#include <iostream>
#include <string>
#include <vector>
namespace qi = boost::spirit::qi;
int main() {
const std::string input = "10, 20, 30, 40";
using It = std::string::const_iterator;
It first = input.begin();
It last = input.end();
std::vector<int> numbers;
// (qi::int_ % ',') 的语意是:被 ',' 分隔的 int 列表,属性为 vector<int>
const bool ok = qi::phrase_parse(first, last,
qi::int_ % ',',
qi::space,
numbers);
if (ok && first == last) {
for (int n : numbers) {
std::cout << n << ' ';
}
std::cout << '\n';
} else {
std::cout << "parse failed, stopped at offset "
<< static_cast<std::ptrdiff_t>(first - input.begin()) << '\n';
}
}几个必须注意的点:
first是按引用传的。qi::phrase_parse与qi::parse的第一个参数都是Iterator&,解析推进后迭代器会被更新。如果你把first传了副本进去,外面永远停在起点,看起来就像「解析没生效」。ok == true不代表整段输入都被消费了。它只保证「从头开始能匹配出一段」。要确认没有残留,必须自己检查first == last。这是最常见的疏漏。qi::space是boost::spirit::ascii::space的便捷简称,属于skipper(跳过器),语义是「在相邻的解析器之间跳过空白字符」。phrase_parse带 skipper,parse不带。用错了就会因为空格而失败。
两个函数的签名可以记成这样一个形状(去掉了重载差异,精确声明以 Boost 头文件为准):
// 大致形状,Iterator& 是引用
bool qi::parse(Iterator& first, Iterator last, Expr const& expr, Attr& attr);
bool qi::phrase_parse(Iterator& first, Iterator last,
Expr const& expr, Skipper const& skipper, Attr& attr);三、把解析结果填进自己的结构体
实际项目里不会只解析int,而是要填进自定义类型。Qi 支持两种方式:std::tuple/std::pair(只要包含对应的 Fusion 适配头文件即可自动支持),以及自己的结构体——后者需要BOOST_FUSION_ADAPT_STRUCT把结构体「伪装」成一个 Fusion 序列(Fusion sequence)。
#include <boost/spirit/include/qi.hpp>
#include <boost/fusion/include/adapt_struct.hpp>
#include <cstddef>
#include <iostream>
#include <string>
#include <vector>
namespace qi = boost::spirit::qi;
namespace ascii = boost::spirit::ascii;
struct Person {
int id;
std::string name;
double score;
};
// 让 Fusion 知道 Person 是「三段数据的序列」,顺序与成员声明一致
BOOST_FUSION_ADAPT_STRUCT(Person, (id)(name)(score))
template <typename Iterator>
struct PersonListParser
: qi::grammar<Iterator, std::vector<Person>(), ascii::space_type>
{
PersonListParser() : PersonListParser::base_type(list) {
// 注意这里用的是 %=(auto rule),它才会把右侧表达式的属性自动
// 传播到规则的属性上;写成普通的 = 会丢掉这层传播
person %= qi::int_
>> qi::lit(',')
>> qi::lexeme[+(qi::char_ - qi::lit(','))]
>> qi::lit(',')
>> qi::double_
>> qi::lit(';');
list %= *person;
}
qi::rule<Iterator, Person(), ascii::space_type> person;
qi::rule<Iterator, std::vector<Person>(), ascii::space_type> list;
};
int main() {
const std::string input = "1,alice,95.5;2,bob,88;3,carol,91.25;";
using It = std::string::const_iterator;
It first = input.begin();
It last = input.end();
std::vector<Person> people;
PersonListParser<It> parser;
const bool ok = qi::phrase_parse(first, last, parser, ascii::space, people);
if (ok && first == last) {
for (const Person& p : people) {
std::cout << p.id << ' ' << p.name << ' ' << p.score << '\n';
}
} else {
std::cout << "parse failed at offset "
<< static_cast<std::ptrdiff_t>(first - input.begin()) << '\n';
}
}逐项核对这段代码里每一处「为什么这么写」:
BOOST_FUSION_ADAPT_STRUCT必须写在结构体所在的命名空间里、且在结构体定义之后。它的成员列表用括号包起来,(id)(name)(score)的顺序决定了>>序列的顺序。写反了不会报「顺序错」,只会在属性赋值时给出一个很难懂的模板错误。注意88能被qi::double_接受(整数可以提升为浮点属性),所以示例输入里混用95.5和88是没问题的。
qi::rule需要三个模板参数:迭代器类型、属性类型、 skipper 类型。属性类型写成函数式Person(),skipper 写ascii::space_type(类型,不是ascii::space那个对象)。省略 skipper 参数会让这条规则不跳过空白,那又是另一种常见的错配。
qi::grammar的基类参数是「迭代器 + 起始规则的属性 + skipper」,构造函数里必须把起始规则(这里是list)传给base_type。传错了会在实例化时报错。
qi::lexeme[...]的作用是「在这段里关掉 skipper」。没有它,+(qi::char_ - qi::lit(','))中每次尝试qi::char_之前都会先跳过空白,于是名字里的空格被吃掉,"alice bob"会变成"alicebob"。这是 Qi 里最需要建立直觉的一点:skipper 作用于每个终结符之前,而不是整个规则之前。
qi::char_ - qi::lit(',')用的是解析器差集运算符,读作「任意字符,但不是逗号」。同理qi::char_ - '"'是解析带引号字符串的经典写法。
四、Spirit X3 的最小对照
前面说过,Spirit V2 在新版本 Boost 中已被标记为 deprecated,官方推荐的继任者是 Spirit X3。X3 的头文件是<boost/spirit/home/x3.hpp>,命名空间boost::spirit::x3,它改用 C++14 的泛型 lambda 来描述语义动作,模板实例化量少得多。最小用法如下:
#include <boost/spirit/home/x3.hpp>
#include <iostream>
#include <string>
#include <vector>
namespace x3 = boost::spirit::x3;
int main() {
const std::string input = "1,2,3,4,5";
using It = std::string::const_iterator;
It first = input.begin();
It last = input.end();
std::vector<int> nums;
const bool ok = x3::phrase_parse(first, last, x3::int_ % ',', x3::space, nums);
if (ok && first == last) {
for (int n : nums) { std::cout << n << ' '; }
std::cout << '\n';
}
}注意这里的分隔符','是字符字面量,X3 会把它当成字面量解析器;x3::int_ % ','整体产出的属性类型是std::vector<int>,与nums对应。前面强调的规则在 X3 里一模一样:phrase_parse返回真只表示「匹配过一段」,必须自己检查first == last——尤其当分隔符写错或输入尾部有多余内容时。
Qi 与 X3 的对应关系:
| Qi 写法 | X3 写法 |
|---|
qi::parse(f, l, p, a) | x3::parse(f, l, p, a) |
qi::phrase_parse(f, l, p, s, a) | x3::phrase_parse(f, l, p, s, a) |
qi::rule<It, T(), Skipper> | x3::rule<class Tag, T> |
qi::grammar<It, T(), Skipper> | x3::rule+BOOST_SPIRIT_DEFINE |
BOOST_FUSION_ADAPT_STRUCT | 同样使用 Fusion 适配 |
常见坑点
1. 只看返回值,不检查是否吃完输入
// ❌ ok 为真但可能只解析了前半段
bool ok = qi::phrase_parse(f, l, qi::int_, qi::space, n);
if (ok) { use(n); }
// ✅ 两个条件都要
bool ok = qi::phrase_parse(f, l, qi::int_, qi::space, n);
if (ok && f == l) { use(n); }2. 把迭代器按值传出去
// ❌ 传的是副本,外层 f 永远不动,解析会无限重复
bool try_parse(It first, It last) { return qi::parse(first, last, qi::int_); }
// ✅ 按引用传,让调用者拿到推进后的位置
bool try_parse(It& first, It last) { return qi::parse(first, last, qi::int_); }3. 该用 lexeme 的地方没加
// ❌ skipper 会在每个 char_ 前跳过空白,名字里的空格被吞掉
auto name = +(qi::char_ - qi::lit(','));
std::string out;
qi::phrase_parse(f, l, name, qi::space, out); // "a b" -> "ab"
// ✅ 需要保留原样的片段用 lexeme 关掉 skipper
auto name2 = qi::lexeme[+(qi::char_ - qi::lit(','))];4. rule 的 skipper 和调用处不一致
// ❌ 规则声明成不带 skipper 的形式,却又用 phrase_parse 调它
qi::rule<It, int()> r = qi::int_; // 无 skipper
qi::phrase_parse(f, l, r, qi::space, n); // 空白不会被跳过
// ✅ 声明时就把 skipper 写对
qi::rule<It, int(), qi::space_type> r = qi::int_;5. 属性类型和结构体成员对不上
// ❌ 解析器产出的是 int,结构体成员却是 std::string,属性对不上
struct Item { std::string value; };
qi::rule<It, Item(), qi::space_type> item %= qi::int_;
// ✅ 让两边类型一致
struct Item { int value; };
qi::rule<It, Item(), qi::space_type> item %= qi::int_;属性不一致的表现是编译期的静态断言。如果一时看不出是哪一段出的问题,最稳的排查办法是把中间的复合表达式拆成一条独立的子规则,显式写出它的属性类型(比如std::string()),从而把出错范围缩到最小——这既是排查手段,也是让属性传播行为变明确的正规写法。
6. 用 istream_iterator 读文件忘了关跳过空白
// ❌ istream_iterator 默认跳过空白,会把换行也吃掉
std::ifstream in("data.txt");
boost::spirit::istream_iterator f(in), l;
// ✅ 必须先 unsetf(skipws)
std::ifstream in("data.txt");
in.unsetf(std::ios::skipws);
boost::spirit::istream_iterator f(in), l;7. 分隔符用 char_ 而不是 lit,属性序列多出一项
// ❌ qi::char_(',') 会产出一个 char 属性,
// 融合后变成 int/char/string/double 四元组,与 Person 的三段对不上
person = qi::int_ >> qi::char_(',') >> qi::lexeme[+(qi::char_ - qi::lit(','))]
>> qi::char_(',') >> qi::double_;
// ✅ 分隔符只想匹配、不想收集,用 qi::lit
person = qi::int_ >> qi::lit(',') >> qi::lexeme[+(qi::char_ - qi::lit(','))]
>> qi::lit(',') >> qi::double_;这个错的表现是编译期静态断言,而且报错里只会说「属性不匹配」,不会告诉你「多了一个 char」。凡是不打算收集进结果的部分(标点、关键字),都应该用qi::lit或者qi::omit[...]。
8. 把编译期错误当成运行期错误来调试
// ❌ 看到 "static assertion failed: Attribute does not match" 就去加打印
// ✅ 先把规则表达式拆小,逐条用 qi::parse 单独验证,再合并Qi 的属性检查发生在编译期,报错信息会很长。有效的做法是给每条qi::rule单独写一个最小输入做验证,而不是在长输入上调试一个复合表达式。
总结
| 事项 | 结论 |
|---|
| 库的形态 | header-only,#include <boost/spirit/include/qi.hpp>即可 |
| 适用标准 | 示例以 C++17 编译;X3 需要 C++14 起的编译器 |
| 输入是否吃完 | parse/phrase_parse不管,必须自己比first == last |
| 迭代器 | 第一个参数是引用,会被推进 |
| 空白处理 | 由 skipper 决定,lexeme可局部关闭 |
| 自定义类型 | 用BOOST_FUSION_ADAPT_STRUCT暴露成 Fusion 序列,成员顺序即解析顺序 |
| 主要成本 | 编译期实例化与报错可读性,不是运行期 |
| 后续方向 | 新代码可考虑 Spirit X3;Qi 已在新版 Boost 中标为 deprecated |
Spirit 的价值在于把「文法」和「代码」合二为一:语法改动时,解析逻辑和数据结构在同一个表达式里一起改,不会出现文法文件和 C++ 结构体两边不同步的问题。但它换来的是模板报错和编译时间,所以它更适合文法相对稳定、输入量不大的场合——配置文件、协议头、领域专用小语言。如果你的场景是每秒解析上百 MB 的日志,Spirit 通常不是那个答案,老老实实手写状态机或上专门的解析生成器更合适。