提升正则可读性的六种方法

2021-05-29 ⏳7.3分钟(2.9千字)

最近读到一篇文章 Writing better Regular Expressions in PHP,讲如何提高正则表达式的可读性。感觉非常好。赶紧联系作者申请授权,现在译成中文分享给大家。如果大家是初学正则,也可以阅读我之前写的正则表达式入门一文。以下是翻译内容。

正则表达式很强大。但众所周知,正则表达式的可读性是比较差的。维护已经写好的正则表达式更是不容易。

PHP 支持 PCRE(从 PHP 7.3 开始使用 PCRE2)正则表达式。PCRE 正则表达式支持一些高级特性。通过它们可以写出更易读、自解释(self-explanatory),而且易维护的正则表达式。PHP 的 filtersctype 函数族 提供了诸如 URL 校验、邮箱校验、和字母数字值校验等功能。有了它们,我们也可以少用一些正则表达式。

IDE 支持正则表达式作语法高亮,这对我们理解正则表达式有很大的帮助。IDE 甚至还能帮我们快速修正一些错误。但从长期来看,我们还是要编写自解释的和可读性更强的正则表达式。

下面会介绍一些在 PHP 中改进正则表达式的技巧。注意,有些特性老版本(7.3之前)的PHP可能不支持。而且,这些使用这些特性可能导致写出来的正则表达式不容易移除到其他语言。譬如说,JavaScript 就不支持命名捕获功能(连旧版 PHP 都支持)。JavaScript 直到 ECMAScript 2018 标准才加入该功能。

但大家也不用太担心。本文讲的多数内容都适用于其他语言。译者注。

选择分割符

正则表达式分为表达式和参数两部分。表达式两边有两个分割符,后面跟着可选的参数。

大家看下面的正则表达式:

/(foo|bar)/i

任何一个正则表达式都是用分割符包住表达式内容,后面接着参数。参数是可选的。在上例中,(foo|bar) 就是表达式本身,i是参数或都修饰符,/ 就是分割符。

虽然最常用见分割符是斜杠(/),我们还可以使用诸如 ~, !, @, #, $ 等符号。但不能使用数字(0-9)、字母(a-zA-Z)、多字节字符(比如 Emoji 表情)和反斜杠(\)。

另外,括号也可以当成分割符。大括号({})、小括号(())、方括号([])和尖括号(<>)都能用,在某些场景下可以提高可读性。

如果正则表达式内容需要使用分割符,那一定要对其进行转义。所以,如何选择合适的分割符就变得非常重要了。转义越少,越容易理解。避免使用正则元字符(诸如 ^, $, 括号和其他在正则中有特殊含义的字符)可以减少转义字符的数量。

虽说斜杠很常用,但它不适合用在处理 URI 相关的正则表达式。

preg_match('/^https:\/\/example.com\/path/i', $uri);

在上例中,因为表达式包含很多斜杠,分割符也是斜杠,所以表达式中的斜杠就得转义。这让降低了正则的可读性。

只须将分割符从 / 变成 # 就可以去掉所有转义,从而让正则更容易阅读:

- /^https:\/\/example.com\/path/i
+ #^https://example.com/path#i
- preg_match('/^https:\/\/example.com\/path/i', $uri);
+ preg_match('#^https://example.com/path#i', $uri);

避免转义字符

除了选择适当的分割符之外,还有一些方法来避免转义。

在正则中,方括号内的元字符不需要转义。例如,., *, +$ 都是有特殊功能的元字符,但在方括号内就是普通字符。

/Username: @[a-z\.0-9]/

在上面的正则中,句点(.)被转义成\.,其实没有必要。在方括号内,. 就是句点,没有特殊含义,不必转义。

再如,如果某个字符不是范围表达式的一部分,那也不需要转义。

举个例子。连字符(-)跟两个字符连用可以表示字符范围。如果连字符两边没有字符,那就没有特殊含义。在正则/[A-Z]/中,连字符- 用于表示从 AZ 这个范围的字符。如果我们对连字符作转义(/[A\-Z]/),那就只能匹配字符 A, Z-。如果不用转义,而是把连字符放到方括号内容的最后面,效果是一样的。正则/[A\-Z]//[AZ-]/是等效的,但后者更易读。

