PHP正则中的贪婪与非贪婪匹配,如何避免匹配过多?

AI绘画1天前发布 jdkjadmin
0 00
PHP正则中的贪婪与非贪婪匹配,如何避免匹配过多?

PHP正则中的贪婪与非贪婪匹配,如何避免匹配过多?

在 PHP 中使用正则处理文本时,最常见的困惑之一是:明明只想匹配一段内容,结果却匹配了更长的一段。例如,正则表达式要提取两个 HTML 标签之间的内容,却从第一个开始标签一直匹配到最后一个结束标签。

这通常与量词的贪婪行为有关。给量词加上问号可以让它变成非贪婪,但这并不意味着“加个问号就能正确匹配”。理解量词如何扩展、正则如何寻找成功结果,以及分隔符和输入边界如何影响匹配,才能可靠地控制范围。

一、什么是贪婪匹配?

量词用于指定前面的字符、字符组或子表达式重复多少次。常见量词包括:

  • *:重复零次或多次。
  • +:重复一次或多次。
  • ?:重复零次或一次。
  • {m,n}:重复至少 m 次、至多 n 次。

在 PHP 的 PCRE 正则中,这些量词默认是贪婪的:在满足后续模式的前提下,先尽可能多地匹配;如果后面的部分无法匹配,正则引擎可能回退一些字符,再尝试完成整个模式。

下面的代码可以直接运行:

<?php
$text = 'a123b456b';

preg_match('/a.*b/', $text, $matches);

var_export($matches);
// array (
//   0 => 'a123b456b',
// )

.* 可以匹配任意数量的字符,默认尽可能扩展。后面的 b 仍能匹配字符串最后一个 b,所以整个匹配从 a 一直延伸到了末尾。

这并不表示正则引擎永远不回退。更准确地说,贪婪量词会先尝试较长的匹配;若导致整个模式失败,就可能回退,直到后续部分能够匹配,或确认失败为止。

二、非贪婪量词:优先从短的匹配开始

在常见量词后面加上 ?,就可以得到非贪婪(也常称懒惰)形式:

  • *?:零次或多次,优先匹配较短内容。
  • +?:一次或多次,优先匹配较短内容。
  • ??:零次或一次,优先匹配零次。
  • {m,n}?:在给定次数范围内,优先尝试较少次数。

将上例改为 .*?:

<?php
$text = 'a123b456b';

preg_match('/a.*?b/', $text, $matches);

var_export($matches);
// array (
//   0 => 'a123b',
// )

非贪婪量词先尝试匹配尽可能少的内容,再检查后续的 b 是否成立。因此它在遇到第一个可用的 b 时就能结束。

关键在“优先尝试”,而不在“绝不变长”。如果较短的匹配无法让整个模式成立,非贪婪量词也会继续扩展。例如:

<?php
$text = 'a12b34c56';

preg_match('/a.*?c/', $text, $matches);

var_export($matches);
// array (
//   0 => 'a12b34c',
// )

模式必须找到 c 才能成功。非贪婪的 .*? 会从短处开始尝试,但仍需扩展到能让整个表达式匹配的位置。

三、量词的贪婪与懒惰,与整体匹配是两回事

非贪婪不等于“只匹配一个字符”或“只匹配最近的分隔符”。它只是改变量词尝试长度的顺序。一个完整正则还可能受起始位置、后续条件、字符集、修饰符等影响。

下面用 preg_match_all() 比较两种写法。该函数会在主题字符串中查找所有匹配:

<?php
$text = '<b>one</b> <b>two</b>';

preg_match_all('/<b>.*<\/b>/', $text, $greedy);
preg_match_all('/<b>.*?<\/b>/', $text, $lazy);

var_export($greedy[0]);
// array ( 0 => '<b>one</b> <b>two</b>' )

echo PHP_EOL;

var_export($lazy[0]);
// array (
//   0 => '<b>one</b>',
//   1 => '<b>two</b>',
// )

第一个表达式中的 .* 会尽可能延伸到后面仍能满足 <\/b> 的位置,因此一次匹配覆盖了两组标签。第二个表达式中的 .*? 优先在第一个结束标签处完成匹配;preg_match_all() 随后继续搜索剩余字符串。

这里的标签只是演示分隔符匹配,不代表正则适合完整解析 HTML。HTML 有嵌套结构、属性、注释、字符引用和其他语法细节;需要解析 HTML 时,应使用 PHP 的 DOM 相关工具,而不是依赖一个简单的标签正则。

四、优先缩小字符范围,而不只是改成非贪婪

