R

ReflexKR

Reflex 官方文档

看得见的正则表达式

·

课程概览

1

消失的支付记录:按原样查找文字

2

肇事逃逸车辆:字符类

3

撕破的电话簿:量词

4

虚假注册:锚点与校验

5

五花八门的日期:分组与选择

6

凌晨 3 点的访问:命名分组与 Match 对象

7

见报前夜:替换与分割

8

回溯者的挑战书:贪婪、懒惰与占有

9

保险箱的规则:前后查找

10

同样的手法:反向引用与条件模式

11

读不懂的正则:标志与编译

12

回溯者的真面目:总复习与分词器

看得见的正则表达式
第12课

时间 9:10–10:00

回溯者的真面目:总复习与分词器

CASE-012

Grep & Co. 数据侦探事务所

委托书

回溯者的真面目

委托人

事务所全体成员

证物

早上九点进入邮箱的一封密文。只由反斜杠、数字和英文字母组成。

破案条件

把密文切成 token 加以破译,查明回溯者究竟是谁。

早上九点,事务所里挤满了人。姜队长站在门边,郑老板摊开了记事本,吴记者把笔记本电脑搁在膝盖上。所长把显示器转过来,让所有人都看得见。“今天大家一起读。一个字一个字地读。”

这是邮箱里收到的密文。

\x46\N{HYPHEN-MINUS}\060\x32 \x3d \uc11c\uc9c4
\x34\x46 \060\063\x3a\060\x32
(a+)+$

吴记者摇摇头。“这哪是文字,就是一堆符号嘛。”所长答道:“符号也有规则。把规则一条条写下来,机器就会替你读。”

把一长段文本切成有意义的小块(token)的程序,叫作分词器(tokenizer)。今天我们把学过的东西全部集中起来,做一个分词器,解开这封密文。

12-1. 分词器:用 | 把命名分组连起来(12 分钟)

先拿 3.5*(2 + x) 这样的算式练练手,把它切开。结构和 Python 官方文档里的“Writing a Tokenizer”示例一样。

import re

TOKEN_SPEC = [
    ("NUMBER",   r"\d+(?:\.\d*)?"),   # 整数或小数
    ("ID",       r"[A-Za-z_]\w*"),    # 名字
    ("OP",       r"[+\-*/]"),         # 运算符
    ("LPAR",     r"\("),
    ("RPAR",     r"\)"),
    ("SKIP",     r"[ \t]+"),          # 空白丢掉
    ("MISMATCH", r"."),               # 其他字符:不认识的字符
]
TOKEN = re.compile("|".join(f"(?P<{name}>{rx})" for name, rx in TOKEN_SPEC))

def tokenize(code):
    for m in TOKEN.finditer(code):
        kind, value = m.lastgroup, m.group()
        if kind == "SKIP":
            continue
        if kind == "MISMATCH":
            raise ValueError(f"모르는 글자 {value!r} (위치 {m.start()})")
        yield kind, value

print(list(tokenize("3.5*(2 + x)")))
# [('NUMBER', '3.5'), ('OP', '*'), ('LPAR', '('), ('NUMBER', '2'), ('OP', '+'), ('ID', 'x'), ('RPAR', ')')]

错误信息是韩文:모르는 글자 是“不认识的字符”,위치 是“位置”。

结构总共就三点。

  • 每条规则做成一个命名分组 (?P<名字>规则),再用 | 连起来。规则写成列表,读起来、改起来都方便。
  • 用 finditer 从前往后一个个抓,用 m.lastgroup(第 6 课)拿到是哪条规则抓到的名字。
  • 最后的 MISMATCH 只有一个 .。不符合任何规则的字符就在这里被逮住。
list(tokenize("3.5 $ 2"))
# ValueError: 모르는 글자 '$' (위치 4)

没有 MISMATCH 会怎样?finditer 会把匹配不上的字符悄无声息地跳过去。密文里就算混进了奇怪的字符,你也会浑然不觉。

NO_GUARD = re.compile(r"(?P<NUMBER>\d+)|(?P<OP>[+*])")
[m.group() for m in NO_GUARD.finditer("3 $ 2")]   # ['3', '2']

| 从左往右尝试,所以顺序就是优先级。把宽泛的规则放在前面,窄的规则就没有出场的机会。

BAD = re.compile(r"(?P<ID>\w+)|(?P<NUMBER>\d+)")
[m.lastgroup for m in BAD.finditer("x 3")]   # ['ID', 'ID']

证据分析室

re.search(r"", text)

返回值

每个分组的颜色都不一样。现在的 NUMBER 规则是 \d+,所以 3.5 被切成了 3、. 和 5。请改成 \d+(?:\.\d*)?。在文本里加个 $,它就会变成 MISMATCH 的颜色。

