用于C语言的单头文件解析器组合子

Hacker News Top 工具

摘要

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 生成的代码同样难以维护,并且会复杂化构建流程。

CParseCC 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_LABELCPC_STRING_ *
CPC_ALT(name, x, y)尝试解析器 x,如果失败则在相同输入上尝试解析器 yN/AN/A
CPC_RIGHT(name, x, y)依次执行 xy,仅返回 y 的输出。N/AN/A
CPC_LEFT(name, x, y)依次执行 xy,仅返回 x 的输出。N/AN/A
CPC_APPLY(name, x, y)依次执行 xy,将两者的输出作为一个列表返回。N/AN/A
CPC_TAKE_WHILE_1(name, pred)pred 为真时消耗一个或多个字符,并返回消耗的切片。CPC_TAKE_WHILE_1_LABELN/A
CPC_MANY_1(name, parser)重复执行 parser 一次或多次,并将输出作为列表返回。CPC_MANY_1_LABELN/A
CPC_SEP_BY_1(name, item, sep)解析一个或多个由 sep 分隔的 item 值,返回列表。CPC_SEP_BY_1_LABELN/A
CPC_PURE(name, value)成功但不消耗输入,返回 valueN/AN/A
CPC_MAP(name, parser, fn)执行 parser 并使用 fn 转换其输出。N/AN/A
CPC_MANY(name, parser)重复执行 parser 零次或多次,并将输出作为列表返回。N/AN/A
CPC_SEP_BY(name, item, sep)解析零个或多个由 sep 分隔的 item 值,返回列表。N/AN/A
CPC_TAKE_WHILE(name, pred)pred 为真时消耗零个或多个字符,并返回消耗的切片。N/AN/A
CPC_MANY_TILL(name, parser, end)重复执行 parser 直到 end 成功,将收集的输出作为列表返回。N/AN/A
CPC_TAKE_TILL(name, pred)消耗输入直到 pred 为真,并返回消耗的切片。N/AN/A
CPC_MATCH(name, parser)执行 parser 并返回实际消耗的输入(切片),而不是解析后的值。N/AN/A
CPC_ONE_OF(name, chars)如果下一个字符是 chars 中的某一个,则成功并返回该字符的切片。CPC_ONE_OF_LABELN/A
CPC_END_OF_LINE(name)解析 \n\r\n 并返回匹配的切片。CPC_END_OF_LINE_LABELCPC_END_OF_LINE_
CPC_ANY(name)消耗并返回任意单个字符(切片)。CPC_ANY_LABELCPC_ANY_
CPC_EOF(name)仅在输入结束时成功。CPC_EOF_LABELCPC_EOF_
CPC_BETWEEN(name, open, parser, close)依次解析 openparserclose,仅返回 parser 的输出。N/AN/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_TILLCPC_ONE_OF 的组合。返回切片。N/AN/A
CPC_TAKE_QUOTED(name, quote, escape)解析带引号的字符串,处理转义内容。返回切片。CPC_TAKE_QUOTED_LABELN/A

与 Haskell 的对比

与 Haskell 的不同之处

  • 要就做,要不就不做,没有 try。与 Haskell 的 Parsec 不同,我们不需要 try,因为基于切片进行回溯成本很低。像 CPC_STRING 这样的解析器在失败时不会消耗输入。
  • CParseC 的解析器总是终止的。与 Haskell 中的 manymanyTillsepbysepby1(它们可能无限循环)不同。
  • 没有与 >> 等价的函数,因为它可以由 *>(即 CPC_RIGHT)表达。
  • 只有那些可能失败且具有内置错误消息的解析器,才可以通过该解析器的 _LABEL 变体覆盖其错误消息。
    • 为什么?根据 https://github.com/steve-chavez/CParseC/pull/2 的发现,将非叶子解析器包装在函数中会严重损害性能,因为会阻碍内联。

与 Haskell 的相似之处

所有函数都受 Haskell 的 Parsec 或 AttoParsec 启发。下表列出了一些等价关系:

CParseCHaskell
CPC_ALT<|>
CPC_RIGHT*>
CPC_LEFT<*
CPC_APPLY<*>
CPC_MAP<$>
CPC_PUREpure

相似文章

Sp.h 是 C 语言应得的标准库

Hacker News Top

sp.h 是一个 15000 行的单头文件 C99 标准库,它绕过 libc 以提供可移植、显式且无堆的原始接口。其旨在用现代的系统调用级抽象取代传统的 libc。

并行括号匹配

Hacker News Top

探讨用于括号匹配的并行算法,这是编译器和文本处理中的一个基本问题。