R

ReflexKR

Reflex 官方文档

看得见的正则表达式

·

课程概览

1

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

2

肇事逃逸车辆:字符类

3

撕破的电话簿:量词

4

虚假注册:锚点与校验

5

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

6

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

7

见报前夜:替换与分割

8

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

9

保险箱的规则:前后查找

10

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

11

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

12

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

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

时间 2:30–3:20

虚假注册:锚点与校验

CASE-004

Grep & Co. 数据侦探事务所

委托书

虚假注册

委托人

网店“郑市集”的郑老板

证物

会员注册申请记录,外加一个检查账号规则的 Python 函数。

破案条件

筛出所有不符合规则的账号,并查明检查器为什么会被突破。

快到中午时,郑老板抱着笔记本电脑进来了。“账号规则我明明定好了,可奇怪的账号还是一个劲儿地注册进来。”所长盯着屏幕看了好一会儿,只说了一句:“规则没错。问法错了。”

郑市集的账号规则和检查函数如下(规则原文是韩语,保留原样)。

아이디 규칙: 영문 소문자로 시작하고, 소문자·숫자·밑줄(_)만 써서 4~16자

意思是:账号以英文小写字母开头,只能使用小写字母、数字和下划线(_),长度 4~16 个字符。

import re

ID = r"[a-z][a-z0-9_]{3,15}"

def is_valid_id(uid):
    return re.search(ID, uid) is not None

注册申请记录如下。

2026-10-18 14:02:51 signup id=jeong_fan
2026-10-19 03:11:02 signup id=ok_id!!!
2026-10-19 03:11:04 signup id=_admin
2026-10-19 03:11:05 signup id=ABC123
2026-10-19 03:11:07 signup id=a1
2026-10-19 09:40:13 signup id=minsu99

4-1. 重现事故:search 在哪儿都能找(5 分钟)

先看看郑老板的检查器会放过什么。

print(is_valid_id("ok_id!!!"))   # True   ← 带感叹号却通过了
print(is_valid_id("_admin"))     # True   ← 以下划线开头却通过了
print(is_valid_id("ABC123"))     # False

re.search 问的是:“这个字符串里某个地方有没有符合规则的部分?”ok_id!!! 里有 ok_id,_admin 里有 admin,所以都成功了。

re.search(ID, "ok_id!!!")    # <re.Match object; span=(0, 5), match='ok_id'>
re.search(ID, "!!!ok_id")    # <re.Match object; span=(3, 8), match='ok_id'>

输入校验要问的是另一个问题:“这个字符串整个都符合规则吗?”为此,必须在模式里写明“这里是开头,这里是结尾”。

4-2. ^ 和 $:钉住开头和结尾(8 分钟)

方括号外面的 ^ 表示字符串的开头,$ 表示字符串的结尾。这类符号叫作锚点(anchor,船锚)。

re.search(r"^id", "id=kim")       # <re.Match object; span=(0, 2), match='id'>
re.search(r"^id", "my id=kim")    # None
re.search(r"kim$", "id=kim")      # <re.Match object; span=(3, 6), match='kim'>
re.search(r"kim$", "id=kim!")     # None
  • 锚点不占字符。它匹配的是字符与字符之间的位置。所以 ^id 匹配到的仍然是 id 这两个字符。
  • 别和第 2 课的 [^...] 搞混了。^ 在方括号最前面是取反,在方括号外面是开头。

在检查器模式的前后加上 ^ 和 $,符合规则的部分就必须从头到尾连成一片。

证据分析室

re.search(r"", text)

返回值

破案条件用 search 来检查。在模式前面加 ^、后面加 $,把假账号挡在门外。

4-3. match 与 fullmatch(8 分钟)

即使不亲手写锚点,也可以通过选择函数来决定在哪里比对。

函数问的是用锚点写的话
re.search某个地方有没有符合的部分(没有锚点)
re.match从最开头起有没有符合的部分\A模式
re.fullmatch整个都符合吗\A模式\Z

\A 和 \Z 是和 ^、$ 相似的锚点,区别在 4-4 里看。

re.match(ID, "ok_id!!!")       # <re.Match object; span=(0, 5), match='ok_id'>
re.match(ID, "!!!ok_id")       # None
re.fullmatch(ID, "ok_id!!!")   # None
re.fullmatch(ID, "ok_id")      # <re.Match object; span=(0, 5), match='ok_id'>
  • re.match 不像名字那样去检查“是否整个都符合”。只要开头部分符合就算成功,所以它会放过 ok_id!!!。
  • 输入校验的正确答案是 re.fullmatch。就算忘了给模式加锚点也安全,还能避开下一节的陷阱。

证据分析室

re.search(r"", text)

返回值

依次点一点 search、match、fullmatch。再把文本改成 !!!ok_id、ok_id,比较这三个函数的差别。

4-4. $ 的陷阱,以及 \A、\Z、\z(6 分钟)

$ 藏着一条规则:它不光在字符串真正的结尾匹配,在最后一个换行符的紧前面也会匹配。所以 ^abc$ 连末尾带换行符的 "abc\n" 也会放行。

re.search(r"^abc$", "abc\n")       # <re.Match object; span=(0, 3), match='abc'>
re.fullmatch(r"abc", "abc\n")      # None
re.search(r"\Aabc\Z", "abc\n")     # None
  • \A 只在字符串的最开头匹配,\Z 只在字符串的最末尾匹配,没有换行符的例外。
  • 从 Python 3.14 起还可以用 \z。在 Python 里它和 \Z 完全一样,是为了和其他语言的正则写法保持一致而加进来的。
re.search(r"\Aabc\z", "abc")      # <re.Match object; span=(0, 3), match='abc'>
re.search(r"\Aabc\z", "abc\n")    # None

多特的提醒

从文件里一行一行读出来的文字,末尾常常带着换行符。用 ^...$ 检查的话,它会对那个换行符装作没看见,直接放行。结果账号就带着末尾的换行符被存进去了。做校验的时候,请用 fullmatch,或者 \A...\Z。

4-5. 单词边界:\b 与 \B(8 分钟)

第 3 课还留着一道作业:电话号码模式从 1234-5678-9012 正中间的 234 开始匹配的那次事故。这次需要的不是整个字符串的边界,而是在一长段文字里,能标出一个号码从哪儿开始、到哪儿结束的锚点。

\b 是单词边界。它匹配 \w(字母、数字、下划线)和 \W(其他字符)之间的位置,或者字符串开头、结尾处挨着 \w 的位置。\B 正好相反,匹配不是边界的位置。两者都不占字符。

re.findall(r"\bid\b", "id=kim userid=lee id_card")   # ['id']
re.findall(r"\Bid\b", "id=kim userid=lee id_card")   # ['id']
re.findall(r"\bid\B", "id=kim userid=lee id_card")   # ['id']

三个结果都是 ['id'],但找到的位置各不相同。

  • \bid\b:前后都是边界的,id=kim 里的 id
  • \Bid\b:前面不是边界的,userid 里的 id
  • \bid\B:后面是下划线(\w)、因而不是边界的,id_card 里的 id

在电话号码前后加上 \b,就不能从数字中间开始了。因为 1 和 2 之间两边都是 \w,不是边界。

re.findall(r"\b\d{2,3}[-. ]?\d{3,4}[-. ]?\d{4}\b", phonebook)
# ['010-0000-1234', '010 0000 9876', '02-0000-5678', '010.0000.4321']

有一点要当心:在 Python 里,韩文(以及汉字)也算 \w。所以紧贴在韩文后面的数字前面,是没有边界的。下面的 전화 就是韩语的“电话”。

re.findall(r"\b\d{2,3}-\d{3,4}-\d{4}\b", "전화010-0000-1234")    # []
re.findall(r"\b\d{2,3}-\d{3,4}-\d{4}\b", "전화 010-0000-1234")   # ['010-0000-1234']

证据分析室

re.search(r"", text)

返回值