处理有明确结束符的内容时,通常可以让中间部分排除结束符。比如,要匹配一对尖括号中的、不含尖括号的内容,可以写成:

<?php
$text = '<first> and <second>';

preg_match_all('/<[^<>]*>/', $text, $matches);

var_export($matches[0]);
// array (
//   0 => '<first>',
//   1 => '<second>',
// )

[^<>]* 表示匹配零个或多个既不是 < 也不是 > 的字符。与 .*? 相比,这种写法直接说明了允许匹配的范围,不会跨越另一个尖括号。注意,示例中的符号在 HTML 代码块里经过了实体转义;复制到 PHP 源文件时,正则里的字面量仍是尖括号。

如果结束标记是一段字符串,例如 END,可以使用“不能以结束标记开头”的前瞻约束:

<?php
$text = 'START:first END START:second END';

preg_match_all(
    '/START:(?:(?!END).)*END/',
    $text,
    $matches
);

var_export($matches[0]);
// array (
//   0 => 'START:first END',
//   1 => 'START:second END',
// )

其中 (?!END) 是负向前瞻:在当前位置确认后面不是 END 后,才允许点号继续匹配。这个写法可以避免中间部分跨过结束标记,但结构较复杂,阅读和性能都应结合实际输入测试。若分隔符是单个字符,否定字符类通常更简洁。

五、点号、换行和修饰符也会改变结果

点号 . 默认不匹配换行符。若文本包含多行内容,表达式可能在换行处停下,或者根本无法匹配预期范围。

可以通过 s 修饰符让点号也匹配换行:

<?php
$text = "BEGIN\nfirst line\nsecond line\nEND";

preg_match('/BEGIN.*?END/s', $text, $matches);

var_export($matches[0]);
// 'BEGIN
// first line
// second line
// END'

s 改变的是点号对换行符的行为,不会把贪婪量词变成非贪婪量词。.* 仍然是贪婪的,.*? 仍然是非贪婪的。

也可以用 [\s\S] 表示匹配任意字符,包括换行;不过若使用它,依然要明确匹配边界。例如 [\s\S]*? 仍可能跨过不希望越过的内容,只是它能跨行而已。

六、用锚点限定匹配位置

如果目标应当位于整个字符串开头或结尾,使用锚点能进一步减少意外匹配。PCRE 中 ^ 和 $ 的具体行为会受 m 修饰符影响;启用多行模式后,它们可以分别匹配行首和行尾。

在 PHP 中,如果要明确匹配整个主题字符串,可以考虑使用 \A 和 \z:它们分别表示主题的绝对开头和绝对结尾,不受多行模式对行锚点的影响。

<?php
$valid = 'user_123';

if (preg_match('/\A[A-Za-z0-9_]+\z/', $valid) === 1) {
    echo "格式符合规则\n";
} else {
    echo "格式不符合规则\n";
}

如果输入是表单值,先确认它的业务规则,再选择允许字符、长度限制和是否允许空字符串。正则中的 + 至少要求一个字符;若改成 *,空字符串也会匹配。不要仅凭一个看似严格的正则,就认为数据已经满足所有业务约束。

七、常见错误与修正思路

错误 1:认为非贪婪量词一定只匹配到第一个分隔符

非贪婪量词会优先尝试短匹配,但必须满足整个表达式。分隔符缺失、后续条件更复杂,或者模式本身允许跨过分隔符时,结果可能与预期不同。修正时要检查完整表达式,并考虑用字符类或断言明确限制中间内容。

错误 2:使用 .*?,却没有写结束条件

.*? 能匹配零个字符。如果它后面没有有效的结束约束,可能得到空匹配,或让结果出现意料之外的行为。提取一段内容时,应明确开始和结束标记;需要至少一个字符时,可使用 .+?,但还要判断空内容是否应当合法。

错误 3:把 < 当成正则中的特殊字符

尖括号在 PCRE 中通常不是需要转义的元字符,但如果正则使用的定界符本身出现在模式里,就需要按 PHP 正则语法处理定界符。例如模式写作 /.../ 时,模式内部的斜线应转义为 \/,或选择合适的其他定界符。HTML 中展示代码时还要将尖括号写成 &lt; 和 &gt;,否则会被浏览器当作标签解析。

错误 4:将 HTML 用简单的非贪婪正则解析

<.*?> 可能适合受控文本中的简单演示,却无法可靠处理任意 HTML。属性值中可能包含引号和特殊字符,标签还可能嵌套,注释和脚本内容也会带来边界问题。对真实 HTML 进行查询或修改,应使用 DOM 解析器;对用户输入则应按具体上下文进行验证和转义。

