Condividi tramite


Generatori dell'origine di espressioni regolari .NET

Un'espressione regolare, o regex, è una stringa che consente allo sviluppatore di esprimere un criterio di ricerca, rendendola un modo comune per cercare testo ed estrarre risultati come sottoinsieme della stringa ricercata. In .NET, lo System.Text.RegularExpressions spazio dei nomi viene usato per definire istanze Regex e metodi statici, nonchè per stabilire corrispondenze con modelli definiti dall'utente. In questo articolo, si apprenderà in che modo usare la generazione di origine per generare istanze Regex e ottimizzare le prestazioni.

Nota

Dove possibile, usare le espressioni regolari generate dall'origine anziché compilare espressioni regolari usando l'opzione RegexOptions.Compiled. La generazione di origine consente all'app di avviarsi in modo più rapido, di eseguire più velocemente e di essere più compatibile con il trimming. Per informazioni su quando è possibile generare l'origine, vedere Quando si usa.

Espressioni regolari compilate

Quando si scrive new Regex("somepattern"), si verificano alcune cose. Il criterio specificato viene analizzato, sia per garantirne la validità sia per trasformarlo in un albero interno che rappresenta la regex analizzata. L'albero viene quindi ottimizzato in modi diversi, in modo da trasformare il criterio in una variante funzionalmente equivalente che può essere eseguita in modo più efficiente. L'albero viene scritto in un modulo che può essere interpretato come una serie di codici operativi e operandi che forniscono istruzioni al motore dell'interprete regex su come effettuare la corrispondenza. Quando viene eseguita una corrispondenza, l'interprete si limita a scorrere le istruzioni, elaborandole rispetto al testo di input. Quando si crea una nuova istanza Regex o si chiama uno dei metodi statici in Regex, l'interprete è il motore predefinito in uso.

Quando si specifica RegexOptions.Compiled, si eseguono tutte le stesse operazioni in fase di costruzione. Le istruzioni risultanti vengono ulteriormente trasformate dal compilatore basato su reflection-emit in istruzioni del linguaggio intermedio che vengono scritte in pochi oggetti DynamicMethod. Quando viene eseguita una corrispondenza, questi metodi DynamicMethod vengono richiamati. Il linguaggio intermedio si comporta essenzialmente come l'interprete, con la sola differenza che è specializzato nel criterio esatto che viene elaborato. Ad esempio, se il criterio contiene [ac], l'interprete visualizzerebbe un codice operativo che dice "corrisponde al carattere di input nella posizione corrente rispetto al set specificato in questa descrizione del set". Mentre il linguaggio intermedio compilato conterrebbe un codice che dice effettivamente "corrisponde al carattere di input nella posizione corrente contro rispetto a 'a' o 'c'". Questa convenzione per l'utilizzo di maiuscole e minuscole speciale e la capacità di eseguire ottimizzazioni in base alla conoscenza del criterio sono alcune delle ragioni principali per cui la specificazione RegexOptions.Compiled produce una velocità effettiva di corrispondenza molto più alta rispetto a quella dell'interprete.

Il RegexOptions.Compiled presenta diversi svantaggi. La più significativa è che la sua costruzione è costosa. Non solo si devono sostenere tutti gli stessi costi dell'interprete, ma è necessario compilare l'albero RegexNode risultante e i codici operativi/operandi generati con il linguaggio intermedio, il che aggiunge spese non trascurabili. Il linguaggio intermedio generato, inoltre, deve essere compilato in modalità just-in-time al primo utilizzo, il che comporta spese ancora maggiori all'avvio. RegexOptions.Compiled rappresenta un compromesso fondamentale tra i costi generali del primo utilizzo e quelli di ogni utilizzo successivo. L'uso di System.Reflection.Emit impedisce anche l'uso di RegexOptions.Compiled in determinati ambienti. Alcuni sistemi operativi non consentono l'esecuzione di codice generato dinamicamente e pertanto, in tali sistemi, Compiled diventa non operativo.

Generazione di origine

.NET 7 ha introdotto un nuovo generatore di origine RegexGenerator. Un generatore di origine è un componente che si collega al compilatore e aumenta l'unità di compilazione con codice sorgente aggiuntivo. .NET SDK (versione 7 e successive) include un generatore di origine che riconosce l'attributo GeneratedRegexAttribute in un metodo parziale che restituisce Regex. Il generatore di origine fornisce un'implementazione di tale metodo che contiene tutta la logica per il Regex. Ad esempio, è possibile che in precedenza sia stato scritto codice simile al seguente:

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

Per usare il generatore di origine, riscrivere il codice precedente come segue:

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

Suggerimento

Il flag RegexOptions.Compiled viene ignorato dal generatore di origine, quindi non è necessario nella versione generata dall'origine.

L'implementazione generata di AbcOrDefGeneratedRegex()memorizza nella cache un'istanza singleton in modo analogoRegex, pertanto non è necessaria alcuna memorizzazione nella cache aggiuntiva per poter usare il codice.

