侧边栏壁纸
博主头像
一笑痕

人生若只如初见,
是可喜亦或者是可悲?

  • 累计撰写 81 篇文章
  • 累计收到 7 条评论

别把 sed -i 的后缀省掉,文件名会被当脚本读

2026-9-21 / 0 评论 / 7 阅读

别把 sed -i 的后缀省掉,文件名会被当脚本读

一个批量替换配置的小脚本,里面写着 sed -i 's/old/new/g' f1.txt。在 Linux 机器上跑了很久没出过事,拿到 macOS 上第一次执行就退出,报错原文是 sed: 1: "f1.txt\n": invalid command code f,退出码 1,文件内容一个字节都没动。这类脚本通常埋在构建步骤或批量改名里,报错响了还算走运,怕的是悄悄改了别的东西。

第一次在 Mac 上执行就退出,文件没动

受影响的是所有在 Mac 上直接跑、又要兼容 Linux 的脚本,还有 Makefile 与 CI 配置里那行看起来无害的 sed。同一条命令在两种实现下的切分方式不一样:BSD 版把 -i 后面的第一个参数当成备份后缀,GNU 版把后缀当成 -i 的可选附加部分。切分不同,后面所有的参数位置就整体挪了一位。批量处理的场景更麻烦,带后缀的写法会在目录里留下一批 .bak,后续按文件名匹配的步骤跟着出错,而不带后缀的写法在两边含义又不一样。

SYNOPSIS 里的 -i extension

定位动作只有两步。先探测实现:sed --version 在本机打印 illegal option -- - 并退出 1(GNU 版会把版本号打印出来并退出 0),这是最快的分流判断。再翻本机 man 页,SYNOPSIS 一节写的是 sed [-EHalnru] command [-I extension] [-i extension] [file ...]-i 后面那个 extension 是必填参数。把这两条合起来,sed -i 's/old/new/' f1.txt 在 BSD 版眼里就是:后缀 s/old/new/,脚本 f1.txt,引擎拿文件名去当脚本解释,报出 invalid command code f,f 正是文件名的首字母。

-i.bak 两边都认,代价是备份文件

四种改法在本机各跑了一遍。BSD 写法 sed -i '' -e 's/foo/bar/' f1.txt 退出码 0,内容改对,目录里没有多余文件。两边都认的写法 sed -i.bak -e 's/foo/bar/' f1.txt 同样退出码 0,代价是留下 f3.txt.bak。一次改两个文件 sed -i '' -e 's/foo/bar/' f4a.txt f4b.txt 两个文件都改到了。绕开 sed 的写法 perl -pi -e 's/foo/bar/' f7.txt 也成功,perl 在这个参数上没有方言差异,代价是多一个运行时依赖。

把方言判断收进一个函数

脚本里加一层分流,把方言判断收在一处:

# 探测当前是 BSD 还是 GNU,两种写法都收在这个函数里
sed_i() {
  if sed --version >/dev/null 2>&1; then
    sed -i "$@"
  else
    sed -i '' "$@"
  fi
}
sed_i -e 's/foo/bar/' f6.txt

这个函数在本机跑过一遍,走的是 else 分支,退出码 0,被改的文件内容正确,目录里没多出备份文件。如果是临时改文件而不是写进仓库的脚本,perl -pi -e 更省心,不用管方言;要留在仓库里长期跑的脚本,建议把上面这层判断留下,别把平台差异散落在每次调用上。

sed 实现: sed: illegal option -- -  (退出码 0)
系统: Darwin 25.2.0
工作目录: /tmp/sedtest.deBkpB

=== 1. sed -i "s/foo/bar/" f.txt(GNU 上最常见的写法)===
sed: 1: "f1.txt
": invalid command code f
退出码 1
文件内容: foo one|foo two|
目录里的文件: f1.txt 

=== 2. sed -i "" -e "s/foo/bar/" f.txt(BSD 写法)===
退出码 0
文件内容: bar one|bar two|
目录里的文件: f1.txt f2.txt 

=== 3. sed -i.bak -e "s/foo/bar/" f.txt(两边都认的写法)===
退出码 0
文件内容: bar one|bar two|
备份内容: foo one|foo two|

=== 4. 一次改两个文件 ===
退出码 0
f4a: bar one|bar two|
f4b: bar one|bar two|

=== 5. 探测当前是哪个实现 ===
sed --version 退出码 1(0 = GNU,非 0 = BSD)

=== 6. 可移植的封装函数在这台机器上跑一遍 ===
退出码 0
文件内容: bar one|bar two|
目录里没多出备份文件: 1

=== 7. 换成 perl 的等价写法(不依赖 sed 实现)===
退出码 0
文件内容: bar one|bar two|

留下的文件: f1.txt f2.txt f3.txt f3.txt.bak f4a.txt f4a.txt.bak f4b.txt f4b.txt.bak f6.txt f7.txt
临时目录未删除,路径: /tmp/sedtest.deBkpB

本机 man 页里与这一条直接相关的两段:

SYNOPSIS
     sed [-EHalnru] command [-I extension] [-i extension] [file ...]
     sed [-EHalnru] [-e command] [-f command_file] [-I extension]
     [-i extension] [file ...]

     -i extension
         Edit files in-place similarly to -I, but treat each file
         independently from other files.  In particular, line numbers in
         each file start at 1, the "$" address matches the last line of
         the current file, and address ranges are limited to the current
         file.  (See Sed Addresses.) The net result is as though each file
         were edited by a separate sed instance.

备份文件还会牵出第二层麻烦。实测留下的 f3.txt.bakf4a.txt.bakf4b.txt.bak 都躺在同一个目录里,同一批处理里若还有一步按 *.txt 去匹配,这些备份会被一起算进去,处理的文件数直接翻倍。这一类我把结论压成一条:仓库脚本里不要裸写 -i,后缀要么显式给全,要么交给上面那个分流函数,让方言判断只出现在一个地方。临时任务不写进仓库的话,用 perl 那一行更省事。

有一块没实测:本机只有 BSD 版 sed,没装 gsed,也没有可用的 Linux 环境,所以 GNU 版那一侧的切分行为只按文档说法描述,没有跑过。第 3 条 -i.bak 这种把后缀贴在 -i 后面的形式,是按两边文档都接受的形式挑的,同样没在 GNU 上执行过;如果哪天有 Linux 机器可测,这一步值得补上,因为它是仓库脚本里最省事的一条。

    🤞 分享