用于C语言的单头文件解析器组合子
摘要
CParseC 是一个基于 C99 的单头文件解析器组合子库,灵感来自 Haskell 的 Parsec,提供零拷贝解析、无隐藏内存分配以及 SIMD 优化的组合子。其目标是提供一种灵活、高性能的手写解析器和 lex/yacc 工具的替代方案。
查看缓存全文
缓存时间: 2026/07/01 07:57
steve-chavez/CParseC 源码:https://github.com/steve-chavez/CParseC
CParseC
C语言中的解析存在以下问题:
- 手写解析器(递归下降、状态机等)难以维护。
- Flex 或 Bison 生成的代码同样难以维护,并且会复杂化构建流程。
CParseC(C Parser Combinators,C 解析器组合子)提供了一种灵活且高性能的解析方案:
- 用纯 C99 编写,可组合、表达力强(受 Haskell 的 Parsec 启发)
- 单头文件(cparsec.h),无依赖(默认不依赖 libc)
- 零拷贝解析
- 无隐藏内存分配,由用户提供分配区(arena)
- 内联友好,热路径使用宏而非函数指针
- 提供 SIMD 专用组合子
演示
一个 CSV 解析器如下所示:
#include <stdio.h>
#include <stdlib.h>
#define CPC_USE_MEMCHR
#define CPC_USE_UNNAMED
#include "cparsec.h"
CPC_TAKE_QUOTED(quotedField, '"', '"')
CPC_TAKE_TILL_ONE_OF(unquotedField, ",\r\n")
CPC_ALT(field, quotedField, unquotedField)
CPC_SEP_BY_1(record, field, CPC_STRING_(","))
CPC_ALT(lineEnd, CPC_END_OF_LINE_, CPC_EOF_)
CPC_LEFT(parse_csv_row, record, lineEnd)
int main(void) {
CpcArena arena;
CpcValue arena_storage[8192];
cpc_arena_init(&arena, arena_storage, sizeof(arena_storage) / sizeof(arena_storage[0]), NULL);
const char csv[] = "alpha,\"beta\",\"ga,mm,a\",d\"\"elta\n";
CpcSlice input = cpc_slice_from_cstr(csv);
CpcResult result = parse_csv_row(&arena, input);
for (size_t i = 0; i < result.out.as.list.len; ++i) {
const CpcValue *cell = cpc_val_list_at(&arena, &result.out, i);
CpcSlice slice = cell->as.slice;
printf("%.*s ", (int)slice.len, slice.ptr);
//alpha "beta" "ga,mm,a" delta
}
return EXIT_SUCCESS;
}
在解析 100 万行 CSV 时,上述解析器比 BurntSushi/rust-csv (https://github.com/BurntSushi/rust-csv) 快约 1.25 倍,比 attoparsec-csv (https://github.com/robinbb/attoparsec-csv/) 快约 20 倍。请参阅 CI 上的持续基准测试 (https://github.com/steve-chavez/CParseC/actions/runs/27530243247) 来确认结果。
API
基本组合子
所有宏本质上都会生成可内联的函数,这些函数接受其他可内联函数作为参数。它们返回 CpcValue,可以是切片(CpcSlice)或列表(CpcList,需要 CpcArena 来存储)。
| 宏 | 描述 | 标签 | 匿名 |
|---|---|---|---|
CPC_STRING(name, lit) | 精确解析字符串字面量 lit,并返回该切片。 | CPC_STRING_LABEL | CPC_STRING_ * |
CPC_ALT(name, x, y) | 尝试解析器 x,如果失败则在相同输入上尝试解析器 y。 | N/A | N/A |
CPC_RIGHT(name, x, y) | 依次执行 x 和 y,仅返回 y 的输出。 | N/A | N/A |
CPC_LEFT(name, x, y) | 依次执行 x 和 y,仅返回 x 的输出。 | N/A | N/A |
CPC_APPLY(name, x, y) | 依次执行 x 和 y,将两者的输出作为一个列表返回。 | N/A | N/A |
CPC_TAKE_WHILE_1(name, pred) | 在 pred 为真时消耗一个或多个字符,并返回消耗的切片。 | CPC_TAKE_WHILE_1_LABEL | N/A |
CPC_MANY_1(name, parser) | 重复执行 parser 一次或多次,并将输出作为列表返回。 | CPC_MANY_1_LABEL | N/A |
CPC_SEP_BY_1(name, item, sep) | 解析一个或多个由 sep 分隔的 item 值,返回列表。 | CPC_SEP_BY_1_LABEL | N/A |
CPC_PURE(name, value) | 成功但不消耗输入,返回 value。 | N/A | N/A |
CPC_MAP(name, parser, fn) | 执行 parser 并使用 fn 转换其输出。 | N/A | N/A |
CPC_MANY(name, parser) | 重复执行 parser 零次或多次,并将输出作为列表返回。 | N/A | N/A |
CPC_SEP_BY(name, item, sep) | 解析零个或多个由 sep 分隔的 item 值,返回列表。 | N/A | N/A |
CPC_TAKE_WHILE(name, pred) | 在 pred 为真时消耗零个或多个字符,并返回消耗的切片。 | N/A | N/A |
CPC_MANY_TILL(name, parser, end) | 重复执行 parser 直到 end 成功,将收集的输出作为列表返回。 | N/A | N/A |
CPC_TAKE_TILL(name, pred) | 消耗输入直到 pred 为真,并返回消耗的切片。 | N/A | N/A |
CPC_MATCH(name, parser) | 执行 parser 并返回实际消耗的输入(切片),而不是解析后的值。 | N/A | N/A |
CPC_ONE_OF(name, chars) | 如果下一个字符是 chars 中的某一个,则成功并返回该字符的切片。 | CPC_ONE_OF_LABEL | N/A |
CPC_END_OF_LINE(name) | 解析 \n 或 \r\n 并返回匹配的切片。 | CPC_END_OF_LINE_LABEL | CPC_END_OF_LINE_ |
CPC_ANY(name) | 消耗并返回任意单个字符(切片)。 | CPC_ANY_LABEL | CPC_ANY_ |
CPC_EOF(name) | 仅在输入结束时成功。 | CPC_EOF_LABEL | CPC_EOF_ |
CPC_BETWEEN(name, open, parser, close) | 依次解析 open、parser、close,仅返回 parser 的输出。 | N/A | N/A |
为了方便,某些解析器可以匿名使用,以减少为每个函数命名带来的开销。标记为 * 的解析器(如 CPC_STRING_)需要 #define CPC_USE_UNNAMED,因为它们依赖于非标准 C99 行为(嵌套函数 (https://gcc.gnu.org/onlinedocs/gcc/Nested-Functions.html)、语句表达式 (https://gcc.gnu.org/onlinedocs/gcc/Statement-Exprs.html) 和 __COUNTER__)。
_LABEL 变体接受一个额外的 label 参数,用于更改内置错误消息。
内部错误消息(显示“分配区大小超出”和“无进展”(针对编写不良的解析器导致的无限循环))不能覆盖。
SIMD 组合子
这些解析器是特殊版本,利用 memchr 实现 SIMD 加速。由于 memchr 并非随处可用,它们需要 #define CPC_USE_MEMCHR。
| 宏 | 描述 | 标签 | 匿名 |
|---|---|---|---|
CPC_TAKE_TILL_ONE_OF(name, stops) | CPC_TAKE_TILL 和 CPC_ONE_OF 的组合。返回切片。 | N/A | N/A |
CPC_TAKE_QUOTED(name, quote, escape) | 解析带引号的字符串,处理转义内容。返回切片。 | CPC_TAKE_QUOTED_LABEL | N/A |
与 Haskell 的对比
与 Haskell 的不同之处
- 要就做,要不就不做,没有
try。与 Haskell 的 Parsec 不同,我们不需要try,因为基于切片进行回溯成本很低。像CPC_STRING这样的解析器在失败时不会消耗输入。 - CParseC 的解析器总是终止的。与 Haskell 中的
many、manyTill、sepby、sepby1(它们可能无限循环)不同。 - 没有与
>>等价的函数,因为它可以由*>(即CPC_RIGHT)表达。 - 只有那些可能失败且具有内置错误消息的解析器,才可以通过该解析器的
_LABEL变体覆盖其错误消息。- 为什么?根据 https://github.com/steve-chavez/CParseC/pull/2 的发现,将非叶子解析器包装在函数中会严重损害性能,因为会阻碍内联。
与 Haskell 的相似之处
所有函数都受 Haskell 的 Parsec 或 AttoParsec 启发。下表列出了一些等价关系:
| CParseC | Haskell |
|---|---|
CPC_ALT | <|> |
CPC_RIGHT | *> |
CPC_LEFT | <* |
CPC_APPLY | <*> |
CPC_MAP | <$> |
CPC_PURE | pure |
相似文章
Sp.h 是 C 语言应得的标准库
sp.h 是一个 15000 行的单头文件 C99 标准库,它绕过 libc 以提供可移植、显式且无堆的原始接口。其旨在用现代的系统调用级抽象取代传统的 libc。
并行括号匹配
探讨用于括号匹配的并行算法,这是编译器和文本处理中的一个基本问题。
@charliermarsh: /目标:寻找能让你解析器提速20-30%的简单单行优化
Charlie Marsh 分享了一个个人目标:寻找能让解析器提速20-30%的简单单行优化。
Gecko:一款支持自动语法错误恢复的快速 GLR 解析器
Gecko 是一个全新的可嵌入 C 库,可为任意上下文无关文法提供 GLR 解析、自动语法错误恢复,并保持 YACC 级速度。
Show HN: Semble – 面向代理的代码搜索,令牌使用量比 grep 减少 98%
Semble 是一款面向 AI 代理的快速代码搜索库,令牌使用量比 grep+read 减少约 98%,在 CPU 上运行,无外部依赖,并通过 MCP 或 CLI 集成。