組合式正規表示法

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 日