字符组:[abc]、[^abc]、[a-z] 与预定义类 \d \w \s
给"任意一个位置"发候选人名单
. 是"谁来都行",太宽泛;字面量是"只要这位",太死板。真实的匹配常常介于两者之间:这一位可以是数字,但不能是字母;可以是这几个标点之一,其余不收。字符组(character class)就是给单个位置列一份候选人名单:[abc] 表示"这一位上是 a、b、c 之一即可命中"。
import re
# 从短句中提取所有单个元音字母
text = "今天 Weather 很 nice"
print(re.findall(r"[aeiou]", text))
# 输出:['e', 'a', 'e', 'i', 'e']
注意字符组永远只消费一个字符,哪怕名单再长。想连吃三位十六进制数,要靠量词配合:[0-9a-fA-F]{3}。
范围与取反:- 和 ^
方括号内的 - 表示区间:[a-z] 小写字母、[0-9] 数字、[\u4e00-\u9fa5] 常用汉字。多个区间可以连写,[a-zA-Z0-9_] 一口气覆盖英文字母数字下划线。
把 ^ 放在方括号的最开头,名单翻转成黑名单:[^0-9] 表示"这一位什么字符都行,只要不是数字"。
import re
# 检查哪些 SKU 不是数字开头:SKU-A77 合规,SKU-88K0 违规
print(re.findall(r"SKU-[^0-9]", "SKU-A77 与 SKU-88K0"))
# 输出:['SKU-A']
SKU-[^0-9] 只在"横杠后不是数字"处命中,SKU-88K0 被规则自然排除。这正是取反字符组"提前筛掉不合格式"的价值。
预定义字符组:引擎送你的速记
手写 [0-9]、[a-zA-Z0-9_] 太啰嗦,各方言都内置了一组速记符号,本质是字符组的缩写:
| 速记 | 等价于(ASCII 语境) | 含义 | 记忆 |
|---|---|---|---|
\d |
[0-9] |
数字 | digit |
\w |
[A-Za-z0-9_] |
单词字符 | word |
\s |
空格/制表/换行等 | 空白 | space |
\D |
[^0-9] |
非数字 | 取反 |
\W |
非单词字符 | 取反 | 取反 |
\S |
非空白 | 取反 | 取反 |
大写形式永远是小写形式的补集,这条规律无一例外。
import re
# 把「商品ID:A1234」这类标签里的编号抠出来
print(re.findall(r"商品ID[::]\s*\w+", "商品ID: A1234;商品ID:B_9x"))
# 输出:['商品ID: A1234', '商品ID:B_9x']
[::] 同时兼容半角与全角冒号——字符组天生就是处理"二义性输入"的好工具。
隐蔽的差异:\w 到底认不认识中文
这是字符组里最大的方言陷阱。JavaScript 中 \w 等价于 [A-Za-z0-9_],永远不含中文;Python 3 的 re 模块默认按 Unicode 解释,\w 会命中汉字。同一个意图,跨语言搬代码就可能出错:
import re
# Python:\w 含汉字
print(re.findall(r"\w+", "订单号 DD7712 已签收"))
# 输出:['订单号', 'DD7712', '已签收']
同样的 \w+ 在 JavaScript 里只会匹配出 DD7712。在 Python 里想要 ASCII 行为,显式写 [A-Za-z0-9_],或加 re.A 标志。跨语言移植正则时,先核对预定义类的语义,是本书反复强调的纪律。
字符组内部的规则:它是个"和平区"
方括号里大部分元字符会失去特殊含义,+*?() 在组内都是普通字面量,这常被新手误转义。但有三条组内铁律:
]要写[\\\]](或直接[]]在部分方言可行)才表示字面量;\]最稳。-想表示连字符本身,放组首、组尾,或转义\-:[a-z-]。^只在组首才是取反,挪到组中间就成了字面量:[a^b]匹配 a、^、b 三者之一。
import re
# 匹配算式中的减号与数字:3-5
print(re.findall(r"\d[-+]\d", "3-5 与 4+2 与 6 8"))
# 输出:['3-5', '4+2']
经验之谈:写
[A-z]是新手经典事故——它在 ASCII 表里横跨了大小写字母之间的一堆标点(\、^、_、反引号全在里面)。区间必须同类型相邻:[A-Za-z]而不是[A-z]。同理[0-125]不是 0 到 125,而是 0、1、2、5 四个字符——区间只看两端,不看中间数的大小。
实战:从一段客服记录里捞时间
14:32、09:05 这类时分,用字符组加区间限界写法是 [01]\d:[0-5]\d 或宽松版 \d{1,2}:\d{2}:
import re
log = "客服 14:32 接单,15:7 记录无效,23:59 归档"
print(re.findall(r"\b(?:[01]\d|2[0-3]):[0-5]\d\b", log))
# 输出:['14:32', '23:59']
15:7 被规则自然排除,因为 [0-5]\d 要求分钟必须是两位且首位 0-5。用字符组刻画"这一位的合法取值范围",比事后再过滤假阳性便宜得多。
组合技巧:字符组不是孤立使用的
真实匹配里,字符组几乎总是和量词、锚点、字面量组队。三个常用组合范式:
- 定长分段:
[0-9]{3}-[0-9]{8}描述"区号-号码"这种分段定长结构; - 取反守门:
[^0-9]放在关键位置,提前排除非法字符,省掉后置过滤; - 白名单收口:整串校验时用
^[字符组]{n,m}$,把所有合法形态压进一个字符组。
import re
print(re.findall(r"[0-9]{3}-[0-9]{8}", "总机 010-88886666,备用 021-55554444"))
# 输出:['010-88886666', '021-55554444']
这条模式没有用一个 \d——POSIX 方言的 grep 里它照样能跑,这就是字符组"全方言通用"的工程价值:核心业务正则优先用显式字符组书写,可移植性最好。
自测两题:[a-c-] 匹配哪些字符?(a、b、c 和连字符本身——组尾的 - 是字面量。)[^a-z0-9] 会命中汉字吗?(会,它只排除小写字母和数字。)第二题正是"用取反组做中文判断"的思路来源:[^一-龥] 命中的第一个字符,就是文本里第一个非汉字字符。
三个常见疑问
问:[abc] 和 (a|b|c) 等价吗? 匹配结果等价,但性能与语义不同:字符组是单字符的原生分支,更快;且 (a|b|c) 会多出一个捕获组,影响 findall 返回结构。单字符多选一,永远用字符组。
问:字符组里需要转义 . 吗? 不需要,组内 . 就是字面量:[a.c] 匹配 a、点、c 三者之一。组内需要留意的只有 ] \ ^ - 四个,其余元字符全部"降级"为普通字符——这也是为什么反斜杠在组内有时要写两个。
问:怎么匹配"汉字、字母、数字、下划线"混排的词? 把区间并列即可:[一-龥A-Za-z0-9_]。注意不要图省事写 [一-龥\w]——\w 的语义随语言变化(Python 含汉字、JS 不含),显式列出区间才能保证跨方言一致。
import re
print(re.findall(r"[一-龥A-Za-z0-9_]{2,}", "用户 u_8817 说:好"))
# 输出:['用户', 'u_8817', '说', '好']
"说:好"中的全角冒号把两个字隔开,所以结果里是分开的两段——字符组只认名单,不管语义。想连成语义词,那是分词的事,不是正则的事。
参考资源
- MDN 字符类完整列表:MDN 正则·字符类
- Python re 语法定义(含 Unicode 语义说明):re — Regular expression operations
本章由 WorkBuddy 原创撰写,示例均经实际验证。
