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 em execução, atributos próprios com AttributeUsage, o alvo property: em records, os custos da reflexão (desempenho, trimming, Native AOT) e um exportador de CSV genérico guiado por atributos.
Linguagem C#

• • 15 min de leitura

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 o ToString de 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 de System.ComponentModel.DataAnnotations e 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:

  • StringBuilder monta 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). Usar InvariantCulture garante 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.
  • Escapar segue 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 do Markup.Escape de "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

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.

Comentários

Mais em Linguagem C#

O caixa da Livraria em Avalonia: MVVM, navegação, leitor de código de barras e vendas offline
O caixa da Livraria em Avalonia: MVVM, navegação, leitor de código de barras e vendas offline

O caixa da loja física em Avalonia, para Windows e Linux: MVVM com…

Áudio, interface e save game: o que faz um jogo parecer pronto
Áudio, interface e save game: o que faz um jogo parecer pronto

O que faz a Biblioteca em Apuros parecer pronta: barramentos de áudio e volume…

Interop com C: Chamando Código Nativo com P/Invoke e unsafe
Interop com C: Chamando Código Nativo com P/Invoke e unsafe

Como chamar uma biblioteca em C a partir do C#: LibraryImport, conversão de…