Source Generators: Código Que Escreve Código Durante a Compilação

Source Generators: Código Que Escreve Código Durante a Compilação

Source generators no C#: o compilador escrevendo código durante a compilação. Tipos e membros parciais, os geradores do .NET (GeneratedRegex, JSON, LoggerMessage), um gerador incremental escrito do zero, o cache que exige igualdade por valor e um CEP validado na compilação.
Linguagem C#

• • 18 min de leitura

O código repetitivo que a reflexão descobria em execução pode ser escrito pelo próprio compilador, antes de o programa existir: sem custo em execução e com toda a verificação de tipos de volta.

Em "Reflexão e Atributos" construímos um exportador de CSV que servia para qualquer tipo, guiado por atributos. Terminamos reconhecendo o preço: cada leitura de valor pagava o custo da reflexão, os erros só apareciam em execução, e a publicação enxuta e nativa do .NET não convive bem com tipos descobertos por strings. Os source generators resolvem esse dilema com uma ideia simples e engenhosa: a análise dos tipos acontece durante a compilação, e o resultado dela é código C# comum, acrescentado ao projeto como se você o tivesse escrito à mão. Nesta aula, que fecha a Fase 3, vemos como usar os geradores que já vêm no .NET e escrevemos um gerador nosso.

A ideia: um plugin do compilador

Em "Da Rivalidade com o Java ao Código Aberto" vimos que o compilador de C#, o Roslyn, é escrito em C# e é de código aberto. Ele também é extensível. Um source generator é uma biblioteca que o Roslyn carrega durante a compilação. Ela recebe a representação completa do seu código (as árvores de sintaxe e o modelo semântico, com os tipos já resolvidos) e pode acrescentar novos arquivos de código-fonte, que são compilados junto com os seus.

Duas regras definem o que um gerador pode fazer:

  • Ele só acrescenta. Um gerador nunca altera nem remove o código que você escreveu. Isso mantém a compilação previsível: o que você lê no seu arquivo é o que existe.
  • O código gerado é código comum. Ele é verificado pelo compilador como qualquer outro, aparece no editor, pode ser depurado passo a passo e não tem custo em execução além do custo do próprio código.

Se o gerador só pode acrescentar, como ele "completa" uma classe sua? Com o mecanismo de tipos parciais.

Tipos e membros parciais

A palavra-chave partial permite que a declaração de um tipo seja dividida em vários arquivos:

// Cliente.cs: escrito por você
public sealed partial class Cliente
{
    public required string Nome { get; init; }
    public required string Email { get; init; }
}

// Cliente.Descricao.g.cs: escrito pelo gerador
partial class Cliente
{
    public string Descrever() => "Cliente { Nome = " + this.Nome + ", Email = " + this.Email + " }";
}

O compilador junta as partes numa única classe. As partes podem omitir modificadores já declarados em outra (o public sealed da primeira vale para a classe inteira), mas todas precisam ter o partial.

