Geradores de origem de expressão regular do .NET
Uma expressão regular, ou regex, é uma cadeia de caracteres que habilita que um desenvolvedor expresse um padrão que está sendo pesquisado, o que a torna uma forma comum de pesquisar texto e extrair resultados como um subconjunto da cadeia de caracteres pesquisada. No .NET, o namespace System.Text.RegularExpressions
é usado para definir instâncias Regex e métodos estáticos e fazer a correspondência com padrões definidos pelo usuário. Neste artigo, você aprenderá a usar a geração de origem para gerar instâncias Regex
para otimizar o desempenho.
Observação
Sempre que possível, use expressões regulares geradas na origem em vez de compilar expressões regulares usando a opção RegexOptions.Compiled. A geração de origem pode ajudar seu aplicativo a iniciar mais rapidamente, a ser executado mais rapidamente e a ser mais ajustável. Para saber quando a geração de origem é possível, confira 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. Em seguida, a árvore é otimizada de várias maneiras, transformando o padrão em uma variação funcionalmente equivalente que pode ser executada com mais eficiência. A árvore é gravada em um formulário que pode ser interpretado como uma série de opcodes e operandos que fornecem instruções ao mecanismo do interpretador regex sobre como fazer a correspondência. Quando uma correspondência é executada, o interpretador simplesmente percorre essas instruções, processando-as em relação ao texto de entrada. Ao instanciar uma nova instância Regex
ou chamar um dos métodos estáticos em Regex
, o interpretador é o mecanismo padrão empregado.
Quando você específica RegexOptions.Compiled, todo o mesmo trabalho de tempo de construção é realizado. As instruções resultantes são transformadas posteriormente pelo compilador baseado em emissão por reflexão em instruções IL que são gravadas em alguns objetos DynamicMethod. Quando uma correspondência é realizada, esses métodos DynamicMethod
são invocados. Essencialmente, essa IL faz exatamente o que o interpretador faria, exceto que é especializado para o padrão exato que está sendo processado. Por exemplo, se o padrão contiver [ac]
, o interpretador verá um opcode que diz "corresponde o caractere de entrada na posição atual com o conjunto especificado nessa descrição de conjunto". Por outro lado, a IL compilada conteria um código que efetivamente diz: "corresponde ao caractere de entrada na posição atual com 'a'
ou 'c'
". Essa caixa especial e a capacidade de executar otimizações com base no conhecimento do padrão são alguns dos principais motivos pelos quais a especificação de RegexOptions.Compiled
resulta em uma taxa de transferência de correspondência muito mais rápida do que o interpretador.
Há várias desvantagens em RegexOptions.Compiled
. O mais impactante é o fato de que a construção é cara. Além de pagar todos os mesmos custos do interpretador, ele precisa compilar a árvore RegexNode
resultante e os opcodes/operands gerados em IL, o que acrescenta uma despesa não trivial. O IL gerado também precisa ser compilado pelo JIT no primeiro uso, o que gera ainda mais despesas na inicialização. RegexOptions.Compiled
representa uma compensação fundamental entre sobrecargas no primeiro uso e sobrecargas em cada uso subsequente. O uso de System.Reflection.Emit também inibe o uso de RegexOptions.Compiled
em determinados ambientes; alguns sistemas operacionais não permitem que o código gerado dinamicamente seja executado e, nesses sistemas, Compiled
se torna inviável.
Geração de origem
O .NET 7 introduziu um novo gerador de origem RegexGenerator
. Um gerador de origem é um componente que se conecta ao compilador e aumenta a unidade de compilação com código-fonte adicional. O SDK do .NET (versão 7 e posteriores) inclui um gerador de origem que reconhece o atributo GeneratedRegexAttribute em um método parcial que retorna Regex
. O gerador de origem fornece uma implementação desse método que contém toda a lógica para Regex
. Por exemplo, você poderia 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 origem, você reescreve o código anterior da seguinte forma:
[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
}
}
Dica
O sinalizador RegexOptions.Compiled
é ignorado pelo gerador de origem e, portanto, não é necessário na versão gerada pela origem.
A implementação gerada de AbcOrDefGeneratedRegex()
armazena em cache de forma semelhante 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 subclasse Regex
que o gerador de origem emite:
Mas como pode ser visto, não é apenas fazer new Regex(...)
. Em vez disso, o gerador de origem está emitindo como código C# uma implementação derivada de personalizado Regex
com lógica semelhante à que RegexOptions.Compiled
emite na 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 de inicialização de Regex.CompileToAssembly
, mas sem a complexidade de CompileToAssembly
. A origem emitida faz parte do seu projeto, o que significa que ele também é facilmente acessível e depurável.
Dica
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, como alternativa, selecione o nó do projeto no Gerenciador de Soluções e expanda Dependências>Analisadores>System.Text.RegularExpressions.Generator>System.Text.RegularExpressions.Generator.RegexGenerator>RegexGenerator.g.cs para ver o código C# gerado desse gerador regex.
Você pode definir pontos de interrupção nele, percorrê-lo e usá-lo como uma ferramenta de aprendizado para entender exatamente como o mecanismo regex está processando seu padrão com sua entrada. O gerador gera até mesmo comentários de barra tripla (XML) para ajudar a tornar a expressão compreensível rapidamente e onde ela é usada.
Dentro dos arquivos gerados pela origem
Com o .NET 7, o gerador de origem e RegexCompiler
foram quase inteiramente reescritos, alterando fundamentalmente a estrutura do código gerado. Essa abordagem foi estendida para lidar com todos os constructos (com uma ressalva), e o RegexCompiler
e o gerador de origem ainda mapeiam principalmente 1:1 entre si, seguindo a nova abordagem. Considere a saída do gerador de origem para uma das funções primárias 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 gerado pela origem é 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 norteador de que o gerador deve emitir código como se um humano o tivesse escrito. Mesmo quando o rastreamento inverso está envolvido, a estrutura do rastreamento inverso 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 rastreamento inverso no código, com um rótulo CharLoopBacktrack
emitido para onde retroceder e um goto
usado para ir para esse local quando uma parte subsequente do regex falhar.
Se você examinar o código que implementa RegexCompiler
e o gerador de origem, eles serão extremamente semelhantes: métodos nomeados da mesma forma, 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 outro em C#. É claro que o compilador C# é então responsável por traduzir o C# para IL, portanto, a IL resultante em ambos os casos provavelmente não será idêntica. O gerador de origem depende disso em vários casos, aproveitando o fato de que o compilador C# otimizará ainda mais vários constructos C#. Há algumas coisas específicas que o gerador de origem produzirá mais código de correspondência 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 um branch para 'a'
e outro branch para 'b'
. Como o compilador C# é muito bom na otimização de instruções switch, com várias estratégias à sua disposição para fazer isso com eficiência, o gerador de origem tem uma otimização especial que RegexCompiler
não tem. Para alternâncias, o gerador de origem examina todos os branches e, se puder provar que cada branch começa com um caractere inicial diferente, emitirá uma instrução switch sobre o primeiro caractere e evitará gerar qualquer código de rastreamento inverso para essa alternância.
Aqui está um exemplo um pouco mais complicado disso. As alternâncias são analisadas mais a fundo para determinar se é possível refatorá-las para serem otimizadas pelos mecanismos de rastreamento inverso com mais facilidade e para que o código gerado pela origem seja mais simples. Uma dessas otimizações dá suporte à extração de prefixos comuns de branches e, se a alternância for atômica de modo que a ordenação não importe, reordene branches para permitir mais extração desse tipo. Você pode ver o impacto disso para o seguinte padrão de dia da semana Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday
, que produz uma função correspondente 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 para enfrentar que simplesmente não existem ao gerar diretamente para IL. Se você observar alguns exemplos de código de mais uma vez, poderá ver algumas chaves um pouco estranhamente comentadas. Isso não é um erro. O gerador de origem reconhece que, se essas chaves não tiverem sido comentadas, a estrutura do rastreamento inverso dependerá do salto de fora do escopo para um rótulo definido dentro desse escopo; esse rótulo não seria visível para tal goto
e o código não seria compilado. Portanto, o gerador de origem 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 constructos que requerem escopos (como um bloco if
de várias instruções) se isso for problemático.
O gerador de origem lida com todos os identificadores RegexCompiler
, com uma exceção. Assim como na manipulação de RegexOptions.IgnoreCase
, as implementações agora usam uma tabela de revestimento para gerar conjuntos no momento da construção e como a correspondência de referência inversa IgnoreCase
precisa consultar essa tabela de revestimento. Essa tabela é interna para System.Text.RegularExpressions.dll
e, por enquanto, pelo menos, o código externo a esse assembly (incluindo o código emitido pelo gerador de origem) não tem acesso a ela. Isso torna a manipulação de referências inversas IgnoreCase
um desafio no gerador de origem e elas não têm suporte. Este é o único constructo sem suporte pelo gerador de origem com suporte pelo RegexCompiler
. Se você tentar usar um padrão que tenha um desses (o que é raro), o gerador de origem não emitirá uma implementação personalizada e, em vez disso, retornará ao cache de uma instância regular Regex
:
Além disso, nem RegexCompiler
nem o gerador de origem suportam o novo RegexOptions.NonBacktracking
. Se você especificar RegexOptions.Compiled | RegexOptions.NonBacktracking
, o sinalizador Compiled
será ignorado e, se você especificar NonBacktracking
para o gerador de origem, ele retornará de maneira semelhante ao cache de uma instância Regex
regular.
Quando usar isso
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 você 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 origem. O gerador de origem dará ao seu regex os seguintes benefícios:
- Todos os benefícios de taxa de transferência de
RegexOptions.Compiled
. - Os benefícios de inicialização de não precisar fazer toda a análise de regex, análise e compilação em tempo de execução.
- A opção de usar a compilação antecipada com o código gerado para o regex.
- Melhor depuração e compreensão do regex.
- A possibilidade de reduzir o tamanho de seu aplicativo aparado, cortando grandes faixas de código associadas com
RegexCompiler
(e potencialmente até mesmo a reflexão em si).
Quando usada com uma opção como RegexOptions.NonBacktracking
para a qual o gerador de origem não pode gerar uma implementação personalizada, ela ainda emitirá cache e comentários XML que descrevem a implementação, tornando-a valiosa. A principal desvantagem do gerador de origem é que ele emite código adicional em seu assembly, portanto, há o potencial de aumento de tamanho. Quanto mais regexes em seu aplicativo e maiores forem, mais código será emitido para eles. Em algumas situações, assim como RegexOptions.Compiled
pode ser desnecessário, o mesmo pode ocorrer com o gerador de origem. Por exemplo, se você tiver um regex que é necessário apenas raramente e para o qual a taxa de transferência não importa, pode ser mais benéfico apenas contar com o interpretador para esse uso esporádico.
Importante
O .NET 7 inclui um analisador que identifica o uso do Regex
que pode ser convertido no gerador de origem e um reparador que faz a conversão para você: