Partilhar via


Geradores de código-fonte de expressões regulares do .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 código pode ajudar a sua aplicação a iniciar mais rápido, ser executada mais rapidamente e ser mais fácil de otimizar. 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 numa forma que pode ser interpretada como uma série de opcodes e operandos que fornecem instruções ao motor de interpretação de expressões regulares sobre como efetuar uma correspondência. 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.

Ao especificar RegexOptions.Compiled, realiza-se todo o trabalho de construção correspondente. As instruções resultantes são transformadas ainda mais pelo compilador baseado em reflexão e emissão em instruções IL que são armazenadas em alguns objetos DynamicMethod. 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: "comparar o carácter de entrada no local 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 também é necessário compilar a árvore resultante RegexNode e os opcodes/operandos gerados para IL, o que implica custos significativos. O IL gerado ainda precisa ser compilado em JIT no primeiro uso, levando a ainda mais despesas na inicialização. RegexOptions.Compiled representa um equilíbrio fundamental entre sobrecargas na primeira utilização e sobrecargas em todas as utilizações subsequentes. O uso de System.Reflection.Emit também inibe o uso de RegexOptions.Compiled em determinados ambientes; alguns sistemas operativos não permitem que o código gerado dinamicamente seja executado e, nesses sistemas, Compiled torna-se uma operação sem efeito.

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 inclui um gerador de código-fonte que reconhece o GeneratedRegexAttribute atributo num método parcial que devolve Regex. A partir do .NET 9, o atributo pode também ser aplicado a propriedades parciais. O gerador de fonte fornece uma implementação desse método ou propriedade 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
    }
}

A partir do .NET 9, também podes aplicar o GeneratedRegexAttribute a uma propriedade parcial em vez de um método parcial. Isto é possível graças ao suporte do C# 13 para propriedades parciais. O exemplo seguinte mostra a propriedade equivalente:

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

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() de forma semelhante tem em cache uma instância singleton Regex, portanto, nenhum cache adicional é necessário para executar o código.

A seguinte imagem é uma captura de tela de uma instância em cache gerada pela fonte, internal para a subclasse Regex que o gerador de fonte 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á a emitir, em código C#, uma implementação derivada personalizada de Regex, com uma lógica semelhante à que RegexOptions.Compiled emite em IL. Consegue todos os benefícios de desempenho de rendimento de RegexOptions.Compiled (mais, na verdade) e as vantagens de arranque de Regex.CompileToAssembly, mas sem a complicação de CompileToAssembly. O código fonte gerado faz parte do seu projeto, o que significa que também é facilmente visualizável e passível de depuração.

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

Gorjeta

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

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 à primeira vista e no contexto em que é usada.

Comentários XML gerados descrevendo regex

Dentro dos arquivos gerados pela fonte

Com o .NET 7, tanto o gerador de código-fonte quanto o RegexCompiler foram quase inteiramente reescritos, alterando fundamentalmente a estrutura do código gerado. Esta abordagem foi estendida para trabalhar com todas as construções (com uma ressalva), e tanto RegexCompiler quanto o gerador de código continuam a mapear-se principalmente 1:1 entre si, seguindo a nova abordagem. Considere a saída do gerador de código-fonte para uma das funções principais da expressão 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;
}

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 uma CharLoopBacktrack etiqueta emitida para retroceder e um goto usado para saltar para esse local quando uma parte do regex falha.

Se você analisar o código que implementa RegexCompiler e o gerador de código, verá que são extremamente semelhantes: métodos com nomes semelhantes, estrutura de chamada semelhante e até mesmo comentários semelhantes em toda a 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, assim, produzirá de forma mais otimizada em termos de código de correspondência 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 padrão de dia da semana seguinte Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday, 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 olhares nos exemplos de código anteriores, podes ver alguns colchetes de forma estranha comentados. Isso não é um erro. O gerador de código-fonte está reconhecendo que, se essas chaves não estivessem comentadas, a estrutura do backtracking depende 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 falharia ao compilar. Assim, o gerador de código precisa evitar que haja um bloqueio 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 fonte trata de tudo o que RegexCompiler trata, 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 nem o gerador de origem suportam o novo RegexOptions.NonBacktracking. Se especificares RegexOptions.Compiled | RegexOptions.NonBacktracking, o Compiled flag será simplesmente ignorado, e se especificares NonBacktracking ao gerador de código-fonte, ele recorrerá de forma semelhante 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 estiveres a usar Regex hoje em C# com argumentos conhecidos em tempo de compilação, e especialmente se já estiveres a usar RegexOptions.Compiled (porque o regex foi identificado como um ponto crítico que se beneficiaria de uma taxa de transferência mais rápida), deves preferir usar o gerador de código-fonte. O gerador de fonte dará ao seu regex os seguintes benefícios:

  • Todos os benefícios em termos de taxa de transferência do RegexOptions.Compiled.
  • O arranque beneficia de não ter de fazer toda a 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.
  • Maior capacidade de depuração e compreensão das expressões regulares.
  • A possibilidade de reduzir o tamanho da sua aplicação otimizada, removendo grandes partes de código associadas a RegexCompiler (e potencialmente até mesmo o próprio reflection emit).

Quando usado com uma opção como RegexOptions.NonBacktracking para a qual o gerador de código não consegue gerar uma implementação personalizada, ainda emitirá armazenamento em cache e comentários XML que descrevem a implementação, tornando-os valiosos. A principal inconveniente do gerador de código-fonte é que ele emite código adicional na sua assembly, podendo assim 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, o gerador de código-fonte também pode ser. 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 que poderia ser convertido para o gerador de Regex de origem, e um corretor que faz a conversão por si.

Analisador e fixador RegexGenerator

Consulte também