次の方法で共有


.NET 正規表現ソース ジェネレーター

正規表現 (regex) は、開発者が検索するパターンを表現できる文字列であり、テキストを検索し、検索対象の文字列から結果をサブセットとして抽出するための一般的な方法です。 .NET では、System.Text.RegularExpressions 名前空間を使って Regex のインスタンスと静的メソッドを定義し、ユーザーが定義したパターンと一致させます。 この記事では、ソース生成を使用して Regex のインスタンスを生成し、パフォーマンスを最適化する方法について説明します。

Note

可能であれば、RegexOptions.Compiled オプションを使用して正規表現をコンパイルするのでなく、ソース生成される正規表現を使用してください。 ソース生成を行うと、アプリの起動および実行がより迅速になり、トリミングもしやすくなります。 ソース生成が可能な場合については、「いつ使用するか」を参照してください。

コンパイルされた正規表現

new Regex("somepattern") を記述すると、いくつかのことが起こります。 指定したパターンが解析され、パターンの有効性が保証されると共に、解析された正規表現を表す内部ツリーに変換されます。 その後、ツリーはさまざまな方法で最適化され、より効率的に実行できる機能的に同等のバリエーションにパターンが変換されます。 ツリーは、照合方法に関する命令を正規表現インタープリター エンジンに提供する、一連のオペコードとオペランドとして解釈できる形式で記述されます。 照合の実行時には、インタープリターはそれらの命令を順番に見ながら、入力テキストに対する処理を行います。 インタープリターは、新しい Regex インスタンスをインスタンス化するとき、またはRegex で静的メソッドのいずれかを呼び出すときに、既定で使われるエンジンです。

RegexOptions.Compiled を指定すると、同じ構築時の処理がすべて実行されます。 結果の命令は、リフレクション出力ベースのコンパイラによって、いくつかの DynamicMethod オブジェクトに書き込まれる IL 命令にさらに変換されます。 照合が実行されると、それらの DynamicMethod メソッドが呼び出されます。 この IL は、処理される正確なパターンに特化されている点を除き、基本的に、インタープリターが行うとおりのことを行います。 たとえば、パターンに [ac] が含まれている場合、インタープリターが認識するオペコードは、"現在位置の入力文字を、このセットの説明で指定されているセットに対して照合する" という内容になります。 一方、コンパイルされた IL に含まれるコードは、実質的に、"現在位置の入力文字を、'a' または 'c' に対して照合する" という内容になります。 この特殊なケーシングと、パターンの知識に基づいて最適化を実行する機能は、RegexOptions.Compiled を指定すると、インタープリターよりはるかに速い照合スループットが得られる主な理由の一部です。

RegexOptions.Compiled にはいくつかの欠点があります。 最も影響が大きいのは、構築にコストがかかることです。 インタープリターと同じコストがすべてかかるだけでなく、結果の RegexNode ツリーおよび生成されたオペコードとオペランドを 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 インスタンスがキャッシュされるため、コードを使うために追加のキャッシュは必要ありません。

次の画像は、ソースが生成されてキャッシュされたインスタンス (ソース ジェネレーターが出力する internalRegex サブクラス) のスクリーン キャプチャです。

キャッシュされた RegEx の静的フィールド

しかし、見てわかるように、それは単に new Regex(...) を行うのではありません. そうではなく、ソース ジェネレーターは、IL で RegexOptions.Compiled が出力するとの同様のロジックを含むカスタム Regex 派生実装を、C# コードとして出力します。 スループット パフォーマンスに関するRegexOptions.Compiled のすべての利点と (実際にはそれ以上)、起動時の Regex.CompileToAssembly の利点を、CompileToAssembly の複雑さなしで得られます。 出力されるソースはプロジェクトの一部であるため、簡単に表示およびデバッグすることもできます。

ソース生成された Regex コードによるデバッグ

ヒント

Visual Studio で、部分メソッドの宣言を右クリックして、[定義に移動] を選びます。 または、ソリューション エクスプローラーでプロジェクト ノードを選び、[依存関係]>[アナライザー]>System.Text.RegularExpressions.Generator>System.Text.RegularExpressions.Generator.RegexGenerator>RegexGenerator.g.cs を展開して、この正規表現ジェネレーターから生成された C# コードを表示します。

それにブレークポイントを設定したり、それをステップ実行したり、それを学習ツールとして使って正規表現エンジンが入力でパターンを処理する方法を正確に理解したりできます。 さらに、ジェネレーターはスラッシュ 3 つの (XML) コメントを生成するので、式を一目で理解したり、使用する場所を理解したりするのに役立ちます。

正規表現について説明している、生成された XML コメント

ソースで生成されるファイルの内容

.NET 7 では、ソース ジェネレーターと RegexCompiler がほぼ完全に書き換えられ、生成されるコードの構造が根本的に変更されました。 このアプローチは、すべてのコンストラクトを処理するように拡張されており (1 つ注意点があります)、RegexCompiler とソース ジェネレーターはどちらもまだ、新しいアプローチに従って、ほぼ 1 対 1 に互いに対応します。 abc|def 式からの主要な関数の 1 つに対するソース ジェネレーターの出力を検討します。

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 ラベルはバックトラック先に対して出力され、goto は正規表現の後続の部分が失敗したときにその場所にジャンプするために使われます。

