上手:真实场景与边界

方言差异:JS / Python / grep

它在解决什么

前面几篇讲的都是 JavaScript。但「靠复制粘贴用正则」的人最常撞的一堵墙是: 从某个回答里抄来的模式,粘进自己的语言就是不工作——而且通常不报错, 只是安静地匹配不到,或者匹配到别的东西。

好消息是 90% 的语法三家一致:.、[]、量词、分组、|、^ $, 你前面学的东西绝大部分可以直接搬。这一篇讲剩下那 10%。

先分清两个家族

差异的根源不是「谁实现得好」,是两套不同的设计目标:

家族 代表 匹配策略
回溯引擎 JavaScript、Python、Perl、Java、grep -P 从左到右试,走不通就退回来换条路
POSIX 引擎 grep、sed、awk 的默认模式 一次扫描,取最长的那个答案

什么时候不该用正则那篇讲的灾难性回溯, 只存在于第一家——POSIX 引擎不回溯,所以它没有这个问题, 代价是它不支持反向引用和先行后行。

差异一:分支怎么选

$ echo category | /usr/bin/grep -oE 'cat|category'
category                      ← POSIX:最左最长

$ node -e "console.log('category'.match(/cat|category/)[0])"
cat                           ← 回溯引擎:最左优先,第一支成功就收工

$ python3 -c "import re; print(re.search(r'cat|category','category').group())"
cat                           ← 同 node

选择分支那篇给过判据:把长的写前面。 这条在两家都不会错——POSIX 本来就取最长,回溯引擎则依赖你的顺序。

差异二:$ 与末尾换行

node     /a$/ 对 "a\n"    →  不匹配
python3  r'a$' 对 "a\n"   →  匹配到 'a'

Python 的 $ 额外允许末尾那一个换行符(\Z 才是严格的字符串结尾)。 JS 没有这个宽容,$(不带 m)就是字符串最末尾。

⚠️ 从文件里按行读出来的字符串常常带着 \n。同一条校验正则, 在 Python 里通过、搬到 JS 里全军覆没——这是这条差异最常见的现场。

grep 不适用:它按行读,交给正则的那一行本来就不含换行符。

差异三:\d 的范围

node     /\d/ 对 "٣"(阿拉伯数字三)  →  false
python3  r'\d' 对 "٣"                →  True

JS 的 \d 永远是 [0-9]。Python 3 的 \d 默认匹配 Unicode 里所有的 十进制数字——阿拉伯数字、天城文数字、全角数字都算。要限回 ASCII 得显式写 re.ASCII 标志或者干脆用 [0-9]。

🚨 这条的危险在于方向:Python 那边更宽松,所以「在 Python 里测通过的校验」 搬到 JS 会变严(可能拒掉本该通过的输入);反过来, 照着 JS 的心智模型在 Python 里写校验,会漏掉一整类你没想到的输入。 如果那个值接下来要 int(),就是一个货真价实的漏洞。

👉 判据:跨语言的数字校验一律写 [0-9],别写 \d。 多打三个字符, 换掉一整类不确定性。

差异四:命名组的两套语法

这条最直接——两边互相不认识对方的写法,而且是编译期报错:

JS Python
定义 (?<name>…) (?P<name>…)
模式内引用 \k<name> (?P=name)
替换串里 $<name> \g<name>

实测:node 拒绝 (?P<x>a)(抛 SyntaxError), Python 拒绝 (?<x>a)(抛 PatternError);各自认自己那套时都正常。

⭐ 这是唯一一条会当场报错的差异,所以反而是最安全的—— 你会立刻知道出了问题。其余几条都是静默的。

差异五:grep 默认是 BRE

$ printf 'aaa\na+\n' | /usr/bin/grep -c 'a+'      →  1    ← BRE:+ 是普通字符
$ printf 'aaa\na+\n' | /usr/bin/grep -cE 'a+'     →  2    ← ERE:+ 是量词

grep 不加参数用的是 BRE(基本正则),里面 + ? | ( ) { } 都不是元字符——想当量词用得写成 \+ \? \|,反过来加了反斜杠才有特殊含义。 这和你在其他地方学的一切都是反的。

👉 在命令行上一律加 -E。 grep -E、sed -E 用的是 ERE, 和常识一致。真需要 JS/Python 那套(反向引用、先行后行)就用 grep -P, 但那个选项不是所有 grep 都带。

对照表

特性 JS Python grep -E
分支选择 最左优先 最左优先 最左最长
$ 吃末尾换行 否 是 不适用
\d 含非 ASCII 否 是 否
命名组 (?<n>) (?P<n>) 不支持
字符串首尾锚点 只有 ^ $ 另有 \A \Z 只有 ^ $
后行 (?<=) ES2018+ 要求定长 需 -P
灾难性回溯 有 有 无(不回溯)

🚨 这些结论是在哪验的

这一节比上面的表格更重要。

本站这些跨语言结论由一道单独的闸门(npm run test:dialects)在发布前实跑, 它会把三个引擎的版本打印出来。因为**「我在哪个实现上验的」本身就是结论的一部分**—— 一句没有环境的「grep 是这样的」是半句话。

写这一篇时踩到的坑正好说明为什么:

我第一次跑 grep --version,它报的是 ugrep 7.8.4。于是我在 《选择分支与优先级》里写下「实测用的是 ugrep」。后来才发现,那个 grep 是我的工具环境注入的一个 shell 函数,把 grep 转到了别的实现上;这台机器上真正的 /usr/bin/grep 是 BSD grep 2.6.0-FreeBSD。

结论本身没错(两个实现都遵循 POSIX,都取最左最长),但我标注的环境是假的—— 它描述的既不是读者的环境,也不是服务器的环境,而是我的终端的偶然状态。

所以那道闸门做了三件事:调用时走绝对路径 /usr/bin/grep、 不经过 shell 直接起进程、把实际路径和版本首行打印出来。 它还有一条专门的提示:如果版本里出现 ugrep,就警告「你测的可能不是系统 grep」。

📌 一般化的教训,不限于正则:跑一条命令去验证某件事之前, 先确认你跑的是你以为的那个程序。 which -a、绝对路径、--version, 三件事加起来不到十秒。

粘贴之前的三个动作

从网上抄来一条正则,粘进代码之前:

1. 确认目标引擎属于哪一家。 命令行上默认是 POSIX,代码里基本都是回溯引擎。

2. 扫一遍「方言词」。 这几个符号出现就要停下来查: \d(范围不同)、(?P< 或 (?<(两套语法)、\A \Z(JS 没有)、 (?<=(支持度不一)、\p{…}(JS 要 u)、+ ? | ( )(BRE 里要转义)。

3. 拿一个「应该不匹配」的输入跑一次。 大多数方言差异的表现是 「本该拒绝的东西被放行了」,而只用正面例子测,两边看起来都对。 这也是本站所有示例都配一个对照写法的理由。

下一步

《让同事读懂,也让机器测得了》—— 到这里为止,你已经能写对、能查错、知道边界、也知道跨语言的坑。 剩下的问题是工程上的:这条正则六个月后还有人敢改吗?它坏了会有测试告诉你吗?