Membros também podem ser parciais: você declara um método (e, desde o C# 13, uma propriedade) sem corpo, e o gerador escreve a implementação:

public partial class Validador
{
    public static partial bool CepValido(string texto);   // o corpo vem do gerador
}

Esse é o padrão que os geradores do .NET usam: você declara a intenção com um atributo e uma assinatura, e o gerador preenche o resto.

Os geradores que já estão no .NET

Na prática, você vai usar geradores muito mais do que escrevê-los. Três deles aparecem com frequência.

Expressões regulares. Uma expressão regular (regex) descreve um padrão de texto: "cinco dígitos, um hífen opcional, três dígitos" é o padrão de um CEP. Tradicionalmente, a classe Regex interpreta o padrão em execução, analisando a string a cada inicialização. Com o atributo [GeneratedRegex], o padrão é analisado na compilação e vira código C# especializado:

using System.Text.RegularExpressions;

public static partial class Padroes
{
    [GeneratedRegex(@"^[0-9]{5}-?[0-9]{3}$")]
    public static partial Regex Cep();
}

Console.WriteLine(Padroes.Cep().IsMatch("80010-000"));   // True
Console.WriteLine(Padroes.Cep().IsMatch("8001-0000"));   // False

Se o padrão tiver um erro de sintaxe, como um parêntese sem par, o erro aparece na compilação, e não em execução. É a mesma migração de erros para a compilação que os genéricos fizeram em "Genéricos".

Serialização JSON. O System.Text.Json pode gerar, em tempo de compilação, o código que lê e escreve as propriedades de cada tipo, dispensando a reflexão:

using System.Text.Json;
using System.Text.Json.Serialization;

public sealed record ResumoPedido(int Numero, string Status, decimal Total);

[JsonSerializable(typeof(ResumoPedido))]
public sealed partial class LivrariaJsonContext : JsonSerializerContext;

string json = JsonSerializer.Serialize(new ResumoPedido(1001, "Pago", 142.02m),
                                       LivrariaJsonContext.Default.ResumoPedido);
// {"Numero":1001,"Status":"Pago","Total":142.02}

O contexto declarado com [JsonSerializable] recebe do gerador toda a lógica de serialização para ResumoPedido. JSON ganha aula própria logo adiante, e na API da Fase 6 usaremos esse contexto para que o serviço possa ser publicado com Native AOT.

Registro de log de alto desempenho. O atributo [LoggerMessage], do pacote de logging do .NET, gera métodos de log que evitam alocações e conversões. Ele aparece quando estudarmos observabilidade na fase de API. Na aula sobre interop com C, veremos ainda o [LibraryImport], que gera o código de chamada a bibliotecas nativas.

Para ver o código que os geradores produzem, peça ao compilador para gravá-lo em disco, acrescentando ao .csproj:

<PropertyGroup>
  <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
</PropertyGroup>

Depois de compilar, os arquivos aparecem em obj/Debug/net10.0/generated/. Na maioria dos editores, basta usar "Ir para definição" sobre o método parcial. Vale a pena abrir o código gerado pelo [GeneratedRegex] ao menos uma vez: é um algoritmo de busca completo, comentado e legível.

Escrevendo um gerador

Agora vamos escrever o nosso. O objetivo é modesto e didático: para toda classe marcada com [GerarDescricao], gerar um método Descrever() que lista as propriedades públicas, como o Descrever por reflexão do exercício 2 da aula anterior, mas sem reflexão nenhuma.

Um gerador é um projeto de biblioteca com três exigências. Ele deve mirar netstandard2.0, porque é carregado dentro do compilador, que roda em ambientes variados. Deve referenciar o pacote do Roslyn. E precisa de um ajuste para usar recursos modernos da linguagem:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
    <LangVersion>latest</LangVersion>
    <Nullable>enable</Nullable>
    <IsRoslynComponent>true</IsRoslynComponent>
    <EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
  </PropertyGroup>

  <ItemGroup>
    <!-- use versões fixas, compatíveis com o SDK instalado -->
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="..." PrivateAssets="all" />
    <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="..." PrivateAssets="all" />
  </ItemGroup>
</Project>

O TargetFramework declarado aqui prevalece sobre o net10.0 do Directory.Build.props de "Do Arquivo Único à Solução", porque as configurações do próprio projeto são aplicadas por último.

O gerador em si:

using System.Linq;
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;

namespace Livraria.Geradores;

[Generator]
public sealed class DescricaoGenerator : IIncrementalGenerator
{
    private const string NomeAtributo = "Livraria.Geradores.GerarDescricaoAttribute";

    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        // 1. Acrescenta o próprio atributo ao projeto que usa o gerador.
        context.RegisterPostInitializationOutput(static ctx => ctx.AddSource(
            "GerarDescricaoAttribute.g.cs",
            """
            // <auto-generated/>
            namespace Livraria.Geradores;

            [System.AttributeUsage(System.AttributeTargets.Class)]
            internal sealed class GerarDescricaoAttribute : System.Attribute { }
            """));

        // 2. Encontra as classes marcadas e extrai só o que importa.
        var tipos = context.SyntaxProvider.ForAttributeWithMetadataName(
            NomeAtributo,
            predicate: static (no, _) => no is ClassDeclarationSyntax,
            transform: static (ctx, _) =>
            {
                var simbolo = (INamedTypeSymbol)ctx.TargetSymbol;
                string? ns = simbolo.ContainingNamespace.IsGlobalNamespace
                    ? null
                    : simbolo.ContainingNamespace.ToDisplayString();
                string propriedades = string.Join(",", simbolo.GetMembers()
                    .OfType<IPropertySymbol>()
                    .Where(p => p.DeclaredAccessibility == Accessibility.Public
                             && !p.IsStatic && !p.IsIndexer && p.GetMethod is not null)
                    .Select(p => p.Name));
                return new InfoTipo(ns, simbolo.Name, propriedades);
            });

        // 3. Gera um arquivo para cada classe encontrada.
        context.RegisterSourceOutput(tipos, static (spc, info) => Gerar(spc, info));
    }

    private static void Gerar(SourceProductionContext spc, InfoTipo info)
    {
        var sb = new StringBuilder();
        sb.AppendLine("// <auto-generated/>");
        if (info.Namespace is not null)
            sb.AppendLine($"namespace {info.Namespace};");
        sb.AppendLine();
        sb.AppendLine($"partial class {info.Nome}");
        sb.AppendLine("{");
        sb.Append("    public string Descrever() => \"").Append(info.Nome).Append(" { \"");

        string[] nomes = info.Propriedades.Split(new[] { ',' }, System.StringSplitOptions.RemoveEmptyEntries);
        for (int i = 0; i < nomes.Length; i++)
        {
            string separador = i == 0 ? "" : ", ";
            sb.Append($" + \"{separador}{nomes[i]} = \" + this.{nomes[i]}");
        }

        sb.AppendLine(" + \" }\";");
        sb.AppendLine("}");

        spc.AddSource($"{info.Nome}.Descricao.g.cs", sb.ToString());
    }
}

