上手:真实场景与边界
方言差异: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. 拿一个「应该不匹配」的输入跑一次。 大多数方言差异的表现是 「本该拒绝的东西被放行了」,而只用正面例子测,两边看起来都对。 这也是本站所有示例都配一个对照写法的理由。
下一步
《让同事读懂,也让机器测得了》—— 到这里为止,你已经能写对、能查错、知道边界、也知道跨语言的坑。 剩下的问题是工程上的:这条正则六个月后还有人敢改吗?它坏了会有测试告诉你吗?