正则表达式全面速查

速查总览:这一页怎么查

本章是全页的索引:章节地图告诉你哪个符号在哪一章,四引擎能力矩阵汇总所有「支持 / 不支持」结论,报错与症状反查表把常见报错原文与「跑起来不对」的现象直接对到改法。正则的难点从来不是符号多,而是同一条模式在 JS / Python / Go / C++ 上行为不同。

全页十一章,按「符号 → 结构 → 行为 → 落地」排列:01–03 是构成模式的字符与位置,04–05 是分组与断言,06–07 是引擎行为与高级特性,08–10 是可直接抄走的模式、测试台与四语言 API。不必顺读——按符号定位,翻该章第一张速查表。

章节地图

章查什么关键符号
00四引擎能力矩阵、按报错反查、调试手段本章
01元字符与转义、字符类 […]、简写与 Unicode 属性、转义序列. [a-z] \d \p{}
02量词与重复次数、贪婪 / 懒惰 / 独占三种回溯行为* + ? {n,m} *? *+
03行首尾与绝对首尾、单词边界、多行模式下的位置语义^ $ \A \z \b
04捕获组、命名组、非捕获组、交替;模式内与替换串两套引用语法(…) (?:…) \1 $1
05四种环视断言、支持度差异、多条件叠加与千位分隔(?=) (?!) (?<=) (?<!)
06标志一览、自由间距 x 模式、内联修饰符、lastIndex 状态坑/gimsuy re.X (?i)
07/v 集合运算、递归、条件分组、\K、ReDoS 与防线(?R) (?(1)) \K
08邮箱 / URL / 数字 / 日期 / IP / 中文六类可直接抄的模式手机号 · 身份证 · 千分位
09浏览器里即时试跑(JS 引擎)▶ 测试台
10JS / Go / Python / C++ 的构造、匹配、替换、分割 API与资料索引re.compile matchAll

三条最常见的查法

你手上有的去哪
一条报错原文(nothing to repeat、Invalid group…)本章第 3 卡的反查表
「这个语法某某引擎支不支持」本章第 2 卡的能力矩阵;细节在对应特性卡的「支持」段
要一条能直接用的模式08 章;跨语言搬运时替换串语法另见 04 章
正则最大的坑不在语法,在「同一条模式换个引擎就换套行为」:\w 认不认中文、$ 吃不吃末尾换行、(?i) 生不生效——这些差异多数不报错,只是结果不同。跨语言搬正则前先看 本章第 2 卡的矩阵。
本页以四门标准库引擎为基准:JS(ECMAScript)、Python re、Go regexp(RE2)、C++ std::regex。凡写「不支持」都指标准库——多数缺口都能靠 PCRE2 或 Python 第三方 regex 库补上,代价是多一个依赖。

下表是全页所有「支持 / 不支持」结论的汇总。看一眼就能判断一条正则能不能跨语言搬——四列都打勾的才是安全交集。

语法能力矩阵

特性JSPython reGo(RE2)C++ std::regex
命名组写法(?<n>)(?P<n>)(?P<n>)无
反向引用 \1✓✓✗✓
先行断言 (?=)(?!)✓✓✗✓
后行断言 (?<=)(?<!)✓ 可变长✓ 须定长✗✗
独占量词 *+ / 原子组 (?>)✗✓ 3.11+✗✗
Unicode 属性 \p{}✓ 需 /u,写全 \p{Script=Han}✗✓ \p{Han}✗
\w 认中文吗不认(加 /u 也不认)认(re.A 退回 ASCII)不认不认
绝对首尾 \A \Z \z✗\A \Z(3.14+ 有 \z)\A \z(无 \Z)✗
内联标志 (?i)✗(ES2025 的 (?i:…) 需 Node 24+)✓ 须在最开头✓ 含 (?i:…)✗(用 regex::icase)
自由间距 x 模式✗✓ re.X✗✗
条件分组 (?(1)a|b)✗✓✗✗
递归 (?R) / \K / \G✗✗✗✗
集合运算 /v✓ ES2024✗✗✗
最坏时间复杂度指数(回溯)指数(回溯)线性(保证)指数(回溯)

什么时候该换引擎

需求换成代价
正则来自不可信输入,不能被卡死RE2(Go 标准库;其他语言有绑定)永久失去反向引用与环视
要递归、条件、\K、原子组、变长后行PCRE2多一个 C 依赖;回溯风险仍在
Python 要 \p{}、变长后行、集合运算第三方 regex 库(pip install regex)API 兼容 re,迁移几乎零成本
C++ 嫌 std::regex 慢RE2 / PCRE2 / CTRE(编译期正则)引第三方库;CTRE 需 C++20

判据:先看这条正则是不是「跨语言共用」。是,就只用四列全 ✓ 的交集写法;不是,就大方用本语言最顺手的特性,别为了假想中的可移植性把正则写得又长又怪。

矩阵里的「✗」分两种,危险程度差很远:报错型(Python 的 \K、JS 的条件分组)一编译就炸,看得见;静默失效型才要命——JS 的 \A 被当字面字符 A、C++ 的 (?i) 既不报错也不生效。移植正则时对这两处必须实跑一条反例,别只测正例。
四门的安全交集其实很够用:字面量与字符类、\d \s(别用 \w 处理非 ASCII)、量词、^ $、非捕获组与编号捕获组、交替。只要一条正则可能跑在 Go 上,就别用环视与反向引用——这两样 RE2 永远不会有。

正则出问题只有两种形态:编译不过(报错原文能直接查)和编译过了但结果不对(只能按症状查)。下面两张表按这两类排。

编译期报错反查