12-2. 模式里的字符转义(8 分钟)

密文里的 \x46、\uc11c 这类东西叫作字符转义。Python 字符串里用的转义,大部分 re 在模式内部也能直接听懂。所以即使用原始字符串写成 r"\t",也照样能找到制表符。

print(re.findall(r"\t|\n", "F-02\t03:02\n"))   # ['\t', '\n']
print(re.fullmatch(r"\\", "\\"))              # <re.Match object; span=(0, 1), match='\\'>
  • 控制字符:\t(制表符)\n(换行)\r \f \v \a(响铃),反斜杠本身是 \\。
  • 十六进制:\x41(两位)、\u00e9(四位)、\U0001F600(八位)。
  • 名字:像 \N{EM DASH} 这样用 Unicode 字符名。
print(re.findall(r"\x46-\x30\x32", "badge=F-02"))   # ['F-02']
print(re.fullmatch(r"caf\u00e9", "café"))           # <re.Match object; span=(0, 4), match='café'>
print(re.fullmatch(r"\N{EM DASH}", "—"))            # <re.Match object; span=(0, 1), match='—'>
print(len("\U0001F600"), "\U0001F600" == "\N{GRINNING FACE}")   # 1 True

还有八进制。以 \0 开头或者正好三位时,就是八进制字符(正如第 10 课所见,\10 是组号)。另外,\b 只有在字符类里面才是退格符。

print(re.findall(r"\101|\0", "A\0B"))    # ['A', '\x00']
print(re.findall(r"[\b]", "F\b-02"))     # ['\x08']

反过来,没有规定含义的英文字母转义会报错。因为这些位置是留着以后赋予新含义的。

re.compile(r"\q")
# re.PatternError: bad escape \q at position 0

多特的提醒

我每次看到 \b,尾巴都要摇三下。在正则外面(普通的 "\b" 字符串)它是退格,在模式里是单词边界,可到了模式的 [ ] 里面,又变回退格了。所以模式一律写成 r"...",[\b] 只在真的需要退格时才用。

证据分析室

re.search(r"", text)

返回值

\x46-\x30\x32 和 F-02 是同一个模式。试着把 - 换成 \N{HYPHEN-MINUS},把 \x46 换成 F,再用 \N{EM DASH} 找找那条长破折号。

12-3. 处理模式错误:re.error(8 分钟)

分析室里弹出红色错误框时显示的信息,来自一个叫 re.error 的异常。从 3.13 起还有 re.PatternError 这个名字,两者是同一个异常。异常对象里装着错在哪里、错在什么位置。

print(re.PatternError is re.error)   # True

try:
    re.compile(r"[a-Z]")
except re.error as e:
    print(e.msg)       # bad character range a-Z
    print(e.pattern)   # [a-Z]
    print(e.pos)       # 1

模式有多行时,lineno(第几行)和 colno(第几列)就派上用场了。位置从 1 开始数。

user_pattern = r'''(?x)
    (?P<badge> F-\d+ )
    (?P<time \d\d:\d\d )
'''
try:
    re.compile(user_pattern)
except re.PatternError as e:
    print(e.msg)                  # missing >, unterminated name
    print(e.pos, e.lineno, e.colno)   # 36 3 9

人输入的模式随时可能出错。像搜索框这样接收模式的程序,一定要用 try/except 包起来。

def safe_compile(text):
    try:
        return re.compile(text)
    except re.error as e:
        print(f"패턴 오류: {e.msg} (위치 {e.pos})")
        return None

safe_compile(r"(?P<SIGN>(a+)+$")   # 패턴 오류: missing ), unterminated subpattern (위치 0)

(패턴 오류 是“模式错误”。)

不过,try/except 能拦住的只有语法错误。像 (a+)+$ 这种语法没错却很慢的模式,不会抛出任何异常,直接卡住(第 8 课)。所以证据分析室把模式交给一个单独的工作进程去跑,超时就换掉那个进程。

证据分析室

re.search(r"", text)

返回值

有人在誊抄第 10 课的日期模式时出了错。看看错误信息和 ^ 标记,把它改好。

12-4. bytes 模式(7 分钟)

从文件或网络读进来的数据,有时不是 str 而是 bytes。对 bytes 要用 rb"..." 这样的 bytes 模式。str 和 bytes 不能混用。

data = "badge=F-02 서진".encode()
print(re.findall(rb"F-\d+", data))   # [b'F-02']

re.findall(r"F-\d+", data)
# TypeError: cannot use a string pattern on a bytes-like object
re.findall(rb"F-\d+", "badge=F-02")
# TypeError: cannot use a bytes pattern on a string-like object

