Como uma biblioteca transforma em JSON uma classe que ela nunca viu? Ela pergunta ao próprio programa quais propriedades essa classe tem.
Terminamos "Sequências Sob Demanda" observando um padrão: [Flags] num enum, [NotNullWhen(true)] num parâmetro. São informações sobre o código, escritas entre colchetes, que não executam nada por si mesmas. Alguém as lê: o compilador, as ferramentas ou o próprio programa em execução. Nesta aula conhecemos os dois mecanismos por trás disso. A reflexão permite a um programa examinar seus tipos, propriedades e métodos como se fossem dados. Os atributos permitem anexar informações a esses elementos. Juntos, eles explicam como funcionam o serializador JSON, o Entity Framework, o ASP.NET e boa parte do ecossistema .NET, e permitem escrever ferramentas genéricas de verdade.
Type: o objeto que descreve um tipo
Todo tipo do .NET é representado, em execução, por um objeto da classe System.Type. Há duas formas de obtê-lo:
Type t1 = typeof(LivroImpresso); // a partir do nome do tipo, em tempo de compilação
Produto p = new Ebook("Vidas Secas", "Graciliano Ramos", 24.90m, 1.8);
Type t2 = p.GetType(); // a partir de um objeto, em execução
Console.WriteLine(t2.Name); // Ebook: o tipo REAL, e não o declarado
Console.WriteLine(t2.FullName); // Livraria.Dominio.Produtos.Ebook
Console.WriteLine(t2.BaseType?.Name); // Produto
Console.WriteLine(t2.IsSealed); // True
Console.WriteLine(typeof(Produto).IsAbstract); // True
Repare que GetType() devolve o tipo real do objeto (Ebook), mesmo com a variável declarada como Produto. É o mesmo tipo real que decide qual override executa, como vimos em "Herança".
O objeto Type sabe tudo sobre o tipo: suas interfaces (GetInterfaces()), seus construtores, propriedades, métodos e campos. Os metadados que o compilador grava no .dll junto com o IL, apresentados em "Do Código-Fonte ao Processo", são exatamente essa informação, e a reflexão é a forma de lê-la.
Propriedades, métodos e valores
GetProperties() devolve a lista de propriedades como objetos PropertyInfo, e cada um sabe ler e escrever o valor da propriedade num objeto qualquer:
var livro = new LivroImpresso("Dom Casmurro", "Machado de Assis", 59.90m, pesoGramas: 350, estoque: 10);
foreach (PropertyInfo prop in livro.GetType().GetProperties())
{
object? valor = prop.GetValue(livro);
Console.WriteLine($"{prop.Name} ({prop.PropertyType.Name}) = {valor}");
}
PesoGramas (Int32) = 350
Estoque (Int32) = 10
Formato (String) = impresso
LimiteEstoqueBaixo (Int32) = 2
Id (Int32) = 1
Titulo (String) = Dom Casmurro
Autor (String) = Machado de Assis
Preco (Decimal) = 59,90
(PropertyInfo vem do namespace System.Reflection, que precisa de um using.) A ordem das propriedades não é garantida pela documentação, então nunca dependa dela. O mesmo vale para métodos: GetMethod("Vender") devolve um MethodInfo, e Invoke(livro, [2]) chama o método. E Activator.CreateInstance(tipo, argumentos) cria um objeto de um tipo conhecido só em execução.
Com isso já dá para escrever código que funciona com qualquer classe sem conhecê-la de antemão: um método que imprime todas as propriedades de um objeto, compara dois objetos campo a campo ou copia valores entre tipos parecidos. É o que o System.Text.Json faz ao serializar um objeto: descobre as propriedades por reflexão, lê cada valor e escreve o nome e o valor no JSON.
Atributos: anotações que o programa pode ler
Um atributo é uma classe derivada de System.Attribute cujas instâncias ficam gravadas nos metadados, anexadas a um tipo, propriedade, método ou parâmetro. Você já usa vários da biblioteca padrão:
[Flags]: muda oToStringde um enum, como vimos em "Enums e Pattern Matching".[NotNullWhen(true)]: instrui a análise de nulidade do compilador, como vimos em "Nullable Reference Types".[Obsolete("Use CalcularTotal.")]: faz o compilador avisar quem ainda usa o elemento marcado.[Required],[Range(1, 100)]e[StringLength(200)]: vêm deSystem.ComponentModel.DataAnnotationse são lidos pelo validador do ASP.NET, que veremos na fase de API.[JsonPropertyName("titulo")]: define o nome de uma propriedade no JSON gerado.
Criar um atributo próprio é declarar uma classe:
[AttributeUsage(AttributeTargets.Property)]
public sealed class ColunaAttribute(string titulo) : Attribute
{
public string Titulo { get; } = titulo;
public int Ordem { get; set; }
}
[AttributeUsage(AttributeTargets.Property)]
public sealed class IgnorarAttribute : Attribute;
Três convenções aparecem aqui. O nome termina em Attribute, mas ao usar pode-se omitir o sufixo: [Coluna(...)] equivale a [ColunaAttribute(...)]. O [AttributeUsage], que é um atributo sobre atributos, restringe onde ele pode ser aplicado. E propriedades públicas com set viram argumentos nomeados no uso:
public sealed class LinhaRelatorio
{
[Coluna("Título", Ordem = 1)] public string Titulo { get; set; } = "";
[Coluna("Preço", Ordem = 2)] public decimal Preco { get; set; }
[Ignorar] public int Id { get; set; }
}
Sozinho, um atributo não faz nada. Ele é só um dado gravado no .dll. O efeito vem de quem o lê, com reflexão:
PropertyInfo prop = typeof(LinhaRelatorio).GetProperty(nameof(LinhaRelatorio.Titulo))!;
ColunaAttribute? coluna = prop.GetCustomAttribute<ColunaAttribute>();
Console.WriteLine(coluna?.Titulo); // Título
Repare no nameof(LinhaRelatorio.Titulo) no lugar de "Titulo". Voltamos a ele no exercício 4, porque é uma das defesas mais importantes contra a fragilidade da reflexão.
Em records posicionais, os parâmetros viram propriedades, e o atributo precisa dizer em qual dos dois elementos deve ficar. O prefixo property: resolve:
public sealed record LinhaCatalogo(
[property: Ignorar] int Id,
[property: Coluna("Título", Ordem = 1)] string Titulo,
[property: Coluna("Preço", Ordem = 2)] decimal Preco);
Sem o property:, o atributo seria aplicado ao parâmetro do construtor. Com os atributos acima, restritos a propriedades pelo AttributeUsage, isso nem compila (erro CS0592). Com um atributo que aceite parâmetros, compila, mas ele fica no parâmetro, e a reflexão sobre as propriedades não o encontra.
O preço da reflexão
Reflexão é poderosa, e por isso mesmo é preciso conhecer seus custos antes de usá-la no dia a dia.
Desempenho. Ler uma propriedade com PropertyInfo.GetValue é muitas vezes mais lento que o acesso direto, porque há verificações, conversões para object e, com tipos de valor, boxing. Em código que executa uma vez por requisição isso é irrelevante. Num laço sobre milhões de registros, pesa. A mitigação padrão é descobrir os PropertyInfo uma vez, guardá-los e reaproveitá-los.
Perda de verificação. Todo o trabalho que o compilador fazia, como verificar se a propriedade existe e se o tipo é compatível, passa a acontecer em execução. GetProperty("Preso"), com um erro de digitação, devolve null sem nenhum aviso de compilação, e uma renomeação de propriedade quebra o código em silêncio.
Encapsulamento. Com as opções certas (BindingFlags.NonPublic), a reflexão lê e altera até membros privados. As proteções que construímos em "Propriedades e Encapsulamento" podem ser contornadas. É um poder que bibliotecas de infraestrutura usam com cuidado, e que código de aplicação praticamente nunca deveria usar.
Incompatibilidade com código enxuto. O .NET pode publicar programas "aparados" (trimming), que removem tudo o que não é usado, e compilados direto para código nativo (Native AOT), como antecipamos na aula de instalação. Para decidir o que remover, a ferramenta analisa o código estaticamente. Uma propriedade acessada só por reflexão, a partir de uma string, parece não usada, é removida, e o programa quebra em execução. Esse é um dos motivos pelos quais o ecossistema .NET vem migrando de reflexão para a geração de código em tempo de compilação.
Juntando tudo: um exportador de CSV que serve para qualquer tipo
A Livraria precisa exportar relatórios em CSV, o formato de texto que planilhas abrem. Com reflexão e atributos, um único exportador serve para qualquer tipo de relatório:
using System.Globalization;
using System.Reflection;
using System.Text;
public static class ExportadorCsv
{
public static string Exportar<T>(IEnumerable<T> itens)
{
var colunas = typeof(T)
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => p.CanRead && p.GetCustomAttribute<IgnorarAttribute>() is null)
.Select(p => new { Propriedade = p, Coluna = p.GetCustomAttribute<ColunaAttribute>() })
.OrderBy(c => c.Coluna?.Ordem ?? int.MaxValue)
.ThenBy(c => c.Propriedade.Name)
.ToList(); // a reflexão acontece UMA vez, e não por linha
var csv = new StringBuilder();
csv.AppendLine(string.Join(";", colunas.Select(c => Escapar(c.Coluna?.Titulo ?? c.Propriedade.Name))));
foreach (T item in itens)
{
var valores = colunas.Select(c => Escapar(Formatar(c.Propriedade.GetValue(item))));
csv.AppendLine(string.Join(";", valores));
}
return csv.ToString();
}
private static string Formatar(object? valor) => valor switch
{
null => "",
IFormattable f => f.ToString(null, CultureInfo.InvariantCulture),
_ => valor.ToString() ?? ""
};
private static string Escapar(string texto) =>
texto.Contains(';') || texto.Contains('"') || texto.Contains('\n')
? $"\"{texto.Replace("\"", "\"\"")}\""
: texto;
}
Algumas peças novas e algumas conhecidas:
StringBuildermonta texto aos pedaços sem criar uma string nova a cada acréscimo. Lembre-se de que strings são imutáveis, como vimos em "Cada Valor Tem um Tipo". Na próxima fase vamos medir a diferença que isso faz.IFormattableé a interface dos tipos que sabem se formatar segundo uma cultura (números, datas). UsarInvariantCulturegarante que o CSV tenha sempre ponto decimal, qualquer que seja o idioma da máquina: é a armadilha da cultura de "Operadores, Texto e Conversa com o Usuário", resolvida para arquivos trocados entre sistemas.Escaparsegue a regra do formato CSV: um valor que contenha o separador, aspas ou quebra de linha vai entre aspas, e as aspas internas são duplicadas. Um título com ponto e vírgula não desalinha as colunas. É o mesmo princípio doMarkup.Escapede "Do Arquivo Único à Solução": dados nunca devem ser interpretados como estrutura.
O uso, projetando o catálogo para o record LinhaCatalogo (acrescido de autor e formato):
public sealed record LinhaCatalogo(
[property: Ignorar] int Id,
[property: Coluna("Título", Ordem = 1)] string Titulo,
[property: Coluna("Autor", Ordem = 2)] string Autor,
[property: Coluna("Formato", Ordem = 3)] string Formato,
[property: Coluna("Preço", Ordem = 4)] decimal Preco);
var linhas = catalogo.Select(p => new LinhaCatalogo(p.Id, p.Titulo, p.Autor, p.Formato, p.Preco));
Console.Write(ExportadorCsv.Exportar(linhas));
Título;Autor;Formato;Preço
Dom Casmurro;Machado de Assis;impresso;59.90
Vidas Secas;Graciliano Ramos;e-book;24.90
Grande Sertão: Veredas;Guimarães Rosa;impresso;89.00
O exportador não sabe nada sobre livros. Amanhã, um relatório de vendas por autor será outro record, com outros atributos, e o mesmo Exportar servirá sem nenhuma mudança. E repare que a reflexão acontece uma vez, na montagem da lista de colunas, e não a cada linha: os PropertyInfo são descobertos antes do laço e reaproveitados.
Ainda assim, cada GetValue dentro do laço paga o custo da reflexão, e nada disso sobrevive intacto a uma publicação aparada ou nativa. Seria ideal ter o melhor dos dois mundos: escrever o exportador de forma genérica, guiado por atributos, e obter um código tão rápido e seguro quanto se cada exportação tivesse sido escrita à mão, propriedade por propriedade. Isso é possível se a análise dos tipos acontecer não em execução, mas durante a compilação, e se o próprio compilador escrever o código repetitivo por nós.
Fontes e leituras recomendadas
- Reflexão no .NET (Microsoft Learn): https://learn.microsoft.com/dotnet/fundamentals/reflection/reflection. É a visão geral de
Type,PropertyInfo,MethodInfoe da criação dinâmica de objetos. - Atributos (C#): https://learn.microsoft.com/dotnet/csharp/advanced-topics/reflection-and-attributes/. Explica como aplicar atributos, seus alvos (
property:,return:e outros) e como lê-los. - Criando atributos personalizados: https://learn.microsoft.com/dotnet/csharp/advanced-topics/reflection-and-attributes/creating-custom-attributes. Cobre
AttributeUsage, argumentos posicionais e nomeados. - Incompatibilidades conhecidas com trimming: https://learn.microsoft.com/dotnet/core/deploying/trimming/incompatibilities. Explica por que reflexão e publicação enxuta não convivem bem.
- RFC 4180, formato CSV: https://www.rfc-editor.org/rfc/rfc4180. É a especificação das regras de separadores, aspas e quebras de linha usadas no exportador.
Exercícios
Exercício 1
Escreva um programa que, dado typeof(LivroImpresso), imprima a cadeia de herança completa até object e a lista de interfaces implementadas.
Ver resposta
✓ Resposta:
Type? atual = typeof(LivroImpresso);
Console.Write("Herança: ");
while (atual is not null)
{
Console.Write(atual.Name);
atual = atual.BaseType;
if (atual is not null) Console.Write(" → ");
}
Console.WriteLine();
foreach (Type i in typeof(LivroImpresso).GetInterfaces())
Console.WriteLine($"Interface: {i.Name}");
A saída mostra LivroImpresso → Produto → Object, e entre as interfaces aparece IEntidade, declarada em Produto em "Genéricos". GetInterfaces() inclui também as interfaces herdadas das classes base. BaseType de object é null, o que encerra o laço. É a confirmação, por reflexão, de que toda classe termina em object, como vimos em "Herança".
Exercício 2
Escreva um método Descrever(object obj) que devolva o nome do tipo seguido de todas as propriedades públicas legíveis no formato Nome = valor, e teste-o com um livro e com um cliente.
Ver resposta
✓ Resposta:
using System.Reflection;
static string Descrever(object obj)
{
ArgumentNullException.ThrowIfNull(obj);
Type tipo = obj.GetType();
var partes = tipo
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => p.CanRead && p.GetIndexParameters().Length == 0)
.Select(p => $"{p.Name} = {p.GetValue(obj) ?? "null"}");
return $"{tipo.Name} {{ {string.Join(", ", partes)} }}";
}
GetIndexParameters().Length == 0 exclui os indexadores (como o this[int] de uma lista), que são propriedades com parâmetros e não podem ser lidas sem um índice. Sem esse filtro, GetValue lançaria exceção ao encontrar um. As chaves duplas {{ e }} produzem chaves literais numa string interpolada. O resultado lembra o ToString automático dos records de "Classe, Struct ou Record": o compilador gera algo parecido para records, mas em tempo de compilação, sem reflexão. É uma prévia da ideia que fecha esta fase.
Exercício 3
Marque um método antigo da Livraria com [Obsolete] de duas formas: uma que gere aviso e outra que gere erro de compilação. Em que situação cada uma é adequada?
Ver resposta
✓ Resposta:
[Obsolete("Use CalcularTotal, que aplica as políticas de desconto.")]
public decimal TotalSemDesconto() => Subtotal;
[Obsolete("Removido: os preços agora são validados na propriedade Preco.", error: true)]
public void DefinirPreco(decimal preco) { }
Com a primeira forma, todo código que chama TotalSemDesconto compila, mas recebe o aviso CS0618 com a mensagem. Com error: true, a chamada vira o erro CS0619, e o código não compila. O aviso é adequado para uma transição: o método ainda funciona, mas quem usa tem tempo de migrar, normalmente ao longo de uma ou duas versões. O erro é adequado quando manter o comportamento antigo seria perigoso ou impossível e é preferível quebrar a compilação a deixar o código seguir errado. Em bibliotecas públicas, a sequência comum é: aviso numa versão, erro na seguinte, remoção na versão maior seguinte, seguindo o versionamento semântico de "Do Arquivo Único à Solução". Repare que [Obsolete] é lido pelo compilador, e não por reflexão: atributos servem aos dois públicos.
Exercício 4
Por que typeof(Produto).GetProperty(nameof(Produto.Preco)) é melhor que typeof(Produto).GetProperty("Preco")? Descreva o que acontece com cada forma quando alguém renomeia a propriedade para PrecoUnitario.
Ver resposta
✓ Resposta: Com a string literal "Preco", a renomeação passa sem nenhum erro de compilação: o texto não está ligado à propriedade, e as ferramentas de refatoração normalmente não o alteram. Em execução, GetProperty("Preco") devolve null, e o erro aparece mais adiante, como um NullReferenceException ao usar o resultado, longe da causa. Com nameof(Produto.Preco), o compilador verifica que a propriedade existe. Ao renomear pela ferramenta de refatoração, o nameof é atualizado junto. Se a renomeação for manual, o nameof antigo vira erro de compilação, apontando exatamente onde corrigir. nameof devolve a mesma string em tempo de compilação, sem nenhum custo em execução. É a forma de trazer a verificação do compilador de volta para o código de reflexão, e vale para qualquer lugar em que o nome de um membro precise virar texto, como em mensagens de exceção e parâmetros de ArgumentException.
Exercício 5
Meça, com System.Diagnostics.Stopwatch, o tempo para ler a propriedade Preco de um livro 10 milhões de vezes, diretamente e via PropertyInfo.GetValue. Depois explique por que o resultado exato varia de máquina para máquina e que conclusão continua válida.
Ver resposta
✓ Resposta:
using System.Diagnostics;
using System.Reflection;
var livro = new LivroImpresso("Dom Casmurro", "Machado de Assis", 59.90m, pesoGramas: 350, estoque: 10);
PropertyInfo prop = typeof(LivroImpresso).GetProperty(nameof(LivroImpresso.Preco))!;
const int N = 10_000_000;
decimal soma = 0;
var relogio = Stopwatch.StartNew();
for (int i = 0; i < N; i++) soma += livro.Preco;
Console.WriteLine($"Direto: {relogio.ElapsedMilliseconds} ms");
soma = 0;
relogio.Restart();
for (int i = 0; i < N; i++) soma += (decimal)prop.GetValue(livro)!;
Console.WriteLine($"Reflexão: {relogio.ElapsedMilliseconds} ms");
Rodando em Release (dotnet run -c Release), o acesso direto costuma levar poucas dezenas de milissegundos, e a reflexão, várias vezes mais: no .NET 10, medido, cerca de 4 vezes (46 ms contra 194 ms). O número exato depende do processador, da versão do .NET (cada versão otimiza a reflexão um pouco mais) e de o JIT ter ou não otimizado o laço direto. Por isso medições assim são indicativas, e não definitivas. Na Fase 10 usaremos o BenchmarkDotNet, que faz aquecimento, repetições e análise estatística. A conclusão que se mantém é que a reflexão tem um custo real por chamada. É aceitável em operações pontuais e deve ser evitada, ou ter seus resultados guardados e reaproveitados, em laços com milhões de iterações. A conversão (decimal)prop.GetValue(...) também revela outro custo: GetValue devolve object, então o decimal é empacotado no heap (boxing) a cada leitura e desempacotado na conversão.