正则表达式烹饪指南 | 书评
今天介绍的这本书是一本正则表达式(Regular Expression)指南。正则表达式是做什么的呢?
几乎所有的 Text Editor(文本编辑器)都支持简单的关键词搜索。你在《金瓶梅》中搜索「西门庆」,会得到所有这三个字出现的位置(共 4750 处)。但这种字面搜索可能会包含无意义的结果,如「打开西门庆祝节日」。因此,要想让搜索更准确,我们要给它附加一些条件,比如规定,如果「庆」后面是「祝」,则把结果舍掉。
这种「可编程」的搜索就叫做「正则表达式」。虽然正则表达式程序员用的比较多,但是我可以肯定地说,它是屠龙刀,而且绝对不会出现「无龙可屠」的情况。只要你经常处理文本,哪怕只是写书评,都一定能用到它,和是否编程没什么关系。
本书特色
1. 口味多(flavor)
这本书叫做 Cookbook(烹饪),即然是烹饪,就免不了众口难调的问题。一种编程语言,以及某些专门为正则表达式开发的「库」,叫做 flavor(口味)。正则表达式的一个现实问题是:flavor 太多且规则不完全一致,例如 Java 这个 flavor 允许 lookbehind(正则表达式的「后视」功能),而 JavaScript 完全不支持。因此当我们要用正则表达式的时候,一定要弄清楚和什么 flavor 一起。
这本书的一大特色是它介绍了 .NET、Java、JavaScript、XRegExp、PCRE、Perl、Python、Ruby 等 8 种流行的 flavor。好处一是,很有可能它包括你熟悉的语言,立马可用。更好的是,对于不感兴趣的语言直接跳过,无形中看书速度快了好几倍。
2. 菜单全(recipe)
这本书一共 9 章,前 3 章介绍了基础知识。从第 4 章开始,一直到结束都是具体的recipe(例题),每个 recipe 按照 flavor 的不同分别解释原理,非常详细。如果你有一定的基础,你可以把它当成一本练习册,直接跳到第 4 章,对照解析研究这些例子,很有意思。大多例子还是挺实用的,如删掉重复行,判断密码的强弱,等等。
下面我介绍两个我实际使用的例子,flavor 是 JavaScript。用什么 flavor 看个人的需求,我经常用 JS,因为它在 iPad OS 中应用比较广泛,仅此而已。
例1: 替换双引号
很长时间我一直用 iA Writer 写文章,它的引号是直引号 “,如:
"你好!" 他说。
为了提高可读性,我想把所有的双引号都换成直角引号:
「你好!」 他说。
也就是说,左 “ 变成 「,右 “ 变成 」。
倘若用 Text Editor 直接替换,可想而知,结果要么是:
「你好!「 他说。
或者:
」你好!」 他说。
解决办法可以使用正则表达式,JavaScript code 如下:
const txt = `"你好!" 他说。`
const regex = /"(.*?)"/g;
let replace = txt.replace(regex, "「$1」");
console.log(replace);
const regex = /”(.*?)”/g; 这一行就是正则表达式,结果是:
「你好!」 他说。
注意如下情况,假设文本变为:
const txt = `"你好!" 他说。"你好!" 她说。`
这里有两对引号,我们希望结果是:
「你好!」 他说。「你好!」 她说。
然而,正则表达式会输出:
「你好!" 他说。"你好!」 她说。
可见,中间的两个引号被「吞掉」了,因为默认情况,正则表达式比较 greedy,它倾向于匹配尽量多的字符,因此它会把第一个左引号和第二个右引号看作一对。避免的方法是,注意 const regex = /”(.*?)”/g; 中的 「?」,它的作用是告诉正则表达式,匹配范围要尽量少,只要遇到匹配就算。
例 2: 整理 txt 文件
以《金瓶梅》为例,网上下载的 txt 文件格式不理想,比如:
-------------
分节阅读 1
……
第一回 西门庆热结十弟兄 武二郎冷遇亲哥嫂
……
二八佳人体似酥,腰间仗剑斩愚夫。
虽然不见人头落,暗里教君骨髓枯。
这一首诗,是昔年大唐国时,一个修真炼性的英雄,入圣超凡的豪杰,到后来位居紫府,名列仙班,率领上八洞群仙,救拔四部洲沉苦一位仙长,姓吕名岩,道号纯阳子祖师所作。
……
分节阅读 2
……
-------------
这里有几个问题:
- 章回标题没有 Markdown 的标识「## 」。
- 很多无意义的 ————-。
- 无意义的「分节阅读 xxx」,xxx 是一个数
- 正文每行前后有很多空格。
- 正文内有很多空格,如「第一回」和「西门庆」,以及「十兄弟」和「武二郎」之间。
正则表达式最擅解决此类问题,因为每个问题都能精确转换为一个定义,例如,第一个问题,相当于要求,凡是以「第xxx回」(xxx 最少一个汉字,如「一」,最多三个,如「九十九」)打头的行,前面插入「## 」(两个 # 及一个空格)。只要定义明确,就很好办。
举例 code 如下,
const cpbd = Pasteboard.paste();
let str = cpbd.replace(/-+/g, ""); //删除 ------
str = str.replace(/^\s+/gm, "").replace(/\s+$/gm, ""); //去掉行前后空格
str = str.replace(/(第.{0,3}回)/gm, "## $1"); // 加 ##
str = str.replace(/分节阅读.*/g, ""); //删除 分节阅读xxx
str = str.replace(/(## 第.{0,3}回)(\s+)(\S+)(\s+)(\S+)$/gm, "$1 $3 $5"); //去掉正文内多余空格
这里的正则表达式是 JavaScript replace() 函数的第一个参数,第二个参数是替换后的内容。整理后的结果是:
## 第一回 西门庆热结十弟兄 武二郎冷遇亲哥嫂
……
二八佳人体似酥,腰间仗剑斩愚夫。
虽然不见人头落,暗里教君骨髓枯。
这一首诗,是昔年大唐国时,一个修真炼性的英雄,入圣超凡的豪杰,到后来位居紫府,名列仙班,率领上八洞群仙,救拔四部洲沉苦一位仙长,姓吕名岩,道号纯阳子祖师所作。
……
《金瓶梅》八十万字,在我的 iPad 上,我复制到 Clipboard,执行这段 JS code,再复制回 Clipboard,毫无压力。
效率
最后简单谈谈效率。我们已经看到,正则表达式的关键是抽象出某种规律,作为定义的条件。这至少带来两个后果:
- 条件越严格,结果越准确。
- 条件越严格,操作成本越高。
对于第一条,「严格」和「准确」是相对的。就说本文开始的例子,在《金瓶梅》中搜索「西门庆」,条件是很宽松的,只要含有「西门庆」三个字即可。但它的结果也是最冗余的,不能处理任何特殊情况,像是「打开西门庆祝节日」。如果要精炼结果,就得加上限制。如规定,假如「西门庆」和「祝」一起出现,则把结果舍掉,这样行不行呢?不行,因为它连「与西门庆祝寿」这种合理的结果都删掉了。
因此,用正则表达式,难度往往在于如何定义清晰简明的规律。而定义规则既取决于问题的复杂性,又涉及到设计正则表达式本身的成本,这就是第二个后果。
给复杂问题设计并维护一个正则表达式很费时间,你永远想不到前面有什么坑在等着,一旦出错,debug 费时费力。所以对于具体问题,先要想好,你是想要一个模糊结果但搜索简单,还是精确的结果但需要精心设计正则表达式。
其实对于「西门庆」这个例子,直接搜索就是最简单的方法,因为它几乎没有歧义,「打开西门庆祝节日」是我编的,《金瓶梅》中不存在。但是,如果单搜「西」、「门」、「庆」,显然就困难多了,那就不得不思考如何添加限制条件,成本也就随之上升。
总之,即使你从不编程,正则表达式也绝对值得熟悉一下。
Some people, when confronted with a problem, think “I know, I’ll use regular expressions.” Now they have two problems. (有些人啊,一遇到问题,就想,「没事儿,我会正则表达式。」于是他们有了俩问题。)
Just kidding.