锚点与边界:^ $ \b \A \z,行首行尾与单词边界
不吃字符的"定位器"
此前所有符号都在"消费"字符——匹配一个就前进一格。锚点(anchor)恰好相反:它们一个字符都不吃,只负责回答"我现在站在哪"。行首、行尾、单词的缝隙,都是锚点的工作岗位。理解"零宽"这个性质,是看懂断言类语法(本章与环视一章)的共同前提。
| 锚点 | 含义 | JS | Python | Java | grep -E |
|---|---|---|---|---|---|
^ |
行首(或文本开头) | 有 | 有 | 有 | 有 |
$ |
行尾(或文本结尾) | 有 | 有 | 有 | 有 |
\b |
单词边界 | 有 | 有 | 有 | 有 |
\B |
非单词边界 | 有 | 有 | 有 | 有 |
\A |
文本绝对开头 | 无 | 有 | 有 | 无 |
\z |
文本绝对结尾 | 无 | 有(\Z) |
有 | 无 |
JS 是这个表里的"穷人":没有 \A/\z,多行语义全靠 m 标志切换;Java 还有一个含义微妙的 \Z(允许结尾再带一个换行)。跨语言使用前以官方文档为准。
^ 与 $:默认管整段,加 m 才管每行
很多语言里,^/$ 默认锚定整个文本的开头和结尾;只有打开多行标志(JS 的 m、Python 的 re.M)后才升级为"每一行的行首行尾"。这对差异决定了一条正则是"全文只中一次"还是"每行都查":
import re
log = """INFO 启动完成
WARN 缓存命中率低
INFO 请求处理 42ms"""
print(re.findall(r"^.*?(\d+)ms$", log, re.M))
# 输出:['42']
没有 re.M 时,^ 只会锚到整段文本的最开头,INFO 启动完成 那行过不了 \d+ms 这关,结果为空。在 Python 里给多行文本的行级匹配加 re.M,在 JS 里加 m 标志,这是最容易忘的一步。
另一个隐蔽点:Python 的 \Z 与 $ 在默认模式下都锚定结尾,但 $ 还能匹配"结尾换行符之前"的位置,\Z 则死守真正的最后一位。要求"整串到此为止、后面连换行都不许有"时用 \Z。
\b:单词的缝隙
\b 匹配"一侧是单词字符(\w)、另一侧不是"的位置。它最经典的应用是整词搜索——避免 cat 误中 category:
import re
doc = "cat category catfish concat"
print(re.findall(r"cat", doc))
print(re.findall(r"\bcat\b", doc))
# 输出:['cat', 'cat', 'cat', 'cat']
# 输出:['cat']
四个 cat 里只有独立成词的那个存活。写代码搜索、日志关键字过滤时,\b 是防误报的第一道闸门。
避坑提醒:
\b的"单词"定义继承自\w,因此它对中文基本无效。老王两个字在 JS 里都是非\w字符,字符之间根本没有\b边界可言,\b老王\b在 JS 中一条也匹配不上(Python 中\w含汉字,行为又不同)。中文关键字要精确匹配,用环视断言(如(?<![一-龥])老王(?![一-龥]))或直接控制上下文,别指望\b。
实战一:只匹配整行的错误日志
import re
log = "ERROR db timeout\nERROR2 重试中\nINFO ok"
print(re.findall(r"^ERROR .+$", log, re.M))
# 输出:['ERROR db timeout']
^ERROR 后面跟空格,ERROR2 自然出局;^...$ 再配合 re.M 把范围锁死在单行内。去掉 $ 的话,ERROR db timeout 与同文本仍一致,但若某行是 ERROR a ERROR b 就会整行吞入——锚点帮你显式声明"我要的就是一整行"。
实战二:给数字加千分位
\b 与替换联手可以写出逗号插入:在"左侧是数字、右侧恰好是 3 的倍数个数字"的缝隙插逗号。环视细节后文再讲,这里感受锚点定位的能力:
import re
print(re.sub(r"\d(?=(\d{3})+$)", lambda m: m.group(0) + ",", "1284937"))
# 输出:1,284,937
\d 消费一个数字,(?=(\d{3})+$) 只是"看一眼"后面是否整整齐齐排着 3 的倍数位——它不前进、不消费,只在正确的缝隙处让替换发生。
锚点使用的四条纪律
- 校验类正则必加双锚:
^\d{11}$与\d{11}是天壤之别,后者会从"13812348000abc"里抠出 11 位报"校验通过"。 - 多行文本先想
m:忘了多行标志,^只站一次岗,结果"看起来少了大半"。 $不等于\Z:结尾允许换行与否,决定$和\Z谁是对的。- 中文场景慎用
\b:换成显式环视或字符类上下文。
经验之谈:审读别人正则时的第一动作是找锚点。一条没有
^/$/\b的"校验"正则,十有八九是忘了,而不是故意的。锚点是正则里最便宜的安全带。
锚点组合的两板斧:逐行加工与结尾钉死
第一个组合:配合 re.M,用"空模式 + 行首锚"给每行加前缀。这是文本加工里极讨巧的一招:
import re
print(re.sub(r"^", "> ", "第一行\n第二行", flags=re.M))
# 输出:> 第一行
# 输出:> 第二行
^ 一个字符都不吃,替换串却能在每个行首"凭空"塞入 >——零宽锚点的定位能力全靠它。
第二个组合:结尾的 $ 与 \Z 在"文本末尾恰好有换行"时表现不同,校验场景必须分清:
import re
print(bool(re.search(r"abc$", "abc\n"))) # True:$ 容忍结尾换行
print(bool(re.search(r"abc\Z", "abc\n"))) # False:\Z 死守最后一位
表单校验请选 \Z(或 Python 的 fullmatch)——否则一个尾随换行就能让"非法输入"溜进系统。自测一题:^\d+$ 加不加 re.M,对三行文本分别命中几次?(不加命中 0 次——^ 只站整段开头那一班岗;加了命中 3 次。)
动手实验:^ $ \A \Z 四锚点全对照
四个锚点的语义差异,靠背不如靠跑。准备一段三行、末尾带换行的文本,一次看清所有行为:
import re
text = "line one\nline two\nline three\n"
# 实验 1:^ 站岗次数。默认模式只站文本开头;多行模式站每一行的行首
print(len(re.findall(r"^", text))) # 1
print(len(re.findall(r"^", text, re.M))) # 4(三个行首 + 末尾换行后的空行首)
# 实验 2:\A 完全不受 re.M 影响——它是"绝对开头",天生的独苗
print(len(re.findall(r"\A", text, re.M))) # 1
# 实验 3:$ 与 \Z 的分水岭在末尾换行
print(bool(re.search(r"three$", text))) # True:$ 容忍结尾换行
print(bool(re.search(r"three\Z", text))) # False:\Z 死守最后一位
三组输出对应三条结论:^ 的"多行化"是开关行为(不开 re.M 它永远只管全文开头,末尾有换行时还会多出一个空行首的岗位);\A 没有"多行版",任何标志都改变不了它,所以"要么全文只中一次"的强约束应该写 \A 而不是 ^——后者会随 re.M 悄悄变语义;\Z 比 $ 严一格,表单校验、配置行解析这类"多一个换行都算非法"的场景必须用 \Z。
再补一个反直觉的观察:把文本末尾的换行去掉再跑实验 1,re.M 下 ^ 的站岗次数从 4 降回 3——^ 对"末尾换行之后的空行"也会开岗,这是日志类文本拼接时容易多算一行的隐藏原因。动手跑一遍这五条命令,四种锚点的边界感就建立起来了,比读十遍定义都牢。
两个高频疑问
问:\b 与 ^ 是一回事吗? 不是。^ 锚定"行的边界",\b 锚定"单词的边界",一个文本里 \b 的数量远多于 ^。判断用哪个,先问自己要约束的是"行结构"还是"词结构"——关键词搜索是后者,日志行解析是前者。
问:为什么我的 ^ 只匹配了第一行? 十有八九是忘了多行标志。^ 的默认语义是"整段文本的开头",逐行匹配必须显式开启 m/re.M。另一个可能是:文本的换行符是 \r\n,而你的 $ 之前留下了 \r 未被消费——处理 Windows 来源的文件时,行尾写 \r?$ 或先统一换行符更稳。
import re
print(len(re.findall(r"^", "a\nb\nc", flags=re.M)))
# 输出:3(每个行首一个空位)
^ 在三个行首各站一次岗,findall 返回三个空命中——零宽锚点"站岗不吃字"的性质在这条命令里展现得淋漓尽致。
参考资源
- MDN 断言与边界说明:MDN 正则·断言
- Python re 语法中的锚点定义:re — Regular expression operations
本章由 WorkBuddy 原创撰写,示例均经实际验证。
