共用方式為


.NET 規則運算式來源產生器

規則運算式 (即 RegEx) 是一個字串,可讓開發人員表達要搜尋的模式,因此常用於搜尋文字,並從搜尋的字串擷取結果做為子集。 在 .NET 中,System.Text.RegularExpressions 命名空間用來定義 Regex 執行個體和靜態方法,並在使用者定義模式上比對。 本文會說明如何使用來源產生來產生 Regex 執行個體,以最佳化效能。

注意

可能的話,請使用來源產生的規則運算式,而不是使用 RegexOptions.Compiled 選項編譯規則運算式。 來源產生可協助您的應用程式更快啟動、執行得更快速,且更易於修剪。 若要了解來源產生是否可行,請參閱使用時機

編譯的規則運算式

撰寫 new Regex("somepattern") 時,會發生一些效果。 指定的模式會受到剖析,以確保模式的有效性;並且將其轉換成內部樹狀結構,可代表所剖析的 RegEx。 然後,會以各種方式將樹狀結構最佳化,將模式轉換具有相等功能的變異版本,而執行方式會更有效率。 會將樹狀結構寫入表單,表單則可解譯為一系列的作業碼和運算元,以提供 RegEx 解譯器引擎有關如何比對的指示。 執行比對時,解譯器只會逐步執行這些指示,並針對輸入文字進行處理。 在具現化新的 Regex 執行個體,或在 Regex 上呼叫其中一個靜態方法時,解譯器是採用的預設引擎。

當您指定 RegexOptions.Compiled 時,即會執行所有相同的建構時間工作。 反映發出型編譯器會進一步將產生的指令轉換成 IL 指令,這些指令會寫入到幾個 DynamicMethod 物件。 執行比對時,會叫用這 DynamicMethod 個方法。 此 IL 執行的作業基本上與解譯器完全相同,但是會針對所處理的模式採取專用設計。 例如,如果模式包含 [ac],解譯器會查看 opcode,其中會指出「比對目前位置的輸入字元與這個集合描述中指定的集合」。 而編譯的 IL 包含的程式碼可有效地指出「比對目前位置的輸入字元與 'a''c'」。 這種特殊大小寫及根據模式知識執行最佳化的能力,是指定 RegexOptions.Compiled 產生的比對輸送量遠快於解譯器的部分主要原因。

RegexOptions.Compiled 有幾個缺點。 最具影響力的是其建構成本高昂。 不但需支付與解譯器相同的費用,還需要編譯產生的 RegexNode 樹狀結構,並將產生的 opcodes/運算元編譯為 IL,這會增加不小的開銷。 產生的 IL 進一步需要在第一次使用時進行 JIT 編譯,導致在啟動時產生更多費用。 RegexOptions.Compiled 代表第一次使用的額外負荷與後續每次使用的額外負荷之間做出基本取捨。 使用 System.Reflection.Emit 也會禁止在特定環境中使用 RegexOptions.Compiled;某些作業系統不允許執行動態產生的程式碼,而且在這類系統上,Compiled 會變成無作業。

來源產生

.NET 7 引進了新的 RegexGenerator 來源產生器。 來源產生器是插入編譯器並使用附加原始碼增強編譯單位的元件。 .NET SDK (7 和更新版本) 包括來源產生器,可辨識傳回 Regex 的部分方法上的 GeneratedRegexAttribute 屬性。 來源產生器會提供該方法的實作,以包含 Regex 的所有邏輯。 例如,您先前可能已撰寫如下的程式碼:

private static readonly Regex s_abcOrDefGeneratedRegex =
    new(pattern: "abc|def",
        options: RegexOptions.Compiled | RegexOptions.IgnoreCase);

private static void EvaluateText(string text)
{
    if (s_abcOrDefGeneratedRegex.IsMatch(text))
    {
        // Take action with matching text
    }
}

若要使用來源產生器,您可以重寫先前的程式碼,如下所示:

[GeneratedRegex("abc|def", RegexOptions.IgnoreCase, "en-US")]
private static partial Regex AbcOrDefGeneratedRegex();

