.NET Performance Regex Source Generators

Regex no .NET com [GeneratedRegex]: performance real sem interpretação em tempo de execução

Aprenda a usar o atributo [GeneratedRegex] do .NET 7+ para gerar código de regex em tempo de compilação, eliminando overhead de interpretação e alocação.

N
Neryx Digital Architects
14 de novembro de 2025
13 min de leitura
250 profissionais leram
Categoria: .NET Público: Times de engenharia e produto Etapa: Aprendizado

Regex é onipresente em aplicações .NET — validação de e-mail, parsing de logs, extração de dados, sanitização de entrada. O problema: a API clássica do System.Text.RegularExpressions.Regex tem três modos de operação com trade-offs radicalmente diferentes, e a maioria dos projetos usa o pior deles por padrão.

O .NET 7 introduziu o atributo [GeneratedRegex], que usa Source Generators para mover toda a compilação da regex do runtime para o build time. O resultado: performance equivalente ao modo RegexOptions.Compiled sem o custo de JIT compilation em runtime, sem alocação para construção do automato, e com suporte total a AOT.

Os três modos de Regex no .NET

Antes de entender [GeneratedRegex], é fundamental entender o que existe antes dele:

1. Interpretado (padrão)

// Cria e interpreta o automato a cada chamada
var match = Regex.Match(input, @"\d{4}-\d{2}-\d{2}");

// Ou cached implicitamente (mas ainda interpretado)
var regex = new Regex(@"\d{4}-\d{2}-\d{2}");

O modo interpretado percorre o padrão caractere por caractere em tempo de execução usando um autômato de estados interpretado. É o mais lento para padrões chamados frequentemente, mas tem startup instantâneo e não aloca estruturas pesadas.

2. Compilado (RegexOptions.Compiled)

private static readonly Regex _regex =
    new Regex(@"\d{4}-\d{2}-\d{2}", RegexOptions.Compiled);

O modo compilado usa Reflection.Emit para gerar IL assembly em runtime — essencialmente, faz JIT de um autômato personalizado na primeira vez que o Regex é instanciado. É muito mais rápido nas chamadas subsequentes, mas tem custo de startup alto (dezenas de ms por regex) e não funciona com Native AOT.

3. Source-Generated ([GeneratedRegex])

public partial class ValidadorService
{
    [GeneratedRegex(@"\d{4}-\d{2}-\d{2}")]
    private static partial Regex DataRegex();
}

O Source Generator analisa o padrão em tempo de compilação e gera um método C# que implementa o automato diretamente — sem Reflection.Emit, sem JIT em runtime, sem startup cost. Funciona perfeitamente com Native AOT e tem o mesmo throughput que o modo compilado (ou melhor, porque o JIT do .NET pode otimizar código C# gerado mais eficientemente que o IL emitido via Reflection.Emit).

Sintaxe completa

O método deve ser static partial e retornar Regex. A classe que o contém deve ser partial:

using System.Text.RegularExpressions;

public partial class Validacoes
{
    // Básico
    [GeneratedRegex(@"^\d{4}-\d{2}-\d{2}$")]
    public static partial Regex Data();

    // Com opções
    [GeneratedRegex(@"^[\w.-]+@[\w.-]+\.[a-z]{2,}$",
        RegexOptions.IgnoreCase | RegexOptions.ExplicitCapture)]
    public static partial Regex Email();

    // Com timeout (recomendado para inputs externos)
    [GeneratedRegex(@"<[^>]+>", RegexOptions.None, matchTimeoutMilliseconds: 1000)]
    public static partial Regex TagHtml();

    // Com grupo nomeado
    [GeneratedRegex(@"(?<ano>\d{4})-(?<mes>\d{2})-(?<dia>\d{2})")]
    public static partial Regex DataComGrupos();
}

O compilador chama o Source Generator que gera, por exemplo, um método DataImpl() com o código do automato. Você nunca vê esse código a não ser que abra o arquivo gerado em obj/.

Uso prático

// Validação simples
bool dataValida = Validacoes.Data().IsMatch("2026-07-18"); // true

// Grupos nomeados
var m = Validacoes.DataComGrupos().Match("2026-07-18");
if (m.Success)
{
    var ano = m.Groups["ano"].Value;  // "2026"
    var mes = m.Groups["mes"].Value;  // "07"
    var dia = m.Groups["dia"].Value;  // "18"
}

// Enumerar todos os matches sem alocação de lista
foreach (var match in Validacoes.TagHtml().EnumerateMatches("<b>texto</b>"))
{
    // match é um ValueMatch (struct) — zero alocação
    Console.WriteLine(match.Index);
}

// Replace
string limpo = Validacoes.TagHtml().Replace(html, string.Empty);

Suporte a Span<char>

A partir do .NET 7, o modo source-generated suporta ReadOnlySpan<char> em alguns métodos — o que é impossível com o Regex compilado via Reflection.Emit, pois string e Span têm APIs distintas:

public partial class Parser
{
    [GeneratedRegex(@"\d+")]
    private static partial Regex Numeros();

    public static bool TemNumero(ReadOnlySpan<char> input)
        => Numeros().IsMatch(input); // Span — zero cópia, zero alocação
}

// Uso com slice de string sem alocar substring
var linha = "Pedido #12345: aprovado";
bool temId = Parser.TemNumero(linha.AsSpan(8, 5)); // "12345"

Isso é especialmente útil em parsing de arquivos grandes, onde você quer trabalhar diretamente com spans de um buffer em vez de criar substrings.

Benchmark: os números reais

[MemoryDiagnoser]
[SimpleJob(RuntimeMoniker.Net80)]
public class RegexBenchmark
{
    private const string Input = "Data de validade: 2026-07-18. Renovar antes de 2027-01-01.";

    // Modo interpretado — instância reutilizada
    private static readonly Regex _interpretado = new(@"\d{4}-\d{2}-\d{2}");

    // Modo compilado
    private static readonly Regex _compilado =
        new(@"\d{4}-\d{2}-\d{2}", RegexOptions.Compiled);

    // Source-generated
    [GeneratedRegex(@"\d{4}-\d{2}-\d{2}")]
    private static partial Regex GeradoImpl();

    [Benchmark(Baseline = true)]
    public int Interpretado() =>
        _interpretado.Matches(Input).Count;

    [Benchmark]
    public int Compilado() =>
        _compilado.Matches(Input).Count;

    [Benchmark]
    public int Gerado() =>
        GeradoImpl().Matches(Input).Count;

    [Benchmark]
    public int Gerado_EnumerateMatches()
    {
        int count = 0;
        foreach (var _ in GeradoImpl().EnumerateMatches(Input)) count++;
        return count;
    }
}

Resultados representativos (.NET 8, x64):

Método Média Ratio Alocações
Interpretado 620 ns 1.00× 320 B
Compilado 180 ns 0.29× 264 B
Gerado 165 ns 0.27× 264 B
Gerado + EnumerateMatches 130 ns 0.21× 0 B

O [GeneratedRegex] é ~4% mais rápido que o Compiled no throughput, mas a vantagem real é outra: ele não tem o custo de startup (construção do automato no primeiro acesso) e funciona com AOT. Combinado com EnumerateMatches, zeramos as alocações — relevante em caminhos de alta frequência.

Regras e grupos nomeados em cenários reais

Parsing de log estruturado

public partial class LogParser
{
    // Formato: [2026-07-18 14:32:01] ERROR RequestId=abc123 Message=Timeout
    [GeneratedRegex(
        @"\[(?<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\] (?<level>\w+) RequestId=(?<rid>\S+) Message=(?<msg>.+)",
        RegexOptions.ExplicitCapture)]
    private static partial Regex LinhaLog();

    public static LogEntry? Parse(string linha)
    {
        var m = LinhaLog().Match(linha);
        if (!m.Success) return null;

        return new LogEntry(
            Timestamp: DateTime.Parse(m.Groups["ts"].Value),
            Level:     m.Groups["level"].Value,
            RequestId: m.Groups["rid"].Value,
            Message:   m.Groups["msg"].Value
        );
    }
}