转义用太多不会影响正则的语义,却会降低可读性。

- /Price: [0-9\-\$\.\+]+/
+ /Price: [0-9$.+-]+/ 

有一个参数x,开启之后,如果一个字符不需要转义但我们对其进行转义,就会报错。但功能有限,因为它没法识别上下文语义(比如,不能根据是在括号内抛出错误等)。

preg_match('/x\yz/X', ''); // "y" is not a special character, but escaped.
Warning: preg_match(): Compilation failed: unrecognized character follows \ at offset 2 in ... on line ...

非捕获分组

在正则表达式中,圆括号()会开启一个捕获组。被圆括号内的正则匹配的内容会保存到匹配列表中:

下面是一个例子,演示如何从文本Price: €24提取价格:

$pattern = '/Price: (£|€)(\d+)/';
$text    = 'Price: €24';
preg_match($pattern, $text, $matches);

上例中有两个捕获组:第一个是捕获货币符号(£|€),后面一个是捕获数字。

变量$matches保存了两个捕获组的匹配内容:

var_dump($matches);
array(3) {
  [0]=> string(12) "Price: €24"
  [1]=> string(3) "€"
  [2]=> string(2) "24"
}

如果不想捕获或者不想知道捕获的结果,那就需要非捕获分组。

非捕获分组的语法是在第一个括号后面加上 ?:。正则引擎会确保内容匹配括号内的规则,但不会将匹配的内容加入结果列表,即不会捕获。

如果前面的正则只想获取数字,那么(£|€)就可以改为非捕获分组(?:£|€)

$pattern = '/Price: (?:£|€)(\d+)/';
$text    = 'Price: €24';
preg_match($pattern, $text, $matches);
var_dump($matches);
array(2) {
  [0]=> string(12) "Price: €24"
  [1]=> string(2) "24"
}

如果正则中有多个分组,使用非捕获分组可以减少$matches中的数据量。

命名捕获

命名捕获在语法上跟非捕获分组有点像,但它可以捕获内容并给内容指定一个名字。这不紧是给匹配结果加上名字,更是给匹配模式加上名字。

我们再次以上面的正则为例进行说明。每个捕获分组都可以指定名字:

/Price: (?<currency>£|€)(?<price>\d+)/

命名语法是在分组括号后面加?<NAME>

在上例中,(?<currency>£|€)的名字是currency(?<price>\d+) 的名字是price。这些名字一定程度上为正则表态式提供了更多的上下文信息,而且也让大家能用名字读取匹配结果。

$pattern = '/Price: (?<currency>£|€)(?<price>\d+)/';
$text    = 'Price: €24';
preg_match($pattern, $text, $matches);
var_dump($matches);
array(5) {
  [0]=> string(12) "Price: €24"
+ ["currency"]=> string(3) "€"
  [1]=> string(3) "€"
+ ["price"]=> string(2) "24"
  [2]=> string(2) "24"
}

现在变量$matches同时包含分组编号和分组名字两类匹配结果。

使用命名捕获分组,我们可以更容易地从$matches提取内容。而且后面只要不改分组名称,就能比较容易地修改正则表达式的内容。

默认情况下捕获组不能重名。如果重名会报错 PHP Warning: preg_match(): Compilation failed: two named subpatterns have the same name (PCRE2_DUPNAMES not set) at offset ... in ... on line ...。如果非要重名,可以使用J参数:

/Price: (?<currency>£|€)?(?<price>\d+)(?<currency>£|€)?/J'

在这个正则中有两个分组都就currency,而且用J参数指明允许重复。在匹配的时候,命名分组只会返回最后的匹配结果,但按编号分组的仍然会返回所有内容:

$pattern = '/Price: (?<currency>£|€)?(?<price>\d+)(?<currency>£|€)?/J';
$text    = 'Price: €24£';
preg_match($pattern, $text, $matches);
var_dump($matches);
array(6) {
  [0]=> string(14) "Price: €24£"
  ["currency"]=> string(2) "£"
  [1]=> string(3) "€"
  ["price"]=> string(2) "24"
  [2]=> string(2) "24"
  [3]=> string(2) "£"
}

