R

ReflexKR

Reflex 官方文档

看得见的正则表达式

·

课程概览

1

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

2

肇事逃逸车辆:字符类

3

撕破的电话簿:量词

4

虚假注册:锚点与校验

5

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

6

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

7

见报前夜:替换与分割

8

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

9

保险箱的规则:前后查找

10

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

11

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

12

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

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

时间 8:20–9:10

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

CASE-011

Grep & Co. 数据侦探事务所

委托书

读不懂的正则

委托人

韩星所长

证物

三封恐吓信的最后一行,以及事务所 4 楼的门禁记录。

破案条件

把三行接成一行正则,再把它拆开写得能读懂,查明这个正则到底在找什么。

夜里十一点,事务所只亮着一盏灯。所长坐在那把旧机械键盘前,把三封信的最后一行逐字敲进电脑。“写成一行的正则,只有写的人读得懂。过三个月,连写的人自己也读不懂了。”

下面是把三封信的尾巴依次誊下来的文件,以及所长一言不发调出来的事务所 4 楼门禁记录。因为正则碎片里出现了 door、4f、badge。

<<(?mi)^(\d{4})([-./])(\d\d)\2(\d\d)[ T]
(0[0-4]):(\d\d)\s+(?:door|gate)=4f\s+
badge=(f-0[12])\b(?!.*denied)>>
2026-10-19 22:41 door=4F badge=F-01 result=ok
2026-10-20 02:13 door=4F badge=S-07 result=ok
2026-10-20 02:58 door=4F badge=F-02 result=denied
2026-10-20 03:02 door=4F badge=F-02 result=ok
2026.10.20 03:05 gate=4F badge=f-02 result=ok
2026-10-20 09:00 door=4F badge=F-01 result=ok

正则最前面的 (?mi) 是第一条线索。它是在模式之外改变模式读法的开关,也就是标志。今天我们把标志一个个打开,解开这一行。

11-1. 大小写与行:re.I、re.M(8 分钟)

看门禁记录,只有后门(gate)那台机器把门禁卡号记成了小写的 f-02。加上 re.I(re.IGNORECASE)就不分大小写了。

import re

re.findall(r"badge=F-02", door_log)          # ['badge=F-02', 'badge=F-02']
re.findall(r"badge=F-02", door_log, re.I)    # ['badge=F-02', 'badge=F-02', 'badge=f-02']

re.M(re.MULTILINE)在第 4 课只是浅尝了一下。本来 ^ 只在字符串的最开头、$ 只在最末尾匹配。打开 re.M 后,它们在每一行的开头和结尾都能匹配。这里的 door_log 就是上面那六行门禁记录。

re.findall(r"^\S+ 0[0-4]:\d\d", door_log)          # []
re.findall(r"^\S+ 0[0-4]:\d\d", door_log, re.M)
# ['2026-10-20 02:13', '2026-10-20 02:58', '2026-10-20 03:02', '2026.10.20 03:05']

re.findall(r"\w+$", door_log)          # ['ok']
re.findall(r"\w+$", door_log, re.M)    # ['ok', 'ok', 'denied', 'ok', 'ok', 'ok']

没有 re.M 的话,第一行是晚上 10 点,凌晨的记录一条也出不来。处理按行记录的日志时,re.M 几乎是标配。

证据分析室

re.search(r"", text)

标志

返回值

别改模式,只按标志按钮试试。打开 re.M,^ $ 就会在每一行匹配;打开 re.I,小写 f-02 那一行也会进来。

11-2. 打开窗户的 re.S(6 分钟)

现在要把信的三行尾巴接成一行。抓住从 << 到 >> 的部分似乎就行,可什么也没找到。

m = re.search(r"<<(.*)>>", tails)
print(m)    # None

从第 1 课起多特就一直在说:. 跨不过换行。<< 后面的 .* 停在了第一行末尾,而那里没有 >>。

所长转过椅子,推开了窗户。“多特,出去看看。”打开 re.S(re.DOTALL),. 就成了连换行也包括在内的真正的任意字符。

m = re.search(r"<<(.*)>>", tails, re.S)
one_line = m.group(1).replace("\n", "")
print(one_line)
# (?mi)^(\d{4})([-./])(\d\d)\2(\d\d)[ T](0[0-4]):(\d\d)\s+(?:door|gate)=4f\s+badge=(f-0[12])\b(?!.*denied)

多特踩着窗框跳了出去,又从隔壁的窗户钻回来,蹲到了桌上。三行变成了一行。

多特的提醒

