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
- Visão geral dos source generators (Microsoft Learn): https://learn.microsoft.com/dotnet/csharp/roslyn-sdk/source-generators-overview. Explica o que são, onde rodam e o que podem e não podem fazer.
- Incremental Generators (documentação do Roslyn no GitHub): https://github.com/dotnet/roslyn/blob/main/docs/features/incremental-generators.md. É a especificação do modelo incremental, com as regras de cache e igualdade que explicam o
InfoTipo. - Geradores de código-fonte de expressões regulares: https://learn.microsoft.com/dotnet/standard/base-types/regular-expression-source-generators. Cobre o
[GeneratedRegex]e a comparação com as outras formas de usarRegex. - Geração de código-fonte no System.Text.Json: https://learn.microsoft.com/dotnet/standard/serialization/system-text-json/source-generation. Mostra os contextos de serialização e a compatibilidade com trimming e Native AOT.
- Membros parciais: https://learn.microsoft.com/dotnet/csharp/language-reference/keywords/partial-member. Traz as regras de métodos e propriedades parciais, a base de todo gerador.
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.