阶段四 · 管道与文本处理

sed:流编辑器

1. sed 是什么

sed(Stream Editor,流编辑器)是一个非交互式的文本处理工具.它从标准输入或文件中逐行读入文本,按照事先写好的"脚本"对每一行进行处理,然后把结果输出到标准输出.

如果用 Word 或 vim 编辑文件,是手动选中文本 → 按删除键 → 输入新内容--这是交互式编辑.
sed 则是:把编辑指令提前写在一张小纸条上,sed 拿着纸条自动对文件的每一行执行相同操作--一行接一行,不问任何问题.

1.1 它和文本编辑器有什么不同

交互式编辑器(vim / nano / VS Code) sed(流编辑器)
操作方式 手动光标定位,逐个字符编辑 写脚本,自动化批量处理
文件 打开 → 编辑 → 保存 读入 stdin/文件 → 处理 → 输出到 stdout
是否原样修改 是(除非不保存) 默认不改原文件(除非加 -i)
适合场景 写代码,改配置,精细调整 批量替换,格式化,提取,自动化处理

一句话总结 sed 的定位:当需要对文本做"批量规则化"的处理时--比如把 100 个文件中的 http:// 全部替换为 https://--打开每个文件逐一修改是荒谬的.sed 就是为这种事而生的.

2. 执行模型:sed 如何工作

sed 的执行模型是理解它的最大门槛.不是语法难,而是它的工作方式和直觉想的可能不一样.

2.1 模式空间(Pattern Space)--sed 的工作台

sed 内部维护着一块内存区域叫模式空间.它的工作流程是一个严格的循环:

┌──────────┐
│ 输入文件的下一行 │
└──────────┘


┌───────────┐
│ 1. 读入模式空间 │
└───────────┘


┌───────────────┐
│ 2. 执行脚本中的所有命令 │
└───────────────┘


┌────────────────┐
│ 3. 默认打印模式空间的内容 │
└────────────────┘


┌───────────────────────┐
│ ["输出 (stdout)"] │
└───────────────────────┘
┌───────────┐
│ 4. 清空模式空间 │
└───────────┘


┌──────────┐
│ 还有下一行? │
└──────────┘
│ "否"

┌──────────┐
│ 结束 │
└──────────┘

关键理解:sed 脚本(比如 s/old/new/)不是对文件整体执行一次--而是对每一行都独立执行一遍完整脚本.脚本是"每条生产线上的操作规范",不是"对整个仓库的管理指令".

2.2 一个例子走完完整生命周期

假设 test.txt 有三行,执行 sed 's/apple/orange/' test.txt:

第 1 行 "I like apple" 进入模式空间
→ 执行 s/apple/orange/ → 模式空间变成 "I like orange"
→ 打印 "I like orange"
→ 清空模式空间

第 2 行 "apple pie is good" 进入模式空间
→ 执行 s/apple/orange/ → 模式空间变成 "orange pie is good"
→ 打印 "orange pie is good"
→ 清空

第 3 行 "no match here" 进入模式空间
→ 执行 s/apple/orange/ → 没匹配到,模式空间不变
→ 打印 "no match here"
→ 清空

结束.每一行都独立经历了"读入 → 执行脚本 → 打印 → 清空"的完整流程.

2.3 默认行为:自动打印(-n 关闭它)

sed 在每行脚本执行完后,会自动把模式空间的内容打印到 stdout.这就是为什么不写任何"打印"命令也能看到输出--它是自动的.

-n 选项关闭这个自动行为.关闭后,只有显式使用 p 命令的行才会输出:

$ sed 's/apple/orange/' test.txt   # 默认自动打印 → 每行都输出
$ sed -n 's/apple/orange/p' test.txt # -n 关闭自动打印,p 手动打印 → 只输出修改过的行

默认自动打印是个好设计

大多数时候想看到处理后的完整结果(改过的行 + 没改的行原样保留),所以自动打印让常见操作变得简洁.只有当想"只提取某些行"时才需要 -n,这就变成了类似 grep 的行为.

3. 脚本语法

sed 脚本的基本结构是:

[地址] 命令 [选项]
# ↑ ↑
# 对哪些行 做什么

地址是可选的--不写地址意味着所有行都执行该命令.

3.1 地址(Address)--对哪些行操作

地址类型 写法 含义
行号 3 第 3 行
行号范围 3,8 第 3 到第 8 行
正则匹配 /pattern/ 包含 pattern 的行
正则范围 /start/,/end/ 从匹配 start 的行到匹配 end 的行
最后一行 $ 文件的最后一行
取反 /pattern/! 匹配 pattern 的行

3.2 命令(Command)--做什么

命令 含义 使用频率
s/old/new/ 替换(substitute)--把 old 换成 new ⭐⭐⭐⭐⭐
d 删除(delete)--删掉这一行 ⭐⭐⭐⭐
p 打印(print)--输出模式空间(通常配合 -n) ⭐⭐⭐⭐
a text 在这行之后追加一行 text(append) ⭐⭐⭐
i text 在这行之前插入一行 text(insert) ⭐⭐⭐
c text 把匹配行替换为 text(change) ⭐⭐
y/abc/xyz/ 逐字符翻译(类似 tr 命令) ⭐⭐
= 打印当前行号
q 退出(quit)--处理完当前行后停止

3.3 多个命令如何组织

三种方式,按场景选用:

# 方式 1:分号分隔(一行写完)
$ sed '/^#/d; /^$/d' nginx.conf

# 方式 2:-e 多次指定(逻辑独立时更清晰)
$ sed -e '/^#/d' -e '/^$/d' nginx.conf

# 方式 3:多行脚本(用单引号包裹,直接换行--推荐复杂脚本)
$ sed '
/^#/d
/^$/d
s/localhost/0.0.0.0/g
' nginx.conf

4. s 命令:替换(最核心)

s 命令占据了 sed 使用的 80%.它的语法是:

s/正则表达式/替换文本/标志位
# ↑ ↑
# 匹配什么 换成什么

4.1 基本替换

$ echo "hello world" | sed 's/world/earth/'
hello earth

# 默认只替换每行的第一个匹配
$ echo "one one one" | sed 's/one/two/'
two one one # ← 只替换了第一个 one

4.2 分隔符不一定是 /

当匹配模式或替换文本中本身包含大量 / 时(比如文件路径),可以换成其他分隔符.sed 使用 s 之后的第一个字符作为分隔符:

# 用 / 做分隔符 → 需要转义成 \/ ,可读性差
$ echo "/usr/local/bin" | sed 's/\/usr\/local/\/opt/'
/opt/bin

# 换成 # 做分隔符 → 清晰多了
$ echo "/usr/local/bin" | sed 's#/usr/local#/opt#'
/opt/bin

# 换个例子:用 | 做分隔符
$ echo "a:b:c" | sed 's|:|,|g'
a,b,c

4.3 标志位(Flags)

标志 含义 示例
g global -- 替换行内所有匹配,不只是第一个 s/foo/bar/g
数字 只替换第 N 次匹配 s/foo/bar/2 只替换第 2 个
p print -- 发生替换时打印这一行(配合 -n) sed -n 's/foo/bar/p'
i 匹配时ignore 大小写 s/hello/HI/i 匹配 Hello,HELLO 等
$ echo "one one one" | sed 's/one/two/g'
two two two # g = 所有都替换

$ echo "one one one" | sed 's/one/two/2'
one two one # 只替换第 2 个出现

$ echo "Hello hello HELLO" | sed 's/hello/hi/ig'
hi hi hi # i = 忽略大小写,g = 全局

4.4 捕获组与反向引用

用小括号 \( \) 捕获匹配的部分,用 \1 \2 ... \9 引用:

# 交换 "名 姓" 为 "姓, 名"
$ echo "John Smith" | sed 's/\([a-zA-Z]*\) \([a-zA-Z]*\)/\2, \1/'
Smith, John

# 给电话号码加括号:123-456-7890 → (123)456-7890
$ echo "123-456-7890" | sed 's/\([0-9]\{3\}\)-\([0-9]\{3\}\)-\([0-9]\{4\}\)/(\1)\2-\3/'
(123)456-7890

# 更复杂的:把 CSV 的第 2 列和第 3 列交换
$ echo "alice,30,engineer" | sed 's/\([^,]*\),\([^,]*\),\(.*\)/\1,\3,\2/'
alice,engineer,30