bytes 模式里的 \w \d \s 只看 ASCII。韩文被拆成了好几个字节,看起来就不像字符了。要处理韩文,先用 decode() 变成 str。

print(re.findall(rb"\w+", data))            # [b'badge', b'F', b'02']
print(re.findall(r"\w+", data.decode()))    # ['badge', 'F', '02', '서진']

标志也反过来了。re.L 只能用于 bytes 模式(第 11 课),re.U 用在 bytes 模式上反而报错。

print(re.compile(rb"\w", re.L).flags)   # 4
re.compile(rb"\w", re.U)
# ValueError: cannot use UNICODE flag with a bytes pattern

12-5. 警告与隐藏的工具(5 分钟)

第 2 课说过,字符类里要避开 [[、--、&&、~~、||。这些位置将来要留给“集合运算”,所以现在会发出 FutureWarning。如果就是要字面意思,就转义。

re.compile(r"[[a-z]")
# FutureWarning: Possible nested set at position 1
re.compile(r"[a-z--]")
# FutureWarning: Possible set difference at position 4
re.compile(r"[\[a-z]")   # 没有警告

re 模块里还有官方文档没写的内部工具,比如 re.Scanner(帮你做分词器的类)、Pattern.scanner()、Match.regs(所有分组的 span)。知道有这些东西就够了。文档里没写的东西随时可能变,实际代码里还是用今天做的 finditer + lastgroup 的方式。

m = re.search(r"(F)-(\d+)", "badge=F-02")
print(m.regs)   # ((6, 10), (6, 7), (8, 10))

破案(10 分钟)

现在把密文的规则写下来:四种字符转义、签名、空白,以及堵住漏洞的 MISMATCH。照第 11 课学的那样,用 re.X 拆开写,加上注释。

import unicodedata

CIPHER = re.compile(r'''
    (?P<HEX>   \\x [0-9a-fA-F]{2} )    # \x46    两位十六进制
  | (?P<UNI>   \\u [0-9a-fA-F]{4} )    # \uc11c  四位十六进制
  | (?P<NAME>  \\N \{ [A-Z -]+ \} )    # \N{...} 字符名
  | (?P<OCT>   \\ [0-7]{3} )           # \060    三位八进制
  | (?P<SIGN>  \(a\+\)\+\$ )           # 签名,等同于 re.escape("(a+)+$")
  | (?P<SPACE> [ \n]+ )                # 空格和换行原样保留
  | (?P<MISMATCH> . )                  # 其他字符一律可疑
''', re.X)

def decode(message):
    out = []
    for m in CIPHER.finditer(message):
        kind, value = m.lastgroup, m.group()
        if kind in ("HEX", "UNI"):
            out.append(chr(int(value[2:], 16)))
        elif kind == "OCT":
            out.append(chr(int(value[1:], 8)))
        elif kind == "NAME":
            out.append(unicodedata.lookup(value[3:-1]))
        elif kind == "SPACE":
            out.append(value)
        elif kind == "MISMATCH":
            raise ValueError(f"모르는 토큰 {value!r} (위치 {m.start()})")
    return "".join(out).strip()

print(decode(message))
# F-02 = 서진
# 4F 03:02

记住,即使在 re.X 里,字符类 [A-Z -] 和 [ \n] 中的空格也会保留。签名 SIGN 什么也不输出,直接丢掉。

证据分析室

re.search(r"", text)

返回值

这是密文分词器的单行版。现在它不认识 \N{...} 和八进制 \060,所以它们被涂成了 MISMATCH 的颜色。把 (?P<NAME>\\N\{[A-Z -]+\}) 和 (?P<OCT>\\[0-7]{3}) 加到 MISMATCH 前面试试。

破译出来的字对不对,也可以直接问 re。密文的第一行本身就是一个合法的正则,因为 re 也听得懂同样的转义。

first = message.splitlines()[0]
print(re.fullmatch(first, "F-02 = 서진"))   # <re.Match object; span=(0, 9), match='F-02 = 서진'>

F-02 = 서진。서진 是用韩文写的一个名字:徐真。4 楼,凌晨 3 点 02 分。和第 11 课门禁记录上的时间分毫不差。

姜队长问:“徐真是谁?”所长从抽屉里取出那个一直扣着的相框,立在桌上。照片里,两个年轻人站在这间事务所的招牌下。

“和我一起创办 Grep & Co. 的人。F-02 是他的门禁卡。”所长用指尖点了点照片里的男人。“是个天才。什么都写成一行正则。没人读得懂他的代码,也没人改得了。所以我们把它们一条条拆开重写……他大概觉得,那是在把他抹掉吧。有一天,他只留下门禁卡,就走了。”

“门禁卡为什么不注销?”所长没有回答,只是看着屏幕上的最后一行 (a+)+$。“说读不懂的正则才是最好的正则的人,却把自己的名字写得让人读得懂。他从一开始,就希望有人能读出来。”

结案报告 — 回溯者的真实身份是 Grep & Co. 的创始成员徐真。他用未被注销的门禁卡 F-02 出入事务所,留下了挑战书和信件。他留下的所有正则都已拆开重写、加上注释,贴进了案件笔记本。门禁卡 F-02 已停用。

重读十二个案件

那天下午,所长和新人把十二份案件档案从头翻了一遍。“换作现在,会怎么写?”

  • CASE-001 支付记录:不用手动加反斜杠,改用 re.escape("$4.99")。
  • CASE-002·003 车牌和电话号码:像 \d{2,3}[가-힣]\d{4} 这样用量词缩短,校验用 fullmatch。
  • CASE-004 虚假注册:不用 search,而用 re.fullmatch(r"[a-z][a-z0-9_]{3,15}", user_id)。
  • CASE-005·010 日期:(?P<y>\d{4})(?P<sep>[-./])(?P<m>\d\d)(?P=sep)(?P<d>\d\d)。有了名字,就不用数编号了。
  • CASE-006 访问日志:用命名分组和 groupdict() 把一行变成 dict。
  • CASE-007 客户名单:函数替换,还有 count= 这样的关键字参数。
  • CASE-008 回溯者的挑战书:用 ^a+$ 代替 ^(a|aa)+$。不写重叠的重复。
  • CASE-009 保险箱规则:一个向前查找对应一个条件,每个条件配一行注释。
  • CASE-011 单行正则:用 re.X 拆开写,用 re.compile 起个名字。
  • CASE-012 密文:把规则写成列表的分词器,最后放上 MISMATCH。

深夜,邮箱里又进来一封邮件。这次不是密文。

韩星。谢谢你读到了最后。当年并不是没人读得懂我的代码,是我没把它写得让人读懂。下次,我会加注释的。

(?x) a+ $ # 不再回头了

签名变了。新人习惯性地把那个签名放进分析室试了试。去掉外层重复的模式,就算失败也毫不犹豫。

sign = r"(?x) a+ $   # 不再回头了"
print(re.fullmatch(sign, "a" * 30 + "b"))   # None

所长把那本旧笔记本放到新人的桌上。从第一页起,每个案件都写满了所长的字迹。“现在它是你的了。赢的不是更短的正则,是读得懂的正则。只要记住这一点。”

练习

  1. 给 12-1 的分词器加上 ASSIGN(对应 =)和 NEWLINE(对应换行),把第一行是 x = 3.5、第二行是 y = x * 2 的两行代码切成 token。
  2. 给密文破译器加上 \U 后跟八位十六进制的 token,然后破译 \U0000C11C\U0000C9C4。
  3. 写一个函数:让用户输入模式,出错时在模式下方 e.pos 的位置打一个 ^ 标出来。这正是证据分析室在做的事。

所长的笔记本

  • 分词器:用 | 连起 (?P<名字>规则),再用 finditer + m.lastgroup。顺序就是优先级,最后放 MISMATCH。

  • 模式里的转义:\t \n \r \f \v \a \\,\x41 \u00e9 \U0001F600 \N{EM DASH},八进制 \0 \101。[\b] 是退格。

  • 没有规定含义的字母转义(\q)会报错。用户输入的模式用 try/except re.error,看 e.msg e.pattern e.pos e.lineno e.colno。

  • bytes 用 rb 模式,和 str 混用会 TypeError。bytes 的 \w 只认 ASCII,re.L 只用于 bytes,re.U 用在 bytes 上会报错。

  • 字符类里的 [[ -- && ~~ || 会触发 FutureWarning。re.Scanner、Pattern.scanner、Match.regs 是文档里没有的内部工具。

  • 赢的不是更短的正则,是读得懂的正则。

← 上一课

第11课 · 读不懂的正则:标志与编译

订阅新课时和新课程通知

本课程新增课时或推出后续课程时,我们会发邮件通知你。

收集项目:电子邮箱 · 用途:本课程的新课时和上线通知 · 保存期限:直到你要求删除。不会用于其他用途,也不会提供给他人。回复任意通知邮件,我们会立即删除。

问答

关于本课有任何疑问都可以在这里留言。所有语言版本页面的提问会汇集在一起。

以纯文本显示,不支持链接和格式。

R

ReflexKR

动手学 Python 和 Reflex。所有示例均已在 Reflex 0.10.0 上实际运行验证。

ReflexKR 免费,没有横幅广告。如果对你有帮助,欢迎请我们喝杯咖啡。 ☕ 请我们喝杯咖啡

联盟链接与赞助说明
한국어English日本語简体中文EspañolDeutsch

© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0

Built with Reflex