组合正则表达式

2009 年 7 月 24 日

编写可维护代码最强大的工具之一是将大型方法分解成命名良好的较小方法——肯特·贝克称之为组合方法模式。

如果人们可以详细地理解你的程序,然后将这些细节组合成更高级别的结构,他们就可以更快、更准确地阅读你的程序。

-- 肯特·贝克

对方法有效的通常对其他事物也有效。我遇到过几次人们没有这样做的情况,就是正则表达式。

假设你有一个文件,里面全是酒店连锁店为常客积分制定的规则。所有规则看起来都像这样

score 400 for 2 nights at Minas Tirith Airport
  

我们需要从每一行中提取积分(400)、住宿天数(2)和酒店名称(米那斯提力斯机场)。

这显然是正则表达式的工作,我相信你现在正在想——哦,是的,我们需要

const string pattern = 
  @"^score\s+(\d+)\s+for\s+(\d+)\s+nights?\s+at\s+(.*)";

然后我们的三个值就会从组中弹出。

我不知道你是否能理解这个正则表达式是如何工作的以及它是否正确。如果你像我一样,你必须仔细研究这样的正则表达式,弄清楚它在说什么。我经常发现自己数括号,以便查看组的位置(在这个例子中并不难,但我见过很多更难的例子)。

你可能读过关于将这种模式注释起来的建议。(通常在将其转换为正则表达式时需要切换。)这样你就可以这样写。

    protected override string GetPattern() {
      const string pattern =
        @"^score
        \s+  
        (\d+)          # points
        \s+
        for
        \s+
        (\d+)          # number of nights
        \s+
        night
        s?             #optional plural
        \s+
        at
        \s+
        (.*)           # hotel name
        ";
  
      return pattern;
    }
  }
  

这更容易理解,但注释永远无法让我满意。我偶尔会被指责说注释不好,你不应该使用它们。这是错误的,从两个方面来说都是错误的。注释并不坏——但通常有更好的选择。我总是试图编写不需要注释的代码,通常通过良好的命名和结构。(我并不总是能做到,但我感觉我比以前做得更多了。)

人们通常不会尝试构建正则表达式,但我发现它很有用。以下是一种实现方法。

    const string scoreKeyword = @"^score\s+";
    const string numberOfPoints = @"(\d+)";
    const string forKeyword = @"\s+for\s+";
    const string numberOfNights = @"(\d+)";
    const string nightsAtKeyword = @"\s+nights?\s+at\s+";
    const string hotelName = @"(.*)";

    const string pattern =  scoreKeyword + numberOfPoints +
      forKeyword + numberOfNights + nightsAtKeyword + hotelName;
  

我将模式分解成逻辑块,然后在最后将它们重新组合在一起。现在我可以查看最终的表达式并理解表达式的基本块,深入研究每个块的正则表达式以查看详细信息。

这里还有另一种方法,它试图将空格隔开,使实际的正则表达式看起来更像标记。

    const string space = @"\s+";
    const string start = "^";
    const string numberOfPoints = @"(\d+)";
    const string numberOfNights = @"(\d+)";
    const string nightsAtKeyword = @"nights?\s+at";
    const string hotelName = @"(.*)";

    const string pattern =  start + "score" + space + numberOfPoints + space +
      "for" + space + numberOfNights + space + nightsAtKeyword + 
       space + hotelName;
  

我发现这使得单个标记更清晰,但所有这些空格变量使得整体结构更难理解。所以我更喜欢前一个。

但这确实提出了一个问题。所有元素都用空格隔开,在模式中添加大量空格变量或\s+感觉很湿。将正则表达式分解成子字符串的好处是,我现在可以使用编程逻辑来创建更适合我特定目的的抽象。我可以编写一个方法,该方法将接受子字符串并将它们与空格连接起来。

    private String composePattern(params String[] arg) {
      return "^" + String.Join(@"\s+", arg);
    }
  

使用此方法,我将拥有。

    const string numberOfPoints = @"(\d+)";
    const string numberOfNights = @"(\d+)";
    const string hotelName = @"(.*)";

    const string pattern =  composePattern("score", numberOfPoints, 
      "for", numberOfNights, "nights?", "at", hotelName);
  

你可能不会完全使用这些替代方法中的任何一种,但我确实敦促你考虑如何使正则表达式更清晰。代码不应该需要弄清楚,它应该只是被阅读。


更新

在本讨论中,我将组合正则表达式的元素设置为局部变量。一种变体是使用常用的正则表达式元素并更广泛地使用它们。这对于使用在许多地方都需要使用的常用正则表达式非常有用。我的同事卡洛斯·维莱拉评论说,需要注意的一点是,如果这些片段没有格式良好,例如有一个在另一个片段中关闭的左括号。这可能很难调试。我还没有觉得有必要这样做,所以还没有遇到这个问题。

有些人提到了使用流畅接口(内部 DSL)作为更易读的正则表达式替代方案。我认为这是另一回事。如果正则表达式很小,我并不介意它们,事实上我更喜欢一个小的正则表达式而不是一个等效的流畅接口。重要的是组合,你可以用这两种技术来实现。

还有一些人提到了命名捕获组。就像注释一样,我发现它们比原始正则表达式更好,但仍然发现组合结构更易读。组合的重点是它将整体正则表达式分解成更容易理解的小片段。

于 2014 年 7 月 31 日重新发布