Bagikan melalui


Generator sumber ekspresi regex .NET

Ekspresi reguler, atau regex, adalah string yang memungkinkan pengembang untuk mengekspresikan pola yang dicari, menjadikannya cara umum untuk mencari teks dan mengekstrak hasil sebagai subset dari string yang dicari. Di .NET, System.Text.RegularExpressions namespace digunakan untuk menentukan Regex instans dan metode statis serta mencocokkan pada pola yang ditentukan pengguna. Dalam artikel ini, Anda akan mempelajari cara menggunakan generasi sumber untuk menghasilkan instance Regex guna mengoptimalkan performa.

Catatan

Jika memungkinkan, gunakan ekspresi reguler yang dihasilkan sumber alih-alih mengkompilasi ekspresi reguler menggunakan RegexOptions.Compiled opsi . Pembuatan sumber dapat membantu aplikasi Anda mulai lebih cepat, berjalan lebih cepat, dan lebih mudah dipangkas. Untuk mempelajari kapan pembuatan sumber dimungkinkan, lihat Kapan menggunakannya.

Ekspresi reguler yang dikompilasi

Ketika Anda menulis new Regex("somepattern"), beberapa hal terjadi. Pola yang ditentukan diurai, baik untuk memastikan validitas pola dan mengubahnya menjadi pohon internal yang mewakili regex yang diurai. Pohon ini kemudian dioptimalkan dengan berbagai cara, mengubah pola menjadi variasi yang setara secara fungsional yang dapat dijalankan dengan lebih efisien. Pohon ditulis ke dalam bentuk yang dapat ditafsirkan sebagai serangkaian opcode dan operand yang memberikan instruksi kepada mesin penerjemah regex tentang cara mencocokkan. Ketika kecocokan dilakukan, interpreter hanya mengikuti instruksi tersebut, memprosesnya terhadap teks masukan. Saat membuat instans baru Regex atau memanggil salah satu metode statis pada Regex, interpreter adalah mesin default yang digunakan.

Ketika Anda menentukan RegexOptions.Compiled, semua pekerjaan konstruksi yang sama dilaksanakan. Instruksi yang dihasilkan diubah lebih lanjut oleh pengkompilasi berbasis pantulan menjadi instruksi IL yang ditulis ke beberapa DynamicMethod objek. Ketika kecocokan dilakukan, metode tersebut DynamicMethod dipanggil. IL ini pada dasarnya melakukan apa yang akan dilakukan interpreter, namun dengan spesialisasi untuk pola spesifik yang diproses. Misalnya, jika pola berisi [ac], interpreter akan melihat opcode yang bertuliskan "cocokkan karakter input pada posisi saat ini terhadap set yang ditentukan dalam deskripsi set ini". Sedangkan IL yang dikompilasi akan berisi kode yang secara efektif mengatakan, "cocokkan karakter input pada posisi saat ini terhadap 'a' atau 'c'". Casing khusus ini dan kemampuan untuk melakukan pengoptimalan berdasarkan pengetahuan tentang pola adalah beberapa alasan utama yang menentukan RegexOptions.Compiled hasil throughput yang jauh lebih cepat cocok daripada penerjemah.

Ada beberapa kelemahan untuk RegexOptions.Compiled. Yang paling berdampak adalah bahwa pembangunannya mahal. Tidak hanya semua biaya yang sama dibayarkan untuk interpreter, tetapi juga perlu mengkompilasi pohon RegexNode yang dihasilkan dan menghasilkan opcodes/operands menjadi IL, yang menambahkan biaya signifikan. IL yang dihasilkan perlu dikompilasi JIT ketika pertama kali digunakan, sehingga meningkatkan biaya saat startup. RegexOptions.Compiled mewakili kompromi mendasar antara overhead pada penggunaan pertama dan overhead setiap kali digunakan berikutnya. Penggunaan System.Reflection.Emit juga menghambat penggunaan RegexOptions.Compiled di lingkungan tertentu; beberapa sistem operasi tidak mengizinkan kode yang dihasilkan secara dinamis untuk dijalankan, dan pada sistem tersebut, Compiled menjadi no-op.