internal sealed record InfoTipo(string? Namespace, string Nome, string Propriedades);

Há bastante coisa aqui, então vamos por partes.

IIncrementalGenerator é a interface moderna de geradores. "Incremental" é a palavra-chave: o gerador roda a cada tecla digitada no editor, e ele monta um pipeline de etapas cujos resultados o Roslyn guarda e compara. Se você altera um arquivo que não tem nada a ver com as classes marcadas, o pipeline percebe que o resultado da etapa 2 não mudou e não refaz a etapa 3. É isso que mantém o editor rápido.

A etapa 1 acrescenta o próprio atributo [GerarDescricao] ao projeto consumidor, de modo que ele não precise de nenhuma outra referência para usá-lo.

A etapa 2 usa ForAttributeWithMetadataName, o caminho otimizado para "encontrar tudo que tem este atributo". O predicate é um filtro rápido sobre a sintaxe. O transform recebe o símbolo, a visão semântica com tipos resolvidos, e extrai só os dados necessários: namespace, nome e propriedades. Repare nas regras que ecoam aulas anteriores: IPropertySymbol é o equivalente, em tempo de compilação, do PropertyInfo da reflexão, e excluímos indexadores e estáticos pela mesma razão do exercício 2 de "Reflexão e Atributos".

A etapa 3 escreve o código com um StringBuilder. O método gerado usa concatenação simples, e não interpolação, porque escrever chaves de interpolação dentro de strings que geram código C# que contém chaves é uma armadilha de escape desnecessária.

