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:
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 Regex
com 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.
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.
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|Sunday
de 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
:
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ê: