Start Debugging

O que é um gerador de código-fonte e quando eu preciso de um?

Um guia em linguagem clara sobre geradores de código-fonte em C#: o que eles realmente fazem, como funciona o pipeline do IIncrementalGenerator, quando eles superam a reflexão ou o T4, e os casos em que você não deve recorrer a um. Com exemplos executáveis em .NET 11 e C# 14.

Um gerador de código-fonte é um trecho de código que o compilador de C# executa enquanto compila o seu projeto, e que pode ler o seu código e adicionar novos arquivos C# à mesma compilação. Ele roda em tempo de compilação, produz código-fonte comum que o compilador então compila como se você o tivesse digitado, e não adiciona nenhum custo em runtime além do código que emite. Você precisa de um quando, de outra forma, pagaria por reflexão em runtime, escreveria à mão código repetitivo, ou rodaria um passo de geração de código separado e fora de banda, e quer que o código gerado tenha tipos fortes, seja depurável, compatível com o trimming e adequado para Native AOT. Se você não tem algum desses problemas, quase com certeza não precisa escrever um gerador. Este guia cobre .NET 11 (preview 5) e C# 14, mas a mecânica se aplica a qualquer projeto em .NET 6 ou posterior.

O que “rodar dentro do compilador” realmente significa

A maior parte do código que você escreve roda depois da build, quando o aplicativo inicia. Um gerador de código-fonte é diferente: ele é um componente do Roslyn que o compilador carrega como analisador e invoca durante a compilação. Ele obtém uma visão somente leitura de tudo o que o Roslyn sabe do seu projeto até então (árvores de sintaxe, símbolos semânticos, referências, arquivos adicionais) e a sua única saída é mais código-fonte. Ele não pode reescrever os seus arquivos existentes, apagar código ou mudar o que você já escreveu. Ele só pode adicionar.

Essa restrição de “só adicionar” é todo o design. O código gerado entra na compilação como arquivos extras, e o padrão dominante é o membro partial: você escreve metade de uma classe à mão, marca como partial, e o gerador emite a outra metade. As duas metades são compiladas juntas em um único tipo. Como a saída é C# real que vira IL real, tudo a jusante o trata como código que você escreveu: o IntelliSense o enxerga, o depurador entra nele, o linker pode fazer trimming nele, e o Native AOT pode compilá-lo de forma antecipada. Não há reflexão em runtime, nem Reflection.Emit, nem proxy dinâmico.

Este é o modelo mental essencial. Um gerador de código-fonte não é um sistema de macros nem um script pós-build. É uma função em tempo de compilação que vai de “o seu código” para “mais código seu”.

Por que ele supera as alternativas que substitui

Antes dos geradores de código-fonte (introduzidos no .NET 5, Roslyn 3.8), as três formas de evitar escrever código repetitivo à mão eram a reflexão, a emissão de IL e os geradores de código externos como os templates T4. Cada um tem um custo real que um gerador de código-fonte elimina.

A reflexão em runtime (pense em serializadores JSON clássicos, contêineres de injeção de dependência, mappers de objetos) inspeciona os tipos na inicialização e ou os interpreta a cada chamada, ou constrói um método dinâmico uma vez e o coloca em cache. Funciona, mas paga um imposto de inicialização, é invisível para o trimmer (então infla as builds com trimming e AOT, ou as quebra de vez), e o custo recai sobre os seus usuários, não sobre a sua build. Uma System.InvalidOperationException ou uma System.PlatformNotSupportedException da reflexão só aparece em runtime, muitas vezes em produção. Cobrimos exatamente esse modo de falha em por que código cheio de reflexão quebra sob Native AOT.

T4 e outros geradores externos rodam como um passo separado, normalmente ligado à build com o seu próprio tooling. Eles não conseguem ver o modelo semântico (fazem parse de texto, não de símbolos), os arquivos gerados ficam em disco e saem de sincronia, e são desajeitados em CI. Os geradores de código-fonte rodam dentro da mesma compilação, enxergam símbolos totalmente resolvidos e nunca escrevem um arquivo desatualizado no seu repositório.

Um gerador de código-fonte move todo esse trabalho para o tempo de compilação e emite C# simples e compilado de forma estática. O serializador não reflete sobre o seu tipo na inicialização; ele já tem o código exato para lê-lo e escrevê-lo. É por isso que o gerador de código-fonte embutido do System.Text.Json é o único caminho de JSON que funciona sob Native AOT, um ponto que destacamos em System.Text.Json vs Newtonsoft.Json em 2026.

Os geradores que você já usa

Você não precisa escrever um para se beneficiar do conceito. O .NET moderno traz vários, e reconhecê-los diz para que tipo de problema os geradores são bons:

A forma compartilhada: pegar um marcador declarativo (um atributo, um método parcial, uma classe parcial) e emitir o código tedioso, propenso a erros e com cara de reflexão que de outra forma seria escrito à mão ou descoberto em runtime.

Como um gerador é construído: o pipeline incremental

Há duas interfaces de gerador, e apenas uma é a atual. A original ISourceGenerator (um par Initialize/Execute que recebia toda a compilação e rodava a cada tecla) está obsoleta. O código-fonte do Roslyn diz isso explicitamente: “ISourceGenerator is deprecated and should not be implemented. Please implement IIncrementalGenerator instead.” (veja a nota de obsolescência do ISourceGenerator em dotnet/roslyn). Para qualquer coisa nova, use IIncrementalGenerator.

