☰
从sed转义地狱到caveman:极致简单的文本替换工具
2026/10/8 5:25:56 网站建设 项目流程

我到现在还记得第一次在 sed 里为了替换一个带斜杠的路径,写出一长串反斜杠转义时的那种崩溃。从那以后我就一直在找一个"只需要替换"的工具,不想背正则语法,也不想去跟 sed 的引号规则较劲。后来在 GitHub 上撞见一个叫 caveman 的小项目,名字很直白,作者的意思就是"像山洞人一样简单":只做一件事,把字符串 A 替换成字符串 B,其他什么都不管。这篇文章就聊聊我研究、复刻并使用这个小工具的全过程,以及它带给我的那些关于"极简工程"的思考。如果你是经常跟日志、配置文件、批量文本打交道的开发或运维,或者你对"把一个工具做到极致简单"这件事感兴趣,那这篇应该对你有用。

1. 从 sed 转义地狱到 caveman:一个只会"替换"的小工具

1.1 我为什么需要一个比 sed 更笨的工具

说句实话,sed 是个好工具,功能强大到几乎无所不能,但恰恰是这份"无所不能",让它在某些场景下变得特别难用。就拿最简单的替换来说,我想把配置文件里的/usr/local/bin改成/opt/custom/bin,在 sed 里要写成:

sed -i 's|/usr/local/bin|/opt/custom/bin|g' app.conf

看着还行?那如果路径里出现了&或者\,或者要替换的字符串里本身带单引号,你就得开始和转义搏斗了。更麻烦的是 sed 的正则语法,圆括号要转义、加号要转义、花括号也要转义,每次写完都要对着屏幕自查半天。

我印象最深的一次,是要批量替换几百个文件里的版本号。版本号里有小数点,有连字符,还有\d这样的占位符。我用 sed 写了一个自以为正确的正则,跑完之后发现有一批文件没匹配上,另一批文件被替换错了位置。从那之后我就彻底明白了一个道理:很多场景下我根本不需要正则,我需要的就是"把这个字面文本换成那个字面文本",连通配符都不需要。

1.2 caveman 的功能边界与基本用法

caveman 这个工具就是冲着这个需求去的。它不是一个新奇的算法,也不是一个庞大的框架,它就是一个很纯粹的命令行小工具:从标准输入读入文本,把指定的旧字符串替换成新字符串,然后写到标准输出。

在我常用的版本里,它的用法非常直白,不需要记任何参数项:

echo "hello world" | caveman "world" "caveman" # 输出:hello caveman

或者处理文件的时候配合重定向:

caveman "v1.2.3" "v1.3.0" < app.conf > app.conf.new mv app.conf.new app.conf

没有-i原地修改,没有正则标志,没有分组捕获,没有延迟匹配。它甚至连"同时替换多个不同字符串"的能力都没有,要替换多个就得串联多个调用。第一次看到这种设计我是愣了一下的,心想这也太"原始"了吧。但用了几次之后,我开始理解作者为什么这么坚持。

因为它把"复杂度"挡在了外面。工具本身不需要维护复杂的正则状态机,调用方不需要记忆各种转义规则,两边都轻松。就像你家里只需要一把螺丝刀的时候,没必要把一整套电动工具箱摆在桌上。

1.3 一张表格看清 sed 和 caveman 的差异

为了把这两者的差别说清楚,我整理过一张对照表,拿去给团队里的小朋友讲过:

对比维度sedcaveman
匹配规则正则表达式字面字符串
学习成本中高,需要懂元字符和转义几乎为零
特殊字符处理容易踩转义坑除了 Shell 引号外无需处理
原地修改支持-i不支持,需配合重定向
处理二进制文件视实现而定,容易损坏原样替换,理论上更适合
代码规模庞大,自带正则引擎极小,只做子串搜索和替换
适合场景复杂文本处理管道简单、无歧义的批量替换

你可能会问,那 sed 能做的 caveman 都做不了,何必还要用它?我的回答是:工具不是越强大越好,而是越"可预测"越好。sed 的能力边界很大,但它的行为边界也很大,你在用它之前必须清楚它在这个文本上会怎么解释你的表达式。而 caveman 的行为边界小到你闭着眼睛都能说出来,这本身就是一种价值。

2. 砍掉正则引擎,速度从哪来

2.1 正则引擎的开销到底在哪