Validação de CPF/CNPJ (formato, não dígito verificador)

public static partial class DocumentoRegex
{
    [GeneratedRegex(@"^\d{3}\.\d{3}\.\d{3}-\d{2}$")]
    public static partial Regex Cpf();

    [GeneratedRegex(@"^\d{2}\.\d{3}\.\d{3}/\d{4}-\d{2}$")]
    public static partial Regex Cnpj();

    public static bool ValidarFormato(string doc)
        => doc.Length == 14 ? Cpf().IsMatch(doc) : Cnpj().IsMatch(doc);
}

Extração de URLs de texto

public partial class UrlExtractor
{
    [GeneratedRegex(
        @"https?://[\w\-.]+(?:/[\w\-./?%&=]*)?",
        RegexOptions.IgnoreCase,
        matchTimeoutMilliseconds: 500)]
    private static partial Regex Url();

    public static IEnumerable<string> Extrair(string texto)
    {
        foreach (var m in Url().EnumerateMatches(texto))
            yield return texto.Substring(m.Index, m.Length);
    }
}

Migrando código existente

A migração de static readonly Regex para [GeneratedRegex] é quase mecânica:

// Antes
public class Servico
{
    private static readonly Regex _emailRegex =
        new Regex(@"^[\w.-]+@[\w.-]+\.[a-z]{2,}$",
            RegexOptions.IgnoreCase | RegexOptions.Compiled);

    public bool ValidarEmail(string email) => _emailRegex.IsMatch(email);
}

// Depois
public partial class Servico      // <-- partial obrigatório
{
    [GeneratedRegex(@"^[\w.-]+@[\w.-]+\.[a-z]{2,}$",
        RegexOptions.IgnoreCase | RegexOptions.ExplicitCapture)]
    private static partial Regex EmailRegex();

    public bool ValidarEmail(string email) => EmailRegex().IsMatch(email);
}

O analisador Roslyn SYSLIB1045 detecta automaticamente instâncias de Regex elegíveis para migração e oferece um code fix automático no Visual Studio / Rider. Você pode ativar o warning em todo o projeto via .editorconfig:

[*.cs]
dotnet_diagnostic.SYSLIB1045.severity = warning

Limitações

  • Padrão deve ser constante em compile time: não funciona com padrões dinâmicos (strings de banco de dados, configurações). Para esses casos, use Regex.IsMatch(input, pattern, options, timeout) com timeout explícito.
  • Classe deve ser partial: em alguns projetos legados, isso requer refatoração adicional.
  • Não suporta todos os recursos de Regex: alguns lookbehind variáveis e backtracking complexo podem não ser suportados pelo Source Generator — o compilador avisa quando isso acontece.
  • Código gerado aumenta tamanho do assembly: cada [GeneratedRegex] gera uma classe de estado. Para dezenas de expressões simples, o impacto no binário pode ser perceptível em ambientes AOT com restrição de tamanho.

Conclusão

[GeneratedRegex] é a forma correta de usar Regex em qualquer código .NET novo ou migrado. O caso de uso é claro: padrões conhecidos em compile time usados em caminhos de alta frequência. O ganho sobre o modo interpretado é ~3–4×, o startup cost é zero (ao contrário do compilado), e a compatibilidade com Native AOT abre a porta para deployments em containers minimalistas.

A migração é simples — adicione partial na classe, troque o campo estático pelo método com atributo, e o Source Generator faz o resto. Ative o warning SYSLIB1045 e deixe o analisador guiar a migração no código existente.

Precisa resolver isso na prática?

Se você tem APIs com regex em caminhos quentes ou está migrando para Native AOT e precisa revisar o código para compatibilidade, podemos ajudar com uma revisão técnica focada.

Falar com um especialista →

Quer transformar esse aprendizado em plano de ação?

Se o tema deste artigo se parece com o momento do seu time, podemos ajudar a decidir o próximo passo com clareza.

Falar com um especialista

Newsletter

Receba artigos como este no seu e-mail

Conteúdo técnico sobre arquitetura de software, .NET, IA e gestão de produto. Sem spam.