Dela via


Källgeneratorer för reguljärt .NET-uttryck

Ett reguljärt uttryck, eller regex, är en sträng som gör det möjligt för en utvecklare att uttrycka ett mönster som söks efter, vilket gör det till ett vanligt sätt att söka efter text och extrahera resultat som en delmängd från den sökta strängen. I .NET används System.Text.RegularExpressions-namnområdet för att definiera Regex-instanser och statiska metoder och för att matcha användardefinierade mönster. I den här artikeln får du lära dig hur du använder källgenerering för att generera Regex instanser för att optimera prestanda.

Kommentar

Använd där det är möjligt källgenererade reguljära uttryck i stället för att kompilera reguljära uttryck med hjälp av RegexOptions.Compiled alternativet . Källgenerering kan hjälpa din app att starta snabbare, köras snabbare och bli mer trimmad. Information om när källgenerering är möjligt finns i När du ska använda den.

Kompilerade reguljära uttryck

När du skriver new Regex("somepattern")händer några saker. Det angivna mönstret parsas, både för att säkerställa mönstrets giltighet och för att omvandla det till ett internt träd som representerar den parsade regexen. Trädet optimeras sedan på olika sätt och omvandlar mönstret till en funktionellt likvärdig variant som kan köras mer effektivt. Trädet skrivs till ett formulär som kan tolkas som en serie opkoder och operander som ger instruktioner till regex-tolkningsmotorn om hur man ska matcha. När en matchning utförs går tolken helt enkelt igenom dessa instruktioner och bearbetar dem mot indatatexten. När du instansierar en ny Regex instans eller anropar någon av de statiska metoderna på Regexär tolken standardmotorn som används.

När du anger RegexOptions.Compiledutförs samma byggtidsarbete. De resulterande instruktionerna omvandlas ytterligare av den reflektionsbaserade kompilatorn till IL-instruktioner som skrivs till några få DynamicMethod objekt. När en matchning utförs anropas dessa DynamicMethod metoder. Detta IL utför i princip exakt det som tolken skulle göra, men är specialiserad på det exakta mönstret som bearbetas. Om mönstret till exempel innehåller [ac], kommer tolken att se en opkod som säger "matcha indatatecknet vid den aktuella positionen mot den uppsättning som anges i denna uppsättningsbeskrivning". Medan den kompilerade IL:en skulle innehålla kod som i praktiken säger "matcha indatatecknet vid den aktuella positionen mot 'a' eller 'c'". Denna speciella hantering och möjligheten att utföra optimeringar baserat på kunskap om mönstret är några av de främsta orsakerna till att specificera RegexOptions.Compiled ger mycket snabbare matchningsgenomströmning än tolken.

Det finns flera nackdelar med .RegexOptions.Compiled Det mest betydelsefulla är att det är dyrt att bygga. Inte bara betalas alla samma kostnader som för tolken, utan det måste sedan kompilera det resulterande RegexNode-trädet och de genererade opkoderna/operanderna till IL, vilket leder till betydande kostnader. Den genererade IL:en måste ytterligare JIT-kompileras vid första användningen, vilket leder till ännu mer kostnader vid start. RegexOptions.Compiled representerar en grundläggande kompromiss mellan omkostnader vid den första användningen och omkostnaderna vid varje efterföljande användning. Användningen av System.Reflection.Emit hämmar också användningen av RegexOptions.Compiled i vissa miljöer. Vissa operativsystem tillåter inte att dynamiskt genererad kod körs, och på sådana system Compiled blir det en no-op.

Källgenerering

.NET 7 introducerade en ny RegexGenerator källgenerator. En källgenerator är en komponent som ansluter till kompilatorn och utökar kompileringsenheten med ytterligare källkod. .NET SDK innehåller en källgenerator som identifierar GeneratedRegexAttribute attributet på en partiell metod som returnerar Regex. Från och med .NET 9 kan attributet också tillämpas på partiella egenskaper. Källgeneratorn tillhandahåller en implementering av metoden eller egenskapen som innehåller all logik för Regex. Du kan till exempel tidigare ha skrivit kod som den här:

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

Om du vill använda källgeneratorn skriver du om den tidigare koden på följande sätt:

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

Från och med .NET 9 kan du även tillämpa på GeneratedRegexAttribute en partiell egenskap i stället för en partiell metod. Detta aktiveras av C# 13:s stöd för partiella egenskaper. I följande exempel visas egenskapens motsvarighet:

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

Tips

Flaggan RegexOptions.Compiled ignoreras av källgeneratorn, vilket innebär att den inte behövs i den källgenererade versionen.

Den genererade implementeringen av AbcOrDefGeneratedRegex() cachelagrar på liknande sätt en singleton-instans av Regex, så ingen ytterligare cachelagring krävs för användning av kod.

Följande bild är en skärmdump av den cachelagrade instansen som källgeneratorn genererar, från internal till den Regex underklass som det emitterar.

Cachelagrat statiskt regex-fält

Som det kan ses, handlar det inte bara om att göra new Regex(...). I stället genererar källgeneratorn som C#-kod en anpassad Regex-härledd implementering med logik som liknar vad som RegexOptions.Compiled genererar i IL. Du får alla prestandafördelar med RegexOptions.Compiled (faktiskt mer) och startfördelarna med Regex.CompileToAssembly, men utan komplexiteten i CompileToAssembly. Källan som emitteras är en del av projektet, vilket innebär att den också är lätt att se och felsöka.

Felsöka via källgenererad Regex-kod

Tips

Högerklicka på den partiella metoden eller egenskapsdeklarationen i Visual Studio och välj Gå till definition. Alternativt kan du välja projektnoden i Solution Explorer och sedan expandera Dependencies>Analyzeers>System.Text.RegularExpressions.Generator>RegexGenerator.g.cs för att se den genererade C#-koden från den här regex-generatorn.

Du kan ange brytpunkter i den, du kan gå igenom den och du kan använda den som ett inlärningsverktyg för att förstå exakt hur regex-motorn bearbetar ditt mönster med dina indata. Generatorn genererar till och med XML-kommentarer (triple-slash) för att göra uttrycket lätt att förstå och var det används.

Genererade XML-kommentarer som beskriver regex

Inuti källgenererade filer

Med .NET 7 skrevs både källgeneratorn och RegexCompiler nästan helt om, vilket i grunden ändrade strukturen för den genererade koden. Den här metoden har utökats för att hantera alla konstruktioner (med en varning), och både RegexCompiler och källgeneratorn mappar fortfarande mestadels 1:1 med varandra, enligt den nya metoden. Överväg källgeneratorns utdata för en av de primära funktionerna från abc|def uttrycket:

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

Målet med den källgenererade koden är att vara begriplig, med en lätt att följa struktur, med kommentarer som förklarar vad som görs i varje steg, och i allmänhet med kod som genereras enligt den vägledande principen att generatorn ska avge kod som om en människa hade skrivit den. Även när backtracking är involverad blir strukturen för backtracking en del av kodens struktur, snarare än att förlita sig på en stack för att ange var du ska hoppa härnäst. Här är till exempel koden för samma genererade matchningsfunktion när uttrycket är [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;
}

Du kan se strukturen för backtracking i koden, med en CharLoopBacktrack etikett som genereras för var du ska backa till och en goto som används för att hoppa till den platsen när en efterföljande del av regex misslyckas.

Om du tittar på kodimplementeringen RegexCompiler och källgeneratorn ser de mycket lika ut: liknande namngivna metoder, liknande anropsstruktur och till och med liknande kommentarer under hela implementeringen. För det mesta resulterar de i identisk kod, om än en i IL och en i C#. Naturligtvis ansvarar C#-kompilatorn sedan för att översätta C# till IL, så den resulterande IL:en i båda fallen kommer sannolikt inte att vara identisk. Källgeneratorn förlitar sig på det i olika fall och drar nytta av det faktum att C#-kompilatorn ytterligare optimerar olika C#-konstruktioner. Det finns några specifika saker som gör att källgeneratorn därmed producerar mer optimerad matchningskod än vad RegexCompiler gör. I ett av de föregående exemplen kan du till exempel se källgeneratorn som genererar en switch-instruktion, med en gren för 'a' och en annan gren för 'b'. Eftersom C#-kompilatorn är skicklig på att optimera switch-instruktioner, med flera strategier till sitt förfogande för att göra det effektivt, har källgeneratorn en speciell optimering som RegexCompiler inte har. För alternationer tittar källgeneratorn på alla grenar, och om det kan bevisa att varje gren börjar med ett annat starttecken, genererar den en switch-instruktion över det första tecknet och undviker att mata ut någon bakåtspårningskod för den växlingen.