很多人觉得,正则引擎只是"处理起来麻烦一点",性能上应该没什么差别吧?这个想法我一开始也有,直到我拿大文件实测之后才发现,差别的确不小,尤其是在你只是做一个字面替换的情况下。

正则引擎的工作方式,本质上是在文本流上维护一个自动机状态。哪怕只是匹配一个最简单的字符串abc,大多数通用正则引擎也要做编译表达式、构建内部状态节点、逐字符驱动状态转移这一整套流程。如果表达式里带了捕获组、回溯或者惰性匹配,那计算量就更是按倍数往上涨了。

更麻烦的是,正则引擎为了支持各种复杂规则,会引入大量的分支判断。每读一个字符,都要做一次当前状态集合的更新。这种做法在"匹配任意正则"这个通用需求下是合理的,但如果你最终想要的只是"找到这个子串,把它换成另一个子串",那这些开销就变成了纯粹的浪费。

我见过一个生产事故,有个定时任务用 sed 在一个很大的日志文件上做逐行替换,原本以为几分钟就跑完,结果跑了将近半小时,把下游任务全堵了。后来把那条规则改成纯字符串替换,执行时间压缩到了一个零头。不是 sed 烂,而是我们用错了工具。

2.2 源码级拆解:纯字符串匹配的一次扫描

我看过一些类似的极简替换工具的实现,它们的核心逻辑通常都长得很像,基本可以浓缩成三件事:找子串、拼新串、继续找。caveman 这类工具的典型实现,不会去用任何正则库,而是直接调用底层的内存搜索函数,比如 C 语言里的strstr或者系统底层的快速子串搜索算法。

整个处理流程其实是一次标准的两阶段扫描:

第一阶段,遍历整个输入,统计旧字符串出现的次数,计算出替换完成后的目标缓冲区总长度。这一步很关键,因为它避免了动态扩容的反复 realloc,也让内存分配只发生一次。

第二阶段,再次遍历输入,遇到旧字符串就跳过它,把新字符串写入目标缓冲区;遇到普通字符就原样拷贝。两个阶段各自走一遍,时间复杂度就是 O(n + m),其中 n 是输入长度,m 是匹配次数带来的额外拷贝量,没有回溯,没有多余的状态分支。

我第一次自己写这类代码的时候,最开始用的是很笨的逐字节比对,跑起来也没问题,但总觉得有点浪费。后来换成内存搜索函数之后,性能有了明显提升。C 标准库里的实现很多都经过了指令集级别的优化,在小字符串上可能看不出差距,在大文件上差距就很可观了。

你可能注意到,这个思路的关键在于"两次扫描"。第二次扫描的时候已经知道了新缓冲区的大小,所以可以放心地直接memcpy,不需要边写边判断"要不要扩容"。这个优化策略不只是这个工具在用,我后来写很多解析器都用上了同样的思路,先算好结果规模,再一次性落地。

2.3 不支持正则不是偷懒,是边界设计

我前面说了,第一次看到 caveman 不支持正则,我的第一反应是"功能残缺"。但在实际使用了一段时间之后,我开始意识到,这不是作者没能力做,而是刻意做的边界设计。

正则表达式本质上是另一种"编程语言",它有自己的语法规则、执行模型和坑。一旦工具引入了正则,它就必须为正则的错误处理负责,为不同风格的正则流派负责,甚至为正则表达式的性能灾难负责。你只需要\d+这种匹配能力,但这背后牵出来的一整套复杂度,全都堆到了工具本身和使用它的人身上。

caveman 的做法是:我不打算解决所有问题,我只解决"字面替换"这个问题。如果你真的需要正则,请你用别的工具。这样反而把责任的边界划清楚了。工具的维护者可以拍胸脯说"我的工具行为是确定的",使用者也可以放心地把它用在自动化脚本里,不用担心某个特殊输入会让匹配行为变得不可预测。

我自己在做组件设计的时候,从这学到的经验是:给工具划边界,比给工具加功能难得多了。拒绝一个需求,比实现一个需求难得多,因为拒绝意味着你必须非常清楚"这个工具的核心价值到底是什么"。

3. 复刻一个最小可用版本

3.1 语言选型:考虑编译体积还是开发速度

在分析完原理之后,我当时的第一反应是"这玩意儿我也能写一个"。正好手头有几个脚本项目需要频繁做替换操作,我就花了一个下午的时间复刻了这么一个小工具出来。关于语言选型,我纠结了一下,最后做了两个版本:一个用 C,追求最小依赖和极致性能;另一个用 Python,方便我在各种没有编译环境的机器上临时用。