sed 默认使用 BRE--捕获组要加反斜杠

默认 sed 中 \( \) 才是捕获组,+ ? { } 也都需要反斜杠.这和 Python/JS 中写的正则很不一样.

如果觉得反斜杠烦,macOS 和现代 Linux 用 sed -E 启用 ERE(扩展正则),然后 ( ) + ? { } 就都不需要反斜杠了:

# ERE 版本(推荐)
$ echo "123-456-7890" | sed -E 's/([0-9]{3})-([0-9]{3})-([0-9]{4})/(\1)\2-\3/'
(123)456-7890

反向引用类比手机输入法的"识别粘贴":(regex) 就好像在文本中"圈出"一块内容贴在 sed 的剪贴板(\1),可以在替换文本中重新贴出来--换位置,加前缀/后缀,改变格式,全由决定.

5. d 命令:删除行

d 把模式空间中的当前行删掉--实际上就是跳过打印步骤,直接进入下一行的处理.

# 删除第 1 行
$ seq 1 5 | sed '1d'
2
3
4
5

# 删除第 2 到第 4 行
$ seq 1 5 | sed '2,4d'
1
5

# 删除所有包含 "TODO" 的行
$ sed '/TODO/d' source.py

# 删除所有空行
$ sed '/^$/d' file.txt

# 删除所有注释行(以 # 开头)
$ sed '/^#/d' config.conf

# 删除最后一行
$ seq 1 5 | sed '$d'
1
2
3
4

# 组合:删除注释和空行
$ sed '/^#/d; /^$/d' nginx.conf

6. p 命令:打印行(配合 -n)

p 用于显式打印模式空间的内容.单独用它会导致每行打印两次(自动打印一次 + p 打印一次),所以p 总是配合 -n 使用:

# 忘记加 -n → 每行输出两次
$ seq 1 5 | sed '2p'
1
2
2 ← 第 2 行被 auto-print 了一次,p 又打印了一次
3
4
5

# -n 关闭自动打印,只输出 p 命令指定的行
$ seq 1 5 | sed -n '2p'
2

# 打印第 3 到第 5 行(类似 head/tail 的组合)
$ seq 1 10 | sed -n '3,5p'
3
4
5

# 打印所有包含 "ERROR" 的行(类似 grep)
$ sed -n '/ERROR/p' app.log

7. a / i / c:插入与替换行

这三个命令不操作行内容,而是操作行本身--在行前,行后插入新行,或把整行替换掉.

命令 含义 位置
a text append(追加) 在匹配行之后加一行
i text insert(插入) 在匹配行之前加一行
c text change(替换) 用 text 替换整行
$ cat fruits.txt
apple
banana
grape

# 在第 2 行后面加一行 cherry
$ sed '2a\cherry' fruits.txt
apple
banana
cherry
grape

# 在 banana 前面插入一行
$ sed '/banana/i\--- FRUITS ---' fruits.txt
apple
--- FRUITS ---
banana
grape

# 把 banana 整行换成 orange
$ sed '/banana/c\orange' fruits.txt
apple
orange
grape

# 在文件末尾追加($ 表示最后一行)
$ sed '$a\the end' fruits.txt
apple
banana
grape
the end

macOS 上的 sed 差异

macOS 的 sed(BSD 版本)要求 a i c 后的反斜杠必须有,且反斜杠后必须换行或紧跟一个转义的换行符.

如果嫌麻烦:用 sed -i '' '...' 并始终在 a / i / c 后写 \ 再接内容.或者更省心--安装 GNU sed:brew install gnu-sed,之后用 gsed 替代 sed,行为和 Linux 一致.

8. 地址进阶

8.1 行号地址

# 只对第 1 行替换
$ sed '1s/apple/orange/' fruits.txt

# 对第 1 到第 3 行替换
$ sed '1,3s/apple/orange/' fruits.txt

# 对最后一行加后缀
$ sed '$s/$/ (final)/' fruits.txt

8.2 模式地址(正则)

# 只有包含 "apple" 的行才执行替换
$ sed '/apple/s/apple/orange/' fruits.txt
# 等价于不加地址的 s/apple/orange/(没区别,因为 s 本身也匹配 apple)
# 但在复杂脚本中,地址可以复用--对匹配行执行多个命令