写注释

有些正则很长,甚至需要写好多行。

我们可以一次写一部分匹配模式,然后将它们连接起来。我们还可以为每一部分添加注释。这样既提高可读性,又能降低代码评审的难度。

- $pattern  = '/Price: (?<currency>£|€)(?<price>\d+)/i';
+ $pattern  = '/Price: ';
+ $pattern .= '(?<currency>£|€)'; // Capture currency symbols £ or €
+ $pattern .= '(?<price>\d+)'; // Capture price without decimals.
+ $pattern .= '/i'; // Flags: Case-insensitive

此外,还可以在正则表达式内部添加注释。

有一个参数叫x,可以让引擎忽略正则中所有的空白字符。这样就能使用空白字符或者换行符重新组织正则表达式了。

- /Price: (?<currency>£|€)(?<price>\d+)/i
+ /Price:  \s  (?<currency>£|€)  (?<price>\d+)  /ix

在正则 /Price: (?<currency>£|€)(?<price>\d+)/i 中,引擎会检查Price:后面是否有空格字符。但如果加上x参数,所有空白都会忽略。如果想匹配一个空白实际字符,需要使用\s

另外,如果加上了x选项,以 # 开头的字符会被当成注释,这类似于 PHP 中的 //# 注释语法。

我们可以在子模式两边添加空白,形成逻辑分组,这样可以让正则更易读。但更好的办法是将正则拆成多行,并逐行添加注释:

- /Price: (?<currency>£|€)(?<price>\d+)/i
+ /Price:           # 检查是否匹配标签 "Price:"
+ \s                # 确保后面有一个空白字符
+ (?<currency>£|€)  # 捕获货币符号 £ 或者 €
+ (?<price>\d+)     # 捕获价格整数部分
+ /ix

在 PHP 中还可以使用 Heredoc 来保留字符串格式。

$pattern = <<<PATTERN
  /Price:           # 检查是否匹配标签 "Price:"
  \s                # 确保后面有一个空白字符
  (?<currency>£|€)  # 捕获货币符号 £ 或者 €
  (?<price>\d+)     # 捕获价格整数部分
  /ix               # 参数: 不区分大小它
PATTERN;
preg_match($pattern, 'Price: £42', $matches);

字符组

正则表达式支持字符组。使用它们,可以不需要关注太多细节,从而提高可读性。

\d 可能是最常用了字符组。\d 表示匹配单个数字,在非Unicode模式下跟 [0-9] 是等价的。此外,\D表示\d的䃼集,跟 [^0-9] 等价。

如果我们想匹配文本中的单个数字(数字后面不是数字),我们可以使用字符组进行简化:

- /Number: [0-9][^0-9]/
+ /Number: \d\D/

正则表达式支持很多字符组,可以更好地简化正则:

如果使用 /u 选项,就开启 Unicode 模式。Unicode 模式有更多的字符组。它的命名字符组都写成 p{EXAMPLE},其中 EXAMPLE 就是字符组的名字。使用大写的P(也就是\P{FOO})来表示对应的补集。

例如,\p{Sc} 表示所有在用和将来要用的货币符号。它还有一个更长的版本(即 \p{Currency_Symbol}),但 PHP 现在在不支持。

$pattern = '/Price: \p{Sc}\d+/u';
$text = 'Price: ¥42';

使用字符组同样可以用于匹配和捕获分组。如果以后有了新的货币符号,只要相关信息加入的 Unicode 数据库,就能匹配到。

Unicode 字符组还包含很多语言相关的字符组。比如,\p{Sinhala} 表示所有僧伽逻语文字,等价于 \x{0D80}-\x{0DFF}

- $pattern = '/[\x{0D80}-\x{0DFF}]/u';
+ $pattern = '/\p{Sinhala}]/u';
$text = 'පීඑච්පී.වොච්`;
$contains_sinhala = preg_match($pattern, $text);

全文完。