如果你也想自己写一个,我的建议是:如果目标是学习底层原理,选 C 或者 Rust 这类系统语言;如果目标是日常脚本里自用,选 Python 或 Go 都行。核心替换逻辑的思路完全一致,只是系统的内存管理方式不同。

Go 因为天生对并发和管道处理友好,写这类工具也特别顺手。编译出来是一个静态二进制,扔到服务器上就能跑,连 libc 的版本都不用担心。我当时没有选 Go 是因为目标机器太旧,C 的兼容性最稳,但如果你没有这个包袱,Go 会省不少事。

3.2 核心替换逻辑:逐字节比较与重建缓冲区

先讲一下设计思路,方便你看后面的代码。

第一步,读入全部输入。你可能觉得"为什么不逐行处理",对于替换需求来说,逐行会带来一个问题:如果旧字符串跨行出现,逐行处理就漏掉了。所以为了行为完整,我会把整个输入一次性读入内存。当然,这也意味着你要预估一下输入的大小,别拿一个几十 GB 的文件直接往内存里怼。对于"替换"这个动作,一次读入是最稳妥的做法。

第二步,第一次遍历统计匹配数。这一趟的目的是算出最终输出的长度。如果旧字符串长度大于新字符串,最后结果变短;反之变长。有了这个长度,我就可以一次性分配好输出缓冲区。

第三步,第二次遍历执行替换。这一步需要维护两个指针,一个指向读取位置,一个指向写入位置。每当找到旧字符串,就把前面的部分拷贝到输出里,然后写入新字符串,再跳过旧字符串继续。整个过程是线性的,不需要回头。

边界情况里最重要的是"旧字符串为空"。这种匹配理论上每个位置都算匹配,处理不好就会死循环。我的做法是遇到空模式直接原样返回输入,并且在文档里明确标注"不支持空模式"。

3.3 完整的参考实现代码

下面是我用 C 写的精简版,去掉错误处理细节之后总共就几十行:

#include <stdio.h> #include <stdlib.h> #include <string.h> static char *read_all(FILE *fp, size_t *len) { size_t cap = 4096, n = 0; char *buf = malloc(cap); if (!buf) return NULL; int c; while ((c = fgetc(fp)) != EOF) { if (n + 1 >= cap) { cap *= 2; char *tmp = realloc(buf, cap); if (!tmp) { free(buf); return NULL; } buf = tmp; } buf[n++] = (char)c; } buf[n] = '\0'; *len = n; return buf; } static char *replace_all(const char *input, size_t len, const char *old, const char *new) { size_t old_len = strlen(old), new_len = strlen(new); if (old_len == 0) { char *out = malloc(len + 1); if (!out) return NULL; memcpy(out, input, len + 1); return out; } size_t count = 0; const char *p = input, *end = input + len; while ((p = strstr(p, old)) != NULL && p < end) { count++; p += old_len; } size_t out_len = len + count * (new_len - old_len); char *out = malloc(out_len + 1); if (!out) return NULL; char *dst = out; const char *cur = input; while ((p = strstr(cur, old)) != NULL && p < end) { memcpy(dst, cur, p - cur); dst += p - cur; memcpy(dst, new, new_len); dst += new_len; cur = p + old_len; } memcpy(dst, cur, end - cur); dst += end - cur; *dst = '\0'; return out; } int main(int argc, char **argv) { if (argc != 3) { fprintf(stderr, "usage: caveman OLD NEW\n"); return 2; } size_t len; char *input = read_all(stdin, &len); if (!input) return 1; char *out = replace_all(input, len, argv[1], argv[2]); if (!out) { free(input); return 1; } fputs(out, stdout); free(out); free(input); return 0; }

编译命令很简单:

cc -O2 -o caveman caveman.c

如果是在 Windows 上用 MSVC,把cc换成cl也一样,代码本身不依赖任何平台特性。

3.4 测试用例:把边缘情况逼出来

代码写完不能直接上生产,我当时的做法是先列一组测试用例,把容易出问题的边缘情况都过一遍:

测试场景输入命令预期输出
基本替换hello worldcaveman world cavemanhello caveman
无匹配hello worldcaveman foo barhello world
连续匹配aaaacaveman aa bbb
替换为空串abc defcaveman " " ""abcdef
空模式abccaveman "" xabc(不变)
跨行匹配a\nbcaveman "a\nb" cc注意这时需要 Shell 传实际的换行符
特殊字符100% donecaveman 100% 50%50% done