# 对匹配行执行多个命令({ } 分组)
$ sed '/^#/{
s/TODO/DONE/
s/FIXME/FIXED/
}' source.py

8.3 范围地址

用两个地址(逗号分隔)定义一个区间:从第一个地址匹配的行开始,到第二个地址匹配的行结束(含两端).

# 从第 3 行到包含 "END" 的行,全部删除
$ sed '3,/END/d' file.txt

# 从包含 "BEGIN" 的行到包含 "END" 的行,所有行的行首加 "> "
$ sed '/BEGIN/,/END/s/^/> /' file.txt

# 从包含 "START" 的行到文件末尾
$ sed '/START/,$s/^/ /' file.txt
# 效果:把 "START" 之后的所有内容缩进 4 个空格

如果文件中有多个 BEGIN...END 块,/BEGIN/,/END/ 会作用于每一个块.它是"遇到 BEGIN 就开启模式,遇到 END 就关闭,再遇到 BEGIN 又开启".这不是全局的一次性范围,而是一种触发器.

8.4 取反地址

在地址后加 ! 表示"不是这些行":

# 删除所有不以 # 开头的行(保留注释行!)
$ sed '/^#/!d' config.conf

# 除第 1 行外,所有行前面加空格
$ sed '1!s/^/ /' file.txt

9. 模式空间与保持空间

这是 sed 中最进阶的概念--也是让从"会用 sed 做简单替换"升级到"能用 sed 做复杂文本变换"的分水岭.

9.1 直觉理解

sed 有两个内存缓冲区:

模式空间(Pattern Space)          保持空间(Hold Space)
──────────────────────── ────────────────────────
日常工作台 临时储物柜
每行进来 → 处理 → 打印 可以随时把当前行的内容
→ 被下一行覆盖 存进去,取出来,或交换

想象在流水线上装配产品.模式空间是工作台--每个零件(每行)依次放上来,处理完就送走.
保持空间是工具架--可以把某个零件先暂存上去,等后面的某个零件来了,再从架子上拿下来和它拼接.没有这个架子,只能处理"手头这一件",有了它,就能让行与行之间发生关联.

9.2 操作命令

命令 模式空间 → 保持空间 保持空间 → 模式空间
复制(覆盖) h -- 把模式空间复制到保持空间 g -- 把保持空间复制到模式空间
追加(拼接) H -- 把模式空间追加到保持空间末尾(前面加一个换行) G -- 把保持空间追加到模式空间末尾(前面加一个换行)
交换 x -- 交换模式空间和保持空间的内容

记忆技巧:小写 = 覆盖,大写 = 追加;h = 往 hold 塞,g = 从 hold 取(get).

9.3 实战:两行变一行

假设有一个文件,每两行是一组数据(第一行是名字,第二行是分数),想把它们合并成一行:

$ cat scores.txt
Alice
95
Bob
87
Carol
92

思路:读到奇数行(名字)时,把它存进保持空间.读到偶数行(分数)时,把保持空间的内容取出来,和当前行拼在一起:

$ sed -n '
1~2{ h } # 奇数行:复制到保持空间(暂存名字)
2~2{ H; g; s/\n/, /; p } # 偶数行:追加到保持空间 → 取回 → 把\n换成, → 打印
' scores.txt
Alice, 95
Bob, 87
Carol, 92

分步走一遍(以第 1–2 行为例):

  1. 第 1 行 "Alice" 进入模式空间 → h 复制到保持空间 → 保持空间 = "Alice"
  2. 自动打印被 -n 抑制,清空模式空间
  3. 第 2 行 "95" 进入模式空间 → H 把模式空间追加到保持空间 → 保持空间 = "Alice\n95"
  4. g 把保持空间复制到模式空间 → 模式空间 = "Alice\n95"
  5. s/\n/, / 把换行变成逗号 → "Alice, 95" → 打印

10. -i:原地修改文件

默认情况下 sed 把结果输出到 stdout,原文件不变.-i 让它直接写入原文件:

# 把文件中所有的 foo 替换为 bar(直接修改文件)
$ sed -i 's/foo/bar/g' file.txt

macOS 上 -i 需要提供备份后缀

macOS 的 sed 要求 -i 后面必须跟一个参数(备份文件的后缀).如果不需要备份,传空字符串:

# macOS 正确写法(Linux 用户忽略)
$ sed -i '' 's/foo/bar/g' file.txt # '' = 不留备份
$ sed -i '.bak' 's/foo/bar/g' file.txt # 留一个 file.txt.bak 备份(推荐)

最佳实践:先用不带 -i 的命令预览结果,确认无误后再加上 -i;或者先用 -i '.bak' 留个备份,确认没问题再删 .bak.

11. 常用配方(Recipes)

以下是最常遇到的 sed 用法,可以直接复制修改:

# ── 替换类 ──
# 全局替换
sed -i 's/old/new/g' file.txt

# 只替换第 3 到第 5 行的内容
sed -i '3,5s/old/new/g' file.txt

# 使用 ERE(扩展正则),少写反斜杠
sed -E -i 's/([0-9]{4})-([0-9]{2})-([0-9]{2})/\3.\2.\1/g' dates.txt

# ── 删除类 ──
# 删除空行
sed -i '/^$/d' file.txt

# 删除注释行(# 开头)
sed -i '/^#/d' config.conf

# 删除第 1 行(比如 CSV 的表头)
sed -i '1d' data.csv

# 删除行尾空格
sed -i 's/[ \t]*$//' file.txt

# ── 提取类 ──
# 打印第 5 到第 10 行
sed -n '5,10p' file.txt

# 打印所有包含 ERROR 的行及其后 3 行上下文
sed -n '/ERROR/,+3p' app.log

# 从一个 HTML 文件中提取所有 src 属性值
sed -n 's/.*src="\([^"]*\)".*/\1/p' index.html

# ── 插入类 ──
# 在第 1 行之后插入一行
sed -i '1a\#!/usr/bin/env bash' script.sh

# 在文件末尾追加一行
sed -i '$a\# END OF CONFIG' config.conf

# ── 格式化类 ──
# 给每行加行号(类似 cat -n)
sed = file.txt | sed 'N; s/\n/ /'

# 删除 HTML 标签(保留内容)
sed 's/<[^>]*>//g' page.html

# 把所有连续空格缩成一个
sed -E 's/ +/ /g' file.txt

快速回顾

  • sed = 流编辑器:非交互式,逐行读入 → 执行脚本 → 打印 → 清空 → 下一行
  • 模式空间:sed 的工作台--每行文本轮流进入,被处理,被打印,被下一行覆盖
  • 脚本结构:[地址] 命令.地址决定"对哪些行",命令决定"做什么"
  • s 命令:核心中的核心.加 g 替换全部,加 i 忽略大小写,用 \1 \2 引用捕获组
  • BRE vs ERE:默认 BRE 需要反斜杠;用 sed -E 切换到 ERE,和 Python/JS 正则一致
  • d/p/a/i/c:d 删除整行,p 打印(配合 -n),a/i/c 插入/替换整行
  • 保持空间:第二个缓冲区--通过 h H g G x 让行与行之间产生关联,实现跨行操作
  • -i 原地修改:macOS 上必须写 sed -i '' '...';建议先预览确认后再加 -i
  • 定位:sed 的强项是重复性,规则化的文本批量处理--一次性的手动编辑不值得写 sed

动手练习

  1. 地址练习:创建一个 10 行的文本文件,练习 sed '3d',sed '2,5d',sed '$d',理解地址的三种写法
  2. g 标志:用 echo "one one one" | sed 's/one/two/'sed 's/one/two/g' 对比,理解 g 标志的作用
  3. 捕获组:写一条 sed 命令把 "2024-06-16" 变成 "16.06.2024"(用捕获组)
  4. 提取模式:找一份真实的配置文件(如 ~/.zshrc),用 sed -n '/^#/!p' 只显示非注释行,然后用 sed -n '/^#/p' 只显示注释行
  5. 删除空行:用 sed '/^$/d' 删除空行,对比删除前后的效果
  6. -n + p:用 seq 1 20 | sed -n '5,10p' 体会 -n + p 的"提取"模式与 grep/head/tail 的异同
  7. 保持空间:用保持空间命令实现把文件所有行倒序输出(参考思路:sed '1!G;h;$!d')--不需要完全理解,但动手跑一遍,观察效果