O record InfoTipo carrega os dados entre as etapas, e é fundamental que ele tenha igualdade por conteúdo, como todo record, para que o Roslyn consiga comparar "o resultado mudou?". Por isso as propriedades viajam como uma string, e não como uma coleção: arrays e listas comparam referências, o que quebraria o cache incremental. Há ainda um detalhe técnico do netstandard2.0: ele é anterior aos records, e o compilador procura um tipo chamado IsExternalInit para permitir as propriedades init. A solução conhecida é declarar esse tipo vazio no projeto do gerador:

namespace System.Runtime.CompilerServices
{
    internal static class IsExternalInit { }
}

Para usar o gerador, o projeto consumidor o referencia de um jeito especial, como analisador e não como biblioteca comum:

<ItemGroup>
  <ProjectReference Include="..\Livraria.Geradores\Livraria.Geradores.csproj"
                    OutputItemType="Analyzer"
                    ReferenceOutputAssembly="false" />
</ItemGroup>

E então:

using Livraria.Geradores;

[GerarDescricao]
public sealed partial class Cliente
{
    public required string Nome { get; init; }
    public required string Email { get; init; }
}

var ana = new Cliente { Nome = "Ana Souza", Email = "ana@exemplo.com" };
Console.WriteLine(ana.Descrever());   // Cliente { Nome = Ana Souza, Email = ana@exemplo.com }

O método Descrever não existe no seu código-fonte, mas o editor o sugere no autocompletar, o compilador verifica cada acesso a propriedade dentro dele, e a execução é tão rápida quanto se você o tivesse escrito à mão. Renomeie Email para EmailPrincipal e o método gerado acompanha na próxima compilação.

Esse gerador tem limitações deliberadas, para caber numa aula: não trata classes aninhadas, classes genéricas, structs nem records, e não emite diagnósticos quando alguém esquece o partial. Geradores de produção cuidam de tudo isso, e é justamente por essa complexidade que a regra prática é preferir os geradores existentes e escrever o seu apenas quando houver um ganho claro: código repetitivo em grande quantidade, desempenho crítico ou necessidade de publicação nativa.

Juntando tudo: um CEP validado na compilação

A Livraria ganha um objeto de valor para o CEP, no mesmo molde do Isbn de "Classe, Struct ou Record", com a validação feita por uma expressão regular gerada:

using System.Text.RegularExpressions;

public readonly partial record struct Cep
{
    public Cep(string valor)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(valor);
        string limpo = valor.Trim();
        if (!Padrao().IsMatch(limpo))
            throw new ArgumentException("O CEP deve ter o formato 00000-000.", nameof(valor));
        Valor = limpo.Replace("-", "");
    }

    public string Valor { get; }

    public override string ToString() => Valor is null ? "(vazio)" : $"{Valor[..5]}-{Valor[5..]}";

    [GeneratedRegex(@"^[0-9]{5}-?[0-9]{3}$")]
    private static partial Regex Padrao();
}

Um detalhe honesto sobre o padrão: usamos [0-9] em vez do atalho \d. No .NET, \d casa com qualquer dígito Unicode, inclusive os dígitos árabes-índicos e dezenas de outros sistemas de numeração. Um CEP digitado com esses caracteres passaria na validação e quebraria depois. [0-9] aceita só os dez dígitos que esperamos. Os intervalos Valor[..5] e Valor[5..] são os recortes de "Guardando Muitos Valores", que também funcionam em strings.

var cep = new Cep("80010000");
Console.WriteLine(cep);                       // 80010-000
Console.WriteLine(cep == new Cep("80010-000"));   // True: igualdade por conteúdo, de graça
var invalido = new Cep("8001-000");           // ArgumentException

