CASE-012
Grep & Co. 数据侦探事务所
委托书
委托人
事务所全体成员
证物
早上九点进入邮箱的一封密文。只由反斜杠、数字和英文字母组成。
破案条件
把密文切成 token 加以破译,查明回溯者究竟是谁。
早上九点,事务所里挤满了人。姜队长站在门边,郑老板摊开了记事本,吴记者把笔记本电脑搁在膝盖上。所长把显示器转过来,让所有人都看得见。“今天大家一起读。一个字一个字地读。”
这是邮箱里收到的密文。
\x46\N{HYPHEN-MINUS}\060\x32 \x3d \uc11c\uc9c4
\x34\x46 \060\063\x3a\060\x32
(a+)+$吴记者摇摇头。“这哪是文字,就是一堆符号嘛。”所长答道:“符号也有规则。把规则一条条写下来,机器就会替你读。”
把一长段文本切成有意义的小块(token)的程序,叫作分词器(tokenizer)。今天我们把学过的东西全部集中起来,做一个分词器,解开这封密文。
先拿 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)
返回值
密文里的 \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)
返回值
分析室里弹出红色错误框时显示的信息,来自一个叫 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)
返回值
从文件或网络读进来的数据,有时不是 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 objectbytes 模式里的 \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第 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))现在把密文的规则写下来:四种字符转义、签名、空白,以及堵住漏洞的 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)
返回值
破译出来的字对不对,也可以直接问 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 已停用。
那天下午,所长和新人把十二份案件档案从头翻了一遍。“换作现在,会怎么写?”
re.escape("$4.99")。\d{2,3}[가-힣]\d{4} 这样用量词缩短,校验用 fullmatch。search,而用 re.fullmatch(r"[a-z][a-z0-9_]{3,15}", user_id)。(?P<y>\d{4})(?P<sep>[-./])(?P<m>\d\d)(?P=sep)(?P<d>\d\d)。有了名字,就不用数编号了。groupdict() 把一行变成 dict。count= 这样的关键字参数。^a+$ 代替 ^(a|aa)+$。不写重叠的重复。re.X 拆开写,用 re.compile 起个名字。MISMATCH。深夜,邮箱里又进来一封邮件。这次不是密文。
韩星。谢谢你读到了最后。当年并不是没人读得懂我的代码,是我没把它写得让人读懂。下次,我会加注释的。
(?x) a+ $ # 不再回头了
签名变了。新人习惯性地把那个签名放进分析室试了试。去掉外层重复的模式,就算失败也毫不犹豫。
sign = r"(?x) a+ $ # 不再回头了"
print(re.fullmatch(sign, "a" * 30 + "b")) # None所长把那本旧笔记本放到新人的桌上。从第一页起,每个案件都写满了所长的字迹。“现在它是你的了。赢的不是更短的正则,是读得懂的正则。只要记住这一点。”
ASSIGN(对应 =)和 NEWLINE(对应换行),把第一行是 x = 3.5、第二行是 y = x * 2 的两行代码切成 token。\U 后跟八位十六进制的 token,然后破译 \U0000C11C\U0000C9C4。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 是文档里没有的内部工具。
赢的不是更短的正则,是读得懂的正则。
订阅新课时和新课程通知
本课程新增课时或推出后续课程时,我们会发邮件通知你。
问答
关于本课有任何疑问都可以在这里留言。所有语言版本页面的提问会汇集在一起。