Partilhar via


Geradores de origem de expressão regular .NET

Uma expressão regular, ou regex, é uma cadeia de caracteres que permite que um desenvolvedor expresse um padrão que está sendo pesquisado, tornando-se uma maneira comum de pesquisar texto e extrair resultados como um subconjunto da cadeia de caracteres pesquisada. No .NET, o System.Text.RegularExpressions namespace é usado para definir Regex instâncias e métodos estáticos e corresponder em padrões definidos pelo usuário. Neste artigo, você aprenderá como usar a geração de origem para gerar Regex instâncias para otimizar o desempenho.

Nota

Sempre que possível, use expressões regulares geradas pelo código-fonte em vez de compilar expressões regulares usando a RegexOptions.Compiled opção. A geração de origem pode ajudar seu aplicativo a iniciar mais rápido, ser executado mais rapidamente e ser mais fácil de controlar. Para saber quando a geração de fontes é possível, consulte Quando usá-la.

Expressões regulares compiladas

Quando você escreve new Regex("somepattern"), algumas coisas acontecem. O padrão especificado é analisado, tanto para garantir a validade do padrão quanto para transformá-lo em uma árvore interna que representa o regex analisado. A árvore é então otimizada de várias maneiras, transformando o padrão em uma variação funcionalmente equivalente que pode ser executada de forma mais eficiente. A árvore é escrita em um formulário que pode ser interpretado como uma série de opcodes e operandos que fornecem instruções para o mecanismo de interpretação regex sobre como combinar. Quando uma correspondência é realizada, o intérprete simplesmente percorre essas instruções, processando-as em relação ao texto de entrada. Ao instanciar uma nova Regex instância ou chamar um dos métodos estáticos no Regex, o interpretador é o mecanismo padrão empregado.

Quando você especifica RegexOptions.Compiled, todo o mesmo trabalho de tempo de construção é executado. As instruções resultantes são transformadas ainda mais pelo compilador baseado em emissão de reflexão em instruções IL que são gravadas em alguns DynamicMethod objetos. Quando uma correspondência é executada, esses DynamicMethod métodos são invocados. Este IL essencialmente faz exatamente o que o intérprete faria, exceto especializado para o padrão exato que está sendo processado. Por exemplo, se o padrão contiver [ac], o intérprete verá um opcode que diz "corresponder o caractere de entrada na posição atual com o conjunto especificado nesta descrição do conjunto". Considerando que o IL compilado conteria código que efetivamente diz, "corresponder o caractere de entrada na posição atual contra 'a' ou 'c'". Esse invólucro especial e a capacidade de executar otimizações com base no conhecimento do padrão são algumas das principais razões pelas quais a especificação RegexOptions.Compiled produz uma taxa de transferência de correspondência muito mais rápida do que o interpretador.

Há várias desvantagens no RegexOptions.Compiled. O mais impactante é que é caro construir. Não só são pagos todos os mesmos custos que para o intérprete, mas ele precisa compilar a árvore resultante RegexNode e opcodes/operandos gerados em IL, o que adiciona despesas não triviais. O IL gerado ainda precisa ser compilado em JIT no primeiro uso, levando a ainda mais despesas na inicialização. RegexOptions.Compiled representa um compromisso fundamental entre as despesas gerais na primeira utilização e as despesas gerais em todas as utilizações subsequentes. O uso de também inibe o uso de System.Reflection.Emit RegexOptions.Compiled em determinados ambientes, alguns sistemas operacionais não permitem que o código gerado dinamicamente seja executado e, nesses sistemas, Compiled torna-se um no-op.

Geração de fontes

O .NET 7 introduziu um novo RegexGenerator gerador de código-fonte. Um gerador de código-fonte é um componente que se conecta ao compilador e aumenta a unidade de compilação com código-fonte adicional. O SDK .NET (versão 7 e posterior) inclui um gerador de código-fonte que reconhece o GeneratedRegexAttribute atributo em um método parcial que retorna Regex. O gerador de código-fonte fornece uma implementação desse método que contém toda a lógica para o Regex. Por exemplo, você pode ter escrito anteriormente um código como este:

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

Para usar o gerador de código-fonte, reescreva o código anterior da seguinte maneira:

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

Gorjeta

O RegexOptions.Compiled sinalizador é ignorado pelo gerador de código-fonte, portanto, não é necessário na versão gerada pelo código-fonte.

A implementação gerada de AbcOrDefGeneratedRegex() forma semelhante armazena em cache uma instância singleton Regex , portanto, nenhum cache adicional é necessário para consumir código.

A imagem a seguir é uma captura de tela da instância em cache gerada pela origem, internal para a Regex subclasse que o gerador de origem emite:

Campo estático regex armazenado em cache

Mas, como se pode ver, não é só fazer new Regex(...). Em vez disso, o gerador de código-fonte está emitindo como código C# uma implementação derivada personalizada Regexcom lógica semelhante à que RegexOptions.Compiled emite em IL. Você obtém todos os benefícios de desempenho de taxa de transferência de RegexOptions.Compiled (mais, na verdade) e os benefícios iniciais do Regex.CompileToAssembly, mas sem a complexidade do CompileToAssembly. A fonte emitida faz parte do seu projeto, o que significa que também é facilmente visível e depurável.

Depuração através do código Regex gerado pelo código-fonte

Gorjeta

No Visual Studio, clique com o botão direito do mouse em sua declaração de método parcial e selecione Ir para definição. Ou, alternativamente, selecione o nó do projeto no Gerenciador de Soluções e, em seguida, expanda Analisadores>de Dependências>System.Text.RegularExpressions.Generator>System.Text.RegularExpressions.Generator.RegexGenerator>RegexGenerator.g.cs para ver o código C# gerado a partir deste gerador de regex.

Você pode definir pontos de interrupção nele, você pode passar por ele, e você pode usá-lo como uma ferramenta de aprendizagem para entender exatamente como o mecanismo regex está processando seu padrão com sua entrada. O gerador até gera comentários de barra tripla (XML) para ajudar a tornar a expressão compreensível rapidamente e onde ela é usada.

Comentários XML gerados descrevendo regex

Dentro dos arquivos gerados pela fonte

Com o .NET 7, tanto o gerador RegexCompiler de código-fonte quanto foram quase inteiramente reescritos, alterando fundamentalmente a estrutura do código gerado. Esta abordagem foi estendida para lidar com todas as construções (com uma ressalva), e ambos RegexCompiler e o gerador de código-fonte ainda mapeiam principalmente 1:1 um com o outro, seguindo a nova abordagem. Considere a saída do gerador de origem para uma das funções primárias da abc|def expressão:

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

O objetivo do código-fonte gerado é ser compreensível, com uma estrutura fácil de seguir, com comentários explicando o que está sendo feito em cada etapa e, em geral, com o código emitido sob o princípio orientador de que o gerador deve emitir código como se um humano o tivesse escrito. Mesmo quando o backtracking está envolvido, a estrutura do backtracking torna-se parte da estrutura do código, em vez de depender de uma pilha para indicar onde saltar em seguida. Por exemplo, aqui está o código para a mesma função de correspondência gerada quando a expressão é [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;
}

Você pode ver a estrutura do backtracking no código, com um CharLoopBacktrack rótulo emitido para onde voltar atrás e um goto usado para saltar para esse local quando uma parte subsequente do regex falhar.

Se você olhar para a implementação RegexCompiler do código e o gerador de código-fonte, eles serão extremamente semelhantes: métodos com nomes semelhantes, estrutura de chamada semelhante e até mesmo comentários semelhantes ao longo da implementação. Na maioria das vezes, eles resultam em código idêntico, embora um em IL e um em C#. É claro que o compilador C# é responsável por traduzir o C# em IL, então o IL resultante em ambos os casos provavelmente não será idêntico. O gerador de código-fonte depende disso em vários casos, aproveitando o fato de que o compilador C# otimizará ainda mais várias construções C#. Há algumas coisas específicas que o gerador de código-fonte produzirá, portanto, um código de correspondência mais otimizado do que o RegexCompiler. Por exemplo, em um dos exemplos anteriores, você pode ver o gerador de origem emitindo uma instrução switch, com uma ramificação para 'a' e outra ramificação para 'b'. Como o compilador C# é muito bom em otimizar instruções de switch, com várias estratégias à sua disposição para como fazê-lo de forma eficiente, o gerador de código-fonte tem uma otimização especial que RegexCompiler não. Para as alternâncias, o gerador de código-fonte examina todas as ramificações e, se puder provar que cada ramificação começa com um caractere inicial diferente, emitirá uma instrução switch sobre esse primeiro caractere e evitará a saída de qualquer código de backtracking para essa alternância.

