00
速查总览:这一页怎么查 本章是全页的索引:章节地图 告诉你哪个符号在哪一章,四引擎能力矩阵 汇总所有「支持 / 不支持」结论,报错与症状反查表 把常见报错原文与「跑起来不对」的现象直接对到改法。正则的难点从来不是符号多,而是同一条模式在 JS / Python / Go / C++ 上行为不同。
◎
章节地图:想查什么,翻哪一章
全页 11 章分工 · 按符号定位 · 三条常见查法
全页十一章,按「符号 → 结构 → 行为 → 落地 」排列:01–03 是构成模式的字符与位置,04–05 是分组与断言,06–07 是引擎行为与高级特性,08–10 是可直接抄走的模式、测试台与四语言 API。不必顺读 ——按符号定位,翻该章第一张速查表。
⚠ 正则最大的坑不在语法,在「同一条模式换个引擎就换套行为」 :\w 认不认中文、$ 吃不吃末尾换行、(?i) 生不生效——这些差异多数不报错,只是结果不同 。跨语言搬正则前先看 本章第 2 卡 的矩阵。
✓ 本页以四门标准库 引擎为基准:JS(ECMAScript)、Python re、Go regexp(RE2)、C++ std::regex。凡写「不支持」都指标准库——多数缺口都能靠 PCRE2 或 Python 第三方 regex 库补上,代价是多一个依赖。
◎
四引擎能力矩阵与选型
JS · Python re · Go RE2 · C++ std::regex · 何时换 PCRE2
下表是全页所有「支持 / 不支持」结论的汇总。看一眼就能判断一条正则能不能跨语言搬 ——四列都打勾的才是安全交集。
什么时候该换引擎 判据:先看这条正则是不是「跨语言共用」。 是,就只用四列全 ✓ 的交集写法;不是,就大方用本语言最顺手的特性,别为了假想中的可移植性把正则写得又长又怪。
⚠ 矩阵里的「✗」分两种,危险程度差很远:报错型 (Python 的 \K、JS 的条件分组)一编译就炸,看得见;静默失效型 才要命——JS 的 \A 被当字面字符 A、C++ 的 (?i) 既不报错也不生效。移植正则时对这两处必须实跑一条反例 ,别只测正例。
✓ 四门的安全交集 其实很够用:字面量与字符类、\d \s(别用 \w 处理非 ASCII)、量词、^ $、非捕获组与编号捕获组、交替。只要一条正则可能跑在 Go 上,就别用环视与反向引用 ——这两样 RE2 永远不会有。
◎
按报错与症状反查
编译期报错 · 跑起来不对 · 调试手段
正则出问题只有两种形态:编译不过 (报错原文能直接查)和编译过了但结果不对 (只能按症状查)。下面两张表按这两类排。
⚠ 最危险的一类问题不在这两张表里 :正则写得过宽,正例全过、反例也全过。^.*@.*$ 能通过任何邮箱测试,也能通过 @。每写一条校验正则,都要配一条「应当被拒绝」的输入 ——这是唯一能发现它的办法。
✓ 查报错前先确认你看到的模式就是引擎看到的模式 :把最终的模式串打印出来(JS r.source、Python p.pattern)。「反斜杠被宿主字符串吃掉一层」制造的报错,占了这张表里相当一部分——而它在源码里完全看不出来。
01
字符与字符类 本章覆盖:元字符清单与何时需要转义、字符类 […] 的写法与类内转义规则、预定义简写 \d \w \s 及其取反、Unicode 属性 \p{}、以及 \n \xHH \uHHHH 等转义序列。四门引擎在 \w 认不认中文、支不支持 \p{} 上分歧最大 ,首卡速查表按语言列齐。
[a-z]
速查表:元字符 · 字符类 · 简写 · 转义
元字符清单 · 类内规则 · \d\w\s 的语义差 · \p{} · 转义序列
一条正则里的每个字符只有两种身份:字面量 (匹配自己)或元字符 (有语法含义)。下面四张表把「谁是元字符、类内外规则有何不同、简写各语言认什么」一次列齐。
元字符与转义 类内仍需操心的只有 ] \ ^ -,以及成对出现时的 [。 类外的元字符里,其余的进了方括号就自动降级为字面量——[.*+] 匹配的就是这三个符号本身。
预定义简写:同一个 \w,四种答案 这张表解释了一类真实事故 :同一条「用户名只能是字母数字」的校验,Python 后端放行中文、JS 前端拦下中文。跨端共用的正则必须把字符集写死,别依赖 \w。
⚠ 把用户输入拼进正则前必须转义 :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 章反查表)。
[a-z]
字面量、元字符与转义
. * + ? ^ $ | \ [ ] ( ) { }
普通字符直接匹配自身。
元字符 有特殊含义:
. * + ? ^ $ { } [ ] | ( ) \,匹配字面量时需
\ 转义。
点号 . 匹配除换行符外的任意单个字符(加
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`。
[a-z]
字符类 [ ]
[abc] · [a-z] · [^…] · 否定
方括号内列出允许的字符(匹配其中之一)。
^ 放在
[ 后表示否定。字符类内大多数元字符失去特殊含义,
^ - ] 需转义或放在特定位置。
类内的转义规则反而更简单 进了 [ ],. * + ? ( ) | 全部退化成普通字符,不需要转义 ——[.*+] 就是这三个符号本身; 只剩四个还需要留意,而且各有「靠位置免转义」的写法:^ 只在紧跟 [ 时表否定(放中间就是字面量)、- 放首位或末位就是字面连字符、] 放首位即字面、\ 永远要转义; 所以 []-] 是合法的「匹配 ] 或 -」——看着别扭,但比到处加反斜杠更不容易写错。 否定类会吃掉换行(实测) 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]*。
✓ 字符类内绝大多数元字符失去特殊义(. * + ( | 都是字面量、无需转义);只需留意 \ ] ^ -:^ 仅在紧跟 [ 时表否定,- 放首尾即字面连字符,] 放首位或转义才是字面。
[a-z]
预定义简写与 Unicode 属性
\d \w \s · \p{L} \p{Han}
\d \w \s 及其大写取反形式是最常用的简写。
\p{} 匹配 Unicode 属性类(字母、数字、汉字等),各语言支持差异大。
同一个 \w,四门语言四种答案(实测) 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。
[a-z]
转义序列
\n \t \0 \uFFFF \u{1F600}
常用转义序列,Unicode 码点写法因语言而异。
码点转义的四种写法,只有一种通用 实测三条边界: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 位十六进制)。
02
量词 本章覆盖:六种量词写法与等价范围、贪婪 / 懒惰 / 独占三种失败处理方式、以及「该用否定字符类还是懒惰量词」的判据。三者匹配结果常常相同,差别只在中间试了多少次 ——这正是 ReDoS 的源头(07 章)。
{n,m}
速查表:六种量词 · 三种回溯行为 · 改写配方
{n,m} 等价表 · 贪婪 懒惰 独占 · 次数上限 · 抓多了怎么改
量词只管一件事:前一个元素重复几次 。真正要分清的是它匹配失败时怎么办 ——贪婪吐回来重试、懒惰多吃一个重试、独占直接判负。这三种行为决定了性能,也决定了 ReDoS 会不会发生。
三种回溯行为与支持度 三者匹配到的结果常常完全相同 ,差别只在中间试了多少次——所以「换成懒惰就对了」多半是碰运气。独占量词的价值不在快,在于把指数级回溯直接砍掉 。
抓多了 / 抓少了:改写配方 经验法则:能用「排除法」描述边界,就别用懒惰量词。 否定字符类每一步都是确定的,懒惰量词要一步步试探回溯。
⚠ ? 出现在两个位置含义完全不同:跟在元素后是「零或一次」 (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}。
{n,m}
贪婪 vs 懒惰 vs 独占
greedy · lazy · possessive
贪婪 (默认):尽可能多匹配,匹配失败时回退。
懒惰 :量词后加
?,尽可能少匹配。
独占 :量词后加
+,匹配后不回退,防止灾难性回溯。回溯指:正则先尽量多地匹配,若后续匹配失败,再逐步释放已匹配的字符重新尝试;懒惰量词则相反,先尽量少匹配、不足再补。
三者的差别就是「失败时怎么办」 三者匹配到的结果可能完全相同 ,差别只在中间尝试了多少次——所以「换成懒惰就对了」这种说法多数时候是碰运气; 独占量词的价值不在快,在于把指数级的回溯直接砍掉 :它让引擎没有退路可走,从而消灭一整类 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。
03
锚点与边界 本章覆盖:^ $ 与绝对首尾 \A \Z \z 的区别、单词边界 \b \B、多行与 dotAll 对位置语义的影响。锚点匹配的是位置而非字符(零宽) ;Python 的 $ 会放行末尾换行这一条,直接关系到输入校验的安全性。
^…$
速查表:锚点 · 边界 · 多行与 dotAll 的影响
^ $ \A \Z \z \b \B \G · 四门支持 · $ 吃末尾换行
锚点匹配的是位置 而不是字符——零宽、不消耗输入,所以可以和任意其他部分叠加。要点全在两处:多行模式改变 ^ $ 的含义 ,以及 Python 的 $ 会放行末尾的换行 。
$ 吃末尾换行:一条真实的校验漏洞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++ 的 $ 本来就是绝对末尾,没有这个坑。⚠ ^ $ 在没开多行 时是串首尾,开了多行才是行首尾——把多行模式当默认,会让 ^\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)。
04
分组与捕获 本章覆盖:捕获组与编号规则、命名组的四套语法、非捕获组 (?:) 与交替 |、反向引用,以及模式内引用(\1)与替换串引用($1)这两套互不相同的语法 ——跨语言搬正则时,模式往往能直接用,替换串几乎总要改。
(…)
速查表:分组语法 · 两套引用 · 交替
(?:) (?) (?P) (?>) · 模式内 vs 替换串 vs 代码 · | 的优先级
括号做三件事:把一段当成一个整体 (好加量词)、把匹配到的内容记下来 (好取用)、划定交替的范围 。三件事可以分开要——不需要记内容就用 (?:…)。
分组语法 默认用 (?:…),需要取值时才升级成捕获组。 好处不只是快一点:组号是按左括号顺序自动排的,中间插一个新括号就会把后面所有编号推后 ,把 \1、$2 全部错位。少一个捕获组就少一次这种风险。
同一个「第 1 组」,三处写法互不相同 跨语言搬正则时,模式往往能直接用,替换串几乎总要改 ——这张表就是为这件事准备的;Go 的替换串必须用 ${1} 形式 ,只要引用后面紧跟字母或数字:实测 $1x 被当成组名 1x、取到空串,${1}x 才对。这一条不报错,只是结果静悄悄地少一截;Python 的替换串不认模式语法 :写 \d 会报 bad escape \d,它只认 \1 与 \g<…>; 组号从 1 开始,0 表示整体匹配 (JS 写 $&,Python 写 \g<0>,Go 写 $0)。 ⚠ 可选的捕获组没匹配上时不是空串 :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 开始),可在模式内
\1 反向引用,或在替换中
$1(JS)/
\1(Python)引用。
模式内引用与替换串引用是两套语法 最容易搞混的一点:同一个「第 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。
(…)
命名捕获组
(?<name>…) · \k<name> · (?P<name>…)
命名组用名称代替编号,提升可读性。语法因语言而异: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)。
(…)
非捕获组与交替
(?:…) · a|b · 原子组 (?>…)
(?:) 只分组不记录,性能更好。
| 优先级最低,通常配合分组使用。
原子组 (?>) 匹配后禁止回溯。
| 的优先级低到超出直觉 ^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。
05
断言(环视) 本章覆盖:四种环视断言的写法与朝向、四引擎支持度(差距最大的一处 )、以及多条件叠加与千位分隔这类只靠位置完成的实战写法。环视是零宽 的:断言成立后位置不前移,所以同一处可叠加任意多个。
(?=)
速查表:环视配方 · 四种写法 · 可跨语言的交集
十条能直接抄的写法 · 朝向记法 · Go 完全没有
环视只做一件事:在当前位置检查前后的内容,但不消耗字符 。于是它能表达其他语法表达不了的两类需求——「同时满足多个条件」和「只替换特定上下文里的内容」。
四种写法与朝向 记法:带 < 的向后看前文 ,那个尖角就指着前文的方向;= 是肯定、! 是否定。
支持度:这是四门差距最大的一处 四门的安全交集只有「先行断言」。 只要这条正则可能跑在 Go 上就别用环视——RE2 放弃它是为了换线性时间保证,这件事不会改。替代办法是先用正则粗匹配,再在代码里判断 ,通常还更好读。
⚠ 密码校验这类需求其实不该塞进一条正则 。一条大正则只能回答「不合格」,拆成几条独立的断言分别测,才能告诉用户缺的是哪一项 。上面那条配方最好的用处是理解「零宽可叠加」,不是照抄进生产代码。
✓ 需要「变长后行」时,四门通用的替代是把前缀也写进模式,只取目标那一组 :key=([^;]+) 取 m[1]。多一行代码,换来四门都能跑——比 \K 和变长后行都可靠(07 章)。
(?=)
四种环视断言
(?=) (?!) (?<=) (?<!)
先行断言 检查当前位置后面的内容:
(?=p) 后面必须匹配 p,
(?!p) 后面不能匹配 p。
后行断言 检查前面的内容:
(?<=p) 前面必须匹配 p,
(?<!p) 前面不能匹配 p。断言只在字符之间的位置检查条件是否成立,本身不消耗任何字符(零宽),因此后续匹配仍从原位置继续。
四种断言的记法 记法:带 < 的向后看(lookbehind),不带的向前看。 < 这个尖角就指向「前文」的方向;四种全是零宽 :断言成立后位置不前移 ,所以同一个位置可以叠加任意多个断言——这正是密码强度校验那类「多条件同时满足」的实现方式。 支持度:这是四门差距最大的一处(实测) 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,}$ 用多个先行断言各查一个条件,彼此不消耗字符、位置不动。
06
修饰符 / 标志 本章覆盖:i g m s x u v y d 各标志的含义与四门各自的写法、自由间距 x 模式、内联修饰符 (?i) 的支持度与位置限制。「全局」只有 JS 把它做成了标志 ,由此带出 lastIndex 这个 JS 里最难查的正则坑。
/…/gi
速查表:标志一览 · 四门写法 · 内联修饰符
i m s x u v g y d · re.I 常量 · (?i) 支持度 · 该不该开
标志改变的是引擎的整体行为 ,不是模式里的某一段。九个标志里真正常用的只有四个:忽略大小写、多行、dotAll、全局 ——其余各有各的适用面,下表一并列出。
标志一览与四门写法 只有 JS 把「全局」做成了正则的属性 ,其余三门都做成了 API 的选择。这个设计差异直接制造了下面那个坑。
内联修饰符 (?i) 内联修饰符的用处是把标志随模式一起传递 ——正则存在配置文件或数据库里时,标志没地方写,只能内联。但这套写法在四门里最不通用 ,跨语言的模式串里别出现它。
lastIndex:JS 独有的状态陷阱const r = /a/g;
r.test("aa") → true lastIndex = 1
r.test("aa") → true lastIndex = 2
r.test("aa") → false ← 同一个字符串,第三次就 false 了 三种解法,按推荐顺序:①校验用途别加 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 把「全局」做成了正则的属性 ,其余三门都把它做成了 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 等构造标志。
/…/gi
x 模式(自由间距)
re.VERBOSE · 可读性利器
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 等常量)。
四门的支持度差到无法通用(实测) 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:…)。
07
高级特性 本章覆盖:JS 的 /v 集合运算、递归 (?R)、条件分组、\K 重置起点,以及 ReDoS 的触发条件与三条防线 。这些特性多数四门标准库都没有 ,需要 PCRE2;本章的价值一半在于说明什么时候该停手改用解析器 。
(?R)
速查表:高级特性支持矩阵 · ReDoS 危险结构
四门都没有的那些 · 什么时候该停手 · 三条防线
本章的特性有个共同点:四门标准库大多没有 。所以这张表的第一用途不是「怎么写」,而是判断你是不是已经该换工具了 ——需要递归和条件分组,通常意味着该用解析器而不是正则。
高级特性支持矩阵 需要递归,通常意味着你在匹配的是一门「语言」而不是一个「模式」 :嵌套括号、JSON、HTML、配置文件——这些都该用解析器。经典结论「正则不能解析 HTML」的准确说法是:正则表达不了任意深度的嵌套结构 ,PCRE 的递归扩展本身已经超出了「正则」的定义。
ReDoS:危险结构与改写 触发条件三个缺一不可:①回溯型引擎;②嵌套量词或可重叠分支;③一个「差一点就匹配上」的输入 。第三条决定了它难以在测试中发现 ——正常输入都很快,只有精心构造的输入才炸。
三条防线,按可靠性排序 别把「把正则写好一点」当成防线 ——能不能看出一条正则有指数回溯,本身就是很难的判断。这正是 Go 当初选择 RE2、宁可放弃反向引用与环视的理由。
⚠ JS 对不认识的转义是静默放行的 :\K、\A、\Z 都会被当成字面字符 K / A / Z,编译通过、永远匹配不上、不给任何提示。从 Perl / PCRE 抄模式过来时,这类「不报错的失败」比报错难查得多——抄完先跑一条应当匹配的样例 。
✓ 判断该不该停手,有一条很实用的标准:这个结构有「嵌套深度」吗? 括号、标签、引号里的引号、JSON——只要深度不定,正则就表达不了,写多长都会有反例。有深度就上解析器,没深度(一行日志、一个 ID、一个日期)正则最合适。
(?R)
Unicode Sets:/v 标志(JS)
集合运算 · -- && · ES2024
/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 均不支持。
(?R)
性能与安全:ReDoS
灾难性回溯 · 嵌套量词 · 防范策略
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)线性时间、天然免疫。
08
常用实战模式 本章覆盖工程中最常被复制的六类模式:邮箱与 URL、数字金额与千分位、日期时间、IPv4 与端口、中文场景(汉字 / 手机号 / 身份证)。先记住一点: 邮箱、URL 的「完全严谨」规则极其复杂,实战用「足够好」的宽松版,再交给业务逻辑二次校验。每条都可复制到 09 章测试台试跑。
★
速查表:常用模式一页抄
按场景排 · 严谨度标注 · 哪些该交给解析器
下表把本章各卡的模式集中到一处,可直接复制到 09 章测试台试跑。「严谨度」一列比模式本身更重要 ——标着「初筛」的那些,正确用法是拦住手滑,不是保证有效。
⚠ 不加锚点时,正则会从更长的串里截出一段 并报告成功: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 里需改用循环手写。
★
日期与时间
YYYY-MM-DD · HH:MM:SS
格式匹配 ≠ 合法性校验 :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 解析函数,而非手写正则。
★
中文场景:汉字 · 手机号 · 身份证
\p{Script=Han} · 手机 · 身份证
匹配汉字优先用 Unicode 属性:
JS 要写 \p{Script=Han} (配
u 标志),Go(RE2) 与 PCRE2 支持简写
\p{Han},Python 标准
re 与 C++
std::regex 则不支持;最通用的退路是码点区间
[\u4e00-\u9fa5]。
匹配汉字:四门四种写法 \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} 最全。
09
实时测试台 浏览器里直接试跑:实时匹配、高亮、标志切换。它调的是浏览器的 RegExp,看到的就是 JS 的行为 ——后行可变长、\w 不认中文、没有内联标志、没有独占量词。要验 Python / Go / C++ 的行为必须去对应运行时,本页各处的支持度结论就是这么逐条跑出来的。
输入正则和测试文本,实时查看匹配结果与高亮。点击标志按钮切换 g/i/m/s/u——
开启 u 才能测试 \p{…}、\u{…} 等 Unicode 写法 。
这个测试台跑的是 JS 引擎 它直接调浏览器的 RegExp,所以看到的行为等于 JS 的行为 :后行断言可以变长、\w 不认中文、没有 (?i) 内联标志、没有独占量词; 要验证 Python/Go 的行为,必须去对应的运行时上试 ——本页每张卡的支持度矩阵就是这么逐条跑出来的; 用它调正则时的一个习惯:先构造「应该匹配」和「不应该匹配」两组输入 。只测前者的话,一条过宽的正则永远显得是对的。 10
各语言 API 本章覆盖 JS / Go / Python / C++ 四门的正则 API:构造与预编译、单次与全局匹配、捕获组取值、替换与回调替换、分割、字面量转义,首卡按任务 横向对照,随后每门一卡列各自的惯用法与坑。末卡是资料索引。各引擎的语法能力差异见 00 章矩阵。
API
速查表:四语言 API 按任务对照
编译 · 匹配 · 取组 · 替换 · 分割 · 转义
语法能力的差异在 00 章矩阵;这里只管调用方式 。同一件事在四门里的函数名毫无共性——按任务横着查最快。
⚠ Go 的 MustCompile 在模式非法时直接 panic。 这是有意为之——正则通常是常量,启动即崩比运行到一半才发现好。但模式若来自用户输入或配置文件,必须改用 regexp.Compile 并处理 error ,否则一条坏配置就能让进程起不来。
✓ 四门都建议把正则提到循环外预编译 ,但理由不同:Python 与 JS 有内部缓存、收益有限;Go 的 MustCompile 失败即 panic,放包级变量能让错误在启动时暴露 ;C++ 的 std::regex 构造是真正的大开销,必须复用。
API
JavaScript
RegExp · /pattern/flags · 内置
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}。
API
Python
re · regex · compile · findall
标准库
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,必须用生命周期够长的具名字符串。
API
资料索引:规范 · 文档 · 工具
查权威定义 · 查引擎行为 · 在线试跑与可视化
正则的「权威答案」不在某一本书里,而在各引擎自己的文档 ——因为分歧本身就是引擎之间的。下表按「要查什么」排。
⚠ regex101 的默认引擎是 PCRE2,能力强于这四门里的任何一个 。在上面调通的模式直接粘进 Python 或 Go,是「本地好好的,一上线就报错」的常见来源——用它之前先把左栏的引擎切成你实际要用的那个 。
✓ 查引擎差异时优先查目标语言自己的文档,别查「正则表达式教程」 ——通用教程多半按 PCRE 写,而 PCRE 恰恰是能力最强的那个,照抄过来的写法在标准库里十有八九不支持。Go 的 regexp/syntax 文档是这方面最好的反面参照:它把「没有什么」写得清清楚楚。