private static void EvaluateText(string text)
{
    if (AbcOrDefGeneratedRegex().IsMatch(text))
    {
        // Take action with matching text
    }
}

提示

來源產生器會忽略 RegexOptions.Compiled 旗標,因此在來源產生的版本中即不需要該旗標。

產生的 AbcOrDefGeneratedRegex() 實作同樣會快取單一資料庫 Regex 執行個體,因此不需要額外的快取來取用程式碼。

以下影像為來源所產生的快取執行個體的螢幕擷取,來源產生器發出的 Regex 子類別的 internal

快取的 RegEx 靜態欄位

但如所見,這不只是執行 new Regex(...)。 相反地,來源產生器會以 C# 程式碼的形式發出自訂 Regex 衍生實作,其邏輯類似於 RegexOptions.Compiled 在 IL 中發出的邏輯。 如此可獲得 RegexOptions.Compiled 在輸送量效能上的所有優勢 (事實上更多) 以及 Regex.CompileToAssembly 在啟動時的優勢,但又沒有 CompileToAssembly 那麼複雜。 發出的來源是專案的一部分,這表示也可以輕易地檢視來源和偵錯。

透過來源產生的 Regex 程式碼進行偵錯

提示

在 Visual Studio 中,以滑鼠右鍵按一下您的部分方法宣告,然後選取 [移至定義]。 或者,選取 [方案總管] 中的專案節點,然後展開 [相依性]>[分析器]>[System.Text.RegularExpressions.Generator]>System.Text.RegularExpressions.Generator.RegexGenerator>[RegexGenerator.g.cs],即可查看以此 RegEx 產生器產生的 C# 程式碼。

您可以在其中設定中斷點、逐步執行,並用來做為學習工具,以確切瞭解 RegEx 引擎如何使用您的輸入來處理您的模式。 產生器甚至會產生三斜線 (XML) 註解,有助於讓運算式一目了然,並確定其使用位置。

描述 RegEx 的產生的 XML 註解

在來源產生的檔案內

使用 .NET 7 時,幾乎完全重寫了來源產生器和 RegexCompiler 兩者,基本上會變更產生的程式碼結構。 此方法已擴大到可處理所有建構 (具有一個警告),而且 RegexCompiler 與來源產生器仍會幾乎 1:1 彼此對應,並遵循新的方法。 考量運算式 abc|def 中的其中一個主要函式的來源產生器輸出:

private bool TryMatchAtCurrentPosition(ReadOnlySpan<char> inputSpan)
{
    int pos = base.runtextpos;
    int matchStart = pos;
    ReadOnlySpan<char> slice = inputSpan.Slice(pos);

    // Match with 2 alternative expressions, atomically.
    {
        if (slice.IsEmpty)
        {
            return false; // The input didn't match.
        }

        switch (slice[0])
        {
            case 'A' or 'a':
                if ((uint)slice.Length < 3 ||
                    !slice.Slice(1).StartsWith("bc", StringComparison.OrdinalIgnoreCase)) // Match the string "bc" (ordinal case-insensitive)
                {
                    return false; // The input didn't match.
                }

                pos += 3;
                slice = inputSpan.Slice(pos);
                break;

            case 'D' or 'd':
                if ((uint)slice.Length < 3 ||
                    !slice.Slice(1).StartsWith("ef", StringComparison.OrdinalIgnoreCase)) // Match the string "ef" (ordinal case-insensitive)
                {
                    return false; // The input didn't match.
                }

                pos += 3;
                slice = inputSpan.Slice(pos);
                break;

            default:
                return false; // The input didn't match.
        }
    }

    // The input matched.
    base.runtextpos = pos;
    base.Capture(0, matchStart, pos);
    return true;
}