这是第 3 课的电话号码模式。在前后加上 \b,挡住银行卡号和过长的号码。

4-6. 多行文本里逐行匹配:先尝尝 re.MULTILINE(5 分钟)

像注册记录这样,一个字符串里装着多行时,^ 只在第一行的开头匹配。想把每一行开头的日期都提取出来,就加上 re.MULTILINE(简写 re.M)标志。这样 ^ 和 $ 就会在每一行的开头和结尾匹配。

re.findall(r"^\d{4}-\d\d-\d\d", signups)
# ['2026-10-18']
re.findall(r"^\d{4}-\d\d-\d\d", signups, re.MULTILINE)
# ['2026-10-18', '2026-10-19', '2026-10-19', '2026-10-19', '2026-10-19', '2026-10-19']

即使加了 re.M,\A 和 \Z 依然只在整个字符串的开头和结尾匹配。

re.findall(r"^\d", "1\n2", re.M)    # ['1', '2']
re.findall(r"\A\d", "1\n2", re.M)   # ['1']

所有标志第 11 课再正式讲。今天只要记住:“有一个开关,能把 ^ 和 $ 切换成按行计算。”

证据分析室

re.search(r"", text)

标志

返回值

把 re.M 按钮打开、关上试试。打开后 ^ 会在每一行匹配。要是把 ^ 换成 \A,又会怎样?

破案(10 分钟)

把检查器里的 re.search 换成 re.fullmatch。模式保持不变。

def is_valid_id(uid):
    return re.fullmatch(r"[a-z][a-z0-9_]{3,15}", uid) is not None

for line in signups.splitlines():
    uid = line.split("id=")[1]
    if not is_valid_id(uid):
        print(line)
# 2026-10-19 03:11:02 signup id=ok_id!!!
# 2026-10-19 03:11:04 signup id=_admin
# 2026-10-19 03:11:05 signup id=ABC123
# 2026-10-19 03:11:07 signup id=a1

被筛出来的四条都违反了规则:ok_id!!! 带感叹号,_admin 以下划线开头,ABC123 用了大写,a1 太短。

结案报告 — 账号检查器用的是 re.search,“只要一部分符合就放行”。改成 re.fullmatch 后,拦下了 4 个违规注册。

可所长的手指顺着打印出来的四行时间一路往下点:03:11:02、03:11:04、03:11:05、03:11:07。“5 秒之内四次。凌晨 3 点。不是人手干的。”郑老板的脸色僵住了。这些假账号是在同一天凌晨一股脑涌进来的。是谁送来的,现在还没人知道。

第一部到此结束。一个字符、一个位置、次数、边界——“要找什么”的写法,你已经全部学会了。第二部里,我们要学怎样从找到的东西里取出需要的片段,一路追查这位凌晨来客。

练习

  1. 先猜猜 re.match(r"\d{4}", "2026-10-19") 和 re.match(r"\d{4}", "#2026") 的结果,再运行看看。
  2. 如果给账号规则再加一条“不能以下划线结尾”,模式该怎么改?(提示:把最后一个字符单独写出来)
  3. re.findall(r"\bcat\b", "cat concat cats") 和 re.findall(r"\Bcat", "cat concat cats") 分别会找到哪个 cat?

所长的笔记本

  • ^ 是开头,$ 是结尾。锚点匹配的不是字符,而是位置。

  • re.search 哪儿都行,re.match 只看最开头,re.fullmatch 看整体。输入校验用 fullmatch。

  • $ 在最后一个换行符前面也会匹配。要不带换行例外地看结尾,用 \Z(3.14 起 \z 也一样)。

  • \b 是 \w 和 \W 之间的边界,\B 是非边界的位置。韩文也是 \w。

  • 加上 re.M,^ $ 就按行匹配。\A \Z 照旧以整个字符串为准。

← 上一课

第3课 · 撕破的电话簿:量词

下一课 →

第5课 · 五花八门的日期:分组与选择

订阅新课时和新课程通知

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

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

问答

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

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

R

ReflexKR

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

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

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

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

Built with Reflex