窗户开啦!打开 re.S,我就能越过换行,跑到下一行、再下一行。不过要小心哦,.* 本来就是个贪心鬼,窗户一开,它会一口气冲到文本末尾。想在多行文本里抓短一点,就用 .*? 吧。

证据分析室

re.search(r"", text)

标志

返回值

按下 re.S 按钮,把窗户打开。. 一旦能越过换行,三行就会被一次抓住。

re.M 和 re.S 名字相近,常常被搞混,可它们做的事完全不同。

  • re.M 改变 ^ 和 $ 能匹配的位置(每一行)。
  • re.S 改变 . 能吃的字符(包括换行)。
re.findall(r"^.+$", "a\nb", re.M)    # ['a', 'b']
re.findall(r"^.+$", "a\nb", re.S)    # ['a\nb']

回溯者的正则只开了 (?mi) 而没开 s,也是有原因的。末尾的 (?!.*denied) 意思是“这一行后面不能有 denied”。要是开了 s,.* 就会越到下一行去,只要下面任何一行有 denied,都会被筛掉。

11-3. 拆开来写:re.X 与 (?#...)(9 分钟)

虽然成了一行,还是读不懂。打开 re.X(re.VERBOSE)后,模式里的空格和换行会被忽略,从 # 到行尾则成为注释。于是可以把模式分成多行来写,并加上说明。

所长把它一块一块拆开,重新敲了一遍。编号分组都起了名字,\2 换成第 10 课学过的 (?P=sep)。

DOOR = re.compile(r'''
    ^
    (?P<year>\d{4}) (?P<sep>[-./]) (?P<month>\d\d) (?P=sep) (?P<day>\d\d)   # 日期,分隔符只有一种
    [ T]                                   # 日期与时间之间:空格或 T
    (?P<hour>0[0-4]) : (?P<minute>\d\d)    # 凌晨 0 点到 4 点多
    \s+ (?:door|gate) = 4f                 # 4 楼前门或后门
    \s+ badge = (?P<badge>f-0[12]) \b      # 门禁卡 F-01 或 F-02
    (?! .* denied )                        # 排除被拒绝的记录
''', re.I | re.M | re.X)

for m in DOOR.finditer(door_log):
    print(m["hour"], m["minute"], m["badge"])
# 03 02 F-02
# 03 05 f-02

还是同一个正则,现在却能一行一行读下来了。它要找的是:凌晨 0 点到 4 点多,通过 4 楼的门,由 F-01 或 F-02 门禁卡真正进门的记录。

re.X 也有例外。想找真正的空格或 #,就要转义或者放进字符类。字符类里的空格不会被删掉,上面模式里的 [ T] 就是这样。

re.fullmatch(r"\d{4} - \d\d", "2026-10", re.X)   # <re.Match object; span=(0, 7), match='2026-10'>
re.fullmatch(r"a b", "a b", re.X)                # None
re.fullmatch(r"a\ b", "a b", re.X)               # <re.Match object; span=(0, 3), match='a b'>
re.fullmatch(r"a[ ]b", "a b", re.X)              # <re.Match object; span=(0, 3), match='a b'>
re.fullmatch(r"a\#b", "a#b", re.X)               # <re.Match object; span=(0, 3), match='a#b'>

不用 re.X、只想在模式中间插一小段注释时,就用 (?#...)。括号里的内容会被整个忽略。下面注释里的韩文是“创始成员的门禁卡”。

re.findall(r"F-0[12](?#창립 멤버 출입증)", door_log, re.I)
# ['F-01', 'F-02', 'F-02', 'f-02', 'F-01']

证据分析室

re.search(r"", text)

标志

返回值

这是一个用空格隔开写的模式。没有 re.X 的话,空格也算字符,所以什么都找不到。打开 re.X 试试。[ ] 里的空格在 re.X 下也会保留。

所长盯着拆开的模式看了很久。“一个变量名都没有,全挤在一行。这种写注释的习惯……不对,是不写注释的习惯。那家伙也总是……”她没再说下去。她把扣在桌上的旧相框收进抽屉,手重新放回键盘上。“继续。”

11-4. 字符的范围:re.A、re.U、re.L(5 分钟)

第 2 课说过,str 模式里的 \w 包括韩文,\d 也包括阿拉伯数字 ٣。因为默认就是 Unicode。打开 re.A(re.ASCII)后,\w \W \d \D \s \S \b \B 就只看 ASCII 字符了。

re.findall(r"\w+", "café 한글 F_02")          # ['café', '한글', 'F_02']
re.findall(r"\w+", "café 한글 F_02", re.A)    # ['caf', 'F_02']
re.findall(r"\d", "٣3")                       # ['٣', '3']
re.findall(r"\d", "٣3", re.A)                 # ['3']

(한글 就是“韩文”这个词本身。)

  • re.U(re.UNICODE)是 str 模式的默认值,不必特意写。留着它是为了兼容旧代码。
  • re.L(re.LOCALE)只能用于 bytes 模式,结果还会随电脑的区域设置而变,不推荐使用。用在 str 模式上会报错。
re.compile(r"\w", re.L)
# ValueError: cannot use LOCALE flag with a str pattern

证据分析室

re.search(r"", text)

标志

返回值

开关 re.A,看看 \w+ 会漏掉什么。再把模式换成 \d,确认一下阿拉伯数字 ٣。

11-5. 组合、打开、关闭标志(6 分钟)

标志用 | 组合。它的值是名为 re.RegexFlag 的枚举,可以像整数一样使用。还有表示“没有任何标志”的 re.NOFLAG(3.11 起,值为 0),很适合用作函数的默认值。

flags = re.I | re.M
print(flags)          # re.IGNORECASE|re.MULTILINE
print(type(flags))    # <flag 'RegexFlag'>
print(int(flags))     # 10

def find_all(pattern, text, flags=re.NOFLAG):
    return re.findall(pattern, text, flags)

也可以像回溯者那样,在模式最前面写上 (?aiLmsux) 中需要的字母。(?im) 等同于 re.I | re.M。不在最前面就会报错。

re.findall(r"(?im)^\S+ 0\d:\d\d(?=.*f-02)", door_log)
# ['2026-10-20 02:58', '2026-10-20 03:02', '2026.10.20 03:05']

re.compile(r"badge=(?i)f-02")
# re.PatternError: global flags not at the start of the expression at position 6

只想给模式的一部分加标志,就用 (?i:...) 这样的括号范围。(?-i:...) 只在该范围内关闭,(?i-s:...) 则打开 i、关闭 s。

re.findall(r"badge=(?i:f)-02", door_log)        # ['badge=F-02', 'badge=F-02', 'badge=f-02']
re.findall(r"(?i)BADGE=(?-i:F)-02", door_log)   # ['badge=F-02', 'badge=F-02']
re.fullmatch(r"(?s)a.(?i-s:b.)", "a\nB\n")      # None
re.fullmatch(r"(?s)a.(?i-s:b.)", "a\nBc")       # <re.Match object; span=(0, 4), match='a\nBc'>

a、L、u 是决定字符范围的标志,可以在范围内打开,却不能关闭。

re.findall(r"(?a:\w+)", "café")    # ['caf']
re.compile(r"(?-a:\w+)")
# re.PatternError: bad inline flags: cannot turn off flags 'a', 'u' and 'L' at position 4

最后,re.DEBUG 会把 re 如何理解模式原原本本地打印出来。我们来对比回溯者的签名,和去掉外层重复后的模式。下面是输出的开头部分,再往下还有一串执行指令。

re.compile(r"(a+)+$", re.DEBUG)
# MAX_REPEAT 1 MAXREPEAT
#   SUBPATTERN 1 0 0
#     MAX_REPEAT 1 MAXREPEAT
#       LITERAL 97
# AT AT_END

re.compile(r"a+$", re.DEBUG)
# MAX_REPEAT 1 MAXREPEAT
#   LITERAL 97
# AT AT_END

重复(MAX_REPEAT)里面又套着一个重复。第 8 课挑战书上的慢模式,正是这个形状。

11-6. re.compile 与 Pattern 对象(6 分钟)

就像所长把拆开写的模式装进了 DOOR 这个名字里一样,re.compile 会把模式变成 Pattern 对象。它拥有和模块函数同名的方法 search match fullmatch findall finditer sub split,还能额外接收搜索范围 pos 和 endpos。

p = re.compile(r"\d\d:\d\d")
line = "2026-10-20 03:02 door=4F"
print(p.search(line))              # <re.Match object; span=(11, 16), match='03:02'>
print(p.match(line))               # None
print(p.match(line, 11))           # <re.Match object; span=(11, 16), match='03:02'>
print(p.fullmatch(line, 11, 16))   # <re.Match object; span=(11, 16), match='03:02'>
print(p.findall(line, 0, 15))      # []
print(len(list(p.finditer(line, 11))))   # 1

pos 和把字符串切开不是一回事。^ 不在 pos 的位置匹配,只在字符串真正的开头(开了 re.M 就是行首)匹配。

q = re.compile(r"^\d\d")
print(q.match(line, 11))     # None
print(q.match(line[11:]))    # <re.Match object; span=(0, 2), match='03'>

Pattern 对象里还存着关于自己的信息。str 模式的 flags 会自动加上 re.UNICODE(32)。

print(DOOR.groups)       # 7
print(DOOR.groupindex)
# {'year': 1, 'sep': 2, 'month': 3, 'day': 4, 'hour': 5, 'minute': 6, 'badge': 7}
print(DOOR.flags)        # 106
print(re.RegexFlag(DOOR.flags))   # re.IGNORECASE|re.UNICODE|re.MULTILINE|re.VERBOSE
print(re.compile("x").flags)      # 32
print(re.compile(one_line).pattern == one_line)   # True
  • 模块函数 re.search(模式, ...) 在内部也会编译。最近用过的模式会留在内部缓存里,不会重复编译。re.purge() 用来清空这个缓存(很少用得上)。
  • 所以 re.compile 真正的好处与其说是速度,不如说是名字。起名叫 DOOR 的模式,在代码里是读得懂的。
  • 类型提示写成 re.Pattern[str]、re.Match[str](bytes 模式就写 [bytes])。
def night_entries(p: re.Pattern[str], log: str) -> list[re.Match[str]]:
    return list(p.finditer(log))

print(len(night_entries(DOOR, door_log)))   # 2

破案(10 分钟)

用拆开写的模式重新读一遍门禁记录。

for m in DOOR.finditer(door_log):
    print(f"{m['year']}-{m['month']}-{m['day']} {m['hour']}:{m['minute']} {m['badge'].upper()}")
# 2026-10-20 03:02 F-02
# 2026-10-20 03:05 F-02

从信里取出的那一行原版正则,找到的也是一模一样的两行。意思相同,区别只在读不读得懂。

[m.group(7) for m in re.finditer(one_line, door_log)]   # ['F-02', 'f-02']

10 月 20 日凌晨,02:58 在前门被拒绝过一次的 F-02 门禁卡,4 分钟后又刷卡进了门,再过 3 分钟又经过了后门。回溯者发来这个正则,意思是:“我什么时候来过,读得懂就读读看。”

所长翻开门禁卡登记簿。“F-01 是我的。F-02 嘛……”登记簿的那一栏里没有名字,只划着两道横线。“十年前就该注销的。一直没舍得。”

结案报告 — 三封信的尾巴是同一个正则。拆开写后确认:2026-10-20 03:02~03:05,门禁卡 F-02 出入过事务所 4 楼。F-02 是创立时发放的门禁卡,即日起停用。

最后,所长把原版那一行正则留在屏幕上,说道:“同一个正则。一个只有写的人读得懂,一个谁都读得懂。我们写谁都读得懂的那种。”

练习

  1. 用 re.X 把第 4 课的用户名规则 [a-z][a-z0-9_]{3,15} 拆开来写,并给每一块加上注释。
  2. 利用 re.compile(r"\d+") 的 pos,在 "no.17 at 03:02" 中只找出 at 后面的数字。
  3. 确认 re.compile(r"badge=(?i)f-02") 的错误之后,用范围标志 (?i:...) 改成同样的意思。

回溯者的踪迹

(a+)+$

拆开写了啊。下一个,你们想拆也拆不开。明早九点,打开邮箱。

所长的笔记本

  • re.I 忽略大小写,re.M 让 ^ $ 按行匹配,re.S 让 . 包括换行。M 和 S 做的事不一样。

  • re.X:忽略空格和 # 注释。字符类里的空格和 \ (反斜杠+空格)会保留。(?#...) 在哪里都是注释。

  • re.A:让 \w \d \s \b 只认 ASCII。str 默认是 re.U,re.L 只用于 bytes,不推荐。

  • 组合 re.I | re.M(re.RegexFlag),没有标志就用 re.NOFLAG。内联 (?im) 只能放在最前面。

  • 范围标志 (?i:...) (?-i:...) (?i-s:...)。a L u 不能关闭。用 re.DEBUG 看模式的内部。

  • re.compile 是起名字的工具。可接收 pos endpos,但 ^ 只在真正的开头匹配。属性有 flags groups groupindex pattern。

← 上一课

第10课 · 同样的手法:反向引用与条件模式

下一课 →

第12课 · 回溯者的真面目:总复习与分词器

订阅新课时和新课程通知

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

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

问答

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

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

R

ReflexKR

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

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

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

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

Built with Reflex