首页 / 资讯中心 / 文章详情

C++ Boost Spirit精通教程

C++ Boost Spirit精通教程 ★ FEATURED ARTICLE
前言手写一个解析器通常从三层循环开始跳过空白、识别记号、按文法组合。代码不长的时候还好一旦文法有嵌套、有可选项、有运算符优先级状态机就会迅速膨胀改一条规则要动好几处。Boost.Spirit 想解决的就是这件事它把文法直接写成 C 表达式由库在编译期把这些表达式组合成一个解析器运行时不再需要单独的语法文件也不需要额外的代码生成步骤。Spirit 不是一个「小而美」的库。它是重度模板元编程的产物编译慢、报错长、学习曲线陡这三点劝退了不少人。但它在「文法需要嵌进 C 程序、并且要和现有类型无缝对接」的场景里确实好用。典型例子是自定义配置格式、领域专用语言domain-specific language, DSL、网络协议报文和日志行解析。有两个常见误解要先澄清。第一Spirit 不是「正则表达式的替代品」。正则适合扁平的、无嵌套的匹配Spirit 能处理递归文法这是正则做不到的。第二Spirit 不需要预编译也不需要flex/bison那样的外部工具但代价是所有文法都在头文件里展开一个中等规模的文法让编译时间翻几倍是常见现象——这属于模板实例化的开销不是运行时开销。本文以 Boost.Spirit 的 Qi读音同「气」子库为主讲清楚从简单解析到规则复用、属性映射、错误定位的完整路径最后说明什么情况下应该换别的方案。代码按 C17 写Boost 版本以 1.70 之后为准Spirit 的接口在版本间有过调整具体细节以官方文档为准。本文没有在本机编译或运行过任何示例。一、Spirit 的组成与基本模型Boost.Spirit 包含两个方向相反的子库子库方向典型用途头文件入口Qi文本 → 数据解析读配置、拆协议、解析表达式boost/spirit/include/qi.hppKarma数据 → 文本生成按格式输出、序列化boost/spirit/include/karma.hppLex词法分析把字符流切成记号boost/spirit/include/lex.hppX3Qi 的重写版更快的编译、更现代的接口boost/spirit/include/x3.hppQi 的核心模型是「解析器 属性」。每个解析器除了判断输入是否匹配还会产生一个属性attribute类型由解析器本身决定qi::int_的属性是intqi::double_是doubleqi::alpha是std::string或std::vectorchar取决于目标类型qi::int_ qi::int_的属性是fusion::vectorint, int或者能由它转换的类型。属性系统是 Spirit 最值得花时间理解的部分理解之后「解析结果怎么自动填进结构体」就不再是魔法。解析器用运算符组合表示顺序|表示择一*表示零次或多次表示一次或多次-表示可选%表示「以某分隔符分隔的列表」和!是断言。这些符号作用在解析器对象上不是作用在字符串上所以qi::int_ ,是一个合法的表达式而, qi::int_也是。二、第一个例子phrase_parse最常用的入口是qi::phrase_parse它在解析过程中自动跳过你指定的「 skipper 」字符通常是空白。// 适用于 C17Boost 1.70 及以上#include boost/spirit/include/qi.hpp#include iostream#include stringnamespace qi boost::spirit::qi;int main(){const std::string input 42 3.5;auto first input.begin();const auto last input.end();int i 0;double d 0.0;const bool ok qi::phrase_parse(first, last,qi::int_ qi::double_,qi::space,i, d);if (ok first last) {std::cout int i double d \n;} else {std::cout parse failed near: std::string(first, last) \n;}return 0;}这段代码里有三处需要留意的地方。第一first是按引用传入的。解析成功时它前进到匹配结束的位置失败时停在出错的地方。所以判断「整段输入是否都被消费」必须同时检查返回值ok和first last。只检查返回值会让42 3.5 garbage这样的输入被当成解析成功。第二qi::space是 skipperphrase_parse会在每个词法单元之间跳过它匹配的字符。如果某个部分必须保留空白比如字符串字面量内部要用qi::lexeme[...]把它包起来局部关闭跳过。第三属性参数是按引用传出的个数和类型必须与解析器的属性匹配。qi::int_ qi::double_的属性可以拆成两个参数也可以整体接到一个fusion::vector或std::pair上。类型不匹配时错误信息会非常长通常要从最后一行的「no matching function」往前找真正的原因。如果不需要跳过空白用qi::parse(first, last, parser, attr...)它是phrase_parse不带 skipper 的版本。三、规则复用与属性映射表达式直接写在调用处只适合很短的文法。一旦文法需要递归或者需要重复使用就应该定义qi::rule。规则把「解析器类型」和「属性类型」显式写出来既提高可读性也能显著降低模板实例化的深度。// 适用于 C17Boost 1.70 及以上#include boost/spirit/include/qi.hpp#include iostream#include string#include vectornamespace qi boost::spirit::qi;int main(){const std::string input 1, 2, 3, 4;using It std::string::const_iterator;It first input.begin();const It last input.end();std::vectorint values;qi::ruleIt, std::vectorint(), qi::space_type list qi::int_ % ,;const bool ok qi::phrase_parse(first, last, list, qi::space, values);if (ok first last) {for (int v : values) {std::cout v ;}std::cout \n;} else {std::cout parse failed near: std::string(first, last) \n;}return 0;}qi::rule的三个模板参数依次是迭代器类型、属性类型、skipper 类型。第三个参数写成qi::space_type表示「这条规则预期在跳过空白的上下文里使用」如果省略它规则就不能在带 skipper 的解析中直接使用。qi::int_ % ,的属性是std::vectorint与规则声明的属性一致也与传出参数values一致。要把解析结果直接填进自定义结构体需要借助 Boost.Fusion 的适配宏让结构体变成一个「融合序列」Spirit 才能按成员顺序把属性写进去。// 适用于 C17Boost 1.70 及以上#include boost/fusion/include/adapt_struct.hpp#include boost/spirit/include/qi.hpp#include iostream#include stringstruct Point {int x;int y;};BOOST_FUSION_ADAPT_STRUCT(Point, x, y)namespace qi boost::spirit::qi;int main(){const std::string input 10, 20;using It std::string::const_iterator;It first input.begin();const It last input.end();Point p{};qi::ruleIt, Point(), qi::space_type point qi::int_ , qi::int_;const bool ok qi::phrase_parse(first, last, point, qi::space, p);if (ok first last) {std::cout x p.x y p.y \n;}return 0;}BOOST_FUSION_ADAPT_STRUCT必须写在全局作用域参数是结构体名和成员名列表成员顺序要与文法中属性的顺序一致。这个宏会为结构体生成 Fusion 所需的元信息不改变结构体本身的大小和布局。成员是私有的时候需要额外写访问器那就更适合用BOOST_FUSION_ADAPT_STRUCT的访问器版本或者干脆先解析到std::tuple再手动赋值。规则可以递归。例如解析括号嵌套的表达式时可以写qi::ruleIt, int(), qi::space_type expr;然后用expr qi::int_ | (( expr ));赋值。注意规则之间互相引用时必须先声明后定义且不能出现「规则 A 的属性依赖规则 B、B 又依赖 A」的循环那会在编译期报类型不完整。四、错误定位与调试Spirit 默认的失败信息只有一个bool。真实项目里需要知道「第几行第几列、期望什么」这就要用qi::on_error挂一个错误处理函数并配合qi::info读取期望列表。错误处理器的签名由qi::on_errorqi::fail(rule, handler)的 handler 决定handler 通常接收迭代器范围、当前迭代器、错误信息和上下文。不同 Boost 版本对 handler 的参数细节有差异写之前请对照官方文档的示例。调试文法时定义BOOST_SPIRIT_DEBUG宏可以打开 Qi 的调试输出再用BOOST_SPIRIT_DEBUG_NODES((rule1)(rule2))指定要跟踪的规则。打开后每次进入和退出规则都会打印到标准错误能直接看出是哪一条分支没有匹配。这个宏只应在排查问题时临时定义正式构建里要关掉否则输出量会非常大。另一个实用技巧是把文法拆成多个小规则分别写单元测试。Spirit 的解析器是普通对象可以单独调用qi::parse测试某条规则不必每次都跑完整文法。文法越大这种分层测试越有价值因为模板报错本身很难定位。什么时候不该用 SpiritSpirit 适合「文法是自己定义的、规模中等、需要和 C 类型紧密集成」的场合。以下情况建议换方案。第一解析 JSON、XML、YAML、INI 这类已有成熟格式时直接用对应的库。自己用 Spirit 写一个 JSON 解析器除了练习之外没有收益还要自己处理转义、数字格式、深度限制这些细节。第二文法非常庞大比如完整的 C 或 SQL时手写递归下降或者用解析器生成器往往更可控。Spirit 的编译时间和错误信息在这样的规模下会变成主要成本。第三只需要在一段文本里找几个固定模式时标准库的std::string::find、std::regex或者手写扫描就够了引入 Boost 反而增加构建负担。还有一个工程上的建议把 Spirit 文法集中放在少量源文件里对外只暴露「解析成某个结构体」的函数。这样模板实例化只发生在这些翻译单元其他代码的编译时间不受影响头文件也不会把 Boost 的模板洪水扩散到整个项目。常见坑点❌ 只检查phrase_parse的返回值不检查first last。✅ 两者都要检查否则尾部有多余字符时会被误判为解析成功。❌ 在qi::rule里省略 skipper 模板参数然后把它用在phrase_parse中。✅ 规则声明为qi::ruleIt, Attr, qi::space_type与调用处的 skipper 对应。❌ 接收缓冲区或属性类型与解析器不匹配例如用std::string接qi::int_的属性。✅ 先确认解析器的属性类型再决定传出参数不确定时先解析到fusion::vector观察。❌ 在头文件里定义大型文法导致每个包含它的翻译单元都重新实例化一遍。✅ 文法实现放在.cpp里头文件只声明解析函数和结果类型。❌ 字符串字面量里需要保留空格却直接写在phrase_parse的 skipper 上下文中。✅ 用qi::lexeme[...]或qi::raw[...]局部关闭 skipper。❌ 为了让文法「更灵活」把所有分支都写成|结果属性类型变成难以推导的变体。✅ 每个分支的属性尽量统一确实需要不同类型时显式定义规则并转换到公共类型。❌ 把BOOST_SPIRIT_DEBUG留在正式构建里。✅ 只在本地排查时定义提交前移除调试输出会影响性能并淹没有用日志。❌ 认为 Spirit 解析一定比手写快或一定比手写慢。✅ 运行时代价取决于文法结构和优化程度应该用自己的输入做基准测试如 Google Benchmark不要凭印象下结论。总结主题要点基本模型解析器组合 属性推导、入口函数qi::parse不跳空白qi::phrase_parse带 skipper成功判定返回值为真且first last规则复用qi::ruleIt, Attr, Skipper递归文法必须先声明结构体映射BOOST_FUSION_ADAPT_STRUCT成员顺序与属性一致调试手段BOOST_SPIRIT_DEBUG、on_error、拆分规则做单元测试适用边界自定义中等文法成熟格式和超大文法另选方案Spirit 的学习成本主要不在语法而在属性系统一旦能预测「这个表达式会产生什么类型」大部分编译错误都能读懂。把它当成一个编译期的文法编译器而不是运行时的字符串工具用起来会顺手很多。真正上线前记得把文法收敛到少数翻译单元并用手上的真实输入做边界测试——空格、空输入、超长输入和非法字符永远比正常样例更能暴露问题。
阅读完成 · 觉得有帮助?
咨询建站