01
字符与字符类
字面量字符直接匹配自身,元字符有特殊含义需转义。字符类 [] 定义字符集合,预定义简写 \d \w \s 覆盖常见场景。
字面量、元字符与转义
. * + ? ^ $ | \ [ ] ( ) { }
普通字符直接匹配自身。元字符有特殊含义:
. * + ? ^ $ { } [ ] | ( ) \,匹配字面量时需 \ 转义。点号 . 匹配除换行符外的任意单个字符(加 s 标志后包含换行)。为什么「转义」这件事要转两层
- 第一层是正则语法:
.在正则里是元字符,要匹配真正的点必须写\.; - 第二层是宿主语言的字符串语法:在普通字符串里
\本身也是转义符,于是正则的\d得写成"\\d"; - 两层叠起来就是所谓「反斜杠地狱」——而原始字符串一次性消掉第二层:Python
r"\d"、Go 反引号、C++R"(\d)"、JSString.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`。字符类 [ ]
[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]*。字符类内绝大多数元字符失去特殊义(
. * + ( | 都是字面量、无需转义);只需留意 \ ] ^ -:^ 仅在紧跟 [ 时表否定,- 放首尾即字面连字符,] 放首位或转义才是字面。预定义简写与 Unicode 属性
\d \w \s · \p{L} \p{Han}
\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。转义序列
\n \t \0 \uFFFF \u{1F600}
常用转义序列,Unicode 码点写法因语言而异。
码点转义的四种写法,只有一种通用
| 要写的 | JS | Python | Go / 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 位十六进制)。02
量词
量词控制前一个元素重复的次数。默认贪婪(尽可能多匹配),加 ? 变懒惰(尽可能少),加 + 变独占(不回退)。
基本量词
* + ? {n} {n,} {n,m}
* 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}。贪婪 vs 懒惰 vs 独占
greedy · lazy · possessive
贪婪(默认):尽可能多匹配,匹配失败时回退。懒惰:量词后加
?,尽可能少匹配。独占:量词后加 +,匹配后不回退,防止灾难性回溯。回溯指:正则先尽量多地匹配,若后续匹配失败,再逐步释放已匹配的字符重新尝试;懒惰量词则相反,先尽量少匹配、不足再补。三者的差别就是「失败时怎么办」
| 先匹配多少 | 失败时 | 写法 | |
|---|---|---|---|
| 贪婪(默认) | 尽可能多 | 逐个吐回来重试 | 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。03
锚点与边界
锚点匹配的是位置而非字符(零宽)。^ $ 匹配行/串首尾,\b 匹配单词边界。
位置锚点
^ $ \A \Z \z \G
^ 和 $ 匹配行或字符串的首尾(取决于多行模式)。\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
\b · \B · 精确匹配整词
\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 · 替换引用
括号内的匹配被编号记住(从 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。命名捕获组
(?<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
断言(环视)
零宽断言只检查位置,不消耗字符。分为先行(Lookahead)和后行(Lookbehind),各有正向和负向。
四种环视断言
(?=) (?!) (?<=) (?<!)
先行断言检查当前位置后面的内容:
(?=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,}$ 用多个先行断言各查一个条件,彼此不消耗字符、位置不动。06
修饰符 / 标志
标志改变正则引擎的整体行为:大小写、多行、dotAll、自由间距等。可全局设置或内联开关。
常用标志一览
i g m s x u v y d
各标志的含义与语法。
g 是 JS 专属概念,其他语言通过 API 控制全局匹配。标志在四门里的表达方式完全不同
| 怎么写 | 有没有「全局」 | |
|---|---|---|
| JS | 字面量后缀 /gimsuvyd | g 是标志 |
| Python | re.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/dg(全局)是 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 模式(自由间距)
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 库模拟。
内联修饰符
(?i) (?-i) (?i:…)
在模式内部局部开启/关闭标志。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:…)。07
高级特性
Unicode Sets、递归模式、条件分组、\K 重置、ReDoS 安全——超越日常使用的进阶能力。
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+。
递归与平衡组
(?R) · (?0) · 嵌套匹配
递归模式用正则匹配嵌套结构(如括号)。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)。条件分组
(?(condition)yes|no)
根据某个捕获组是否匹配成功,选择不同的子模式。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 重置匹配起点
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
灾难性回溯 · 嵌套量词 · 防范策略
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、数字、日期、IP、中文场景。先记住一点:邮箱、URL 等的「完全严谨」规则极其复杂,实战通常用「足够好」的宽松版,再交给业务逻辑二次校验。下方每条都可复制到「实时测试台」试跑。
邮箱与 URL
email · url · 域名
日常够用的宽松版。真正的邮箱标准(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 · 端口
IP · port
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、Gonet.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](基本区,不含扩展区与生僻字) |
| 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} 最全。09
实时测试台
在浏览器中直接测试正则表达式,实时查看匹配结果和高亮。基于 JS 引擎。
正则测试台
实时匹配 · 标志切换 · 快速示例
输入正则和测试文本,实时查看匹配结果与高亮。点击标志按钮切换 g/i/m/s/u——开启
u 才能测试 \p{…}、\u{…} 等 Unicode 写法。这个测试台跑的是 JS 引擎
- 它直接调浏览器的
RegExp,所以看到的行为等于 JS 的行为:后行断言可以变长、\w不认中文、没有(?i)内联标志、没有独占量词; - 要验证 Python/Go 的行为,必须去对应的运行时上试——本页每张卡的支持度矩阵就是这么逐条跑出来的;
- 用它调正则时的一个习惯:先构造「应该匹配」和「不应该匹配」两组输入。只测前者的话,一条过宽的正则永远显得是对的。
//
gimsu
匹配结果
在上方输入正则表达式…
高亮预览
快速加载示例
10
各语言 API
JavaScript、Go、Python、C++ 四种语言的正则 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
regexp · RE2 引擎 · 线性时间
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}。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++
std::regex · · PCRE2
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,必须用生命周期够长的具名字符串。