这里面最容易出问题的是连续匹配。拿aaaa和aa来说,正确的处理方式是匹配完一个之后,从被替换文本的后面继续找,这样得到的两个匹配是[0,2)和[2,4),输出是bb。如果实现不小心在匹配完之后从当前匹配的第二个字符继续找,就会得到三个匹配,输出就错了。这种细节不写测试根本注意不到。

还有一个我在实际使用中发现的点:如果你打算处理二进制文件,最好在测试用例里加入\x00字节的替换测试。因为我这个 C 版本用的是strstr,它对\0是敏感的,输入一旦包含空字节,搜索就会提前结束。处理纯文本没问题,处理二进制就没那么保险了。如果你真有二进制需求,得把代码改成用显式长度的内存搜索函数,而不是依赖字符串函数。

4. 实测对比:哪些场景该用它,哪些场景别碰

4.1 三组实测:热替换、大文件、批量修改

我复刻完这个小工具之后,专门针对三个典型场景做了对比测试。测试对象是 sed 和我的 caveman 复刻版,跑的机器只是一台普通的 Linux 虚拟机,所以具体数字不能代表所有环境,但几轮测试下来的趋势很稳定。

第一组是"热替换":在一个约 200MB 的日志文件里,把某个固定的错误码从E123替换成E456。这一组是 sed 和 caveman 差距最明显的一组。sed 的表现也不差,但在这类纯字面替换、不需要正则的场合,它花在正则引擎状态维护上的时间就变成纯开销了。caveman 的优势在 CPU 时间上可以明显感知到。

第二组是"无匹配":同样的大文件,替换一个文件中根本不存在的字符串。这组很有意思,两者都不会输出任何变化过的东西,但 sed 仍然需要走一遍正则匹配流程。caveman 同样也要全量扫一遍,因为必须确认没有匹配。差距依然存在,但比第一组要小。

第三组是"批量修改":对几十个小配置文件逐个执行替换。这种场景下,文件本身很小,工具启动速度和 I/O 开销是主要成本,两者差距可以忽略不计。真正影响体验的,反而是 sed 的转义规则会不会导致我写错命令。

4.2 结果解读:简单替换场景下的真实收益

综合来看,我的结论是:在"大规模、纯字面替换"这个特定场景里,极简替换工具确实有真实收益,而且收益不只是心理上的"简单"。它减少了正则引擎的 CPU 开销,减少了调用者思考转义规则的时间,也减少了出错概率。

但我也要强调,这不是说 sed 不行。sed 在文本处理界的位置是综合工具,你用它做这件事的时候,其实是用它 10% 的能力在处理一个需求,剩下 90% 的复杂度被白白背负在了每次调用上。而 caveman 这样的工具,相当于把一个高频小需求单独拆了出来,让专业工具做专业的事。

有一类场景我会明确建议别用 caveman:字符串里带有"实际需要正则表达"的匹配逻辑时,比如你想匹配"任意以err_开头的单词"或者"所有包含数字的变量名",这种需求用极简替换工具就是自找麻烦,老老实实用 sed、perl 或者你熟悉的脚本语言。极简工具的边界,就是它只接受"我知道我要把这段字面文本变成那段字面文本"的场景。

4.3 我踩过的三个坑

既然是自己写工具自己用,踩坑是免不了的。这里分享三个我实际遇到过的坑,希望能帮后面的人省点时间。

第一个坑是没有意识到"没有原地修改"。我一开始在自动化脚本里直接写:

caveman "old" "new" < app.conf > app.conf

你猜发生了什么?输出重定向会在命令执行前把文件截断,结果就是读进去的是空文件,替换完输出也是空的,配置文件原地蒸发。我当时盯着空文件愣了好几秒才反应过来。正确做法是写到临时文件,确认成功后再mv回去,或者用一个临时后缀。

第二个坑是 Shell 引号问题。caveman 的参数是字面字符串,所以如果你想换的内容里包含空格,必须记得加引号。这个和 sed 的转义问题是两个方向的反面:sed 是"不需要引号的地方容易被转义规则坑",caveman 是"参数一多容易忘记加引号导致被 Shell 拆词"。所以写脚本时有一个习惯很重要:所有参数,不管是什么,一律用双引号包起来。