來源產生的程式碼目標需要能易於理解,並具有容易遵循的結構,並以註解說明每個步驟所執行的工作,而且在遵循指導方針下發出程式碼的一般情況下,產生器發出的程式碼應該會如同人類撰寫的程式碼一樣。 即使在涉及回溯時,回溯的結構也會成為程式碼結構的一部分,而不是依賴堆疊來指出下一個跳躍的位置。 例如,以下是運算式為 [ab]*[bc] 時,相同產生的比對函式的程式碼:

private bool TryMatchAtCurrentPosition(ReadOnlySpan<char> inputSpan)
{
    int pos = base.runtextpos;
    int matchStart = pos;
    int charloop_starting_pos = 0, charloop_ending_pos = 0;
    ReadOnlySpan<char> slice = inputSpan.Slice(pos);

    // Match a character in the set [ABab] greedily any number of times.
    //{
        charloop_starting_pos = pos;

        int iteration = slice.IndexOfAnyExcept(Utilities.s_ascii_600000006000000);
        if (iteration < 0)
        {
            iteration = slice.Length;
        }

        slice = slice.Slice(iteration);
        pos += iteration;

        charloop_ending_pos = pos;
        goto CharLoopEnd;

        CharLoopBacktrack:

        if (Utilities.s_hasTimeout)
        {
            base.CheckTimeout();
        }

        if (charloop_starting_pos >= charloop_ending_pos ||
            (charloop_ending_pos = inputSpan.Slice(charloop_starting_pos, charloop_ending_pos - charloop_starting_pos).LastIndexOfAny(Utilities.s_ascii_C0000000C000000)) < 0)
        {
            return false; // The input didn't match.
        }
        charloop_ending_pos += charloop_starting_pos;
        pos = charloop_ending_pos;
        slice = inputSpan.Slice(pos);

        CharLoopEnd:
    //}

    // Advance the next matching position.
    if (base.runtextpos < pos)
    {
        base.runtextpos = pos;
    }

    // Match a character in the set [BCbc].
    if (slice.IsEmpty || ((uint)((slice[0] | 0x20) - 'b') > (uint)('c' - 'b')))
    {
        goto CharLoopBacktrack;
    }

    // The input matched.
    pos++;
    base.runtextpos = pos;
    base.Capture(0, matchStart, pos);
    return true;
}

您可以在程式碼中看到回溯的結構,其中針對回溯到的位置發出 CharLoopBacktrack 標籤,並在後續的 RegEx 部分失敗時,使用 goto 來跳至該位置。

如果您查看實作 RegexCompiler 的程式碼和來源產生器,它們看起來會非常相似:類似命名的方法、類似的呼叫結構,甚至是整個實作中的類似註解。 在大部分的情況下,它們會導致相同的程式碼,但一個使用 IL,另一個使用 C#。 當然 C# 編譯器接著會負責將 C# 轉譯為 IL,因此這兩種情況下產生的 IL 可能不相同。 在各種情況下,來源產生器都會依賴該情況,而利用 C# 編譯器會進一步最佳化各種 C# 建構的事實。 有一些特定因素會使來源產生器產生比 RegexCompiler 最佳化程度更高的比對程式碼。 例如,在上述其中一個範例中,您可以看到來源產生器發出 switch 陳述式,其中一個分支用於 'a',另一個分支用於 'b'。 C# 編譯器非常適合用來最佳化 switch 陳述式,有多種策略可供運用,達到十分良好的處理效率。因此來源產生器具有 RegexCompiler 所無法達到的特殊最佳化程度。 就替代而言,來源產生器會查看所有分支,如果可以證明每個分支的開頭都是不同的起始字元,它會在該第一個字元上發出 switch 陳述式,並避免輸出該替代的任何回溯程式碼。

以下是稍微複雜的範例。 對替代會進行更深入的分析,以確定是否有可能採取某種方式加以重構,讓回溯引擎能更易於將其最佳化,進而獲得更為簡明的來源產生程式碼。 其中一種這類最佳化支援會從分支擷取常見的前置詞,而如果是不可部分完成的替代 (而順序無關緊要),則重新排序分支以允許進行更多這類擷取作業。 您可以看到該情況對下列工作日模式 Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday 的影響,這會產生如下的比對函式:

private bool TryMatchAtCurrentPosition(ReadOnlySpan<char> inputSpan)
{
    int pos = base.runtextpos;
    int matchStart = pos;
    char ch;
    ReadOnlySpan<char> slice = inputSpan.Slice(pos);

    // Match with 6 alternative expressions, atomically.
    {
        int alternation_starting_pos = pos;

        // Branch 0
        {
            if ((uint)slice.Length < 6 ||
                !slice.StartsWith("monday", StringComparison.OrdinalIgnoreCase)) // Match the string "monday" (ordinal case-insensitive)
            {
                goto AlternationBranch;
            }

            pos += 6;
            slice = inputSpan.Slice(pos);
            goto AlternationMatch;

            AlternationBranch:
            pos = alternation_starting_pos;
            slice = inputSpan.Slice(pos);
        }

        // Branch 1
        {
            if ((uint)slice.Length < 7 ||
                !slice.StartsWith("tuesday", StringComparison.OrdinalIgnoreCase)) // Match the string "tuesday" (ordinal case-insensitive)
            {
                goto AlternationBranch1;
            }

            pos += 7;
            slice = inputSpan.Slice(pos);
            goto AlternationMatch;

            AlternationBranch1:
            pos = alternation_starting_pos;
            slice = inputSpan.Slice(pos);
        }

        // Branch 2
        {
            if ((uint)slice.Length < 9 ||
                !slice.StartsWith("wednesday", StringComparison.OrdinalIgnoreCase)) // Match the string "wednesday" (ordinal case-insensitive)
            {
                goto AlternationBranch2;
            }

            pos += 9;
            slice = inputSpan.Slice(pos);
            goto AlternationMatch;

            AlternationBranch2:
            pos = alternation_starting_pos;
            slice = inputSpan.Slice(pos);
        }

        // Branch 3
        {
            if ((uint)slice.Length < 8 ||
                !slice.StartsWith("thursday", StringComparison.OrdinalIgnoreCase)) // Match the string "thursday" (ordinal case-insensitive)
            {
                goto AlternationBranch3;
            }

            pos += 8;
            slice = inputSpan.Slice(pos);
            goto AlternationMatch;

            AlternationBranch3:
            pos = alternation_starting_pos;
            slice = inputSpan.Slice(pos);
        }

        // Branch 4
        {
            if ((uint)slice.Length < 6 ||
                !slice.StartsWith("fr", StringComparison.OrdinalIgnoreCase) || // Match the string "fr" (ordinal case-insensitive)
                ((((ch = slice[2]) | 0x20) != 'i') & (ch != 'İ')) || // Match a character in the set [Ii\u0130].
                !slice.Slice(3).StartsWith("day", StringComparison.OrdinalIgnoreCase)) // Match the string "day" (ordinal case-insensitive)
            {
                goto AlternationBranch4;
            }

            pos += 6;
            slice = inputSpan.Slice(pos);
            goto AlternationMatch;

            AlternationBranch4:
            pos = alternation_starting_pos;
            slice = inputSpan.Slice(pos);
        }

        // Branch 5
        {
            // Match a character in the set [Ss].
            if (slice.IsEmpty || ((slice[0] | 0x20) != 's'))
            {
                return false; // The input didn't match.
            }

            // Match with 2 alternative expressions, atomically.
            {
                if ((uint)slice.Length < 2)
                {
                    return false; // The input didn't match.
                }

                switch (slice[1])
                {
                    case 'A' or 'a':
                        if ((uint)slice.Length < 8 ||
                            !slice.Slice(2).StartsWith("turday", StringComparison.OrdinalIgnoreCase)) // Match the string "turday" (ordinal case-insensitive)
                        {
                            return false; // The input didn't match.
                        }

                        pos += 8;
                        slice = inputSpan.Slice(pos);
                        break;

                    case 'U' or 'u':
                        if ((uint)slice.Length < 6 ||
                            !slice.Slice(2).StartsWith("nday", StringComparison.OrdinalIgnoreCase)) // Match the string "nday" (ordinal case-insensitive)
                        {
                            return false; // The input didn't match.
                        }

                        pos += 6;
                        slice = inputSpan.Slice(pos);
                        break;

                    default:
                        return false; // The input didn't match.
                }
            }

        }

        AlternationMatch:;
    }

    // The input matched.
    base.runtextpos = pos;
    base.Capture(0, matchStart, pos);
    return true;
}

