未闻Code
首发于未闻Code

回《驳 <Python正则表达式,请不要再用re.compile了!!!>》

知乎用户@Manjusaka 在阅读了我的文章

青南:Python正则表达式,请不要再用re.compile了!!!zhuanlan.zhihu.com图标

以后,写了一篇驳文

Manjusaka:驳 《Python正则表达式,请不要再用re.compile了!!!》zhuanlan.zhihu.com图标

今天,我在这里回应一下这篇驳文。首先标题里面,我用的是,意为回复,而不是继续驳斥@Manjusaka的文章。因为没有什么好驳斥的,他的观点没有什么问题。

首先说明,我自己在公司的代码里面,也会使用re.compile。但是,我现在仍然坚持我的观点,让看这篇文章的人,不要用re.compile

你真的在意这点性能?

在公司里面,我使用re.compile的场景是这样的:

每两小时从10亿条字符串中,筛选出所有不符合特定正则表达式的字符串。

这个程序可以简化为如下结构:

import re
regex_list = ['恭喜玩家:(.*?)获得', '欢迎(.*?)回家', '组队三缺一']
sentence_list = ['字符串1', '字符串2', ..., '字符串10亿']
useful_sentence = []
for sentence in sentence_list:
    for regex in regex_list:
        if re.search(regex, sentence):
            break
    else:
        useful_sentence.append(sentence)

在这个场景下面,对于10亿个字符串,3个正则表达式,需要循环30亿次。虽然读取正则表达式缓存的时间很短,假设只有1毫秒,那么也会浪费833小时。为了实现2小时内处理10亿条数据,我做了很多优化,其中之一就是提前re.compile

import re
regex_list = ['恭喜玩家:(.*?)获得', '欢迎(.*?)回家', '组队三缺一']
sentence_list = ['字符串1', '字符串2', ..., '字符串10亿']
compiled_regex_list = [re.compile(x) for x in regex_list]
useful_sentence = []
for sentence in sentence_list:
    for regex in compiled_regex_list:
        if regex.search(sentence):
            break
    else:
        useful_sentence.append(sentence)

在这样的场景下,这样的数据量级下面,你是用re.compile,当然可以。

然而,你日常接触到的工作,都是这个量级吗?知乎上流行一句话:

抛开剂量谈毒性,都是耍流氓。

同样的,在数据处理上也适用:

抛开量级谈性能差异,都是耍流氓

处理几百条数据,还需要担心读取缓存字典的这点小小的性能开销?

我在另一篇文章

青南:为什么Python 3.6以后字典有序并且效率更高?zhuanlan.zhihu.com图标

中提到,从Python 3.6开始,字典不会再提前申请更多的空间了,同时也有序了,作为代价就是从字典读取值的过程多了一步。多出来的这一步实际上也会有性能开销,因为它需要先查询indices,然后再查询entries。为什么Python愿意放弃这一点点的性能开销?因为新的实现方式,在整体迭代、空间利用率上面都更高。

维护自文档性

回到正则表达式的例子来,Python区别于其他语言的一个非常重要的点是什么?是它的自文档性。

网上有这样一个段子:

问:如何把伪代码改写为Python代码? 答:把.txt改成.py即可。

Python的自文档性非常好,即便完全不懂编程的人,看到Python的代码,也能猜的出代码想实现什么功能。

请大家对比下面两种写法:

re.findall('密码: (.*?)$', sentence)

regex = re.compile('密码: (.*?)$')
regex.findall(sentence)

如果让一个完全不会编程的人来看,他看到第一段代码,会猜测:“findall是查找全部,这段代码可能是要从sentence找什么东西”。

而如果让他看第二段代码,他肯定会先问一句:“compile?编译?什么是编译?编写翻译吗?”

而对于刚刚学编程的人来说,如果他看的Python正则表达式入门的文档里面用了re.compile,他也会很疑惑,为什么要compile?编译成了什么东西?为什么不能直接查询?于是新人可能会过早去研究底层的东西。

但如果他看的文章直接是re.findall,那么语义非常明确:正则表达式.查询所有,一目了然,轻轻松松就能理解并学会。

以官方文档的实例入门

当我们学习一门新的语言的时候,第一应该参考的就是它的官方文档。在正则表达式官方文档docs.python.org/3/libra的例子中,无论是search还是findall都是使用re.xxx的形式。如下图所示:





所以网上那些首先使用pattern = re.compile,再pattern.xxx的人,要不就是直接从其他语言把先compile再查询的思维定势带到了Python中,要不就是做正则表达式调优做太久了,思维僵化了,一抬手就是re.compile

面向接口编程还是面向人类编程?

在我文章的评论里面,有人说,应该面向接口编程,而不是面向实现编程。

我想跟你们讲:你们对面向接口编程,理解得太狭隘了!

我们来看看,在Python著名的http库requests出来之前,使用urllib2发起一个请求是怎么写的:

import urllib2
gh_url = 'https://api.github.com'
req = urllib2.Request(gh_url)
password_manager = urllib2.HTTPPasswordMgrWithDefaultRealm()
password_manager.add_password(None, gh_url, 'user', 'pass')
auth_manager = urllib2.HTTPBasicAuthHandler(password_manager)
opener = urllib2.build_opener(auth_manager)
urllib2.install_opener(opener)
handler = urllib2.urlopen(req)

有了requests以后,实现同样的功能,我们是这样写的:

import requests

r = requests.get('https://api.github.com', auth=('user', 'pass'))

大家自己品位一下,req = urllib2.Request(gh_url)如果类比为pattern = re.compile('xxxx')handler = urllib2.urlopen(req)类比为pattern.findall(sentence) 那么,requests.get(xxx)就是re.findall

为什么我们现在愿意使用requests而不愿意使用urllib2

因为requestsfor human,而urllibfor interface.

不是问题的问题

在评论里面,有人质疑我使用re.findall,正则表达式不好维护?

@Manjsaka 也举出了下面这样的例子:



为什么使用re.findall,就一定要把正则表达式复制粘贴很多遍?

我单独定义一个文件不行吗:

# regex_str.py

NAME_REGEX = 'name:(.*?),'
AGE_REGEX = 'age:(\d+)'
ADDRESS_REGEX = 'address:(.*?),'

然后我要使用正则表达式的地方直接导入进来:

import re
import regex_str

name = re.findall(regex_str.NAME_REGEX, sentence)
age = re.findall(regex_str.AGE_REGEX, sentence)

请问哪里不好维护了?

总结

我的观点如下:

  1. re.compile很重要,也有用。但是大多数时候你不需要使用它。
  2. 对于初学者,请直接使用re.findall re.search,不要使用re.compile
  3. 对于有经验的工程师,在开发项目的时候,请首先使用re.findall re.search等等上层函数,直到你需要考虑优化正则表达式查询性能的时候,再考虑re.compile。因为很多时候,你的代码性能,还不至于需要靠几行re.compile来提高。
  4. 有人问正则表达式默认缓存512条,这个数字没有写在文档里面,如果哪天改了怎么办?我的回答是:看看你写过的代码,涉及到的正则表达式有几次超过了100条?
  5. 正则表达式基于DFA,在它的原理上,compile这一步确实是必需的。但这并不意味着,在写代码的时候,我们一定要自己手动写compile. 毕竟封装、抽象才是高级语言的一大特征,直接。在其他编程语言里面,没有把compile和查询封装成一个整体接口,但是在Python里面这样做了。那么我们就应该用这个更上层的接口。而不是手动compile再查询。
  6. 为什么Java程序员常常加班,而Python程序员常常提前完成任务?正是因为这种Language Specific的特性提高了生产效率,屏蔽了前期不需要太早关心的实现细节。如果抱着写代码要语言无关,要通用而故意放弃了一些语言特性,那为什么不直接写1010?那才是真正的语言无关,所有语言都是建立于二进制的1010上的。

多说一句

以下内容与本次讨论的re.compile无关。

@Manjusaka给出了一个compile需要3秒钟的大型正则表达式,并以此作为例子说明re.compile的合理性。

首先这种情况下,确实需要提前re.compile。

但我所想表达的是,在这种情况下,就不应该使用正则表达式。既然要做Redis的语法校验,那么就应该使用有限状态机。这种使用很多的f表达式拼出来的正则表达式,才是真正的难以维护,难以阅读。

否则为什么里面需要用一个csv文件来存放命令呢?为什么不直接写在正则表达式里面呢?使用CSV文件每行一个命令尚且可以理解,但是SLOT/SLOTS/NODE/NEWKWY这些正则表达式,可就说不过去了。或条件连接的每一段都要加上这些东西,如果直接写进去,这个正则表达式你们自己都看不下去了,所以才会需要使用拼接的方式生成。

我在读这段代码的时候,首先看到正则表达式里面的t[xxx],会先去找t是什么东西,发现t是一个字典,字典是在commands_csv_loader.py中生成的,然后去到这个文件里面,发现它读的是一个存放Redis命令的CSV文件。然后去项目根目录读取这个csv文件的内容,知道了它的结构,于是推测出t的结构。然后再回到正则表达式里面,继续看这个超大的正则表达式。整个过程会非常费时间和脑子。

但是,我又不能直接打印REDIS_COMMANDS这个变量,因为它多且乱,不同命令长短不一,拼出来以后再打印出来根本没法看。

这个正则表达式只有两位维护者知道什么意思,如果别人想贡献新的Redis命令,那么理解这个超大正则表达式都需要花很久的时间。

如果换成有限状态机,并且t使用Python的data class来表示,而不是使用字典,那么就会简洁很多。有限状态机的一个特点是,只需要关注当前状态、转移条件和目标状态,可能一开始写起来有点麻烦,但是以后维护和新增,都是直接定位目标,直接修改,不用担心会影响不相干的其他地方。

算上维护时间,正则表达式真是一个非常糟糕的方式。

编辑于 2019-08-18

文章被以下专栏收录