陷阱 - 效率关注正则表达式

正则表达式匹配是一个强大的工具(在 Java 和其他上下文中)但它确实有一些缺点。其中一个正则表达式往往相当昂贵。

应重用 Pattern 和 Matcher 实例

请考虑以下示例:

/**
 * Test if all strings in a list consist of English letters and numbers.
 * @param strings the list to be checked
 * @return 'true' if an only if all strings satisfy the criteria
 * @throws NullPointerException if 'strings' is 'null' or a 'null' element.
 */
public boolean allAlphanumeric(List<String> strings) {
    for (String s : strings) {
        if (!s.matches("[A-Za-z0-9]*")) {
            return false;
        }  
    }
    return true;
}

这段代码是正确的,但效率很低。问题出在 matches(...) 调用中。在引擎盖下,s.matches("[A-Za-z0-9]*") 相当于:

Pattern.matches(s, "[A-Za-z0-9]*")

这相当于

Pattern.compile("[A-Za-z0-9]*").matcher(s).matches()

Pattern.compile("[A-Za-z0-9]*") 调用解析正则表达式,对其进行分析,并构造一个 Pattern 对象,该对象包含将由正则表达式引擎使用的数据结构。这是一个非平凡的计算。然后创建一个 Matcher 对象来包装 s 参数。最后我们调用 match() 来进行实际的模式匹配。

问题是每次循环迭代都会重复这项工作。解决方案是重构代码如下:

private static Pattern ALPHA_NUMERIC = Pattern.compile("[A-Za-z0-9]*");

public boolean allAlphanumeric(List<String> strings) {
    Matcher matcher = ALPHA_NUMERIC.matcher("");
    for (String s : strings) {
        matcher.reset(s);
        if (!matcher.matches()) {
            return false;
        }  
    }
    return true;
}

请注意,Patternjavadoc 指出:

此类的实例是不可变的,并且可以安全地供多个并发线程使用。Matcher 类的实例对于此类使用并不安全。

你应该使用 find() 时不要使用 match()

假设你要测试字符串 s 是否包含一行中的三个或更多数字。你可以通过各种方式表达这一点,包括:

  if (s.matches(".*[0-9]{3}.*")) {
      System.out.println("matches");
  }

要么

  if (Pattern.compile("[0-9]{3}").matcher(s).find()) {
      System.out.println("matches");
  }

第一个更简洁,但也可能效率较低。从表面上看,第一个版本将尝试将整个字符串与模式匹配。此外,由于“。*”是一种贪婪模式,模式匹配器很可能急切地尝试到字符串的末尾,然后回溯直到找到匹配。

相比之下,第二个版本将从左到右进行搜索,并在连续找到 3 个数字后立即停止搜索。

使用更有效的正则表达式替代方法

正则表达式是一个强大的工具,但它们不应该是你唯一的工具。可以通过其他方式更有效地完成许多任务。例如:

 Pattern.compile("ABC").matcher(s).find()

做同样的事情:

 s.contains("ABC")

除了后者效率更高。 (即使你可以分摊编译正则表达式的成本。)

通常,非正则表达式更复杂。例如,由 matches() 执行的测试调用早期的 allAlplanumeric 方法可以重写为:

 public boolean matches(String s) {
     for (char c : s) {
         if ((c >= 'A' && c <= 'Z') ||
             (c >= 'a' && c <= 'z') ||
             (c >= '0' && c <= '9')) {
              return false;
         }
     }
     return true;
 }

现在这比使用 Matcher 更多的代码,但它也会明显更快。

灾难性的回溯

(这可能是正则表达式的所有实现的问题,但我们在这里会提到它,因为它是 Pattern 使用的一个陷阱。)

考虑这个(人为的)例子:

Pattern pat = Pattern.compile("(A+)+B");
System.out.println(pat.matcher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB").matches());
System.out.println(pat.matcher("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAC").matches());

第一个 println 调用将快速打印 true。第二个将打印 false。最终。实际上,如果你试验上面的代码,你会发现每次在 C 之前添加 A 时,时间会加倍。

这是行为是灾难性回溯的一个例子。实现正则表达式匹配的模式匹配引擎无效地尝试模式可能匹配的所有可能方式。 **

让我们来看看 (A+)+B 究竟意味着什么。从表面上看,它似乎说“一个或多个 A 个字符后跟一个 B 值”,但实际上它表示一个或多个组,每个组由一个或多个 A 字符组成。所以,例如:

  • ‘AB’仅匹配一种方式:’(A)B’
  • ‘AAB’匹配两种方式:’(AA)B’或’(A)(A)B`
  • ‘AAAB’匹配四种方式:’(AAA)B’或’(AA)(A)Bor '(A)(AA)B 或’(A)(A)(A)B`
  • 等等

换句话说,可能的匹配数是 2 N ,其中 N 是 A 个字符的数量。

上面的例子显然是设计的,但是当使用考虑不周的正则表达式时,经常会出现表现出这种性能特征的模式(即,对于大型的 thhuan28,O(2^N)O(N^K))。有一些标准的补救措施:

  • 避免在其他重复模式中嵌套重复模式。
  • 避免使用太多重复模式。
  • 适当时使用非回溯重复。
  • 不要将 regex 用于复杂的解析任务。 (改为编写一个合适的解析器。)

最后,请注意用户或 API 客户端可以提供具有病理特征的正则表达式字符串的情况。这可能导致意外或故意拒绝服务

参考文献: