用 PHP 编写更好的正则表达式regex

PHP 正则表达式 - 提高可读性和可维护性

PHP正则表达式功能强大,但不知道它们是否可读,而且通常情况下,维护正则表达式并不是一项简单的任务。

PHP 使用PCRE自 PHP 7.3 起为 PCRE2)正则表达式风格,它带有一些高级功能,可以帮助编写可读、不言自明且易于维护的正则表达式。PHP 的过滤器ctype 函数提供 URL、电子邮件和字母数字值等验证,这有助于首先不使用正则表达式。

IDE 可以提供更好的语法突出显示,以帮助使给定的正则表达式更具可读性和更容易掌握,甚至提供快速修复来改进它们。但是,从长远来看,首先编写一个不言自明且更具可读性的正则表达式会有所帮助。

这里有一些技巧可以改进和编写更好的 PHP 正则表达式。请注意,它们可能不适用于较旧的 PHP 版本(早于 PHP 7.3)。此外,使用这些改进也意味着正则表达式对其他语言的可移植性可能较差。例如,即使在较旧的 PHP 版本中也支持命名捕获,但在 JavaScript 中,命名捕获功能仅在 ECMAScript 2018 中添加。



分隔符的选择

每个正则表达式都有两部分:表达式和标志。正则表达式包含在两个字符内,后跟可选标志。

考虑下面的正则表达式:

/(foo|bar)/i

在任何正则表达式中,分隔符包含表达式,后跟可选标志。在上面的例子中,(foo|bar)是表达式本身,i是一个标志/修饰符。该/字符是分隔符。

正斜杠(/)经常被用作分隔符,但它可以是任何字符,例如~!@#$,等字母数字字符(AZ,az和0-9),多字节的字符(例如表情符号)和反斜杠(\)是允许是分隔符。

或者,大括号也可以用作分隔符。也接受带有{}()[], 和 的正则表达式,并且<>根据上下文可能更具可读性。

分隔符的选择很重要,因为必须对表达式中出现的所有分隔符进行转义。正则表达式中的转义字符越少,可读性就越高。不选择元字符(如^$, 大括号等在正则表达式中带有特殊含义的字符)可以减少转义字符的数量。

尽管正斜杠作为正则表达式分隔符很常见,但它通常不适合包含 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]/,破折号字符-用于创建从A到的匹配范围Z。如果连字符转义(/[A\-Z]/),正则表达式只匹配字符AZ-。不是转义破折号 ( \-),只需将破折号移到方括号的末尾即可减少需要转义的字符数;正则表达式/[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"
}

对于根本不需要捕获或限制传递给$matches数组的匹配项数量的正则表达式,非捕获组可以提供帮助。

非捕获组的语法是一个以 开头(?:并以 结尾的大括号)。正则表达式引擎断言大括号内的表达式,但它不会作为匹配项返回;即未捕获。

如果上面的表达式只对数值感兴趣,(£|€)捕获组可以变成非捕获组:(?:£|€)

$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+)/

命名捕获组的语法为(?<,后跟组名,并以).

在上面的例子中,(?<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标志明确允许。当它与字符串匹配时,它只会返回命名捕获值的最后一个匹配项,但位置值 ( 012, …) 包含所有匹配项。

$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 中的//and#注释语法。

子模式的逻辑组周围有更多的间距,可以使模式更具可读性。但是,更好的方法是将表达式拆分为多行并添加注释:

- /Price: (?<currency>£|)(?<price>\d+)/i
+ /Price:           # Check for the label "Price:"
+ \s                # Ensure a white-space after.
+ (?<currency>£|)  # Capture currency symbols £ or €
+ (?<price>\d+)     # Capture price without decimals.
+ /ix

在 PHP 变量中存储时,使用 Heredoc/Nowdoc 可以保留格式。自 PHP 7.3 起,heredoc/nowdoc 语法也更加宽松

$pattern = <<<PATTERN
  /Price:           # Check for the label "Price:"
  \s                # Ensure a white-space after.
  (?<currency>£|€)  # Capture currency symbols £ or €
  (?<price>\d+)     # Capture price without decimals.
  /ix               # Flags: Case-insensitive
PATTERN;
preg_match($pattern, 'Price: £42', $matches);

命名字符类

正则表达式支持字符类,它们可以帮助消除对正则表达式的审查,同时使它们更具可读性。

\d可能是最常用的字符类。\d代表一个数字,相当于[0-9](在非 Unicode 模式下)。此外,\D是 的倒数\d,并且等价于[^0-9]

精心寻找数字,后跟非数字的正则表达式可以在不改变功能的情况下进行简化:

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

正则表达式支持更多的字符类,这可以使差异更加突出。

  • \w相当于[A-Za-z0-9_]
    - /[A-Za-z0-9_]/
    + /\w/
  • [:xdigit:]是匹配所有十六进制字符的命名字符类,等效于[a-fA-F0-9]
    - /[a-fA-F0-9]/
    + /[[:xdigit:]]/
  • \sis a 匹配所有空白字符,相当于[ \t\r\n\v\f]
    - / \t\r\n\v\f/
    + /\s/

使用具有 Unicode 支持 ( /uflag) 的正则表达式时,它会启用更多字符类。Unicode 命名字符类具有模式\p{EXAMPLE},其中EXAMPLE是字符类的名称。使用大写字母P(例如\P{FOO})与该字符类相反。

例如,\p{Sc}对于指定的字符类的所有当前和未来 ç urrency小号ymbols。它们有更长的形式(例如\p{Currency_Symbol}),但 PHP 目前不支持它们。

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

即使没有关于字符的先验知识,字符类也允许捕获/匹配类。未来引入的新货币符号将开始匹配,只要该信息包含在下一次 Unicode 数据库更新中。

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);

本文的先前版本错误地在命名字符类部分下混淆了,并且有一个 PHP 不支持的长格式 Unicode 字符类的示例。多亏了Bruno Verley (@brnvrl),这个问题现在已经解决了。感谢Sergey Lebedev和 Taoshu,这篇文章还有俄文中文版本

转载请注明出处:https://www.onexin.net/php-regex/

相关文章:

1、PHP 版本 8.2,8.1,8.0,7.4
https://www.onexin.net/php-8_2-8_1-8_0-7_4/

2、PHP中的Liskov替换原则
https://www.onexin.net/php-liskov/

3、php实现自运行的do实例详解
https://www.onexin.net/php-do/

4、PHP 后端实现JWT认证方法示例
https://www.onexin.net/php-jwt/

5、Yii 2.0 框架中使用Smarty视图模板引擎
https://www.onexin.net/yii-2-0-smarty/

Leave a Reply