L'immagine seguente è un'acquisizione dello schermo dell'istanza della cache generata dall'origine, internal alla sottoclasse Regex che emette il generatore di origine:

Campo statico regex memorizzato nella cache

Ma come si può vedere, non si tratta solo di fare new Regex(...). Il generatore di origine genera invece come codice C# un'implementazione personalizzata basata su Regexcon logica simile a quella RegexOptions.Compiled, che emette il linguaggio intermedio. Si ottengono tutti i vantaggi in termini di prestazioni della velocità effettiva di RegexOptions.Compiled e di avvio di Regex.CompileToAssembly, anzi di più, ma senza la complessità di CompileToAssembly. L'origine generata fa parte del progetto, il che significa che è anche facilmente visualizzabile e sottoponibile a debug.

Debug tramite codice regex generato dall'origine

Suggerimento

In Visual Studio, fare clic con il pulsante destro del mouse sulla dichiarazione parziale del metodo e scegliere Vai a definizione. In alternativa, selezionare il nodo del progetto in Esplora soluzioni, quindi espandere Dependencies>Analyzers>System.Text.RegularExpressions.Generator>System.Text.RegularExpressions.Generator.RegexGenerator>RegexGenerator.g.cs per visualizzare il codice C# generato da questo generatore di regex.

È possibile impostarvi punti di interruzione, eseguire dei passaggi e usarlo come strumento di apprendimento per comprendere esattamente come il motore della regex elabora il modello tramite l'input. II generatore produce anche commenti indicati da barre triple (XML) per rendere l'espressione comprensibile a colpo d'occhio e nei casi in cui viene usata.

Commenti XML generati che descrivono regex

All'interno dei file generati dall'origine

Con .NET 7, sia il generatore di origine che il RegexCompiler sono stati quasi interamente riscritti, modificando radicalmente la struttura del codice generato. Questo approccio è stato esteso alla gestione di tutti i costrutti, sebbene con un avvertimento, e sia RegexCompiler che il generatore di origine sono tuttora in grado di riprodurre il codice in modo quasi identico, seguendo tale approccio. Si consideri l'output del generatore di origine per una delle funzioni principali dell'espressione 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;
}

L'obiettivo del codice generato dall’origine è quello di essere comprensibile, dotato di una struttura facile da seguire, di commenti che spieghino cosa viene fatto in ogni passaggio e, in generale, di codice generato in base al principio guida secondo cui il generatore dovrebbe emettere codice come se fosse stato scritto da un essere umano. Anche quando è coinvolto il backtracking, la sua struttura diventa parte della struttura del codice, anziché basarsi su uno stack per indicare dove passare successivamente. Ad esempio, di seguito è riportato il codice per la stessa funzione di corrispondenza generata quando l'espressione è [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;
}

È possibile visualizzare la struttura del backtracking nel codice, con un'etichetta CharLoopBacktrack generata per indicare il punto in cui eseguire il backtrack e una goto etichetta usata per passare a tale posizione quando una parte successiva dell'espressione regolare ha esito negativo.

Se si esamina l'implementazione del codice RegexCompiler e il generatore di origine, si noterà che sono estremamente simili, ovvero metodi denominati in modo simile, con una struttura di chiamata simile e anche commenti simili in tutta l'implementazione. Nella maggior parte dei casi, generano codice identico, anche se in un caso viene espresso tramite il linguaggio intermedio e nell'altro attraverso il codice C#. Naturalmente, il compilatore C# è quindi responsabile della traduzione del codice C# in linguaggio intermedio, pertanto il risultato finale non sarà identico in entrambi i casi. Il generatore di origine si basa su questa funzione in vari casi, traendo vantaggio dal fatto che il compilatore C# ottimizzerà ulteriormente diversi costrutti di C#. Esistono pertanto alcune funzioni specifiche che consentono al generatore di origine di produrre un codice di corrispondenza più ottimizzato rispetto a RegexCompiler. Ad esempio, in uno degli esempi precedenti, è possibile visualizzare il generatore di origine che emette un'istruzione switch, con un ramo per 'a' e un altro ramo per 'b'. Poiché il compilatore C# è molto abile nell'ottimizzare le istruzioni switch, avendo a disposizione più strategie per farlo in modo efficiente, dispone di una capacità di ottimizzazione speciale di cui è privo RegexCompiler. In caso di alternanze, il generatore di origine esamina tutti i rami e, se è in grado di dimostrare che ogni ramo presenta un carattere iniziale diverso, emette un'istruzione switch per il carattere iniziale ed evita di emettere il codice di backtracking tale alternanza.

Di seguito è riportato un esempio leggermente più complicato di questo. Le alternanze vengono analizzate in modo più approfondito per determinare se è possibile effettuarne il refactoring in modo da renderle più facilmente ottimizzabili dai motori di backtracking e ottenere codice generato dall'origine più semplice. Un'ottimizzazione di questo tipo supporta l'estrazione di prefissi comuni dai rami e, se l'alternanza è atomica e l'ordinamento non ha importante, riordina i rami per consentire un'ulteriore estrazione. È possibile osservarne l'impatto nel caso del criterio Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday relativo al giorno feriale seguente, che produce una funzione di corrispondenza simile a quella indicata di seguito:

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

Allo stesso tempo, il generatore di origine presenta altri problemi che normalmente non si verificano quando si esegue l'output direttamente nel linguaggio intermedio. Se si guarda a un paio di esempi di codice, è possibile notare alcune parentesi graffe commentate in modo un po' strano. Non si tratta di un errore. Il generatore di origine riconosce che, se tali parentesi graffe non sono state commentate, la struttura del backtracking si basa sul passaggio dall'esterno dell'ambito a un'etichetta definita all'interno di tale ambito; tuttavia, un'etichetta del genere non sarebbe visibile a tale goto e il codice non verrebbe compilato. Pertanto, il generatore di origine deve evitare che vi sia un ambito di intralcio. In alcuni casi, è sufficiente impostare come commento l'ambito come è stato fatto qui. In altri casi in cui ciò non è possibile, si può talvolta evitare di usare i costrutti che richiedono ambiti, ad esempio un blocco ifcon più istruzioni, se farlo diventa problematico.

Il generatore di origine gestisce tutti gli handle RegexCompiler, con un'unica eccezione. Come per la gestione di RegexOptions.IgnoreCase, le implementazioni ora usano una tabella per l’utilizzo di maiuscole e minuscole per generare set in fase di costruzione e il modo in cui la corrispondenza di backreference IgnoreCase deve consultare tale tabella. Questa tabella è interna a System.Text.RegularExpressions.dlle, almeno per il momento, il codice esterno a tale assembly, incluso quello generato dal generatore di origine, non vi ha accesso. Ciò rende la gestione dei backreference IgnoreCase nel generatore di origine piuttosto complicata e, pertanto, non sono supportati. Si tratta di un costrutto non supportato dal generatore di origine che è supportato da RegexCompiler. Se si tenta di usare un criterio che ne contenga uno, benché sia un evento raro, il generatore di origine non produrrà un'implementazione personalizzata ed eseguirà invece il fallback alla memorizzazione nella cache di un'istanza Regex regolare:

Regex non supportato ancora memorizzato nella cache

Inoltre, né RegexCompiler né il generatore di origine supporta il nuovo RegexOptions.NonBacktracking. Se si specifica RegexOptions.Compiled | RegexOptions.NonBacktracking, il flag Compiled verrà semplicemente ignorato, mentre se si specifica NonBacktracking al generatore di origine, verrà eseguito il fallback alla memorizzazione nella cache di un'istanza Regex regolare.

Quando usarlo

Le indicazioni generali raccomandano di usare il generatore di origine, purché sia possibile. Se attualmente si usa Regex in C# con argomenti noti in fase di compilazione, e in particolare se si sta già usando RegexOptions.Compiled, poiché la regex è stata identificata come un punto critico che trarrebbe vantaggio da una velocità effettiva più elevata, è consigliabile usare il generatore di origine. Il generatore di origine offrirà i vantaggi seguenti:

  • Vantaggi della velocità effettiva di RegexOptions.Compiled.
  • I vantaggi in fase di avvio derivano dalla possibilità di non dover eseguire la parsificazione, l'analisi e la compilazione dell’espressione regolare in fase di esecuzione.
  • Opzione di utilizzo della compilazione anticipata con il codice generato per l'espressione regolare.
  • Migliore debugging e comprensione dell'espressione regolare.
  • Possibilità di ridurre le dimensioni dell'app tagliata eliminando ampie porzioni di codice associato a RegexCompiler e, potenzialmente, anche la reflection emit stessa.

Se usata con un'opzione come RegexOptions.NonBacktracking per cui il generatore di origine non può generare un'implementazione personalizzata, emetterà comunque commenti di memorizzazione nella cache e XML che descrivono l'implementazione, rendendola utile. Lo svantaggio principale del generatore di origine consiste nel fatto che genera codice aggiuntivo nell'assembly, per cui è possibile che le dimensioni aumentino. Maggiori sono il numero e la dimensione delle espressioni regolari presenti nell'app, maggiore sarà la quantità di codice che verrà per le stesse generato. In alcune situazioni, proprio come RegexOptions.Compiled potrebbe non essere necessario, così come potrebbe esserlo anche il generatore di origine. Ad esempio, se si dispone di un'espressione regolare che è necessaria solo raramente e per la quale la velocità effettiva non è rilevante, potrebbe essere più utile affidarsi all'interprete solo per l'uso sporadico.

Importante

.NET 7 include un analizzatore che identifica l'uso di Regex che può essere convertito nel generatore di origine e una correzione che esegue automaticamente la conversione:

Analizzatore e correzione RegexGenerator

Vedi anche