第三个坑是把"不支持正则"记成了"不支持复杂匹配"。有一次我在替换版本号时,想顺手把带有\d的写法也过滤掉,结果那个字符串当然没有被替换,因为\d在极简替换工具里就是两个普通字符。这类问题通常不会造成破坏性错误,但会让你的脚本结果跟你预期不一致,在自动化流程里这种"静默失败"往往最麻烦。

5. 极简工具给我的后续启发

5.1 单一职责在真实项目里的落地

研究完这个工具之后,我最大的收获其实不在工具本身,而是它让我重新审视了自己项目里的那些"多功能模块"。

我之前写过一个小工具,用来处理部署时的环境变量模板,功能包括解析 YAML、支持多级变量、带条件判断、还能在模板里写循环。功能听着很全,但每次改需求我都得小心翼翼,生怕动了一个功能影响到另一个。后来我做了一次重构,按照 caveman 的精神把它拆成了三个独立的工具:第一个只负责"把${VAR}替换成环境变量值",第二个负责"读取一份简单的键值配置文件",第三个负责"把多份文件拼接输出到指定位置"。每个工具都只有一个明确职责,组合起来反而比原来一个大工具更好维护。

那次重构的结果,是把核心代码从三百行减到了不到一百行,而且测试变得极其简单。因为每个小工具的输入输出都是确定的,我不需要构造一大堆复杂的 YAML 用例来验证分支逻辑,只需要测最核心的数据流。这个经验后来被我反复用在别的项目上,"宁可用三个小工具拼出一个大功能,也不写一个什么都能干的大工具"成了我的一个默认原则。

5.2 少依赖带来的部署自由

另一个我很深的体会是"零依赖"的价值。这个复刻的 caveman,我把它编译成了一个静态二进制,大小也就几 KB 或者说几十 KB 量级。我把它扔在服务器上、扔在容器镜像里、扔在 CI 的临时环境里,都没问题。它不需要运行时,不需要 Python 解释器,不需要装任何依赖包。

对比一下,我之前的很多脚本工具,哪怕只是一个小功能,也要带上一堆依赖声明。在开发机上没什么感觉,一旦要部署到客户的隔离环境、或者跑在各种精简容器里,依赖问题就会变成最大的敌人。有一个项目甚至出现过因为基础镜像里没有某个动态链接库,导致整个工具链跑不起来的尴尬局面。

从那以后,我写内部小工具时会有意识地控制依赖数量。能用标准库解决的就不用第三方库,能静态编译的就静态编译。如果这个工具注定了要陪着我到处搬家,那它最好轻装出行。

5.3 极简的边界:什么时候不要硬简

当然,我也得说说极简的边界在哪里,免得你把这篇文章看完之后,把所有工具都拆成"只会做一件事"的玩具。有些场景下,极简反而是不负责任的。

一个典型的例子是,如果你处理的数据格式本身是结构化的,比如 JSON 或者 YAML,正确的做法是用对应的解析库去操作结构,而不应该用"字符串替换"来硬改。字符串替换只知道内容不知道结构,一旦数据换行格式变化、转义方式不同、字段嵌套层级调整,你的替换逻辑就可能产生语法上合法但含义上完全错误的结果。这种时候,"只做一件事"并不能保护你,反而会让你对危险操作掉以轻心。

另一个极简导致的陷阱是"隐藏的条件"。当你把一个大工具拆成很多小工具之后,每个小工具内部的逻辑确实简单了,但你的调用方代码会变复杂,需要记住工具之间的组合规则和参数顺序。工具简单了,系统不简单。所以在设计时要平衡:把"复杂度"放在哪个层面更合理,是从工具内部挪出去,还是留在工具内部更安全。

就我个人而言,如果一个工具的核心逻辑可以在一屏代码里讲清楚,那它就该被拆成极简工具;如果它需要跨多个状态去协调数据,那它还是老老实实用一个能表达复杂流程的语言去写吧。

最后分享一个小技巧:我在写 Makefile 的时候,经常要用替换来生成不同的构建产物名字。以前我都是用sed或者写一段 shell 来做,现在直接调这个复刻的 caveman,配合变量传参,整条规则变得异常清晰。你如果平时经常做类似的批量文本处理,不妨也花个把小时动手写一个属于自己的极简替换工具,它不只是给你一个工具,更重要的是它会改变你对"工具设计"这件事的思考方式。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询