Aqui está um exemplo um pouco mais complicado disso. As alternâncias são mais fortemente analisadas para determinar se é possível refatorá-las de uma forma que as torne mais facilmente otimizadas pelos mecanismos de backtracking e que leve a um código-fonte gerado mais simples. Uma dessas otimizações suporta a extração de prefixos comuns de ramos, e se a alternância for atômica tal que a ordenação não importa, reordenar ramos para permitir mais essa extração. Você pode ver o impacto disso para o seguinte padrão Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sundayde dia da semana, que produz uma função de correspondência como esta:

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

Ao mesmo tempo, o gerador de origem tem outros problemas a enfrentar que simplesmente não existem ao enviar diretamente para a IL. Se você olhar alguns exemplos de código para trás, você pode ver algumas chaves um pouco estranhamente comentadas. Isso não é um erro. O gerador de fontes está reconhecendo que, se essas chaves não foram comentadas, a estrutura do backtracking está dependendo de saltar de fora do escopo para um rótulo definido dentro desse escopo; tal rótulo não seria visível para tal goto e o código deixaria de ser compilado. Assim, o gerador de fonte precisa evitar que haja um escopo no caminho. Em alguns casos, ele simplesmente comentará o escopo como foi feito aqui. Em outros casos em que isso não é possível, às vezes pode evitar construções que exigem escopos (como um bloco de várias instruções if ) se isso for problemático.

O gerador de origem lida com todas RegexCompiler as alças, com uma exceção. Assim como no manuseio RegexOptions.IgnoreCase, as implementações agora usam uma tabela de invólucro para gerar conjuntos no momento da construção, e como IgnoreCase a correspondência de backreference precisa consultar essa tabela de invólucro. Essa tabela é interna à System.Text.RegularExpressions.dll, e por enquanto, pelo menos, o código externo a esse assembly (incluindo o código emitido pelo gerador de código-fonte) não tem acesso a ela. Isso faz com que o manuseio de IgnoreCase referências de retorno seja um desafio no gerador de código-fonte e elas não são suportadas. Esta é a única construção não suportada pelo gerador de origem que é suportado pelo RegexCompiler. Se você tentar usar um padrão que tenha um desses (o que é raro), o gerador de código-fonte não emitirá uma implementação personalizada e, em vez disso, voltará ao cache de uma instância regular Regex :

Regex não suportado ainda está sendo armazenado em cache

Além disso, nem RegexCompiler o gerador de origem suporta o novo RegexOptions.NonBacktracking. Se você especificar RegexOptions.Compiled | RegexOptions.NonBacktracking, o Compiled sinalizador será simplesmente ignorado e, se você especificar NonBacktracking para o gerador de origem, ele também voltará ao cache de uma instância regular Regex .

Quando Utilizar

A orientação geral é se você pode usar o gerador de fonte, use-o. Se você estiver usando Regex hoje em C# com argumentos conhecidos em tempo de compilação, e especialmente se já estiver usando RegexOptions.Compiled (porque o regex foi identificado como um ponto de acesso que se beneficiaria de uma taxa de transferência mais rápida), você deve preferir usar o gerador de código-fonte. O gerador de fonte dará ao seu regex os seguintes benefícios:

  • Todos os benefícios de taxa de transferência do RegexOptions.Compiled.
  • A startup se beneficia de não ter que fazer toda a análise, análise e compilação de regex em tempo de execução.
  • A opção de usar a compilação antecipada com o código gerado para o regex.
  • Melhor depurabilidade e compreensão do regex.
  • A possibilidade de reduzir o tamanho do seu aplicativo cortado, cortando grandes partes de código associado ( RegexCompiler e potencialmente até mesmo o próprio reflexo).

Quando usado com uma opção para RegexOptions.NonBacktracking a qual o gerador de código-fonte não pode gerar uma implementação personalizada, ele ainda emitirá cache e comentários XML que descrevem a implementação, tornando-a valiosa. A principal desvantagem do gerador de código-fonte é que ele emite código adicional em sua montagem, então há o potencial para aumentar o tamanho. Quanto mais regexes em seu aplicativo e quanto maiores eles forem, mais código será emitido para eles. Em algumas situações, assim como RegexOptions.Compiled pode ser desnecessário, também pode ser o gerador de fonte. Por exemplo, se você tem um regex que é necessário apenas raramente e para o qual a taxa de transferência não importa, pode ser mais benéfico confiar apenas no intérprete para esse uso esporádico.

Importante

O .NET 7 inclui um analisador que identifica o uso do que pode ser convertido para o gerador de Regex origem e um fixador que faz a conversão para você:

Analisador e fixador RegexGenerator

Consulte também