Com os source generators, a Fase 3 está completa, e ela mudou a natureza do código que você escreve. Na Fase 2 você aprendeu a modelar um domínio. Na Fase 3 aprendeu a escrever código que funciona para qualquer domínio: genéricos que servem para todos os tipos, delegates e lambdas que tornam o comportamento um argumento, o LINQ que transforma laços em perguntas, a nulidade verificada pelo compilador, sequências produzidas sob demanda, reflexão para examinar tipos em execução e geradores para fazer o mesmo durante a compilação. São as ferramentas com que as bibliotecas do .NET são construídas, e você passou a lê-las como quem conhece a gramática.

Mas houve um tema que atravessou a fase inteira sem ser enfrentado de frente: o custo. O boxing dos inteiros guardados como object, as closures que alocam, o StringBuilder preferido à concatenação, a reflexão mais lenta que o acesso direto, o gerador que existe justamente para evitar trabalho em execução. Por trás de todos esses casos está a mesma pergunta: onde os dados moram na memória, quem os coloca lá e quem os retira. É com ela que a Fase 4 começa.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Remova a palavra partial da declaração de Cliente (mantendo o atributo [GerarDescricao]) e compile. Que erro aparece e por que ele faz sentido?

Ver resposta

✓ Resposta: O compilador acusa o erro CS0260, "falta o modificador parcial na declaração do tipo Cliente; existe outra declaração parcial deste tipo". O gerador continua acrescentando partial class Cliente { ... }, mas agora a sua declaração não é parcial, e o C# exige que todas as partes de um tipo dividido sejam marcadas com partial. É uma proteção: sem essa regra, qualquer arquivo poderia "abrir" uma classe alheia e acrescentar membros a ela sem que o autor original soubesse. Exigir partial na sua declaração é você autorizando explicitamente que outras partes existam. Geradores de produção costumam emitir um diagnóstico próprio e mais claro para esse caso, do tipo "a classe marcada com [GerarDescricao] precisa ser partial".

Exercício 2

Ative EmitCompilerGeneratedFiles num projeto que use o Cep desta aula, compile e abra o arquivo gerado para a expressão regular. Descreva, em linhas gerais, o que você encontra.

Ver resposta

✓ Resposta: Em obj/Debug/net10.0/generated/, dentro da pasta do gerador de expressões regulares, há um arquivo com a implementação do método parcial Padrao(). Ele contém uma classe derivada de Regex com um método de busca escrito especificamente para o padrão ^[0-9]{5}-?[0-9]{3}$: verificações de tamanho mínimo da entrada, um teste direto de cinco caracteres entre '0' e '9', o tratamento opcional do hífen e o teste dos três dígitos finais. Tudo com comentários explicando cada trecho em termos do padrão original. Não há interpretação de padrão em execução: o "motor" de regex virou código C# sob medida. Isso explica os ganhos de desempenho e de inicialização, e mostra que o código gerado não é mágico nem opaco: é C# que você poderia ter escrito, só que muito mais trabalhoso.

Exercício 3

Estenda o gerador para ignorar propriedades marcadas com um novo atributo [IgnorarNaDescricao], que o próprio gerador deve acrescentar ao projeto consumidor.

Ver resposta

✓ Resposta: São duas mudanças. Na etapa 1, acrescente o novo atributo junto com o primeiro:

context.RegisterPostInitializationOutput(static ctx =>
{
    ctx.AddSource("GerarDescricaoAttribute.g.cs", /* como antes */ "...");
    ctx.AddSource("IgnorarNaDescricaoAttribute.g.cs", """
        // <auto-generated/>
        namespace Livraria.Geradores;

        [System.AttributeUsage(System.AttributeTargets.Property)]
        internal sealed class IgnorarNaDescricaoAttribute : System.Attribute { }
        """);
});

No transform, filtre as propriedades que carregam o atributo:

.Where(p => p.DeclaredAccessibility == Accessibility.Public
         && !p.IsStatic && !p.IsIndexer && p.GetMethod is not null
         && !p.GetAttributes().Any(a =>
                a.AttributeClass?.ToDisplayString() == "Livraria.Geradores.IgnorarNaDescricaoAttribute"))