A razão é o desempenho no IDE. Um gerador incremental não recalcula tudo do zero a cada execução. Você declara um pipeline, um grafo de passos de transformação, e o Roslyn coloca em cache a saída de cada passo. Se as entradas de um passo não mudaram desde a última tecla, o Roslyn pula esse passo e reutiliza o resultado em cache. É isso que torna seguro reexecutar um gerador centenas de vezes por minuto enquanto você digita.

Aqui está um gerador mínimo mas completo. Ele encontra as classes marcadas com [AutoToString] e emite um override de ToString() que lista as propriedades públicas.

// Generator project: netstandard2.0, references Microsoft.CodeAnalysis.CSharp
// .NET 11 preview 5, Roslyn 4.x, C# 14
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;

[Generator]
public sealed class AutoToStringGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        // 1. Cheap syntactic filter, then a semantic transform into a small,
        //    value-equatable model. Returning a record (not a symbol) is what
        //    lets Roslyn cache this step and skip work when nothing changed.
        var classes = context.SyntaxProvider.ForAttributeWithMetadataName(
            "AutoToStringAttribute",
            predicate: static (node, _) => node is ClassDeclarationSyntax,
            transform: static (ctx, _) =>
            {
                var symbol = (INamedTypeSymbol)ctx.TargetSymbol;
                var props = symbol.GetMembers()
                    .OfType<IPropertySymbol>()
                    .Where(p => p.DeclaredAccessibility == Accessibility.Public)
                    .Select(p => p.Name)
                    .ToImmutableArray();
                return new Model(symbol.ContainingNamespace.ToDisplayString(),
                                 symbol.Name, props);
            });

        // 2. Emit one source file per matched class.
        context.RegisterSourceOutput(classes, static (spc, model) =>
        {
            var sb = new StringBuilder();
            sb.AppendLine($"namespace {model.Namespace};");
            sb.AppendLine($"partial class {model.Name}");
            sb.AppendLine("{");
            sb.AppendLine("    public override string ToString() =>");
            var body = string.Join(" + \", \" + ",
                model.Properties.Select(p => $"\"{p}=\" + {p}"));
            sb.AppendLine($"        {body};");
            sb.AppendLine("}");
            spc.AddSource($"{model.Name}.AutoToString.g.cs", sb.ToString());
        });
    }

    private record Model(string Namespace, string Name,
                         ImmutableArray<string> Properties);
}

O lado do consumidor é só um atributo e uma partial class:

// Consumer project, C# 14
[AutoToString]
public partial class Order
{
    public int Id { get; set; }
    public string Customer { get; set; } = "";
}

// Elsewhere: new Order { Id = 7, Customer = "Acme" }.ToString()
// => "Id=7, Customer=Acme"   (the ToString override is generated)

Dois detalhes carregam quase todo o peso. Primeiro, ForAttributeWithMetadataName é o ponto de entrada rápido adicionado no Roslyn 4.3: ele permite que o Roslyn pré-filtre para os nós que de fato carregam o seu atributo em vez de percorrer cada nó de sintaxe, o que é a maior alavanca de desempenho em um gerador real. Segundo, o transform retorna um record pequeno (Model), não o INamedTypeSymbol. Isso importa mais do que parece: a incrementalidade depende da igualdade por valor. Como diz o cookbook do Roslyn, você quer que tipos comparáveis por valor como record, struct, tuplas e ImmutableArray<T> fluam pelo pipeline, porque no momento em que um passo retorna um valor igual à sua saída anterior, o Roslyn para e reutiliza o resultado em cache a jusante. Passe um Symbol ou um Compilation pelo pipeline e você derrota o cache por completo, porque esses tipos são grandes, comparáveis por referência e mudam a cada tecla.

Quando você deve recorrer a um

Escreva ou adote um gerador de código-fonte quando tudo isto for verdade:

  1. O código é mecânico e derivável de algo que já está no fonte (o formato de um tipo, um atributo, a assinatura de um método). Se um humano que o escrevesse estivesse apenas transcrevendo, um gerador pode fazê-lo.
  2. Você atualmente paga por isso com reflexão em runtime, e esse custo é real: latência de inicialização, alocações em um caminho quente, ou uma incompatibilidade com trimming/AOT.
  3. Você quer que o resultado seja depurável e com tipos estáticos, não um método dinâmico no qual você não consegue entrar para depurar.

As vitórias mais claras: serialização, mapeamento de DTO, registro de injeção de dependência, INotifyPropertyChanged, binding de configuração com tipos fortes, geração de clientes a partir de um contrato, e a substituição de qualquer caminho quente com Activator.CreateInstance ou Expression.Compile. Se você mira Native AOT, o cálculo pende ainda mais para os geradores, já que as abordagens baseadas em reflexão são justamente o que quebra ali. Percorremos essa restrição em usar Native AOT com minimal APIs do ASP.NET Core.

Quando você não precisa de um (e não deve escrever um)

Geradores não são de graça para construir nem para manter. Pule escrever o seu próprio quando:

O padrão honesto para a maioria dos times é: consuma geradores com liberalidade, escreva os seus próprios raramente.

As armadilhas que mordem primeiro

Algumas coisas fazem todo mundo tropeçar na primeira vez:

O atalho mental que te mantém fora de encrenca: um gerador de código-fonte é uma função pura e em cache que vai de entradas imutáveis para texto-fonte. Mantenha as entradas pequenas e comparáveis por valor, mantenha a função rápida, nunca deixe que ela lance exceção, e só escreva um quando a reflexão ou o código repetitivo à mão estiver lhe custando algo que você consiga medir.

Fontes e leituras adicionais

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Voltar