Generasi kode sumber

.NET 7 memperkenalkan generator sumber baru RegexGenerator . Generator kode sumber adalah komponen yang terintegrasi dengan pengompilasi dan menambahkan kode sumber tambahan ke unit kompilasi. .NET SDK menyertakan generator sumber yang mengenali atribut GeneratedRegexAttribute pada metode parsial Regex yang mengembalikan nilai. Mulai dari .NET 9, atribut juga dapat diterapkan ke properti parsial. Generator sumber menyediakan implementasi metode atau properti yang berisi semua logika untuk Regex. Misalnya, Anda sebelumnya mungkin telah menulis kode seperti ini:

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
    }
}

Untuk menggunakan generator sumber, Anda menulis ulang kode sebelumnya sebagai berikut:

[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
    }
}

Mulai dari .NET 9, Anda juga dapat menerapkan GeneratedRegexAttribute ke properti parsial alih-alih metode parsial. Ini diaktifkan oleh dukungan C# 13 untuk properti parsial. Contoh berikut menunjukkan properti yang setara:

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

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

Petunjuk / Saran

Bendera RegexOptions.Compiled diabaikan oleh generator sumber, sehingga tidak diperlukan dalam versi yang dihasilkan sumber.

Implementasi yang dihasilkan dari AbcOrDefGeneratedRegex() menyimpan sebuah instans singleton Regex yang serupa, sehingga tidak diperlukan penyimpanan sementara tambahan untuk menggunakan kode.

Gambar berikut adalah tangkapan layar dari instans cache yang dihasilkan oleh sumber, internal ke subkelas Regex yang dipancarkan oleh generator sumber.

Bidang statis regex yang di-cache

Tetapi seperti yang dapat dilihat, tidak hanya melakukan new Regex(...). Sebaliknya, generator sumber menghasilkan implementasi turunan sebagai kode C# yang memiliki logika serupa dengan apa yang RegexOptions.Compiled hasilkan dalam IL. Anda mendapatkan semua manfaat performa throughput dari RegexOptions.Compiled (justru lebih), dan manfaat start-up dari Regex.CompileToAssembly, namun tanpa kerumitan CompileToAssembly. Sumber yang dipancarkan adalah bagian dari proyek Anda, yang berarti juga mudah dilihat dan dapat di-debug.

Mendebug kode Regex yang dihasilkan oleh sumber

Petunjuk / Saran

Di Visual Studio, klik kanan pada metode parsial atau deklarasi properti Anda dan pilih Buka Definisi. Atau, alternatifnya, pilih simpul proyek di Solution Explorer, lalu perluas Dependencies>Analyzers>System.Text.RegularExpressions.Generator>System.Text.RegularExpressions.Generator.RegexGenerator>RegexGenerator.g.cs untuk melihat kode C# yang dihasilkan dari generator regex ini.

Anda dapat mengatur titik henti di dalamnya, Anda dapat menelusurinya, dan Anda dapat menggunakannya sebagai alat pembelajaran untuk memahami dengan tepat bagaimana mesin regex memproses pola Anda dengan input Anda. Generator bahkan menghasilkan komentar tiga garis miring (XML) untuk membantu membuat ekspresi mudah dipahami sekilas dan di mana ia digunakan.

Komentar XML yang dihasilkan yang menjelaskan regex

Di dalam file yang dihasilkan oleh sumber

Dengan .NET 7, generator sumber dan RegexCompiler hampir sepenuhnya ditulis ulang, pada dasarnya mengubah struktur kode yang dihasilkan. Pendekatan ini telah diperluas untuk menangani semua konstruksi (dengan satu peringatan), dan keduanya RegexCompiler dan generator sumber masih memetakan sebagian besar 1:1 satu sama lain, mengikuti pendekatan baru. Pertimbangkan output generator sumber untuk salah satu fungsi utama dari abc|def ekspresi:

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;
}