GetAttributes() é o equivalente, em tempo de compilação, do GetCustomAttribute da reflexão. A comparação usa o nome completo do atributo, com namespace, para não confundir com outro atributo homônimo. Com isso, [IgnorarNaDescricao] public string Senha { get; init; } deixa de aparecer no Descrever(). É exatamente o cuidado com dados sensíveis citado no exercício 5 de "Classe, Struct ou Record".

Exercício 4

Explique por que o transform do gerador devolve um record com as propriedades numa string, em vez de devolver o próprio INamedTypeSymbol ou uma ImmutableArray<string>. O que aconteceria com o editor se isso fosse ignorado?

Ver resposta

✓ Resposta: O modelo incremental guarda o resultado de cada etapa e, a cada alteração no código, compara o resultado novo com o anterior. Se forem iguais, as etapas seguintes não são refeitas. Essa comparação usa a igualdade do tipo devolvido. Um INamedTypeSymbol é recriado a cada compilação e nunca é "igual" ao anterior, e, além disso, mantê-lo em cache prenderia na memória toda a compilação a que ele pertence. Uma ImmutableArray<string> compara referências, e não conteúdo, então duas listas com os mesmos nomes seriam consideradas diferentes. Nos dois casos o cache nunca acertaria, e a etapa de geração rodaria a cada tecla digitada no editor, em todos os arquivos, deixando autocompletar e realce lentos em projetos grandes. Um record com strings tem igualdade por conteúdo: se as propriedades da classe não mudaram, o resultado é igual, e nada é regerado. É a distinção entre identidade e valor de "Classe, Struct ou Record" decidindo o desempenho de uma ferramenta.

Exercício 5

Compare o exportador de CSV por reflexão da aula anterior com uma versão gerada em tempo de compilação: dê duas vantagens e uma desvantagem de cada abordagem, e diga quando você escolheria cada uma.

Ver resposta

✓ Resposta: Reflexão. Vantagens: é simples de escrever e depurar (é código C# comum, sem projeto especial nem APIs do Roslyn) e funciona com tipos descobertos em execução, inclusive carregados de plugins que não existiam na compilação. Desvantagem: paga custo a cada leitura de valor, perde a verificação de tipos e é incompatível com trimming e Native AOT sem anotações adicionais. Gerador. Vantagens: desempenho igual ao de código escrito à mão, sem nenhuma reflexão em execução, e segurança, porque o código gerado é verificado pelo compilador e compatível com publicação nativa. Desvantagem: é bem mais complexo de escrever e manter (netstandard2.0, modelo incremental, casos de borda como genéricos e aninhamento), e só enxerga tipos presentes na compilação. A escolha prática: para uma ferramenta interna, usada em poucos lugares e sem exigência de desempenho, a reflexão resolve com uma fração do esforço. Para uma biblioteca usada por muitos projetos, em caminhos quentes ou em aplicações que serão publicadas com Native AOT, o gerador compensa. E, antes das duas, verifique se já existe um gerador pronto para o problema, como o do System.Text.Json.

Comentários

Mais em Linguagem C#

Reflexão e Atributos: Quando o Programa Examina a Si Mesmo
Reflexão e Atributos: Quando o Programa Examina a Si Mesmo

Reflexão e atributos em C#: o objeto Type, ler propriedades e invocar métodos…

MySQL: MySqlConnector, o Provider do EF e as Diferenças Que Importam
MySQL: MySqlConnector, o Provider do EF e as Diferenças Que Importam

A Livraria no MySQL com MySqlConnector e EF Core, e as diferenças que fazem um…

Capstone: o jogo, a API, o banco e o site num só fluxo, e a retrospectiva do curso
Capstone: o jogo, a API, o banco e o site num só fluxo, e a retrospectiva do curso

O capstone: a Biblioteca em Apuros envia partidas que a API valida com as…