报错原文语言原因与改法
Nothing to repeat / nothing to repeat at position NJS / Python量词前没有可重复的元素。多半是照抄了独占量词 a*+(02 章)或模式以 ?、* 开头
Invalid regular expression: Invalid groupJS用了 JS 没有的 (?P<n>)、(?i)、(?(1)…)、(?>…)。改成 (?<n>) 或把标志提到字面量后缀(04、06、07 章)
Invalid property nameJS\p{Han} 简写不存在,写全 \p{Script=Han},并加 /u(01 章)
look-behind requires fixed-width patternPython后行断言里用了量词。拆成捕获组取子串,或换第三方 regex 库(05 章)
bad escape \K / bad escape \dPython前者:\K 标准库没有;后者:在替换串里写了模式语法——替换串只认 \1 / \g<n>(04 章)
global flags not at the start of the expressionPython 3.11+(?i) 必须在模式最开头;要局部作用改用 (?i:…)(06 章)
invalid or unsupported Perl syntax: (?= / (?<=GoRE2 没有环视,挪到代码里判断(05 章)。同类还有 invalid escape sequence: \1(无反向引用)、invalid repeat count({n,m} 上限 1000)
invalid escape sequence / unexpected )Go / 任意反斜杠被宿主字符串吃掉了。用原始字符串:Go 反引号、Python r""、C++ R"(…)"、JS String.raw(01 章)
std::regex_error(error_badrepeat / error_paren…)C++std::regex 只认 ECMAScript 子集,多半用了它没有的写法;what() 里的 error_* 码指明了是哪一类
PatternError: invalid group reference NPython引用了不存在的组号——条件分组与替换串都会这样报(04、07 章)

编译过了,但结果不对

症状最可能的原因
本地测通过,线上少抓一半. 不含换行。开 dotAll:JS /s、Python re.S、内联 (?s);C++ 没有这个开关(01、06 章)
同一个正则反复 test(),true / true / false 交替JS 带 g 的正则有 lastIndex 状态。校验用途别加 g(06 章)
只拿到第一个匹配没开全局:JS 加 g,Python 用 findall / finditer,Go 用 FindAll*,C++ 用 regex_iterator(10 章)
match() 拿到了结果却没有分组JS match 带 g 时丢掉捕获组与 index,改用 matchAll(10 章)
「只允许数字」的校验放进了换行Python / PCRE 的 $ 也匹配末尾换行之前。改用 \Z(Python)/ \z(Go)(03 章)
中文被 \w / \b 漏掉,或前后端校验结果不一致\w 的 Unicode 语义各语言不同。跨端共用的正则要把字符集写死(01、03 章)
抓多了:<.*> 吞掉整行贪婪。优先改成否定字符类 <[^>]*>,其次才是懒惰 <.*?>(02 章)
某个输入下 CPU 打满、服务卡死灾难性回溯(ReDoS)。嵌套量词是首要嫌疑(07 章)
从别的语言抄来的模式「什么都不匹配」该语法在本引擎被当成了字面量:JS 的 \A、C++ 的 (?i)(本章第 2 卡)

调试手段

手段怎么用
二分法把模式从中间切开,分别试——定位一条长正则哪段没匹配上,这招比读代码快
Python re.DEBUGre.compile(p, re.DEBUG) 打印引擎解析出的语法树,能立刻看出转义是否如你所想
x 模式 + 注释Python re.X:拆行写、# 加注(06 章)。超过一行的正则都值得开
正反两组样例同时准备「应匹配」和「不应匹配」的输入。只测前者,过宽的正则永远显得是对的
09 章测试台即时高亮,但跑的是 JS 引擎——验 Python / Go 行为必须去对应运行时
超时与长度上限对不可信输入必备:限制输入长度 + 给匹配设超时,是 ReDoS 的最后一道防线
最危险的一类问题不在这两张表里:正则写得过宽,正例全过、反例也全过。^.*@.*$ 能通过任何邮箱测试,也能通过 @。每写一条校验正则,都要配一条「应当被拒绝」的输入——这是唯一能发现它的办法。
查报错前先确认你看到的模式就是引擎看到的模式:把最终的模式串打印出来(JS r.source、Python p.pattern)。「反斜杠被宿主字符串吃掉一层」制造的报错,占了这张表里相当一部分——而它在源码里完全看不出来。

字符与字符类

本章覆盖:元字符清单与何时需要转义、字符类 […] 的写法与类内转义规则、预定义简写 \d \w \s 及其取反、Unicode 属性 \p{}、以及 \n \xHH \uHHHH 等转义序列。四门引擎在 \w 认不认中文、支不支持 \p{} 上分歧最大,首卡速查表按语言列齐。

一条正则里的每个字符只有两种身份:字面量(匹配自己)或元字符(有语法含义)。下面四张表把「谁是元字符、类内外规则有何不同、简写各语言认什么」一次列齐。

元字符与转义

字符类外含义类内 […] 含义
.任意字符(默认不含换行)字面点,不必转义
^行 / 串首仅在首位表示取反,其余位置是字面量
$行 / 串尾字面量
* + ?量词(02 章)字面量
{ }{n,m} 次数字面量
[ ]字符类的起止] 要转义或放首位;[ 在多数引擎里可裸写
( ) |分组与交替(04 章)字面量
-字面量表示区间;要匹配减号就转义或放首 / 末位
\类内类外都是转义符,匹配反斜杠本身写 \\

类内仍需操心的只有 ] \ ^ -,以及成对出现时的 [。类外的元字符里,其余的进了方括号就自动降级为字面量——[.*+] 匹配的就是这三个符号本身。

字符类写法

写法含义备注
[abc]三者之一类内是「或」,且只吃一个字符
[^abc]除三者外的任意字符包括换行——这点与 . 不同,也是它常比 .*? 更该用的原因
[a-z0-9_]区间并集区间按码点算,[A-z] 会连带吃进 [ \ ] ^ _ `
[\d\s.]简写与字面量混用简写在类内照常可用
[[:alpha:]]POSIX 括号类Go 与 C++ 支持;JS / Python 不支持且不报错——会被当成普通字符集,静默匹配错东西
[\p{Han}]类内嵌 Unicode 属性仅 Go 与加了 /u 的 JS
[a-z--[aeiou]]差集(JS /v)ES2024,JS 独有(07 章)

预定义简写:同一个 \w,四种答案

简写等价于取反Unicode 语义
\d[0-9]\DPython 默认也认全角与他国数字,加 re.A 退回 ASCII
\w[A-Za-z0-9_]\WPython 认中文;JS / Go / C++ 都不认(JS 加 /u 也不认)
\s空格 \t\n\r\f\v\SPython / JS 还含全角空格等 Unicode 空白
\b单词边界(零宽)\B定义随 \w 而变,中文场景不可靠(03 章)

这张表解释了一类真实事故:同一条「用户名只能是字母数字」的校验,Python 后端放行中文、JS 前端拦下中文。跨端共用的正则必须把字符集写死,别依赖 \w。

Unicode 属性与转义序列

写法含义支持
\p{L} \p{Lu} \p{N} \p{P}字母 / 大写字母 / 数字 / 标点Go 原生;JS 需 /u;Python 标准库无
\p{Han}汉字Go 可用;JS 必须写全 \p{Script=Han},简写直接报错
\P{…}属性取反同上
\n \r \t \f \v \0换行 / 回车 / 制表 / 换页 / 纵向制表 / NUL四门通用
\xHH两位十六进制码点四门通用
\uHHHH / \u{…}UTF-16 码元 / 完整码点\u{…} 需 JS /u;Go 写 \x{…};Python 用 \uHHHH / \UHHHHHHHH
\Q…\E整段当字面量四门都没有。改用库函数转义:Python re.escape、Go regexp.QuoteMeta、JS 手写替换
把用户输入拼进正则前必须转义:Python re.escape(s)、Go regexp.QuoteMeta(s)。漏了这一步,输入里一个 ( 就能让正则编译失败,一串 (a+)+ 就能把服务卡死(07 章 ReDoS)。JS 标准库至今没有内置转义函数,得自己写 s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")。
只要正则里出现了反斜杠,就用原始字符串写它——Python r"\d"、Go 反引号、C++ R"(\d)"、JS String.raw。这不是风格问题:普通字符串里 \d 得写成 "\\d",两层转义叠起来就是所谓「反斜杠地狱」,也是编译期报错里最常见的一类来源(00 章反查表)。
普通字符直接匹配自身。元字符有特殊含义:. * + ? ^ $ { } [ ] | ( ) \,匹配字面量时需 \ 转义。点号 . 匹配除换行符外的任意单个字符(加 s 标志后包含换行)。

为什么「转义」这件事要转两层

  • 第一层是正则语法:. 在正则里是元字符,要匹配真正的点必须写 \.;
  • 第二层是宿主语言的字符串语法:在普通字符串里 \ 本身也是转义符,于是正则的 \d 得写成 "\\d";
  • 两层叠起来就是所谓「反斜杠地狱」——而原始字符串一次性消掉第二层:Python r"\d"、Go 反引号、C++ R"(\d)"、JS String.raw;
  • 判据:只要正则里出现了反斜杠,就该用原始字符串写它。这不是风格问题,是少一层出错来源。

点号的边界(实测)

  • JS /a.b/ 对 "a\nb" 为 false,加 s 标志后为 true;
  • 所以拿 .* 去抓「整段内容」在多行文本上会静默少抓——症状是「本地测试通过,线上少了一半数据」;
  • C++ std::regex 没有 dotAll,. 永远不含换行,这条没有开关可开。
# 普通字符直接匹配
cat        # 匹配 "cat"

# 转义元字符
3\.14      # 匹配 "3.14"(. 转义为字面点)
C\+\+      # 匹配 "C++"
\$price   # 匹配 "$price"

# 点号:除换行外任意单字符
c.t        # cat, cot, c9t, c t …
. 默认不匹配换行符。要让它连换行一起吃需开 dotAll——JS /s、Python re.S、模式内 (?s)(Go/Python 支持);C++ std::regex 无 dotAll,. 始终不含 \n。
各语言源码字符串里反斜杠要再转义一层:正则 \d 在普通字符串里得写成 \\d。改用原始字符串可免掉这层——Python r"\d"、Go 反引号 `\d`、C++ R"(\d)"、JS String.raw`\d`。
方括号内列出允许的字符(匹配其中之一)。^ 放在 [ 后表示否定。字符类内大多数元字符失去特殊含义,^ - ] 需转义或放在特定位置。

类内的转义规则反而更简单

  • 进了 [ ],. * + ? ( ) | 全部退化成普通字符,不需要转义——[.*+] 就是这三个符号本身;
  • 只剩四个还需要留意,而且各有「靠位置免转义」的写法:^ 只在紧跟 [ 时表否定(放中间就是字面量)、- 放首位或末位就是字面连字符、] 放首位即字面、\ 永远要转义;
  • 所以 []-] 是合法的「匹配 ] 或 -」——看着别扭,但比到处加反斜杠更不容易写错。

否定类会吃掉换行(实测)

JS:     /"[^"]*"/.test('"a\nb"')   →  true
Python: re.search('"[^"]*"', '"a\nb"')  →  命中,且跨了行
  • [^"] 的字面意思是「除了引号之外的任意字符」,换行也在其中;
  • 抓「一行里引号内的内容」时这会一路吞到下一行的引号,把两条记录粘成一条。要限制在本行必须写 [^"\n]*;
  • 同类问题也出现在 [^>]* 抓 HTML 属性、[^,]* 抓 CSV 字段上——凡是「按行」的语义,都要把 \n 一并排除。
[abc]      # a、b 或 c 之一
[a-z]      # a-z 任意小写字母
[A-Za-z0-9_]  # 字母、数字、下划线

# 否定字符类
[^aeiou]  # 非元音
[^0-9]    # 非数字

# 类内特殊规则
[.]        # 字面点号(. 在类内不是通配符)
[\^\-\]]  # ^ - ] 需转义
[-az]      # - 放首尾无需转义
否定类 [^…] 是「除列出者外的任意字符」,默认也包含换行符——[^"]* 会跨行吞内容。多行文本里若不想跨行,得显式排除:[^"\n]*。
字符类内绝大多数元字符失去特殊义(. * + ( | 都是字面量、无需转义);只需留意 \ ] ^ -:^ 仅在紧跟 [ 时表否定,- 放首尾即字面连字符,] 放首位或转义才是字面。
\d \w \s 及其大写取反形式是最常用的简写。\p{} 匹配 Unicode 属性类(字母、数字、汉字等),各语言支持差异大。

同一个 \w,四门语言四种答案(实测)

\w 认中文吗\p{}怎么写汉字
Python 3(str)认(\w+ 匹配到「你好」)标准库不支持[\u4e00-\u9fa5] 或第三方 regex
JS不认(加 /u 也不认)需 /u\p{Script=Han}
Go(RE2)不认原生支持\p{Han}
C++ std::regex不认不支持只能列码点区间
  • Python 的「认」是默认行为,加 re.ASCII 才退回 ASCII(实测 re.findall(r"\w+", "你好 abc") 得 ['你好', 'abc'],加 re.ASCII 后只剩 ['abc']);
  • JS 的 \p{Han} 简写不存在——实测直接 SyntaxError: Invalid property name,必须写全 \p{Script=Han};
  • 这张表解释了一类真实事故:同一条「用户名只能是字母数字」的校验正则,Python 后端放行中文、JS 前端拦下中文,两边行为不一致。跨端共用正则时必须显式写死字符集,别依赖 \w。
\d   # 数字 [0-9]
\D   # 非数字 [^0-9]
\w   # 单词字符 [a-zA-Z0-9_]
\W   # 非单词字符
\s   # 空白(空格 \t \n \r \f \v)
\S   # 非空白

# Unicode 属性类
\p{L}     # 任意 Unicode 字母
\p{N}     # 任意 Unicode 数字
\p{Han}   # 汉字
\P{L}     # 非字母(大写 P = 取反)
\d \w \s 的 Unicode 范围各语言不同。Python 3 对 str 默认 Unicode-aware:\w 认中文、\d 认非 ASCII 数字(加 re.ASCII 才退回 ASCII);而 JS(即便加 /u)、Go、C++ 的 \w 默认只认 ASCII,中文不算单词字符。
\p{} 支持:JS 需 /u 标志(脚本名要写 \p{Script=Han});Go(RE2) 原生支持(含 \p{Han});Python 标准 re 不支持,需第三方 regex 库;C++ std::regex 不支持,需 PCRE2。
常用转义序列,Unicode 码点写法因语言而异。

码点转义的四种写法,只有一种通用

要写的JSPythonGo / PCRE2
BMP 内字符\uFFFF(四门通用,恰好 4 位十六进制)
星光面(如 😀)\u{1F600} + /u\U0001F600(8 位)\x{1F600}
  • 实测三条边界:Python 写 \x{1F600} 报 incomplete escape \x;JS 不加 /u 时 \u{1F600} 无效;\xFF 恰好两位,多一位少一位都会改变含义;
  • 位数固定是这里所有坑的根源:\x41 之后再跟一个 2,得到的是「A」后跟字面「2」,而不是 \x412;
  • 实务建议:非 ASCII 字符直接写字符本身,不要用码点转义。可读性高得多,也避开了这一整张表。
\n   # 换行 LF
\r   # 回车 CR
\t   # 制表符
\0   # 空字符 NUL
\xFF       # 2 位十六进制字节
\uFFFF     # 4 位 Unicode 码点(JS / C++ / Python)
\u{1F600}  # 扩展码点花括号:JS(需 /u)
\x{1F600}  # 扩展码点:Go(RE2) / PCRE2
\U0001F600 # 扩展码点:Python(8 位十六进制)
十六进制转义位数固定:\xFF 恰好 2 位、\uFFFF 恰好 4 位,位数不符会报错或改变含义(如 Python 写 \x{1F600} 直接报 incomplete escape)。JS 不加 /u 时 \uFFFF 仅覆盖 BMP,星光面字符须用 \u{…}+/u 或代理对。
码点转义写法各异:\uFFFF(4 位十六进制,JS/Python/C++ 通用);星光面码点则 JS 写 \u{1F600}(需 /u)、Go/PCRE2 写 \x{1F600}、Python 写 \U0001F600(8 位十六进制)。

量词

本章覆盖:六种量词写法与等价范围、贪婪 / 懒惰 / 独占三种失败处理方式、以及「该用否定字符类还是懒惰量词」的判据。三者匹配结果常常相同,差别只在中间试了多少次——这正是 ReDoS 的源头(07 章)。

量词只管一件事:前一个元素重复几次。真正要分清的是它匹配失败时怎么办——贪婪吐回来重试、懒惰多吃一个重试、独占直接判负。这三种行为决定了性能,也决定了 ReDoS 会不会发生。

量词与等价范围

写法等价含义
*{0,}零次或多次
+{1,}至少一次
?{0,1}零次或一次(量词后的 ? 是另一回事,见下表)
{n}—恰好 n 次
{n,}—至少 n 次
{n,m}—n 到 m 次;Go(RE2) 的上限是 1000,a{1001} 直接报 invalid repeat count
{,m}—别写:Python 当字面量、JS 也当字面量。要写 {0,m}

三种回溯行为与支持度

先吃多少失败时写法支持
贪婪(默认)尽可能多逐个吐回来重试a*四门通用
懒惰尽可能少逐个多吃一个重试a*? a+? a??四门通用(Go 另有 (?U) 整体反转贪婪性)
独占尽可能多不回退,直接判失败a*+ a++仅 Python 3.11+;JS 报 Nothing to repeat、Go 报 invalid nested repetition operator
原子组同独占同独占,可包住一段(?>…)仅 Python 3.11+

三者匹配到的结果常常完全相同,差别只在中间试了多少次——所以「换成懒惰就对了」多半是碰运气。独占量词的价值不在快,在于把指数级回溯直接砍掉。

抓多了 / 抓少了:改写配方

症状别这么写改成
<.*> 吞掉整行标签<.*><[^>]*>(首选)或 <.*?>
取引号内容跨到了下一个引号".*?""[^"\n]*"——边界写死,读代码的人一眼能确认
多行文本里 .* 只抓到第一行.*开 dotAll(/s、re.S、(?s)),C++ 没有这个开关
某些输入下 CPU 打满(a+)+ (\s*\S*)+拆掉嵌套量词;不可信输入再加长度上限与超时(07 章)
校验型正则被超长输入拖慢\w+\w{1,64}——校验场景把上限写出来,既是规格也是防线

经验法则:能用「排除法」描述边界,就别用懒惰量词。否定字符类每一步都是确定的,懒惰量词要一步步试探回溯。

? 出现在两个位置含义完全不同:跟在元素后是「零或一次」(colou?r),跟在量词后是「变懒惰」(a*?)。于是 a?? 合法且有意义——「零或一次,优先零次」。读到连续两个问号别以为是笔误。
量词只作用于紧挨着的那一个元素:一个字符、一个字符类、一个分组或一次转义。abc+ 是「ab 后面跟一个以上的 c」,要重复整段得写 (?:abc)+——用非捕获组,别白白占掉一个组号(04 章)。
* 0+次,+ 1+次,? 0或1次,{n,m} 精确控制。量词作用于紧前面的元素(字符、字符类或分组)。

「量词只管紧前面那一个」的三个后果

  • ab+ 是「a 后面跟一个或多个 b」,不是「ab 重复」——要后者必须写 (?:ab)+;
  • \d{3}-\d{4} 里两个 {n} 各管各的 \d,这是对的;但 \d-\d{3} 里 {3} 只作用于第二个 \d,不是「整个 \d-\d 重复三次」;
  • 量词后面直接跟量词(如 a**)大多数引擎会报错,但 a*+ 在支持独占量词的引擎里是合法的「独占」语义——实测 JS 报 Nothing to repeat,Python 3.11+ 则正常匹配。同一串在两门语言里一个报错一个静默改变语义,这是移植时的隐蔽坑。

{n,m} 的三处写法差异

  • {n} 精确 n 次、{n,} 至少 n 次、{n,m} 闭区间;
  • {,m}(省略下界)不通用——有的引擎当字面量处理,有的当 {0,m}。稳妥写法永远是补全 {0,m};
  • 不构成合法量词的 {,JS 与 Python 会宽松地当字面花括号;但这属于「实现宽容」而非规范保证,要匹配字面花括号请老老实实写 \{。
*      # 0 次或多次
+      # 1 次或多次
?      # 0 次或 1 次
{n}    # 恰好 n 次
{n,}   # 至少 n 次
{n,m}  # n 到 m 次

# 示例
colou?r      # color 或 colour
\d{4}        # 恰好 4 位数字
\s*,\s*      # 逗号前后允许任意空白
ab+ 是「a 后跟一个或多个 b」,不是「ab 整体重复」——这是最常踩的一处。另外单独的 { 若不构成合法量词,JS/Python 会宽松地当字面量,但依赖此行为不可移植;确要匹配字面花括号请转义 \{。
量词只作用于紧邻的前一个元素(单字符、字符类或分组)。要重复多字符序列必须先分组:(?:ab)+,而不是 ab+。省略下界的 {,m} 并非各处通用,稳妥写 {0,m}。
贪婪(默认):尽可能多匹配,匹配失败时回退。懒惰:量词后加 ?,尽可能少匹配。独占:量词后加 +,匹配后不回退,防止灾难性回溯。回溯指:正则先尽量多地匹配,若后续匹配失败,再逐步释放已匹配的字符重新尝试;懒惰量词则相反,先尽量少匹配、不足再补。

三者的差别就是「失败时怎么办」

先匹配多少失败时写法
贪婪(默认)尽可能多逐个吐回来重试a*
懒惰尽可能少逐个多吃一个重试a*?
独占尽可能多不回退,直接判失败a*+
  • 三者匹配到的结果可能完全相同,差别只在中间尝试了多少次——所以「换成懒惰就对了」这种说法多数时候是碰运气;
  • 独占量词的价值不在快,在于把指数级的回溯直接砍掉:它让引擎没有退路可走,从而消灭一整类 ReDoS;
  • 支持度实测:Python 3.11+ 原生支持(a*+b 对 "aaab" 正常匹配);JS 报 Nothing to repeat、Go 与 C++ std::regex 也没有。

比懒惰更该先想到的是否定字符类

  • 抓引号内容时,"[^"]*" 比 ".*?" 又快又准:前者每一步都是确定的,后者要一步步试探回溯;
  • 更重要的是语义:".*?" 在 "a" 和 "b" 这种输入上仍会正确停在第一个引号,但把 . 换成能跨行之后行为就变了;而 [^"\n]* 的边界是写死的,读代码的人一眼能确认;
  • 经验法则:能用「排除法」描述边界,就别用懒惰量词。
# 文本:"<a>text</a>"
<.+>     # 贪婪 → 整个 "<a>text</a>"
<.+?>    # 懒惰 → "<a>"
<[^>]+> # 否定字符类 → "<a>"(推荐,更高效)

# 懒惰量词
*?  +?  ??  {n,m}?

# 独占量词(不回退)
*+  ++  ?+  {n,m}+
否定字符类通常优于懒惰量词。匹配引号内容:"[^"]*" 比 ".*?" 更快更准确。
独占量词(*+ ++ ?+)支持:Python 3.11+(re 已原生支持);JS、Go、C++(std::regex) 均不支持——需要时改用 PCRE2。

锚点与边界

本章覆盖:^ $ 与绝对首尾 \A \Z \z 的区别、单词边界 \b \B、多行与 dotAll 对位置语义的影响。锚点匹配的是位置而非字符(零宽);Python 的 $ 会放行末尾换行这一条,直接关系到输入校验的安全性。

锚点匹配的是位置而不是字符——零宽、不消耗输入,所以可以和任意其他部分叠加。要点全在两处:多行模式改变 ^ $ 的含义,以及 Python 的 $ 会放行末尾的换行。

锚点一览与支持度

写法匹配的位置JSPythonGoC++
^串首(多行下为每行首)✓✓✓✓
$串尾(多行下为每行尾)✓ 绝对尾✓ 也匹配末尾换行之前✓ 绝对尾✓ 绝对尾
\A绝对串首,不受多行影响✗ 静默当字母 A✓✓✗
\Z绝对串尾(Python 语义)✗✓✗ 报 invalid escape sequence✗
\z绝对串尾✗✓ 3.14+✓✗
\b \B单词边界 / 非边界✓ 仅 ASCII✓ 认中文✓ 仅 ASCII✓ 仅 ASCII
\G接续上次匹配结束处✗✗✗✗

多行与 dotAll:改的是谁

开关怎么开改变了什么没改什么
多行JS /m、Python re.M、内联 (?m)、C++ multiline^ $ 变成每一行的首尾\A \Z \z 不受影响——这正是它们存在的意义
dotAllJS /s、Python re.S、内联 (?s). 开始包含换行锚点语义完全不变;C++ 没有这个开关
两者无关—多行管锚点、dotAll 管点号。「多行文本抓不全」多半该开的是 dotAll,不是多行

$ 吃末尾换行:一条真实的校验漏洞

Python:  re.match(r"^\d+$",  "123\n")   →  匹配   ← 换行被放行了
Python:  re.match(r"^\d+\Z", "123\n")   →  不匹配
Go:      ^\d+\z  与  ^\d+$  对 "123\n"  →  都不匹配
JS:      /^\d+$/.test("123\n")          →  false
  • Python(与 PCRE / Perl)的 $ 在非多行下也匹配「末尾换行之前」的位置——这是从 Perl 继承来的历史行为;
  • 后果很实际:用 ^\d+$ 做「只允许数字」的校验,攻击者在末尾加一个换行就能带进额外字节,后续按行处理时可能被拆成两条记录;
  • Python 侧的正解是 \Z(3.14+ 也可用 \z),Go 用 \z。 JS 与 C++ 的 $ 本来就是绝对末尾,没有这个坑。

\b 的定义与中文

写法含义
\bword\b整词匹配——但「词」的定义等于 \w 的定义,随语言而变
中文场景JS / Go / C++ 的 \w 不含汉字,\b 会落在汉字与字母交界处而非词与词之间
替代写法用「前后不是某类字符」的环视:(?<![\p{L}])词(?![\p{L}])(05 章)
\B非边界。千位分隔里的 \B(?=(\d{3})+(?!\d)) 就靠它避开数字开头
^ $ 在没开多行时是串首尾,开了多行才是行首尾——把多行模式当默认,会让 ^\d+$ 这类校验对多行输入逐行放行。校验场景的稳妥写法是绝对锚点 \A…\z(Python 用 \Z),它不受多行开关影响。
要求「整串完全匹配」时,与其到处补锚点,不如挑对 API:Python 用 fullmatch、Java 用 matches、Go 与 JS 仍需 ^…$(Go 建议写 \A…\z)。re.match 只锚首不锚尾——这是 Python 里最常被误解的一个函数(10 章)。
^ 和 $ 匹配行或字符串的首尾(取决于多行模式)。\A \Z \z 始终匹配字符串绝对首尾,不受多行模式影响。\G 匹配上次匹配结束位置。

$ 的行尾语义:一个真实的校验绕过(实测)

Python:  re.match(r"^\d+$",  "123\n")  →  匹配成功
Python:  re.match(r"^\d+\Z", "123\n")  →  不匹配
Python:  re.match(r"^\d+\z", "123\n")  →  不匹配(3.14 起可用)
JS:      /^\d+$/.test("123\n")         →  false
  • Python / PCRE 的 $ 在无多行模式下也会匹配「末尾换行之前」的位置——于是 ^\d+$ 会接受 "123\n";
  • 这不是学术问题:用它做「只允许数字」的输入校验,攻击者在末尾加一个换行就能带进额外字节(后续按行处理时可能被拆成两条);
  • 正解是用绝对末尾锚点:Python 用 \Z(或 3.14+ 的 \z)、Go 用 \z。JS 的 $ 本来就是绝对末尾,没有这个坑;
  • 反过来,开了多行(/m / re.M)之后 ^ $ 才变成每一行的首尾——而 \A \Z \z 不受多行影响,这正是它们存在的意义。

支持度(实测)

  • JS 与 C++ std::regex 完全没有 \A \Z \z——实测 new RegExp("\Ax") 编译通过但当字面量 A 处理,会静默失效,这比报错更危险;
  • Go(RE2)有 \A 与 \z,没有 \Z;
  • \G(接续上次匹配位置)四门都没有。
^    # 行/字符串开头
$    # 行/字符串结尾
\A   # 字符串绝对开头(忽略多行模式)
\Z   # 字符串末尾(允许末尾有一个换行)
\z   # 字符串绝对末尾(不允许末尾换行)

# 示例
^\d{4}$         # 整个字符串恰好 4 位数字
^https?://      # 以 http(s):// 开头
\.(jpg|png)$   # 以 .jpg 或 .png 结尾
$ 的行尾语义有坑:Python/PCRE 下 $(无多行)默认还匹配末尾换行前的位置——^\d+$ 会接受 "123\n",校验易被绕过;要严格到串尾请用 \z(Go、Python 3.14+)或 \Z(Python)。JS 的 $(无 /m)只在绝对末尾、不含换行前。开 m/re.M 后 ^ $ 才变每行首尾。
\A \Z \z 支持:Go(RE2) 支持 \A \z(无 \Z);Python 支持 \A \Z(\Z 即绝对末尾;Python 3.14 起也支持 \z);JS 与 C++ std::regex 都不支持这些,只能用 ^ $(非多行下等效绝对首尾)。\G:这四种语言均不支持。
\b 匹配 \w 与 \W 之间的位置(或字符串首尾),用于精确匹配整个单词。\B 是其取反。

\b 处理中文时直接失效(实测)

Python:  re.findall(r"\b\w+\b", "你好,世界")  →  ['你好', '世界']
JS:      "你好,世界".match(/\b\w+\b/g)          →  null
  • 原因不在 \b 本身,而在它依赖的 \w:\b 的定义是「\w 与非 \w 之间的位置」,\w 不认中文,边界自然无从谈起;
  • 所以上一张卡那张 \w 对照表在这里直接结账:Python 能用、JS/Go/C++ 完全失效;
  • 在 JS 里要做中文「词」边界,只能自己界定:(?<!\p{L})…(?!\p{L}) 配 /u,即「前后都不是字母」。但中文本来就不用空格分词,「词边界」这个概念在中文文本上多数时候是伪需求——真要分词该用分词库;
  • \b 是零宽的:它不消耗字符,所以 \bcat\b 的长度就是 cat,替换时不会误删两侧的字符。
\bcat\b    # 匹配 "cat" 但不匹配 "concatenate" 中的 cat

# 文本:"cat concatenate scat"
cat        # 匹配 3 处
\bcat\b   # 只匹配第 1 处

\bprice\b # "the price." → 匹配(. 是 \W)
Unicode 局限:\b 的「单词字符」定义随语言而异——Python 3(对 str)默认 Unicode-aware,中文会算作 \w;而 JS、Go(RE2)、C++ std::regex 的 \w 默认只认 ASCII,中文不算单词字符。因此处理中文等文字的「词边界」时行为不同,常需改用 \p{L} 结合断言来实现。
\b 是零宽位置,典型用法 \bword\b 匹配整词。跨语言差异大:JS、Go、C++ 的 \b 基于 ASCII \w,对中文等无效,需改用 \p{L} 配环视自行界定边界;Python 3 的 \b 默认 Unicode-aware(加 re.ASCII 退回 ASCII)。

分组与捕获

本章覆盖:捕获组与编号规则、命名组的四套语法、非捕获组 (?:) 与交替 |、反向引用,以及模式内引用(\1)与替换串引用($1)这两套互不相同的语法——跨语言搬正则时,模式往往能直接用,替换串几乎总要改。

括号做三件事:把一段当成一个整体(好加量词)、把匹配到的内容记下来(好取用)、划定交替的范围。三件事可以分开要——不需要记内容就用 (?:…)。

分组语法

写法作用支持
(…)捕获组,按左括号出现顺序从 1 编号四门通用
(?:…)非捕获组:只分组不记录,不占编号四门通用
(?<n>…)命名组JS ✓;Go 1.22+ 也接受;Python ✗;C++ ✗
(?P<n>…)命名组(Python 风格)Python ✓、Go ✓;JS 报 Invalid group;C++ ✗
(?>…)原子组,不回退(02 章)仅 Python 3.11+
(?i:…)局部标志(06 章)Python ✓、Go ✓;JS 需 Node 24+;C++ ✗
(?#…)模式内注释Python ✓;其余三门 ✗(改用 x 模式或代码注释)

默认用 (?:…),需要取值时才升级成捕获组。好处不只是快一点:组号是按左括号顺序自动排的,中间插一个新括号就会把后面所有编号推后,把 \1、$2 全部错位。少一个捕获组就少一次这种风险。

同一个「第 1 组」,三处写法互不相同

模式内反向引用替换串里代码里取值
JS\1 / \k<n>$1 · $<n> · 整体 $&m[1] · m.groups.n
Python\1 / (?P=n)\1 · \g<1> · \g<n> · 整体 \g<0>m.group(1) · m["n"]
Go(RE2)不支持$1 · ${1} · $n · 整体 $0m[1](FindStringSubmatch)
C++\1$1 · 整体 $&m[1].str()
  • 跨语言搬正则时,模式往往能直接用,替换串几乎总要改——这张表就是为这件事准备的;
  • Go 的替换串必须用 ${1} 形式,只要引用后面紧跟字母或数字:实测 $1x 被当成组名 1x、取到空串,${1}x 才对。这一条不报错,只是结果静悄悄地少一截;
  • Python 的替换串不认模式语法:写 \d 会报 bad escape \d,它只认 \1 与 \g<…>;
  • 组号从 1 开始,0 表示整体匹配(JS 写 $&,Python 写 \g<0>,Go 写 $0)。

交替 | 与优先级

写法实际含义
^a|b$不是「a 或 b 且首尾锚定」,而是「以 a 开头」或「以 b 结尾」——| 的优先级最低,会把整条切两半
^(?:a|b)$这才是本意。用到 | 就先想括号
cat|category回溯引擎从左到右取第一个成功的分支,不取最长的——对 category 只会匹配到 cat。把长的写前面
(a|b|c)只为分组而捕获,纯属浪费。写 [abc] 或 (?:a|b|c)
可选的捕获组没匹配上时不是空串:JS 给 undefined、Python 给 None、Go 给空串。(\d+)? 后直接 m[1].length 或 m.group(1).strip() 会当场崩。取组值一律先判空,Python 可用 m.group(1) or ""。
命名组是可读性投资,也是抗重构的保险:(?<year>\d{4}) 在模式里插入新括号后依然指向同一段,编号却会整体错位。四门里唯一通用的写法是编号引用,要跨 JS 与 Python 就只能写 (?P<n>…) 之外的模式、在代码里各自取值——这也是替换串尽量短的理由。
括号内的匹配被编号记住(从 1 开始),可在模式内 \1 反向引用,或在替换中 $1(JS)/ \1(Python)引用。

模式内引用与替换串引用是两套语法

模式内引用第 1 组替换串里引用第 1 组替换串里引用整体
JS\1$1$&
Python\1\1 或 \g<1>\g<0>
Go(RE2)不支持$1 / 1$0
C++\1$1$&
  • 最容易搞混的一点:同一个「第 1 组」,在模式里和在替换串里写法不同,而且不同语言的替换语法还各不相同。跨语言搬正则时,模式往往能直接用,替换串几乎总要改;
  • Go 的 1 带花括号版本在「引用后紧跟数字或字母」时是必需的——$1x 会被当成组名 1x;
  • 实测 JS:"abc".replace(/b/, "[$&]") 得 a[b]c。

Go 为什么没有反向引用

  • RE2 保证匹配时间与输入长度成线性关系,代价就是放弃反向引用与环视——这两样都需要回溯,而回溯正是指数级爆炸的来源;
  • 所以这不是「Go 的正则弱」,是一个明确的工程取舍:用能力换「任何输入都不会把服务卡死」的保证;
  • 在 Go 里需要反向引用时,标准做法是先用正则粗匹配、再在代码里比较——通常也更好读。
# 捕获与替换引用
(\d{4})-(\d{2})-(\d{2})
# "2024-01-15" → 组1:"2024", 组2:"01", 组3:"15"

# JS 替换
"2024-01-15".replace(/(\d{4})-(\d{2})-(\d{2})/, '$3/$2/$1')
# → "15/01/2024"

# 反向引用:模式内匹配之前捕获的内容
(['"]).*?\1   # 匹配配对引号:'hello' 或 "world"
(\w+) \1    # 匹配重复单词:"the the"

# HTML 标签配对
<(\w+)>.*?</\1>
# 匹配 <div>content</div>
Go(RE2) 不支持反向引用——线性时间引擎为保证性能放弃了它。JS、Python、C++ std::regex 都支持 \1。在 Go 里需要反向引用时只能在代码层面处理,或换用 PCRE2。
模式内引用之前捕获统一用 \1(JS/Python/C++ 一致,Go(RE2) 不支持);替换串里的引用语法则各异:JS 用 $1、Python 用 \1 或 \g<1>、Go 用 $1/${1}、C++ 用 $1;整体匹配各家不同——JS/C++ 用 $&、Go 用 $0/${0}、Python 用 \g<0>/\0。
命名组用名称代替编号,提升可读性。语法因语言而异:JS 用 (?<name>),Python 用 (?P<name>),Go 两种都支持(1.22 起也认 (?<name>));C++ std::regex 不支持命名组(要用需 PCRE2)。

重名组:ES2025 放宽了,但你的运行时未必(实测)

Node 22:  new RegExp("(?<n>a)|(?<n>b)")
          → SyntaxError: Duplicate capture group name
Node 22:  new RegExp("(?:(?<n>a)|(?<n>b))")   ← 互斥分支
          → 同样报错
Python:   re.compile(r"(?P<n>a)|(?P<n>b)")
          → PatternError: redefinition of group name 'n'
  • ES2025 的放宽是「允许出现在互斥的交替分支里」,但实测 Node 22 两种写法都报错——需要 Node 24+ / 较新的 V8;
  • 所以「网上说 JS 现在允许重名组了」这条要配上运行时版本一起看,照抄进 Node 22 的项目会直接编译失败;
  • 组名还必须是合法标识符:不能以数字开头、不能含连字符。

为什么命名组反而更难跨语言

  • 编号引用 \1 四门里有三门通用;而命名组是声明与引用两处都不一样:JS (?<n>)+\k<n>、Python (?P<n>)+(?P=n)、Go 两种声明都认、C++ std::regex 两种都不认;
  • 所以可读性的收益是本地的,可移植性的损失是全局的;
  • 务实的做法:正则只在一门语言里用时尽管命名(可读性提升非常实在);要跨端共用的正则就老实用编号,在代码里给组编号起个常量名。
# JS 命名捕获
const m = "2024-01-15".match(
  /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/
);
m.groups.year;  // "2024"

# JS 替换中引用命名组
"2024-01-15".replace(
  /(?<y>\d{4})-(?<m>\d{2})-(?<d>\d{2})/,
  '$<d>/$<m>/$<y>'
)

# Python 命名组
m = re.search(r'(?P<year>\d{4})', s)
m.group('year')

# 命名反向引用
(?<q>['"]).*?\k<q>     # JS
(?P<q>['"]).*?(?P=q)   # Python
组名须是合法标识符且通常不可重名——Python 重名直接报错,JS 在 Node 22 等主流在用引擎里同样一律报错(ES2025 放宽为「允许出现在互斥的交替分支」,但需较新的 V8/Node 24+)。加之命名反向引用写法不通用(\k<n> 对 (?P=n)),含命名组的正则难以跨语言照搬。
命名组语法矩阵:JS (?<n>)、反向引用 \k<n>;Python (?P<n>)、反向引用 (?P=n);Go 自 1.22 起 (?<n>) 与 (?P<n>) 皆可;C++ std::regex 两种都不支持(需 PCRE2)。
(?:) 只分组不记录,性能更好。| 优先级最低,通常配合分组使用。原子组 (?>) 匹配后禁止回溯。

| 的优先级低到超出直觉

^cat|dog$    实际含义是  (^cat) | (dog$)
                        不是  ^(?:cat|dog)$

「abc」 匹配 ^cat|dog$ ?  否
「cat123」 匹配?          是(^cat 命中)
「123dog」 匹配?          是(dog$ 命中)
  • | 是整个正则里优先级最低的运算符,两侧会一路延伸到最近的分组边界或字符串边界;
  • 规则很简单:只要用了 |,就把它包进 (?:…)——哪怕看起来不需要;
  • 另一半是顺序:回溯引擎的交替是「最左优先」而不是「最长优先」。(?:a|ab) 对 "ab" 只会吃到 a,更长的分支必须放前面。这与 POSIX 引擎的「最长匹配」语义相反,是从 awk/grep 迁过来的人最容易出错的一处。

(?:) 与 (?>) 别混

  • (?:…) 只是不记录捕获,回溯行为与普通分组完全一样——它省的是内存和编号,不是回溯;
  • (?>…) 是原子组:一旦匹配完成就丢弃组内所有回溯点,是真正能砍掉回溯的那个;
  • 支持度实测:Python 3.11+ 原生支持 (?>…);JS 实测报 Invalid group,Go 与 C++ std::regex 也不支持。
# 非捕获组:不记录
(?:red|blue|green)  # 匹配三色之一,不捕获
(?:https?://)?      # 可选的协议前缀
gr(?:a|e)y           # gray 或 grey

# 交替
cat|dog              # "cat" 或 "dog"
(?:cat|dog)s?        # cat/cats/dog/dogs

# 原子组:匹配后禁止回溯
(?>abc|ab)   # 匹配到 abc 就不再尝试 ab
| 优先级最低,会一路吃到两侧的边界:^cat|dog$ 意思是「^cat」或「dog$」,而非 ^(?:cat|dog)$——限定范围必须分组。且交替是有序、最左优先:回溯引擎里 (?:a|ab) 对 "ab" 只会先吃到 a,应把更长的分支放前面。
原子组 (?>…) 支持:Python 3.11+(re 已原生支持);JS、Go、C++(std::regex) 不支持——需要时改用 PCRE2。

断言(环视)

本章覆盖:四种环视断言的写法与朝向、四引擎支持度(差距最大的一处)、以及多条件叠加与千位分隔这类只靠位置完成的实战写法。环视是零宽的:断言成立后位置不前移,所以同一处可叠加任意多个。

环视只做一件事:在当前位置检查前后的内容,但不消耗字符。于是它能表达其他语法表达不了的两类需求——「同时满足多个条件」和「只替换特定上下文里的内容」。

四种写法与朝向

写法朝向要求读法
(?=p)看后面后面必须能匹配 p正向先行
(?!p)看后面后面不能匹配 p负向先行
(?<=p)看前面前面必须能匹配 p正向后行
(?<!p)看前面前面不能匹配 p负向后行

记法:带 < 的向后看前文,那个尖角就指着前文的方向;= 是肯定、! 是否定。

十条能直接抄的配方

要做的事写法说明
多条件同时满足(密码强度)^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$断言零宽、位置不动,所以可叠加任意多条
千位分隔\B(?=(\d{3})+(?!\d))匹配的是位置,替换即插入;只用先行,可移植性好
匹配不含某词的整行^(?!.*foo).*$「否定整行」的标准写法
取 key= 后面的值(?<=key=)[^;]+不带上 key=;Go 里改用捕获组
排除关键字后的标识符\b(?!if\b|for\b)\w+\b否定先行 + \b 防止误伤 iffy
只替换不在引号里的内容(?=(?:[^"]*"[^"]*")*[^"]*$)能用,但已经到该换解析器的边缘(07 章)
校验「不以某后缀结尾」^.*(?<!\.tmp)$负向后行,定长——Python 也能用
数字后没跟单位才匹配\d+(?!\s*(?:kg|km))负向先行放在量词右边
找重复单词\b(\w+)\s+\1\b这是反向引用不是环视;Go 同样没有
去掉行尾空白[ \t]+$(多行)用不着环视——能不用就别用

支持度:这是四门差距最大的一处

先行 (?=)(?!)后行 (?<=)(?<!)变长后行
JS✓✓ ES2018+✓(四门里最强)
Python✓✓✗ 报 look-behind requires fixed-width pattern
Go(RE2)✗ 报 unsupported Perl syntax: (?=✗—
C++ std::regex✓✗ 报 regex_error—

四门的安全交集只有「先行断言」。只要这条正则可能跑在 Go 上就别用环视——RE2 放弃它是为了换线性时间保证,这件事不会改。替代办法是先用正则粗匹配,再在代码里判断,通常还更好读。

密码校验这类需求其实不该塞进一条正则。一条大正则只能回答「不合格」,拆成几条独立的断言分别测,才能告诉用户缺的是哪一项。上面那条配方最好的用处是理解「零宽可叠加」,不是照抄进生产代码。
需要「变长后行」时,四门通用的替代是把前缀也写进模式,只取目标那一组:key=([^;]+) 取 m[1]。多一行代码,换来四门都能跑——比 \K 和变长后行都可靠(07 章)。
先行断言检查当前位置后面的内容:(?=p) 后面必须匹配 p,(?!p) 后面不能匹配 p。后行断言检查前面的内容:(?<=p) 前面必须匹配 p,(?<!p) 前面不能匹配 p。断言只在字符之间的位置检查条件是否成立,本身不消耗任何字符(零宽),因此后续匹配仍从原位置继续。

四种断言的记法

写法朝向要求
(?=p)看后面后面必须能匹配 p
(?!p)看后面后面不能匹配 p
(?<=p)看前面前面必须能匹配 p
(?<!p)看前面前面不能匹配 p
  • 记法:带 < 的向后看(lookbehind),不带的向前看。 < 这个尖角就指向「前文」的方向;
  • 四种全是零宽:断言成立后位置不前移,所以同一个位置可以叠加任意多个断言——这正是密码强度校验那类「多条件同时满足」的实现方式。

支持度:这是四门差距最大的一处(实测)

先行 (?=)(?!)后行 (?<=)(?<!)后行变长
JS支持支持支持(实测 (?<=a+)c 可用)
Python支持支持不支持(实测报 look-behind requires fixed-width pattern)
Go(RE2)完全不支持完全不支持—
C++ std::regex支持不支持—
  • JS 的后行断言反而是四门里最强的(ES2018 起支持变长),这与「JS 正则弱」的刻板印象正好相反;
  • Go 的「完全不支持」和前一章反向引用是同一个原因:RE2 用能力换线性时间保证;
  • 结论很实际:只要这条正则可能要跑在 Go 上,就别用环视;要跨四门通用,能用的只有「先行断言 + 定长后行」这个交集。
# 正向先行 (?=...)
\d+(?= dollars)
# "100 dollars, 50 euros" → "100"

# 负向先行 (?!...)
\bJava(?!Script)\b
# 匹配 "Java" 但不匹配 "JavaScript"

# 正向后行 (?<=...)
(?<=\$)\d+
# "$42" → "42"(不含 "$")

# 负向后行 (?<!...)
(?<!\d)\d{3}(?!\d)
# 仅匹配独立的 3 位数
Go(RE2) 完全不支持环视断言(线性引擎限制),需在代码层面处理或换用 PCRE2。JS(ES2018+)、Python 的先行 / 后行都支持(Python 后行须定长);C++ std::regex 只支持先行 (?=) (?!),不支持后行。
环视是零宽的:匹配成功后位置不前移,所以同一处可叠加多个断言做多条件校验。判断朝向看有没有 <——带 < 的 (?<=)/(?<!) 向「后」看前文,不带的 (?=)/(?!) 向「前」看后文。
多个先行断言可叠加在同一位置,实现「同时满足多条件」的校验。后行断言可用于提取特定前缀后的内容。

密码校验:逐个断言各查一件事

^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$
  │ └─ 有小写   └─ 有大写   └─ 有数字   └─ 长度 ≥ 8
  └─ 从头开始,三个断言都在同一个位置检查,彼此不消耗字符
  • 拆开写的价值不只是可读:加一个规则就多一个 (?=…),去掉一个就删一行,不用重排整条正则;
  • 而且报错时能定位:想告诉用户「缺少数字」,就把这几个断言拆成几条独立正则分别测——一条大正则只能给出「不合格」;
  • 推论:密码校验其实不该塞进一条正则。这个例子最好的用处是理解「零宽 + 可叠加」,而不是照抄进生产代码。

千位分隔:一个纯位置匹配

JS:  "1234567".replace(/\B(?=(\d{3})+(?!\d))/g, ",")  →  1,234,567
        └─ 不在词边界      └─ 后面剩的数字个数是 3 的整数倍
  • 它匹配的是位置而不是字符——所以 replace 是在往位置里「插入」逗号,原数字一个都没被改;
  • 这条正则依赖后行断言吗?不依赖,只用了先行——所以它是少数几个四门里都能跑的环视用法之一(Go 除外,Go 连先行都没有);
  • Python 里同样写法可用,但更地道的是 f"{n:,}"——有内置格式化就别写正则。
# 密码强度验证(多先行叠加)
^(?=.*[A-Z])(?=.*[a-z])(?=.*\d)(?=.*[@$!]).{8,}$
# 必须含大写、小写、数字、特殊符号,至少 8 位

# 数字千位分隔符
(?<=\d)(?=(\d{3})+$)
"1234567".replace(/(?<=\d)(?=(\d{3})+$)/g, ',')
// "1,234,567"
后行断言在 Python 里必须定长:(?<=ab) 行,(?<=a+) 报错;JS 无此限制。而 Go(RE2)、C++ std::regex 连后行都没有,别把这类校验全塞进一条正则、指望四门通用。
密码强度校验是经典用法:^(?=.*[a-z])(?=.*[0-9]).{8,}$ 用多个先行断言各查一个条件,彼此不消耗字符、位置不动。

修饰符 / 标志

本章覆盖:i g m s x u v y d 各标志的含义与四门各自的写法、自由间距 x 模式、内联修饰符 (?i) 的支持度与位置限制。「全局」只有 JS 把它做成了标志,由此带出 lastIndex 这个 JS 里最难查的正则坑。

标志改变的是引擎的整体行为,不是模式里的某一段。九个标志里真正常用的只有四个:忽略大小写、多行、dotAll、全局——其余各有各的适用面,下表一并列出。

标志一览与四门写法

作用JSPythonGoC++
忽略大小写/ire.I / re.IGNORECASE(?i)regex::icase
多行(^ $ 逐行)/mre.M(?m)regex::multiline
dotAll(. 含换行)/sre.S(?s)没有
自由间距 + 注释没有re.X没有没有
全局匹配/g(是标志)findall / finditerFindAll…regex_iterator
Unicode 模式/u(\p{} 必需)默认开(re.A 关)默认按 UTF-8取决于字符类型
集合运算/v(与 /u 互斥)没有没有没有
粘连匹配/y(从 lastIndex 起必须立刻匹配)match 即锚首没有match_continuous
返回匹配位置/d(indices)m.span()FindStringIndexm.position()
整体反转贪婪性没有没有(?U)没有

只有 JS 把「全局」做成了正则的属性,其余三门都做成了 API 的选择。这个设计差异直接制造了下面那个坑。

内联修饰符 (?i)

全局 (?i)分组 (?i:…)关闭 (?-i)
Go(RE2)✓✓✓
Python✓ 必须在模式最开头,写在中间报 global flags not at the start✓✓(分组内)
JS✗ 报 Invalid groupES2025,需 Node 24+同左
C++ std::regex✗ 抛 regex_error✗✗

内联修饰符的用处是把标志随模式一起传递——正则存在配置文件或数据库里时,标志没地方写,只能内联。但这套写法在四门里最不通用,跨语言的模式串里别出现它。

lastIndex:JS 独有的状态陷阱

const r = /a/g;
r.test("aa")  →  true    lastIndex = 1
r.test("aa")  →  true    lastIndex = 2
r.test("aa")  →  false   ← 同一个字符串,第三次就 false 了
会踩不会踩
r.test() · r.exec()(带 g 或 y)str.match() · str.replace() · str.matchAll()——它们自己处理 lastIndex

三种解法,按推荐顺序:①校验用途别加 g;②每次现造一个正则;③手动 r.lastIndex = 0。把正则提成模块级常量再反复 test,是 JS 里最难查的一类 bug——单测里通常只调一次,永远复现不出来。

x 模式下空格的含义变了:类外的空白被忽略,字符类 […] 内部的空格仍是字面量。开了 re.X 之后要匹配真正的空格,写 [ ]、\s 或转义的 \ ;# 之后到行尾算注释,匹配字面 # 得转义。
i 对非 ASCII 的效果别想当然:土耳其语的无点 i、德语 ß 与 SS、希腊语词尾 sigma,都不是简单的大小写一一对应。涉及多语言的「不区分大小写比较」,应该用语言自带的大小写折叠(Python str.casefold()、JS toLocaleLowerCase())先归一,而不是指望 /i。
各标志的含义与语法。g 是 JS 专属概念,其他语言通过 API 控制全局匹配。

标志在四门里的表达方式完全不同

怎么写有没有「全局」
JS字面量后缀 /gimsuvydg 是标志
Pythonre.I/re.M/re.S/re.X 或内联 (?i)靠 findall/finditer
Go内联 (?i) 等,只有 i m s U靠 FindAll*
C++构造参数 std::regex::icase靠 regex_iterator
  • 只有 JS 把「全局」做成了正则的属性,其余三门都把它做成了 API 的选择——这个设计差异直接导致了下面那个坑。

lastIndex:JS 独有的状态陷阱(实测)

const r = /a/g;
r.test("aa")  →  true   lastIndex = 1
r.test("aa")  →  true   lastIndex = 2
r.test("aa")  →  false  ← 同一个字符串,第三次就 false 了
  • 带 g(或 y)的正则对象是有状态的:test/exec 会从 lastIndex 继续找,并把它往后推;
  • 于是把正则提成模块级常量再反复 test,结果会随调用次数「跳着变」——这是 JS 里最经典、也最难查的一个正则 bug,因为单测里通常只调一次;
  • 三种解法:①校验用途别加 g(最简单);②每次现造一个正则;③手动 r.lastIndex = 0;
  • String.prototype.match/replace/matchAll 会自己处理这件事,只有 test 与 exec 会踩。
i   # 忽略大小写          /pat/i  re.I
g   # 全局匹配(JS 专属)  /pat/g
m   # 多行:^ $ 匹配每行   /pat/m  re.M
s   # dotAll:. 匹配换行   /pat/s  re.S
x   # 自由间距:空格+注释   re.X(JS 不支持)
u   # Unicode 模式(JS)   /pat/u
v   # Unicode Sets(ES2024)/pat/v
y   # 粘性匹配(JS)       /pat/y
d   # 子串索引(ES2022)   /pat/d
g(全局)是 JS 独有——Python 用 re.findall/finditer,Go 用 FindAll,C++ 用 regex_iterator。JS 里带 g 的正则对象有 lastIndex 状态,复用同一对象反复 test() 会「跳着匹配」,是高频坑。
标志名各语言不同:JS 用字面量后缀 /gimsuy;Python 用 re.I/M/S/X 常量或内联 (?i);Go 只有 i m s U(无 x、无 g);C++ 用 std::regex::icase 等构造标志。
x 模式忽略空白并允许 # 注释,极大提升长正则的可读性。Python 支持(re.X);JS、Go、C++(std::regex) 均不支持。

开了 x 之后,空格的含义变了(实测)

Python:  re.match(r"a b", "ab", re.X)   →  匹配(模式里的空格被忽略)
Python:  re.match(r"[ a]", " ", re.X)   →  匹配(字符类内的空格仍是字面量)
  • 规则是「类外忽略、类内保留」——所以开了 x 之后要匹配真正的空格,写 [ ]、\s 或转义的 \ ;
  • # 之后到行尾算注释,要匹配字面 # 得转义;
  • JS 没有 x 标志(实测 new RegExp("a b", "x") 报 Invalid flags),只能用字符串拼接来模拟——但拼接会把注释留在 JS 代码里而不是正则里,效果其实差不多。

什么时候值得开

  • 判据很直接:这条正则超过一行,或者三个月后你还要改它;
  • 没有注释的长正则是一次性代码:出问题时多数人的做法是重写而不是读懂它——这本身就说明它没起到「代码」的作用;
  • 更进一步的做法是拆成几条短正则 + 代码里的逻辑。一条能塞进 x 模式写满二十行的正则,通常也能拆成三条各自可测的短正则。
# Python 示例
email_pat = re.compile(r"""
  ^                   # 字符串开头
  [\w.+-]+            # 用户名
  @                   # @ 符号
  [\w-]+              # 域名主体
  (?:\.[\w-]+)*       # 可选子域
  \.                  # 最后一个点
  [a-zA-Z]{2,}        # 顶级域名
  $                   # 字符串结尾
""", re.VERBOSE)
开了 x 模式后模式里的空格被忽略,想匹配真正的空格得写 \ (转义)、[ ] 或 \s;但字符类 [...] 内部的空格不受影响、仍是字面量。
复杂正则一定要加注释。没有注释的长正则是「一次性代码」,一个月后连自己都看不懂。JS 中可用字符串拼接或 XRegExp 库模拟。
在模式内部局部开启/关闭标志。Go(RE2)、Python 完整支持 (?i) 与 (?i:…);JS 传统上不支持内联标志(全局 (?i) 与分组 (?i:…) 皆无),ES2025 虽新增 (?i:…) 分组修饰符但需较新引擎(Node 24+,Node 22 尚不支持);C++ std::regex 不支持内联标志(改用 std::regex::icase 等常量)。

四门的支持度差到无法通用(实测)

全局 (?i)分组 (?i:…)
Go(RE2)支持支持
Python支持,但必须在模式最开头支持
JS(Node 22)报 Invalid group报 Invalid group
C++ std::regex不认(静默失效或报错)不认
  • Python 的位置限制是实测出来的:Hello(?i)world 直接报 global flags not at the start of the expression——而这个写法在 Perl/PCRE 里是合法的,从那边搬过来就会炸;
  • ES2025 给 JS 加了 (?i:…),但实测 Node 22 仍不支持,需要更新的引擎;
  • C++ 的情况最危险:(?i)abc 可能既不报错也不生效,只能靠构造时传 std::regex::icase——移植时这类「静默失效」比报错难查得多。
(?i)      # 从此处开始忽略大小写
(?-i)     # 关闭忽略大小写
(?i:pat)  # 仅在此分组内忽略大小写

# 示例
Hello(?i)world
# Hello 区分大小写,world 不区分
C++ std::regex 完全不认内联标志:(?i)abc 不会开启忽略大小写,只能在构造时传 std::regex::icase。把 (?i) 直接从别的语言搬进 C++ 正则会静默失效或报错,是移植时的隐蔽坑。
Python 限制:Python 3.11+ 要求「全局」内联标志(如 (?i))必须置于模式最开头,写在中间(如 Hello(?i)world)会直接报错;需局部作用时改用分组形式 (?i:…)。

高级特性

本章覆盖:JS 的 /v 集合运算、递归 (?R)、条件分组、\K 重置起点,以及 ReDoS 的触发条件与三条防线。这些特性多数四门标准库都没有,需要 PCRE2;本章的价值一半在于说明什么时候该停手改用解析器。

本章的特性有个共同点:四门标准库大多没有。所以这张表的第一用途不是「怎么写」,而是判断你是不是已经该换工具了——需要递归和条件分组,通常意味着该用解析器而不是正则。

高级特性支持矩阵

特性写法谁有没有时怎么办
集合运算[[a-z]--[aeiou]] &&仅 JS /v(ES2024)手动展开区间,或用两个断言取交
递归(?R) (?0) (?N)四门全无,需 PCRE2换解析器——见下
条件分组(?(1)yes|no)仅 Python写两条独立正则,在代码里分支
重置起点\K四门全无,需 PCRE2用捕获组取子串(05 章)
原子组 / 独占量词(?>…) a*+仅 Python 3.11+重写模式消除歧义,或换 RE2
接续锚点\G四门全无JS 用 /y 粘连标志(06 章)
平衡组(?<-n>…)仅 .NET同递归:换解析器
模式内注释(?#…)仅 Pythonx 模式,或把注释写在代码里

需要递归,通常意味着你在匹配的是一门「语言」而不是一个「模式」:嵌套括号、JSON、HTML、配置文件——这些都该用解析器。经典结论「正则不能解析 HTML」的准确说法是:正则表达不了任意深度的嵌套结构,PCRE 的递归扩展本身已经超出了「正则」的定义。

ReDoS:危险结构与改写

危险写法为什么炸改成
(a+)+$量词套量词,同一段输入有指数级多种拆分a+$——内外两层量词本就等价
(a|a)*$交替分支可重叠,每个字符都有多条路径消除重叠:a*$
(\s*\S*)+$两个都能匹配空串的量词嵌套拆成确定的结构,或整体锚定
(.*),(.*) 用于长行两个贪婪 .* 互相试探([^,]*),(.*)
任何由用户输入拼出的正则攻击者直接控制模式转义输入(01 章),或干脆别让用户提供模式

触发条件三个缺一不可:①回溯型引擎;②嵌套量词或可重叠分支;③一个「差一点就匹配上」的输入。第三条决定了它难以在测试中发现——正常输入都很快,只有精心构造的输入才炸。

三条防线,按可靠性排序

防线做法强度
①换引擎用 RE2(Go 标准库,或其他语言的绑定)引擎级保证:线性时间,代价是没有环视与反向引用
②砍回溯独占量词 / 原子组(Python 3.11+)消灭一整类爆炸,但要你能看出该加在哪
③设上限限制输入长度 + 给匹配加超时兜底,不解决问题但能保住服务

别把「把正则写好一点」当成防线——能不能看出一条正则有指数回溯,本身就是很难的判断。这正是 Go 当初选择 RE2、宁可放弃反向引用与环视的理由。

JS 对不认识的转义是静默放行的:\K、\A、\Z 都会被当成字面字符 K / A / Z,编译通过、永远匹配不上、不给任何提示。从 Perl / PCRE 抄模式过来时,这类「不报错的失败」比报错难查得多——抄完先跑一条应当匹配的样例。
判断该不该停手,有一条很实用的标准:这个结构有「嵌套深度」吗?括号、标签、引号里的引号、JSON——只要深度不定,正则就表达不了,写多长都会有反例。有深度就上解析器,没深度(一行日志、一个 ID、一个日期)正则最合适。
/v 标志(ES2024)在字符类内支持集合差集 -- 和交集 &&,可以精确组合 Unicode 属性。

它不是 /u 的超集,是替代品(实测)

new RegExp("a", "uv")   →  SyntaxError: Invalid flags   ← 只能二选一
new RegExp("[[]", "u")  →  允许(类内未转义的 [ 当字面量)
new RegExp("[[]", "v")  →  SyntaxError: Unterminated character class
  • /v 换来的是类内的集合运算:差集 --、交集 &&、以及多字符元素——这让「所有字母但排除 ASCII」这种需求能直接写出来;
  • 代价是语法更严格:一批在 /u 下宽容通过的写法会直接报错。所以从 /u 切到 /v 不是「加个字母」,要重测;
  • 这是 JS 独有的特性,Python / Go / C++ 都没有对应物——跨语言共用的正则里别出现它。
# /u 标志:正确处理 Unicode(ES2015)
/^\p{L}+$/u    # 纯字母字符串
/\p{Emoji}/u   # 匹配 emoji
/\p{Script=Han}/u # 匹配汉字(JS 里 script 名须写 Script=)

# /v 标志:集合运算(ES2024)
/[\p{Letter}--\p{ASCII}]/v  # 非 ASCII 字母
/[\p{Letter}&&\p{ASCII}]/v  # ASCII 字母
/[[a-z]&&[^aeiou]]/v        # 小写辅音字母
/v 与 /u 互斥、只能二选一;/v 下字符类语义更严格,某些在 /u 能用的写法(如类内未转义的 [)会直接报错。此特性 JS 独有,Python/Go/C++ 无对应物,跨语言正则别依赖它。
浏览器支持:Chrome 112+、Node 20+、Safari 17+。
递归模式用正则匹配嵌套结构(如括号)。JS / Go / Python 标准库 / C++ std::regex 都没有递归,需借助 PCRE2(C++ 可链接):(?R) 或 (?0) 递归整个模式,(?N) 递归第 N 组。

该停手的信号

  • 需要递归,通常意味着你在匹配的是一门语言而不是一个模式:嵌套括号、JSON、HTML、配置文件——这些都该用解析器;
  • 经典结论「正则不能解析 HTML」的准确说法是:正则(正规语言)表达不了任意深度的嵌套结构(上下文无关文法)。PCRE 的递归扩展本身已经超出了「正则」的定义;
  • 四门标准库(JS、Python re、Go、C++ std::regex)全都不支持递归;真需要时用 PCRE2 或 Python 的第三方 regex 库;
  • 即便能用,递归正则性能开销大、嵌套深时会栈溢出,而且几乎不可维护——它在工程里的正确用途是「一次性脚本」,不是产品代码。
# PCRE2:(?R) 递归整个模式
\((?:[^()]*|(?R))*\)
# 匹配任意深度嵌套括号:((a+b)*c)

# 递归第 1 组(等价写法)
(\((?:[^()]*|(?1))*\))
递归正则性能开销大,嵌套深时容易栈溢出。工程中通常用解析器(Parser)代替。JS、Go、Python 标准 re、C++ std::regex 都不支持递归——需要时用 PCRE2 或 Python 的 regex 库。
四门标准库(JS、Python re、Go、C++ std::regex)都不支持递归——遇到嵌套括号、JSON、HTML 这类结构别硬用正则,用真正的解析器。确需正则递归时上 PCRE2 或 Python 第三方 regex 库的 (?R)。
根据某个捕获组是否匹配成功,选择不同的子模式。Python(re) 支持;JS、Go、C++(std::regex) 不支持(要用需 PCRE2)。

四门里只有 Python 有(实测)

Python:  re.match(r"(a)?(?(1)b|c)", "ab")  →  匹配
Python:  re.match(r"(?(9)b|c)", "c")       →  PatternError: invalid group reference 9
JS:      new RegExp("(a)?(?(1)b|c)")       →  SyntaxError: Invalid group
  • 语义是「若第 1 组匹配成功走 yes 分支,否则走 no 分支」——用来表达「有开括号就必须有闭括号」这类成对约束;
  • 引用的组必须真实存在,Python 会直接报错而不是当条件为假;
  • 可读性极差是它最大的问题:(?(1)b|c) 这串符号里没有任何一个字面上提示「这是条件」。多数场景写两条独立正则、或在代码里分支,都比它清楚得多。
# 语法:(?(condition)yes|no)
(\()?\d+(?(1)\))
# 如果有左括号(组1匹配),则要求右括号
# 匹配: "123" 或 "(123)"
# 不匹配: "(123" 或 "123)"
条件引用的组号 / 组名必须真实存在,在 Python 里引用不存在的组会直接报错。这类写法可读性极差,多数场景用两条独立正则或代码里的分支更清晰。
条件分组 (?(1)yes|no) 意为「若第 1 组匹配成功走 yes 分支,否则走 no」。四门里只有 Python 标准库 re 支持(实测可用);JS、Go、C++ std::regex 都不支持,需 PCRE2。
\K 丢弃已匹配的部分,重置匹配起点。比后行断言更灵活(允许变长前缀)。

它在四门标准库里根本不存在(实测)

Python:  re.search(r"ab\Kc", "abc")   →  PatternError: bad escape \K
JS:      new RegExp("ab\Kc")          →  编译通过,但 \K 被当成字面字符 K
  • JS 的行为比 Python 危险:不报错、静默当字面量——照抄 PCRE/Perl 的 \K 过来会得到一条永远匹配不上的正则,而且没有任何提示;
  • \K 的价值在于「变长后行」:前缀\K目标 等价于 (?<=前缀)目标 但前缀可以是任意长度。而 Python 的后行必须定长(实测 (?<=a+) 直接报错);
  • 替代方案:用捕获组取子串。 把「前缀」和「目标」都放进模式,只取目标那一组——多一行代码,但四门通用。
\bfoo\K\s*bar\b
# "foo bar" → 匹配 " bar"(foo 被 \K 重置掉)
# 等效于 (?<=\bfoo)\s*bar\b 但 \K 更灵活
别把 \K 当后行断言的等价替换:它在四门标准库(JS、Python re、Go、C++ std::regex)里根本不存在,照抄 PCRE/Perl 的 \K 过来会报错或被当字面量。需要「变长后行」效果时改用捕获组取子串。
支持:需 PCRE2 或 Python 的 regex 库。JS、Go、Python 标准 re、C++ std::regex 均不支持。
ReDoS(正则拒绝服务攻击):有缺陷的正则在恶意输入下产生指数级回溯。核心危险模式是嵌套量词。

爆炸的形状:嵌套量词 + 可重叠的分支

危险:  (a+)+$        (a|a)*$       (\s*\S*)+$
        └─ 量词套量词,同一段输入有指数级多种拆分方式

对 "aaaaaaaaaaaaaaaaaaaaaaaaaaX" 这类输入,
引擎要把每一种拆分都试一遍才能确定失败
  • 触发条件有三个,缺一不可:①回溯型引擎;②嵌套量词或可重叠的交替;③一个「差一点就匹配上」的输入;
  • 第三条决定了它难以在测试中发现——正常输入都很快,只有精心构造的输入才炸;
  • 三条防线,按可靠性排序:①用 RE2(Go 的标准库、以及各语言的 re2 绑定)——线性时间是引擎级保证;②用独占量词/原子组砍掉回溯(Python 3.11+);③给正则匹配加超时;
  • 不要靠「把正则写好一点」当防线——能不能看出一条正则有指数回溯,本身就是很难的判断。这也正是 Go 当初选择 RE2、放弃反向引用与环视的理由。
# 危险模式(嵌套量词)
(a+)+      # "aaaaab" → 灾难性回溯
(a|a)+     # 同样危险
(\d+)*$    # 非数字结尾 → 灾难性回溯

# 修复策略
# 1. 用否定字符类代替嵌套量词
"[^"]*"     # 代替 ".*?"
# 2. 使用独占量词(如语言支持)
\d++
# 3. 限制输入长度
# 4. 设置超时(某些语言/库支持)
# 5. 用 RE2 引擎(线性时间保证)
任何从用户输入构建正则的场景都必须防范 ReDoS。JS、Python re、C++ std::regex 都是回溯引擎,没有时间保证;Go(regexp) 基于 RE2、线性时间,天然免疫。
防 ReDoS 实用招:避开嵌套量词与重叠可选分支(如 (a+)+、(a|a)*);能锚定就加 ^…$;对不可信输入设超时。或直接选线性引擎——Go 的 regexp(RE2)线性时间、天然免疫。

常用实战模式

本章覆盖工程中最常被复制的六类模式:邮箱与 URL、数字金额与千分位、日期时间、IPv4 与端口、中文场景(汉字 / 手机号 / 身份证)。先记住一点:邮箱、URL 的「完全严谨」规则极其复杂,实战用「足够好」的宽松版,再交给业务逻辑二次校验。每条都可复制到 09 章测试台试跑。

下表把本章各卡的模式集中到一处,可直接复制到 09 章测试台试跑。「严谨度」一列比模式本身更重要——标着「初筛」的那些,正确用法是拦住手滑,不是保证有效。

一页抄

要匹配的东西模式严谨度
邮箱[\w.+-]+@[\w-]+\.[\w.-]+初筛——真正的校验是发验证邮件
URL(http/https)https?://[^\s/$.?#].[^\s]*提取可用;校验请用 new URL() / net/url
域名(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}够用
整数-?\d+够用;校验加 ^…$
小数 / 浮点-?\d+(?:\.\d+)?不含科学计数法
金额千分位\d{1,3}(?:,\d{3})*(?:\.\d+)?必须加锚点,否则从错分组的串里截片段
插入千分位(替换用)\B(?=(?:\d{3})+(?!\d))用了先行断言,Go 不支持
十六进制颜色#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})\b够用
日期 YYYY-MM-DD\d{4}-\d{2}-\d{2}初筛——会放行 2 月 31 日
日期(月日限范围)\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])较严;闰年仍写不进去
时间 24 小时制(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d)?够用
ISO 8601 时间戳\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?丢时区偏移(+08:00 匹配不到)
IPv4(简版)(?:\d{1,3}\.){3}\d{1,3}错的——放行 999.999.999.999
IPv4(0–255)(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)正确,但必须加 ^…$
IPv6—别写:用 ipaddress / net.ParseIP
汉字(通用)[\u4e00-\u9fa5]+只覆盖基本区,生僻字会被拒
汉字(全覆盖)\p{Script=Han}+(JS,需 /u)· \p{Han}(Go)正确;Python 标准库没有
大陆手机号1[3-9]\d{9}初筛——号段会变,真实性靠验证码
身份证 18 位\d{17}[\dXx]初筛——末位校验码正则算不出来

用这些模式前先过三道

问为什么
是提取还是校验?校验要加 ^…$(Python 用 \Z);提取不加,但要留意会从长串里截出子串
有没有现成的解析器?URL、IP、日期、邮箱地址都有。解析器比正则准,还直接给你拆好的字段
这条模式要跑在哪几门上?带环视的(千分位)在 Go 上直接编译失败;带 \p{} 的在 Python 标准库上编译失败
不加锚点时,正则会从更长的串里截出一段并报告成功:2024-01-155 会匹配出 2024-01-15、1234.5.6.78 会抽出 234.5.6.78、1,23,456 只匹配到 1。凡是校验用途,一律加 ^…$ 并准备一条应当被拒绝的输入。
表里凡不含环视、反向引用与 \p{} 的模式,四门可原样编译并匹配。这也说明了一件事:可移植的正则通常就是简单的正则——把复杂度留给代码,比塞进模式串更容易测试、也更容易改。
日常够用的宽松版。真正的邮箱标准(RFC 5322)几乎无法用可读正则覆盖,别追求「一条正则搞定一切」。

邮箱:正则校验是个陷阱

  • 完整符合 RFC 5322 的邮箱正则有几千个字符,而且即便完全正确,也证明不了这个地址真的存在;
  • 业界共识是「只做最粗的形状检查 + 发验证邮件」:形状上只要求「有 @、@ 前后非空、后面有点」就够了;
  • 严格的正则反而会误伤合法地址:加号别名(a+tag@b.com)、新顶级域(.museum)、国际化域名,都被大量流传的「严谨邮箱正则」拒之门外;
  • 结论:这类正则的正确用途是「拦住明显的手滑」,不是「保证有效」。

URL:先找有没有现成的解析器

  • JS 有 new URL()、Python 有 urllib.parse、Go 有 net/url——它们都比正则准确,而且直接给你拆好的各个部分;
  • 正则该出场的场合是「从一段自由文本里找出 URL」(解析器做不到),而不是「校验这个 URL 合不合法」;
  • 即便是提取,边界也很难:URL 末尾的句号、中文括号、Markdown 语法都会干扰。做完提取再交给解析器验一遍是最稳的组合。
# 邮箱(日常够用版)
[\w.+-]+@[\w-]+\.[\w.-]+
# user.name+tag@sub.example.com

# URL(http / https)
https?://[^\s/$.?#].[^\s]*
# http(s):// 开头、后接域名与路径

# 域名
(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}
# example.com、a.b.co.uk
别在正则里硬抠邮箱合法性——真要严格校验,发一封验证邮件比任何正则都靠谱。
这三条都不含环视或反向引用,JS·Python·Go·C++ 四门可原样编译并匹配(已实测)。抽取 URL 时注意 [^\s]* 是贪婪的,实测 https://ex.com/a). 会把结尾的 ). 也一并吞入,按需再裁掉尾部标点。
注意锚点:单独提取用 \b,整串校验加 ^...$。

校验数字时最容易漏的四种输入

  • 前导零:007 该不该收?\d+ 会收;
  • 正负号与小数点:-1.5、.5、1. 各算不算?
  • 科学计数法:1e10 在 \d+ 下会被拆成 1 和 10;
  • 末尾换行:Python/PCRE 下 ^\d+$ 会接受 "123\n"(见「位置锚点」那一卡的实测),校验场景必须用 \z/\Z;
  • 金额还要多想一层:别用浮点存钱。正则只负责「形状对不对」,值该用定点数或整数分来存。
# 整数(可带正负号)
-?\d+

# 小数 / 浮点
-?\d+(?:\.\d+)?          # 42、3.14、-0.5

# 金额千分位:1,234,567.89
\d{1,3}(?:,\d{3})*(?:\.\d+)?

# 给纯数字插入千分位(配合替换)
\B(?=(?:\d{3})+(?!\d))     # 每 3 位前的位置 → 替换为 ,

# 十六进制颜色
#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})\b
金额正则只认「格式像」而非「值合法」:不加锚点时 \d{1,3}(?:,\d{3})* 会从错误分组的串里截取片段,实测 1,23,456 只匹配到 1。整串校验务必加 ^…$,别在长串里裸抽。
千分位插入靠零宽前瞻 \B(?=(?:\d{3})+(?!\d)) 定位,配全局替换插入逗号,实测 1234567 → 1,234,567。但它用了前瞻,Go(RE2) 不支持环视(实测报 unsupported Perl syntax: (?=),Go 里需改用循环手写。
格式匹配 ≠ 合法性校验:2 月 30 日、13 月这类正则很难排除,真正校验交给日期库。

正则只能验形状,验不了「这天存在吗」

  • \d{4}-\d{2}-\d{2} 会痛快地接受 2026-02-31 和 2026-13-45;
  • 把月份和天数的合法组合写进正则是可能的((?:0[1-9]|1[0-2]) 之类),但闰年规则写不进去——写得进去也没人愿意维护;
  • 正确分工:正则粗筛形状,然后交给日期库真正解析。 解析失败就是无效日期,这一步顺便还替你处理了时区与格式变体;
  • 更好的做法是从一开始就用 ISO 8601(2026-07-26T12:00:00Z),格式唯一、可直接字典序排序、各语言都有现成解析。
# 日期 YYYY-MM-DD(宽松)
\d{4}-\d{2}-\d{2}

# 月、日限定范围(更严一点)
\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])

# 时间 HH:MM(:SS),24 小时制
(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d)?

# ISO 8601 时间戳
\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?
缺锚点时 \d{4}-\d{2}-\d{2} 会在更长串里截取子串:实测 2024-01-155 仍匹配出 2024-01-15。另外 ISO 那条的 Z? 只是可选,实测遇到 +08:00 偏移时只匹配到秒、把时区丢掉。
这几条日期时间正则都不含环视/反向引用,四门语言可原样移植。范围收紧靠非捕获选择支 (?:0[1-9]|1[0-2]) 之类,但仍只管格式——2 月 30 日这类非法日期正则难以排除,交给日期库。
IPv4 要精确到 0–255 得按段限定;简版只管「点分四段数字」。

\d{1,3} 四段是错的

  • \d{1,3}(\.\d{1,3}){3} 会接受 999.999.999.999——每段的上界是 255,正确写法要按位数分支:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d);
  • 端口同理:上界 65535,\d{1,5} 会放进 99999;
  • IPv6 用正则基本不现实:压缩写法(::)、内嵌 IPv4、区域标识符加起来的组合太多;
  • 结论和日期一样:有解析函数就用解析函数。 Python ipaddress、Go net.ParseIP、JS 侧的成熟库——它们连「这是不是私有地址」都能一并告诉你。
# IPv4(简版:点分四段)
(?:\d{1,3}\.){3}\d{1,3}

# IPv4(精确 0–255)
(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)

# 端口号(常用简版)
:\d{1,5}\b
简版 (?:\d{1,3}\.){3}\d{1,3} 会放行非法 IP,实测 999.999.999.999 也判真;精确版 (?:25[0-5]|2[0-4]\d|1?\d?\d) 不加锚点又会在长数字里截取,实测 1234.5.6.78 被抽出 234.5.6.78。要校验就用精确版并加 ^…$。
完整 IPv6 正则极长,实战建议用语言内置的 IP 解析函数,而非手写正则。
匹配汉字优先用 Unicode 属性:JS 要写 \p{Script=Han}(配 u 标志),Go(RE2) 与 PCRE2 支持简写 \p{Han},Python 标准 re 与 C++ std::regex 则不支持;最通用的退路是码点区间 [\u4e00-\u9fa5]。

匹配汉字:四门四种写法

写法
通用(四门皆可)[\u4e00-\u9fa5](基本区,不含扩展区与生僻字)
JS\p{Script=Han} + /u(不能简写成 \p{Han},实测报错)
Go\p{Han}(原生支持)
Python标准库没有 \p{},用码点区间或第三方 regex 库
  • \u4e00-\u9fa5 只覆盖基本区:姓名里的生僻字(如「𰻞」)在扩展区,会被这条正则判为非法——做姓名校验时这是真实的投诉来源;
  • 要覆盖全,用 \p{Script=Han}(JS/Go);Python 侧只能列多个区间或换库。

手机号与身份证:正则做不到的那一半

  • 手机号:号段是会变的(工信部不断放号)。把号段写死进正则,意味着每次放新号段都要改代码——更稳的做法是只校验「1 开头的 11 位数字」,真实性交给短信验证码;
  • 身份证:18 位的最后一位是校验码,用前 17 位加权求和算出来。正则完全表达不了这个计算——只写正则的校验会放进大量伪造号;
  • 两者的共同结论:正则负责形状,业务规则负责有效性。 把业务规则往正则里塞,得到的是又长又错、还要不断修改的东西。
# 汉字(JS:需 u 标志)
\p{Script=Han}+
# 简写 \p{Han}:Go 支持;Python 标准库 / C++ 不支持
# 汉字(码点区间,处处可用)
[\u4e00-\u9fa5]+

# 中国大陆手机号(宽松初筛)
1[3-9]\d{9}

# 邮政编码(6 位)
[1-9]\d{5}

# 18 位身份证(含 X 结尾)
\d{17}[\dXx]
手机号 / 身份证正则只做「格式初筛」。号段会变、身份证末位是校验码——严格校验必须在代码里做(如身份证的 ISO 7064 校验)。
退路 [\u4e00-\u9fa5] 只覆盖基本汉字区(到 U+9FA5),会漏掉后续扩展字:实测 鿿(U+9FFF)用它匹配失败,改成 [\u4e00-\u9fff] 才命中;要连生僻字/Ext-B 一起收,仍以 \p{Script=Han}(JS 配 u)或 Go 的 \p{Han} 最全。

实时测试台

浏览器里直接试跑:实时匹配、高亮、标志切换。它调的是浏览器的 RegExp,看到的就是 JS 的行为——后行可变长、\w 不认中文、没有内联标志、没有独占量词。要验 Python / Go / C++ 的行为必须去对应运行时,本页各处的支持度结论就是这么逐条跑出来的。

输入正则和测试文本,实时查看匹配结果与高亮。点击标志按钮切换 g/i/m/s/u——开启 u 才能测试 \p{…}、\u{…} 等 Unicode 写法。

这个测试台跑的是 JS 引擎

  • 它直接调浏览器的 RegExp,所以看到的行为等于 JS 的行为:后行断言可以变长、\w 不认中文、没有 (?i) 内联标志、没有独占量词;
  • 要验证 Python/Go 的行为,必须去对应的运行时上试——本页每张卡的支持度矩阵就是这么逐条跑出来的;
  • 用它调正则时的一个习惯:先构造「应该匹配」和「不应该匹配」两组输入。只测前者的话,一条过宽的正则永远显得是对的。
//
gimsu
匹配结果
在上方输入正则表达式…
高亮预览
快速加载示例

各语言 API

本章覆盖 JS / Go / Python / C++ 四门的正则 API:构造与预编译、单次与全局匹配、捕获组取值、替换与回调替换、分割、字面量转义,首卡按任务横向对照,随后每门一卡列各自的惯用法与坑。末卡是资料索引。各引擎的语法能力差异见 00 章矩阵。

语法能力的差异在 00 章矩阵;这里只管调用方式。同一件事在四门里的函数名毫无共性——按任务横着查最快。

按任务对照

任务JSPythonGoC++
构造/p/flags · new RegExp(s, f)re.compile(p, flags)regexp.MustCompile(p)std::regex r(p)
判断是否匹配r.test(s)r.search(s)(返回对象或 None)r.MatchString(s)std::regex_search
整串完全匹配^…$ 自己加r.fullmatch(s)\A…\z 自己加std::regex_match
首个匹配s.match(r)r.search(s)r.FindString(s)regex_search + smatch
全部匹配[...s.matchAll(r)](需 /g)r.findall / r.finditerr.FindAllString(s, -1)sregex_iterator
取捕获组m[1] · m.groups.nm.group(1) · m["n"]m := r.FindStringSubmatch(s) → m[1]m[1].str()
取位置m.index · /d 的 m.indicesm.span() · m.start()r.FindStringIndex(s)m.position(0)
替换s.replace(r, "$1") · replaceAllr.sub(R"\1", s)r.ReplaceAllString(s, "${1}")std::regex_replace
回调替换s.replace(r, (m, g1) => …)r.sub(lambda m: …, s)r.ReplaceAllStringFunc(s, f)没有——手写 regex_iterator 循环
分割s.split(r)r.split(s)(捕获组会保留在结果里)r.Split(s, -1)sregex_token_iterator
转义字面量没有内置,手写替换re.escape(s)regexp.QuoteMeta(s)没有内置
预编译正则字面量即已缓存re.compile(模块级也有缓存)MustCompile 放包级变量std::regex 对象构造很贵,务必复用

四条最容易踩的 API 差异

差异后果
Python match 只锚首不锚尾re.match(r"\d+", "12ab") 成功。要整串匹配用 fullmatch
JS match 带 g 时丢掉捕获组与 index既要分组又要位置,改用 matchAll
Go 替换串 $1x 被当成组名 1x静默取到空串。一律写 ${1}
C++ std::regex 构造开销大循环里现构造会慢几个数量级;提成静态对象
Go 的 MustCompile 在模式非法时直接 panic。这是有意为之——正则通常是常量,启动即崩比运行到一半才发现好。但模式若来自用户输入或配置文件,必须改用 regexp.Compile 并处理 error,否则一条坏配置就能让进程起不来。
四门都建议把正则提到循环外预编译,但理由不同:Python 与 JS 有内部缓存、收益有限;Go 的 MustCompile 失败即 panic,放包级变量能让错误在启动时暴露;C++ 的 std::regex 构造是真正的大开销,必须复用。
JS 正则是一等公民,有字面量语法和构造函数两种创建方式。核心方法在 RegExp 和 String 原型上。

JS 侧最该记住的三件事

  • 带 g 的正则对象有状态:lastIndex 会累加,反复 test() 同一个字符串会跳着给出 true/true/false(实测)。校验用途别加 g;
  • \w 与 \b 只认 ASCII,加 /u 也不改(实测 "你好".match(/\w+/gu) 为 null)。中文要用 \p{Script=Han};
  • /u 与 /v 互斥(实测传 "uv" 直接报 Invalid flags);
  • 好消息是 JS 的后行断言不要求定长,这一点强于 Python。
// 创建
const r1 = /\d+/g;
const r2 = new RegExp('\\d+', 'g'); // 动态模式

// 测试
/^\d+$/.test("123");           // true

// 匹配
"abc123".match(/\d+/);         // ["123", index:3, ...]
[..."a1b2".matchAll(/\d/g)];   // ES2020 迭代器

// 替换
"foo bar".replace(/(\w+)/g, '[$1]');
"foo".replace(/(\w+)/, (m, g1) => g1.toUpperCase());

// 拆分、搜索
"a1b2c3".split(/\d/);           // ["a","b","c",""]
"hello123".search(/\d+/);       // 5
带 g 的 RegExp 是有状态的:test()/exec() 会推进 lastIndex,同一个 /\d/g 连续 test() 实测交替返回 true、false。循环里别复用同一个全局正则做 test(),否则漏配。
String.prototype.match 不带 g 时返回带捕获组和 index 的数组,带 g 则只返回所有整体匹配、丢掉分组与 index(实测 "a1b2".match(/(\d)/g) 得 ["1","2"])。既要分组又要位置改用 matchAll;替换里用 $1、$&、$<name> 引用。
Go 标准库基于 RE2 引擎,保证线性时间,但不支持环视断言和反向引用。原始字符串用反引号免转义。

RE2 的取舍是本页最值得理解的一处

  • Go 标准库用 RE2,保证匹配时间与输入长度成线性关系——代价是砍掉了反向引用与环视断言,因为这两样都需要回溯;
  • 这不是「功能少」,是把 ReDoS 这一整类安全问题从工程里消除掉:任何用户输入的正则、任何用户输入的文本,都不可能把服务卡死;
  • 所以在 Go 里遇到「这条正则翻译不过来」时,正确反应不是找绕过办法,而是把那部分逻辑挪到代码里——通常也更好读、更好测;
  • Go 从 1.22 起 (?<name>) 与 (?P<name>) 两种命名组语法都认,比以前好搬了一些。
import "regexp"

r := regexp.MustCompile(`\d+`)

r.MatchString("abc123")        // true
r.FindString("abc123")         // "123"
r.FindAllString("a1b2", -1)  // ["1","2"]
r.ReplaceAllString("a1b2", "X")

// 捕获组
r2 := regexp.MustCompile(`(\d+)-(\d+)`)
r2.FindStringSubmatch("12-34")
// ["12-34","12","34"]
Go 不支持:环视断言、反向引用、独占量词、原子组。需要这些时考虑用 C 绑定的 PCRE2 库。
MustCompile 编译失败直接 panic(适合包级常量),要处理错误改用 regexp.Compile。MatchString/FindString 都是 search(任意位置)语义,要整串匹配得自己加 ^…$(RE2 无专门的锚定方法)。替换引用组用 $1 或 ${1}——实测 $1x 会被当成组名 1x 而替成空,紧跟字母数字时必须写 ${1}。
标准库 re 覆盖大部分场景。需要 \p{}、原子组等高级特性时用第三方 regex 库。注意 match() 只匹配开头。

标准库 re 的强项与短板(实测)

  • 强项:\w \b \d 默认 Unicode-aware(re.findall(r"\w+", "你好 abc") 得 ['你好', 'abc']);3.11+ 支持独占量词与原子组;四门里唯一支持条件分组;有 re.X 自由间距;
  • 短板:没有 \p{}(实测 bad escape \p);后行断言必须定长(实测 (?<=a+) 报 look-behind requires fixed-width pattern);没有递归、没有 \K;
  • 两个坑:re.match 只从开头匹配(要搜全串用 re.search);$ 会匹配末尾换行之前的位置,严格校验要用 \Z(3.14+ 亦可用 \z);
  • 需要 \p{}、变长后行、递归时,装第三方 regex 库——API 与 re 兼容,可以直接换。
import re

# 编译(复用时推荐)
pat = re.compile(r'\d+', re.IGNORECASE)

# match 只匹配开头,search 搜索整个字符串
re.match(r'\d+', '123abc')    # Match 对象
m = re.search(r'\d+', 'abc123')
m.group()  # "123"
m.span()   # (3, 6)

# 所有匹配
re.findall(r'\d+', 'a1b22c3')   # ['1', '22', '3']
re.finditer(r'\d+', s)          # 迭代器

# 替换、拆分
re.sub(r'(\w+)', r'[\1]', 'foo bar')
re.split(r'\s+', '  a  b  c  ')

# 命名组用 ?P<name>
m = re.search(r'(?P<year>\d{4})', '2024-01')
m.group('year')  # "2024"
re.findall 在正则含捕获组时返回的是组内容而非整体匹配,多个组时返回元组列表:实测 re.findall(r'(a)(b)?','a ab') 得 [('a',''),('a','b')]。想要整体匹配请用 re.finditer 再取 m.group()。
re.match 只从开头锚定、re.search 搜任意位置、re.fullmatch 要求整串匹配:实测 re.match(r'\d+','abc123') 为 None,换成 '123abc' 才命中。替换反向引用用 \1 或消歧义的 \g<1>(如 \g<1>0 避免和 \10 混淆)。
C++11 起标准库 <regex> 内置正则,默认 ECMAScript 语法(近似 JS)。功能较基础,要用全功能正则(后行断言、命名组、\p{} 等)主流做法是链接 PCRE2 库。

std::regex 是四门里最弱的一个

  • 不支持:命名组、后行断言、\p{}、内联标志、独占量词、原子组、递归、条件分组;
  • 而且性能长期被诟病:std::regex 的构造与匹配开销都很大,热路径上常常比手写字符串处理慢一个数量级;
  • 内联标志的失效方式最危险:(?i)abc 从别的语言搬过来时可能既不报错也不生效,只能靠构造时传 std::regex::icase;
  • 结论:C++ 里但凡正则稍微复杂一点,主流做法就是链接 PCRE2——它支持本页提到的几乎全部特性,包括递归与 \K。
// 创建(原始字符串 R"()" 免转义反斜杠)
#include <regex>
std::regex re(R"(\d+)");

std::regex_search(s, re);      // 是否含匹配
std::regex_match(s, re);       // 整串是否匹配

// 取捕获组
std::smatch m;
if (std::regex_search(s, m, re))
  m[0].str();           // 整体匹配;m[1] 为组 1

// 遍历全部匹配
auto it = std::sregex_iterator(s.begin(), s.end(), re);
for (; it != std::sregex_iterator(); ++it)
  it->str();

// 替换($& = 整体,$1 = 组 1)
std::regex_replace(s, re, "[$&]");

// 忽略大小写等标志用 regex_constants
std::regex re2(R"(abc)", std::regex::icase);
std::regex 局限多且慢:不支持后行断言、命名捕获组、原子组、独占量词、内联标志 (?i) 与 \p{};且是回溯引擎,易受 ReDoS 影响。要全功能 + 高性能,改用 PCRE2。
std::regex_search 命中子串即真、std::regex_match 要求整串匹配:实测同一 \d+ 对 "abc123" search 为真、match 为假。捕获走 std::smatch(m[0] 整体、m[1] 起为分组),替换用 $1/$&。注意 smatch 存的是指向源串的迭代器,不能把临时 std::string 传给带 smatch 的 regex_search——实测编译即报 deleted function,必须用生命周期够长的具名字符串。

正则的「权威答案」不在某一本书里,而在各引擎自己的文档——因为分歧本身就是引擎之间的。下表按「要查什么」排。

规范与官方文档

要查去哪
JS 的完整语法与标志MDN 的 RegExp 页;语义的最终依据是 ECMA-262 的 Patterns 一节
Python 的模式语法与函数标准库文档 re;变长后行、\p{} 等在第三方 regex 库的文档里
Go 支持哪些语法regexp/syntax 包文档——它就是 RE2 语法的完整清单,比任何博客都准
C++ 的六种语法风格std::regex_constants::syntax_option_type;默认是 ECMAScript
PCRE2 的扩展语法pcre2pattern 手册——递归、\K、条件分组的定义都在这里
Unicode 属性名UTS #18(正则的 Unicode 支持分级)与 UAX #44 的属性列表

工具

工具用途注意
09 章测试台即时匹配与高亮JS 引擎
regex101分步调试、解释每个符号、给出回溯次数先在左栏选对引擎(PCRE / Python / Go / JS),默认的 PCRE 比多数标准库能力强
regexper / Debuggex把模式画成铁路图长正则的结构问题一眼可见
Python re.DEBUG打印引擎解析出的语法树确认转义是否如你所想的最快办法
ReDoS 检查器静态扫描指数回溯有漏报,别当唯一防线(07 章)

自测:会了没有

题答在哪
说出 ".*?" 与 "[^"]*" 的两处区别02 章
^\d+$ 在 Python 下会接受什么非数字输入03 章
同一个「第 1 组」在模式内、替换串、代码里各怎么写04 章
一条用了后行断言的正则,能跑在哪几门上05 章 / 00 章矩阵
为什么 const r = /a/g 反复 test 会变 false06 章
给出一条会 ReDoS 的正则,并说出三条防线07 章
校验邮箱的正确做法是什么08 章
regex101 的默认引擎是 PCRE2,能力强于这四门里的任何一个。在上面调通的模式直接粘进 Python 或 Go,是「本地好好的,一上线就报错」的常见来源——用它之前先把左栏的引擎切成你实际要用的那个。
查引擎差异时优先查目标语言自己的文档,别查「正则表达式教程」——通用教程多半按 PCRE 写,而 PCRE 恰恰是能力最强的那个,照抄过来的写法在标准库里十有八九不支持。Go 的 regexp/syntax 文档是这方面最好的反面参照:它把「没有什么」写得清清楚楚。