Tujuan dari kode yang dihasilkan sumber adalah untuk dapat dimengerti, dengan struktur yang mudah diikuti, dengan komentar yang menjelaskan apa yang sedang dilakukan di setiap langkah, dan secara umum dengan kode yang dipancarkan di bawah prinsip panduan bahwa generator harus mengeluarkan kode seolah-olah manusia telah menulisnya. Bahkan ketika backtracking terlibat, struktur backtracking menjadi bagian dari struktur kode, bukan menggunakan tumpukan untuk menunjukkan di mana harus melompat berikutnya. Misalnya, berikut adalah kode untuk fungsi pencocokan yang dihasilkan yang sama saat ekspresinya adalah [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;
}

Anda dapat melihat struktur backtracking dalam kode, dengan label CharLoopBacktrack dipancarkan untuk menentukan tempat backtracking, dan goto digunakan untuk melompat ke lokasi tersebut ketika bagian regex berikutnya gagal.

Jika Anda melihat implementasi kode RegexCompiler dan generator kode sumber, mereka akan terlihat sangat mirip: metode bernama serupa, struktur panggilan serupa, dan bahkan komentar serupa sepanjang implementasi. Untuk sebagian besar, mereka menghasilkan kode yang identik, meskipun satu di IL dan satu di C#. Tentu saja, pengkompilasi C# kemudian bertanggung jawab untuk menerjemahkan C# ke dalam IL, sehingga IL yang dihasilkan dalam kedua kasus kemungkinan tidak akan identik. Generator sumber mengandalkan itu dalam berbagai kasus, memanfaatkan fakta bahwa pengkompilasi C# akan lebih mengoptimalkan berbagai konstruksi C#. Ada beberapa hal spesifik yang membuat pembangkit kode sumber menghasilkan kode yang dioptimalkan untuk pencocokan lebih baik daripada RegexCompiler. Misalnya, dalam salah satu contoh sebelumnya, Anda dapat melihat generator sumber memancarkan pernyataan pengalihan, dengan satu cabang untuk 'a' dan cabang lain untuk 'b'. Karena pengkompilasi C# sangat baik dalam mengoptimalkan pernyataan switch, dengan beberapa strategi yang digunakan untuk cara melakukannya secara efisien, generator sumber memiliki pengoptimalan khusus yang RegexCompiler tidak. Untuk alternasi, generator sumber memeriksa semua cabang, dan jika dapat membuktikan bahwa setiap cabang dimulai dengan karakter awal yang berbeda, maka akan menghasilkan pernyataan switch berdasarkan karakter pertama tersebut dan menghindari menghasilkan kode backtracking untuk alternasi itu.

Berikut adalah contoh yang sedikit lebih rumit dari itu. Alternasi dianalisis secara lebih mendalam untuk menentukan apakah dapat direfaktor dengan cara yang memungkinkan mesin backtracking lebih mudah mengoptimalkannya dan menghasilkan kode sumber yang lebih sederhana. Salah satu pengoptimalan tersebut mendukung ekstraksi awalan umum dari cabang, dan jika pergantian bersifat atomik sehingga urutannya tidak berpengaruh, menyusun ulang cabang untuk mengizinkan lebih banyak ekstraksi tersebut. Anda dapat melihat dampaknya untuk pola hari kerja berikut Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday, yang menghasilkan fungsi cocok seperti ini:

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;
}

Pada saat yang sama, generator kode sumber memiliki masalah lain yang harus dihadapi, yang tidak ada saat menghasilkan output langsung ke IL. Jika Anda melihat beberapa contoh kode sebelumnya, Anda dapat melihat beberapa kurung kurawal yang dikomentari dengan cara yang agak aneh. Itu bukan kesalahan. Generator kode sumber mengenali bahwa, jika kurung kurawal tersebut tidak dikomentari, struktur backtracking mengandalkan melakukan lompatan dari luar cakupan ke label yang ditentukan di dalam cakupan tersebut; label seperti itu tidak akan terlihat oleh goto tersebut, dan kode akan gagal dikompilasi. Dengan demikian, generator sumber perlu menghindari adanya cakupan di jalan. Dalam beberapa kasus, hanya akan mengomentari cakupan seperti yang dilakukan pada contoh di sini. Dalam kasus lain di mana hal itu tidak mungkin, terkadang menghindari konstruksi yang memerlukan cakupan (seperti blok multi-pernyataan if) jika hal tersebut dianggap bermasalah.

