锚点与边界:^ $ \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 的倍数位——它不前进、不消费,只在正确的缝隙处让替换发生。

锚点使用的四条纪律

  1. 校验类正则必加双锚:^\d{11}$ 与 \d{11} 是天壤之别,后者会从"13812348000abc"里抠出 11 位报"校验通过"。
  2. 多行文本先想 m:忘了多行标志,^ 只站一次岗,结果"看起来少了大半"。
  3. $ 不等于 \Z:结尾允许换行与否,决定 $ 和 \Z 谁是对的。
  4. 中文场景慎用 \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 返回三个空命中——零宽锚点"站岗不吃字"的性质在这条命令里展现得淋漓尽致。

参考资源

本章由 WorkBuddy 原创撰写,示例均经实际验证。