Här är ett lite mer komplicerat exempel på det. Alternationer analyseras mer för att avgöra om det är möjligt att omstrukturera dem på ett sätt som gör dem enklare optimerade av backtracking-motorerna och som leder till enklare källgenererad kod. En sådan optimering stöder extrahering av vanliga prefix från grenar, och om växlingen är atomisk så att ordningen inte spelar någon roll, ordna om grenar för att möjliggöra mer sådan extrahering. Du kan se effekten av det för följande veckodagsmönster Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday, som ger en matchande funktion som den här:

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

Samtidigt har källgeneratorn andra problem att hantera som helt enkelt inte finns när du matar ut till IL direkt. Om du tittar tillbaka på några kodexempel kan du se några klammerparenteser som är något konstigt kommenterade. Det är inget misstag. Källgeneratorn inser att om dessa klammerparenteser inte vore bortkommenterade, skulle backtracking-strukturen förlita sig på att hoppa från utanför blocket till en etikett definierad inuti det blocket; en sådan etikett skulle inte vara synlig för en sådan goto och koden skulle misslyckas med att kompilera. Källgeneratorn måste därför undvika att det finns ett omfång i vägen. I vissa fall kommenterar den bara ut omfånget som det gjordes här. I andra fall där det inte är möjligt kan det ibland undvika konstruktioner som kräver omfång (till exempel ett block med flera instruktioner if ) om det skulle vara problematiskt.

Källgeneratorn hanterar allt som RegexCompiler hanterar, med ett undantag. Precis som med hanteringen RegexOptions.IgnoreCaseanvänder implementeringarna nu en höljetabell för att generera uppsättningar vid byggtid, och hur IgnoreCase matchning av backreference måste konsultera den höljetabellen. Tabellen är intern för System.Text.RegularExpressions.dll, och för tillfället har åtminstone koden som är extern till den sammansättningen (inklusive kod som genereras av källgeneratorn) inte åtkomst till den. Det gör hantering IgnoreCase av backreferences till en utmaning i källgeneratorn och de stöds inte. Det här är den enda konstruktion som inte stöds av källgeneratorn som stöds av RegexCompiler. Om du försöker använda ett mönster som har något av dessa (vilket är ovanligt) genererar källgeneratorn inte någon anpassad implementering och återgår i stället till att cachelagra en vanlig Regex instans:

Regex som inte stöds lagras fortfarande i cache

Dessutom stöder varken RegexCompiler eller källgeneratorn den nya RegexOptions.NonBacktracking. Om du anger RegexOptions.Compiled | RegexOptions.NonBacktracking, ignoreras Compiled-flaggan helt enkelt, och om du anger NonBacktracking för källgeneratorn återgår den på samma sätt till att cachelagra en vanlig Regex-instans.

När du ska använda detta

Den allmänna vägledningen är om du kan använda källgeneratorn, använd den. Om du använder Regex i dag i C# med argument som är kända vid kompileringstiden, och särskilt om du redan använder RegexOptions.Compiled (eftersom regex har identifierats som en frekvent punkt som skulle dra nytta av snabbare dataflöde), bör du föredra att använda källgeneratorn. Källgeneratorn ger din regex följande fördelar:

  • Alla dataflödesfördelar med RegexOptions.Compiled.
  • Startfördelarna med att inte behöva utföra all regex-parsning, analys och kompilering vid körning.
  • Alternativet att använda förkompilering för den kod som genereras för regex.
  • Bättre felsökningsmöjligheter och förståelse för reguljära uttryck.
  • Möjligheten att minska storleken på din trimmade app genom att skära bort stora mängder kod som är associerad med RegexCompiler (och potentiellt även reflektionutsläpp).

När det används med ett alternativ som RegexOptions.NonBacktracking som källgeneratorn inte kan generera en anpassad implementering för, genererar den fortfarande cachelagrings- och XML-kommentarer som beskriver implementeringen, vilket gör den värdefull. Den huvudsakliga nackdelen med källgeneratorn är att den genererar ytterligare kod i din sammansättning, så det finns potential för ökad storlek. Ju fler regexes i din app och ju större de är, desto mer kod genereras för dem. I vissa situationer kan RegexOptions.Compiled vara onödigt, och på samma sätt kan källgeneratorn också vara det. Om du till exempel har ett regex som bara behövs sällan och för vilket dataflöde inte spelar någon roll, kan det vara mer fördelaktigt att bara förlita sig på tolken för den sporadiska användningen.

Viktigt!

.NET 7 innehåller en analysator som identifierar användningen av Regex som kan konverteras till källgeneratorn och en korrigering som utför konverteringen åt dig:

RegexGenerator-analysator och fixare

Se även