Pembuat kode sumber menangani semua yang RegexCompiler tangani, dengan satu pengecualian. Seperti halnya penanganan RegexOptions.IgnoreCase, implementasi sekarang menggunakan tabel casing untuk menghasilkan set pada waktu konstruksi, dan bagaimana IgnoreCase pencocokan backreference perlu berkonsultasi dengan tabel casing tersebut. Tabel tersebut bersifat internal untuk System.Text.RegularExpressions.dll, dan untuk saat ini, setidaknya, kode eksternal untuk perakitan tersebut (termasuk kode yang dipancarkan oleh generator sumber) tidak memiliki akses ke dalamnya. Itu membuat penanganan referensi balik IgnoreCase menjadi tantangan dalam generator kode sumber dan tidak didukung. Ini adalah satu-satunya konstruksi yang tidak didukung oleh pembangkit kode sumber yang didukung oleh RegexCompiler. Jika Anda mencoba menggunakan pola yang memiliki salah satu dari ini (yang jarang terjadi), generator kode sumber tidak akan menghasilkan implementasi kustom dan sebaliknya akan menggunakan caching untuk instance reguler Regex.

Regex yang tidak didukung masih di-cache

Selain itu, baik RegexCompiler maupun generator sumber tidak mendukung yang baru RegexOptions.NonBacktracking. Jika Anda menentukan RegexOptions.Compiled | RegexOptions.NonBacktracking, flag Compiled akan diabaikan, dan jika Anda menentukan NonBacktracking pada generator sumber, flag tersebut akan kembali melakukan caching pada instance reguler Regex.

Kapan harus menggunakannya

Panduan umumnya adalah jika Anda dapat menggunakan generator sumber, gunakan. Jika Anda menggunakan Regex hari ini di C# dengan argumen yang diketahui pada waktu kompilasi, dan terutama jika Anda sudah menggunakan RegexOptions.Compiled (karena regex telah diidentifikasi sebagai hot spot yang akan mendapat manfaat dari throughput yang lebih cepat), Anda sebaiknya menggunakan generator kode sumber. Generator sumber akan memberi regex Anda manfaat berikut:

  • Semua manfaat throughput dari RegexOptions.Compiled.
  • Keuntungan bagi startup adalah tidak perlu melakukan semua pemrosesan regex, analisis, dan kompilasi saat runtime.
  • Opsi untuk menggunakan kompilasi sebelumnya dengan kode yang dihasilkan untuk regex.
  • Pembenahan kesalahan (debuggability) dan pemahaman yang lebih baik tentang ekspresi reguler.
  • Kemungkinan untuk mengurangi ukuran aplikasi dengan memangkas bagian besar kode yang terkait dengan RegexCompiler (dan bahkan berpotensi menghilangkan pengeluaran refleksi itu sendiri).

Ketika digunakan dengan opsi seperti RegexOptions.NonBacktracking yang tidak dapat menghasilkan implementasi kustom oleh generator sumber, tetap akan menghasilkan log caching dan komentar XML yang menjelaskan implementasi tersebut, sehingga menjadikannya berharga. Kelemahan utama dari generator sumber daya adalah bahwa ia menghasilkan kode tambahan ke dalam assembly Anda, sehingga ada potensi peningkatan ukuran. Semakin banyak regex di aplikasi Anda dan semakin besar, semakin banyak kode yang akan dipancarkan untuk mereka. Dalam beberapa situasi, sama seperti RegexOptions.Compiled mungkin tidak perlu, begitu juga halnya dengan generator sumber. Misalnya, jika Anda memiliki regex yang hanya jarang diperlukan dan di mana throughput tidak penting, akan lebih bermanfaat jika hanya mengandalkan interpreter dalam penggunaan yang bersifat sporadis.

Penting

.NET 7 menyertakan penganalisis yang mengidentifikasi penggunaan Regex yang dapat dikonversi ke generator sumber, dan fixer yang melakukan konversi untuk Anda:

Penganalisis dan perbaikan RegexGenerator

Lihat juga