RegexCompiler とソース ジェネレーターを実装しているコードを見ると、同じような名前のメソッド、同じような呼び出し構造、さらには実装全体を通して同じようなコメントで構成され、非常によく似ています。 一方は IL でもう一方は C# ですが、ほとんどの部分は同じコードになります。 もちろん、C# コンパイラによって C# は IL に変換されるため、両方のケースの結果の IL はおそらく同じではないでしょう。 ソース ジェネレーターはさまざまなケースでそれに依存しており、C# コンパイラがさまざまな C# コンストラクトをさらに最適化するという事実を利用しています。 ソース ジェネレーターによって RegexCompiler より最適化された照合コードが生成される具体的な場合がいくつかあります。 たとえば、前の例の 1 つでは、ソース ジェネレーターによって、'a' に対する分岐と 'b' に対する別の分岐を含む switch ステートメントが出力されることがわかります。 C# コンパイラは switch ステートメントの最適化に非常に優れており、複数の戦略を自由に使ってそれを効率的に行うことができるため、ソース ジェネレーターは、RegexCompiler にはない特別な最適化を備えています。 代替の場合、ソース ジェネレーターはすべての分岐を調べて、すべての分岐が異なる開始文字で始まっていることが証明できる場合は、その最初の文字で switch ステートメントを出力し、その代替に対するバックトラッキング コードは出力しません。

そのもう少し複雑な例を次に示します。 代替はさらに詳しく分析されて、バックトラッキング エンジンによる最適化がさらに簡単になり、ソース生成コードがより単純になるような方法でリファクタリングできるかどうかが判断されます。 このような最適化の 1 つでは、分岐からの共通プレフィックスの抽出がサポートされており、順序付けが重要ではないようなアトミックな代替の場合、そのような抽出をさらに可能にするためのブランチの並べ替えがサポートされています。 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 ブロックなど) が回避される可能性があります。

ソース ジェネレーターは、1 つの例外を除き、すべての RegexCompiler ハンドルを処理します。 RegexOptions.IgnoreCase の処理と同様に、実装では、構築時にケーシング テーブルを使ってセットが生成され、IgnoreCase の前方参照の照合ではそのケーシング テーブルを参照する必要があります。 そのテーブルは System.Text.RegularExpressions.dll の内部であり、現在のところ、少なくとも、そのアセンブリの外部のコード (ソース ジェネレーターによって出力されたコードを含む) はそれにアクセスできません。 これにより、ソース ジェネレーターでの IgnoreCase 前方参照の処理が困難になり、サポートされていません。 これは、RegexCompiler によってサポートされていて、ソース ジェネレーターではサポートされていない 1 つのコンストラクトです。 これらのいずれかを含むパターン (まれなパターン) を使おうとすると、ソース ジェネレーターはカスタム実装を出力せず、代わりに通常の Regex インスタンスのキャッシュにフォールバックします。

まだキャッシュされているサポートされていない正規表現

また、RegexCompiler もソース ジェネレーターも、新しい RegexOptions.NonBacktracking はサポートしていません。 RegexOptions.Compiled | RegexOptions.NonBacktracking を指定した場合、Compiled フラグは単に無視され、ソース ジェネレーターに NonBacktracking を指定すると、同じように通常の Regex インスタンスのキャッシュにフォールバックします。

いつ使用するか

一般的なガイダンスとして、ソース ジェネレーターを使用できる場合は、それを使用します。 現在、C# でコンパイル時に引数がわかっている Regex を使っていて、特に既に RegexOptions.Compiled を使っている場合は (正規表現がスループットの向上のメリットを得るホット スポットとして識別されているため)、ソース ジェネレーターを使うことをお勧めします。 正規表現にソース ジェネレーターを使うと、次のような利点があります。

  • RegexOptions.Compiled のスループットに関するすべての利点。
  • 実行時にすべての正規表現の解析、分析、コンパイルを行う必要がないという起動時の利点。
  • 正規表現用に生成されたコードで事前コンパイルを使用するオプション。
  • 正規表現のデバッグ可能性と理解の向上。
  • RegexCompiler に関連するコードの大きな範囲をトリミングすることで、トリミングされたアプリのサイズを小さくすることが可能 (また、可能性としてはリフレクションの出力自体も)。

ソース ジェネレーターがカスタム実装を生成できない RegexOptions.NonBacktracking のようなオプションで使用しても、実装を記述するキャッシュと XML コメントが出力され、価値があります。 ソース ジェネレーターの主な欠点は、アセンブリに追加のコードを出力するため、サイズが大きくなる可能性があることです。 アプリ内の正規表現が多いほど、そしてそれが大きいほど、より多くのコードが出力されます。 状況によっては、RegexOptions.Compiled が不要な場合があるのと同様に、ソース ジェネレーターも不要な場合があります。 たとえば、まれにしか必要ない正規表現があり、スループットが重要でない場合は、その散発的な使用についてはインタープリターに依存する方が有益なことがあります。

重要

.NET 7 には、ソース ジェネレーターに変換できる Regex の使用を識別するアナライザーと、変換を行う修正ツールが含まれています:

RegexGenerator アナライザーと修正ツール

関連項目