错误 5:以为非贪婪一定更快

贪婪与非贪婪描述的是尝试匹配的顺序,不是性能等级。不同模式、文本长度和失败位置都会影响回溯工作量。复杂表达式可能产生大量回溯;盲目将 .* 改成 .*?,不一定能解决性能问题。更直接的改进通常是缩小字符集合、限定输入长度、避免含糊的重复结构,并使用代表性数据测试。

八、检查 preg_match 的返回值和错误

preg_match() 找到匹配时返回 1,未找到时返回 0,发生错误时返回 false。不要只用真假判断把“未匹配”和“正则出错”混为一谈。

<?php
$pattern = '/BEGIN(.*?)END/s';
$subject = "BEGIN\ntext\nEND";

$result = preg_match($pattern, $subject, $matches);

if ($result === false) {
    echo '正则执行失败:' . preg_last_error_msg() . PHP_EOL;
} elseif ($result === 0) {
    echo "没有匹配结果\n";
} else {
    echo '匹配内容:' . $matches[0] . PHP_EOL;
    echo '中间内容:' . $matches[1] . PHP_EOL;
}

preg_last_error_msg() 从 PHP 8.0 起提供,可用于取得最近一次 PCRE 操作的错误说明。它应在正则操作失败后及时调用。对于旧版 PHP,可用 preg_last_error() 查看错误代码,并按运行环境的 PHP 文档解释。部署项目时,应确认目标 PHP 版本及其所使用的 PCRE 实现。

九、正则限制与安全用途:不要把量词当作防护措施

PHP 的 PCRE 操作会受到当前运行环境的资源限制影响。常见配置包括 pcre.backtrack_limit,用于限制回溯次数,以及 pcre.recursion_limit,用于限制递归相关深度。具体可用配置、默认值及行为与 PHP 和 PCRE 版本、构建方式及运行环境有关;应通过部署环境中的 phpinfo()、ini_get() 和对应版本文档确认,而不是假设所有服务器的限制相同。

超过限制时,正则操作可能失败,而不是返回一个可信的“未匹配”结果。应检查返回值与错误信息。对不可信输入处理时,还要设置合理的输入长度上限,避免过于复杂、存在歧义的正则,并针对最坏情况测试。PCRE2 的 JIT 等执行优化也不能代替对模式结构和资源消耗的审查。

贪婪或非贪婪本身都不是安全机制。它们不会自动防止跨字段读取、SQL 注入、HTML 注入或恶意输入;也不能保证正则不会遭遇高成本回溯。安全性需要结合输入验证、上下文转义、参数化查询、长度限制和合适的解析器来实现。

例如,验证一个简单标识符时,除了用锚点匹配完整字符串,还应把长度限制明确写入模式或在代码中单独检查:

<?php
$value = 'user_123';

$matched = preg_match(
    '/\A[A-Za-z0-9_]{1,32}\z/',
    $value
);

if ($matched === false) {
    throw new RuntimeException(
        '正则执行失败:' . preg_last_error_msg()
    );
}

if ($matched === 1) {
    echo "通过格式检查\n";
} else {
    echo "格式不符合要求\n";
}

这个例子只说明如何限制为 1 到 32 个 ASCII 字母、数字或下划线,并不代表适用于所有用户名规则。真实项目还要考虑唯一性、保留名称、字符规范化和业务长度定义。正则只能完成它明确覆盖的格式检查。

十、选择写法的实用步骤

  1. 先明确目标的开始和结束边界,避免直接从宽泛的 .* 开始。
  2. 如果中间部分不能包含某些字符,优先用否定字符类表达,例如 [^,]* 表示不跨逗号。
  3. 如果结束标记是多字符字符串,考虑使用非贪婪量词加明确终止条件,或经过测试的前瞻约束。
  4. 确认是否允许跨行;需要跨行时再使用 s,不要把修饰符当成贪婪性开关。
  5. 若需要匹配整段输入,使用适当的开头、结尾锚点,并确认空字符串、换行和 Unicode 字符是否符合规则。
  6. 对较长或不可信输入限制长度,检查 preg_match() 的三种结果,并测试失败路径。

简单记忆:默认的 *、+、{m,n} 等量词通常贪婪;在量词后加 ?,会让它优先尝试较短匹配。但真正可靠的边界来自完整模式:限定允许出现的字符、明确结束条件、正确处理换行,并确认输入和错误情况。这样才能避免“匹配过多”,也不会把一次看似成功的正则匹配误当成安全保证。

© 版权声明

相关文章