同時,來源產生器有其他問題要應付,但在直接輸出到 IL 時就不會發生。 如果回頭看一下幾個程式碼範例,會發現一些大括弧有點奇怪地遭到註解化。這不是錯誤。 來源產生器會辨識出如果這些大括弧未受到註解化,回溯的結構會依賴從範圍外部跳到該範圍內定義的標籤;這類 goto 將不會看到這類標籤,而且程式碼將會無法編譯。 因此,來源產生器必須避免在執行過程設下範圍。 在某些情況下,它只會將範圍註解化,如同這裡所執行的。 在其他無法這麼做的情況下,如果這樣做會造成問題,則有時可能會避免需要範圍的建構 (例如多陳述式 if 區塊)。

來源產生器會處理 RegexCompiler 處理的所有事項,但有一個例外狀況。 如同處理 RegexOptions.IgnoreCase,實作現在會使用大小寫資料表在建構時間產生集合,以及 IgnoreCase 反向參考比對需要參考該大小寫資料表的方式。 該資料表在 System.Text.RegularExpressions.dll 內部,而且目前至少該組件外部的程式碼 (包括來源產生器所發出的程式碼) 沒有存取權。 這會讓處理 IgnoreCase 反向參考成為來源產生器中的挑戰,而且不受支援。 這是 RegexCompiler 支援的來源產生器不支援的其中一個建構。 如果您嘗試使用具有下列其中一個建構 (這很罕見) 的模式,則來源產生器不會發出自訂實作,而是回復為快取一般 Regex 執行個體:

仍在快取的不受支援 RegEx

此外,來源產生器和 RegexCompiler 都不支援新的 RegexOptions.NonBacktracking。 如果您指定 RegexOptions.Compiled | RegexOptions.NonBacktracking,則只會忽略 Compiled 旗標,而且如果指定 NonBacktracking 給來源產生器,它同樣會回復為快取一般 Regex 執行個體。

使用時機

一般方針是如果能使用來源產生器,就使用它。 如果目前以 C# 使用 Regex 搭配在編譯時期已知的引數,特別是如果已經使用 RegexOptions.Compiled (因為已確認 RegEx 是會受益於更快速輸送量的作用點),使用來源產生器應當是更合適的選擇。 來源產生器會為您的 RegEx 提供下列優點:

  • RegexOptions.Compiled 的所有輸送量優點。
  • 在執行階段不需要執行所有 RegEx 剖析、分析和編譯的啟動優勢。
  • 使用預先編譯的選項,以及針對 RegEx 產生的程式碼。
  • 對 RegEx 更好的偵錯性以及更加理解。
  • 藉由精簡與 RegexCompiler (甚至可能反映發出本身) 相關聯的大量程式碼,所修剪的應用程式大小有可能縮減。

搭配使用來源產生器無法產生自訂實作的選項時 (如 RegexOptions.NonBacktracking),它仍會發出快取和 XML 註解來描述實作,使其具有價值。 來源產生器的主要缺點是,它會將額外的程式碼發出至您的組件,因此可能會增加大小。 應用程式中的 RegEx 越多、大小越大,對它們發出的程式碼也更多。 在某些情況下,就如同 RegexOptions.Compiled 可能不必要一樣,來源產生器也可能如此。 例如,如果您有只需要很少使用的 RegEx,且輸送量並不重要,則只依賴解譯器進行零星使用可能會更有益。

重要

.NET 7 包含分析器,可識別可轉換成來源產生器的 Regex 使用,以及為您執行轉換的修正程式:

RegexGenerator 分析器和修正程式

另請參閱