O Programa que se Examina — Anotações e Reflexão

O Programa que se Examina — Anotações e Reflexão

Como declarar anotações próprias em Java, escolher a retenção certa e usar a API de reflexão para examinar e chamar classes, campos e métodos durante a execução. Constrói um validador e um executor de testes em miniatura e mostra os custos da reflexão e o limite imposto pelos módulos.
Java

• • 19 min de leitura

Como anexar informações ao código com anotações, como um programa pode inspecionar e manipular a própria estrutura durante a execução, e por que quase todo framework Java depende dessas duas coisas.

"Um Valor que Pode Não Existir" terminou com uma pergunta sobre algo que o curso usa desde a Fase 2 sem explicar a fundo: as anotações. @Override pede ao compilador que confira uma sobrescrita. @FunctionalInterface pede que confira uma interface. @SuppressWarnings silencia avisos. Mas anotações não servem só ao compilador. Os frameworks da trilha de web, que transformam uma classe em uma API com uma linha, funcionam lendo anotações durante a execução, com uma técnica chamada reflexão. Esta aula mostra as duas coisas: como declarar anotações próprias e como um programa pode examinar classes, campos e métodos para agir sobre eles. No fim, você vai construir um pequeno validador e um pequeno executor de testes, versões simplificadas de ferramentas que usará na vida real. Com ela, termina a Fase 4.

Anotações: informação sobre o código

Uma anotação é um rótulo com dados, preso a uma declaração: uma classe, um método, um campo, um parâmetro. Ela não muda o que o código faz. Ela informa algo sobre ele, e alguma ferramenta, seja o compilador ou um framework, decide o que fazer com essa informação.

As anotações da própria linguagem que você já conhece:

  • @Override confere que o método realmente sobrescreve outro ("Do Geral ao Específico").
  • @FunctionalInterface confere que a interface tem um único método abstrato ("Comportamento como Valor").
  • @SuppressWarnings("unchecked") silencia um aviso específico ("Escreva uma Vez para Todo Tipo").

E uma nova, @Deprecated, que marca algo como obsoleto:

class Antiga {
    @Deprecated(since = "2.0", forRemoval = true)
    static int calcular(int x) { return x * 2; }
}

Quem usar Antiga.calcular recebe um aviso do compilador:

Dep.java:6: warning: [removal] calcular(int) in Antiga has been deprecated and marked for removal

since informa desde que versão, e forRemoval = true avisa que o método vai desaparecer. É assim que a biblioteca do Java anuncia com antecedência as remoções, como aconteceu com os métodos antigos de Date em "Tempo, Dinheiro e Padrões". Anotações podem ter elementos, parâmetros com nome escritos entre parênteses.

Declarando uma anotação

Uma anotação é declarada com @interface:

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD, ElementType.RECORD_COMPONENT})
@interface NaoVazio {
    String mensagem() default "não pode ser vazio";
}

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD, ElementType.RECORD_COMPONENT})
@interface Faixa {
    double min();
    double max();
}

Os elementos parecem métodos sem corpo. Um elemento com default é opcional. Sem default, é obrigatório. Os tipos permitidos são limitados: primitivos, String, Class, enums, outras anotações e arrays desses tipos. Um elemento chamado value pode ser passado sem o nome, como em @SuppressWarnings("unchecked").

As duas anotações que decoram a declaração são meta-anotações, anotações sobre anotações:

  • @Target diz onde a anotação pode ser usada: em classes (TYPE), métodos (METHOD), campos (FIELD), parâmetros, componentes de records e outros lugares. Usá-la em outro lugar é erro de compilação.
  • @Retention diz até quando a anotação existe. Com SOURCE, ela é descartada pelo compilador, como o @Override, que só serve à compilação. Com CLASS, que é o padrão, ela vai para o arquivo .class, mas não fica visível durante a execução. Com RUNTIME, ela fica disponível para o programa ler enquanto roda.

Essa última escolha é decisiva:

@interface SemRetencao { }
@Retention(RetentionPolicy.RUNTIME) @interface ComRetencao { }

class Alvo {
    @SemRetencao @ComRetencao void m() { }
}

Lendo as anotações de m durante a execução, só @ComRetencao aparece. @SemRetencao tem retenção CLASS, e a JVM a ignora. Uma anotação feita para ser lida por um framework precisa de RUNTIME, e esquecer isso é a causa mais comum de "a anotação não funciona".

Com as anotações declaradas, podemos usá-las num record:

record Produto(
    @NaoVazio String nome,
    @NaoVazio(mensagem = "o código é obrigatório") String codigo,
    @Faixa(min = 0.01, max = 100_000) double preco,
    @Faixa(min = 0, max = 9_999) int estoque) { }

Por enquanto, as anotações não fazem nada. São só rótulos. Para que tenham efeito, algum código precisa lê-los.

Reflexão: o programa se examinando

Todo tipo carregado pela JVM tem um objeto Class que o descreve. Você já o encontrou em "Do Geral ao Específico", com getClass(), e em "O Preço da Compatibilidade", como token de tipo. Há três formas de obtê-lo:

Class<?> a = conta.getClass();                        // a partir de um objeto
Class<?> b = Conta.class;                             // a partir do nome, no código
Class<?> c = Class.forName("java.util.ArrayList");    // a partir de um texto, na execução

A partir do Class, a API de reflexão (pacote java.lang.reflect) permite examinar tudo o que a classe declara. Um exemplo, com uma classe Conta simples:

import java.lang.reflect.*;

public class Exame {

    static class Conta {
        private double saldo;
        public static int criadas = 0;

        public Conta() { }
        public Conta(double saldo) { this.saldo = saldo; }

        public void depositar(double valor) { saldo += valor; }
        private void auditoria() { }
        public double getSaldo() { return saldo; }
    }

    public static void main(String[] args) throws Exception {
        Class<?> tipo = Conta.class;
        IO.println(tipo.getSimpleName() + ", superclasse " + tipo.getSuperclass().getSimpleName());

        for (Field f : tipo.getDeclaredFields()) {
            IO.println("campo " + Modifier.toString(f.getModifiers()) + " "
                       + f.getType().getSimpleName() + " " + f.getName());
        }
        for (Method m : tipo.getDeclaredMethods()) {
            IO.println("método " + Modifier.toString(m.getModifiers()) + " "
                       + m.getName() + " " + m.getParameterCount() + " parâmetro(s)");
        }
        IO.println(tipo.getDeclaredConstructors().length + " construtores");
    }
}
Conta, superclasse Object
campo private double saldo
campo public static int criadas
método public depositar 1 parâmetro(s)
método public getSaldo 0 parâmetro(s)
método private auditoria 0 parâmetro(s)
2 construtores

Os métodos com Declared no nome devolvem tudo o que a própria classe declara, inclusive os membros privados. Os sem Declared, como getMethods(), devolvem só os públicos, incluindo os herdados. A ordem em que os membros aparecem não é garantida, então nunca dependa dela.

A reflexão também permite agir: criar objetos, chamar métodos e ler ou alterar campos, tudo a partir de nomes e tipos conhecidos só durante a execução:

Object conta = tipo.getConstructor(double.class).newInstance(100.0);
Method depositar = tipo.getMethod("depositar", double.class);
depositar.invoke(conta, 50.0);
IO.println(tipo.getMethod("getSaldo").invoke(conta));    // 150.0

Os métodos são localizados pelo nome e pelos tipos dos parâmetros, e invoke os chama sobre um objeto. Se o nome estiver errado, o erro só aparece na execução, como uma exceção verificada: NoSuchMethodException: Exame$Conta.sacar(double). Todas as exceções da reflexão descendem de ReflectiveOperationException, o que permite capturá-las juntas.

A reflexão conhece também os recursos modernos da linguagem: getRecordComponents() lista os componentes de um record, e getPermittedSubclasses() lista os tipos permitidos de uma hierarquia selada.

Furando o encapsulamento

A reflexão pode até acessar membros privados:

Field saldo = tipo.getDeclaredField("saldo");
saldo.setAccessible(true);
saldo.set(conta, -999.0);
IO.println(((Conta) conta).getSaldo());    // -999.0

Todo o cuidado de "O que Fica Dentro, o que Fica Fora" foi contornado em três linhas: um campo privado, protegido por métodos que validam as regras, recebeu um valor inválido. Frameworks que salvam objetos em bancos de dados ou os convertem para JSON usam exatamente essa capacidade para preencher campos privados.

Mas há um limite. Tentar o mesmo com um campo privado da biblioteca padrão falha:

Field f = String.class.getDeclaredField("value");
f.setAccessible(true);
// InaccessibleObjectException: Unable to make field private final byte[] java.lang.String.value
// accessible: module java.base does not "opens java.lang" to unnamed module

Desde o Java 9, a biblioteca é dividida em módulos, que controlam o que outros códigos podem acessar, inclusive pela reflexão. O módulo java.base não abre seu interior para ninguém, e é por isso que ninguém consegue alterar um String imutável por baixo. A sua classe Conta, que não está num módulo, fica aberta. Módulos são o assunto de "Fronteiras Explícitas", três aulas à frente.

Construindo um validador

Agora as duas peças juntas. Um validador que examina qualquer record, lê as anotações de cada componente e verifica as regras:

class Validador {
    static List<String> validar(Record objeto) {
        List<String> erros = new ArrayList<>();
        for (RecordComponent componente : objeto.getClass().getRecordComponents()) {
            Object valor;
            try {
                valor = componente.getAccessor().invoke(objeto);
            } catch (ReflectiveOperationException e) {
                throw new IllegalStateException("Não foi possível ler " + componente.getName(), e);
            }

            NaoVazio naoVazio = componente.getAnnotation(NaoVazio.class);
            if (naoVazio != null && (valor == null || valor.toString().isBlank())) {
                erros.add(componente.getName() + ": " + naoVazio.mensagem());
            }

            Faixa faixa = componente.getAnnotation(Faixa.class);
            if (faixa != null) {
                double numero = ((Number) valor).doubleValue();
                if (numero < faixa.min() || numero > faixa.max()) {
                    erros.add("%s: %s fora da faixa [%s, %s]".formatted(
                        componente.getName(), valor, faixa.min(), faixa.max()));
                }
            }
        }
        return erros;
    }
}
IO.println(Validador.validar(new Produto("Teclado", "TK-01", 250.0, 12)));
IO.println(Validador.validar(new Produto("  ", "", -5, 20_000)));
[]
[nome: não pode ser vazio, codigo: o código é obrigatório, preco: -5.0 fora da faixa [0.01, 100000.0], estoque: 20000 fora da faixa [0.0, 9999.0]]

Para cada componente, getAccessor() devolve o método de acesso gerado pelo record, e invoke obtém o valor. getAnnotation devolve a anotação, se o componente a tiver, ou null, e os elementos são lidos como métodos: naoVazio.mensagem(), faixa.min(). A exceção verificada da reflexão é convertida numa não verificada com a causa preservada, como em "Quando o Contrato se Quebra".

O ponto importante é que o Validador não conhece a classe Produto. Ele funciona com qualquer record anotado, inclusive os que ainda não foram escritos. As regras ficam declaradas junto dos dados, e a verificação fica num só lugar. É exatamente o funcionamento da Bean Validation (@NotBlank, @Min, @Max, @Email), a especificação usada pelos frameworks web para validar os dados que chegam a uma API. Na trilha de web você vai usá-la, e agora sabe o que ela faz por dentro.

Construindo um executor de testes

Mais um exemplo, também tirado do mundo real. Uma anotação que marca métodos de teste, e um executor que encontra e roda todos eles:

import java.lang.annotation.*;
import java.lang.reflect.*;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@interface Teste { }

public class MiniTeste {

    static class TestesDaCalculadora {
        @Teste
        void somaDeDoisPositivos() {
            verificar(2 + 3 == 5, "2 + 3 deveria ser 5");
        }

        @Teste
        void divisaoInteira() {
            verificar(7 / 2 == 3.5, "7 / 2 deveria ser 3.5");
        }

        @Teste
        void textoMaiusculo() {
            verificar("java".toUpperCase().equals("JAVA"), "maiúsculas");
        }

        void auxiliarQueNaoETeste() {
            throw new IllegalStateException("não deveria rodar");
        }

        static void verificar(boolean condicao, String mensagem) {
            if (!condicao) {
                throw new AssertionError(mensagem);
            }
        }
    }

    public static void main(String[] args) throws Exception {
        Class<?> classe = TestesDaCalculadora.class;
        Object instancia = classe.getDeclaredConstructor().newInstance();
        int passaram = 0;
        int falharam = 0;
        for (Method m : classe.getDeclaredMethods()) {
            if (!m.isAnnotationPresent(Teste.class)) {
                continue;
            }
            try {
                m.invoke(instancia);
                IO.println("OK    " + m.getName());
                passaram++;
            } catch (InvocationTargetException e) {
                IO.println("FALHA " + m.getName() + ": " + e.getCause().getMessage());
                falharam++;
            }
        }
        IO.println(passaram + " passaram, " + falharam + " falharam");
    }
}
OK    somaDeDoisPositivos
FALHA divisaoInteira: 7 / 2 deveria ser 3.5
OK    textoMaiusculo
2 passaram, 1 falharam

O executor percorre os métodos, ignora os que não têm @Teste e chama os demais. Quando um método chamado por invoke lança uma exceção, ela chega embrulhada numa InvocationTargetException, e a original é obtida com getCause(). O teste divisaoInteira falhou, e com razão: é a divisão inteira de "Operadores, Texto e Conversa com o Usuário", e o teste estava errado.

Isto é, em essência, o JUnit, a ferramenta de testes mais usada em Java, com a anotação @Test no lugar de @Teste. Você vai usá-lo de verdade em "Provando que Funciona".

Um detalhe sobre arquivos compactos: os exemplos desta aula usam classes clássicas, com as classes auxiliares declaradas como static class. Numa classe declarada dentro de um arquivo compacto, sem static, a reflexão enxerga um construtor com um parâmetro escondido, a instância da classe implícita, porque essas classes são internas, como vimos em "Ordem e Critério". getDeclaredConstructor() sem argumentos não a encontra. O exercício 4 explora isso.

O preço da reflexão

A reflexão é poderosa e tem custos que justificam usá-la com parcimônia.

Perde-se a verificação do compilador. Um método chamado pelo nome, num texto, não é conferido na compilação. Um erro de digitação ou uma renomeação só aparece durante a execução, como exceção. Todo o valor da tipagem estática, defendido desde "Cada Valor em sua Caixa", fica suspenso.

Fura o encapsulamento. Como vimos, a reflexão pode alterar campos privados e ignorar as regras de uma classe.

É mais lenta. Um teste com 50 milhões de chamadas a length() levou cerca de 8 ms com a chamada direta e 330 ms por reflexão. A JVM otimiza a reflexão, mas não consegue otimizar tanto quanto uma chamada conhecida na compilação. Para trabalho fora de laços intensos, a diferença é irrelevante.

Dificulta a compilação antecipada. Ferramentas que compilam Java para código nativo, como o GraalVM Native Image de "Do JAR ao Executável", precisam saber na compilação quais classes serão usadas, e a reflexão esconde isso.

Por isso, a regra é: código de aplicação quase nunca deve usar reflexão diretamente. Ela é a ferramenta de quem escreve frameworks e bibliotecas, como validadores, executores de testes, conversores de JSON e injeção de dependência, para que o código de aplicação possa ser simples e declarativo, cheio de anotações e vazio de reflexão.

Existe ainda uma alternativa que resolve parte desses custos: processar as anotações na compilação, em vez de na execução. Um processador de anotações é um plugin do compilador que lê as anotações do código-fonte e gera código novo, como classes, métodos e mapeamentos, antes de o programa rodar. É assim que ferramentas como o MapStruct geram código de conversão entre objetos, e como o Lombok gera getters e construtores (numa técnica controversa, que altera a própria compilação). Com records e com os recursos modernos da linguagem, a necessidade de ferramentas desse tipo diminuiu bastante, e "Lições de Quem Veio Antes" discute quando vale a pena usá-las.

Com esta aula termina a quarta fase. Você aprendeu a tratar comportamento como valor com lambdas e referências a métodos, a descrever processamentos de dados com streams, coletores e gatherers, a lidar com a ausência de valores com Optional, e a entender como anotações e reflexão permitem que um programa examine e manipule a própria estrutura. O estilo dos programas mudou: menos laços e variáveis acumuladoras, mais descrições do resultado desejado.

Mas os programas continuam pequenos, em um ou dois arquivos, compilados e executados à mão. Os exemplos desta fase já sugerem um problema: um validador, um executor de testes, uma biblioteca de textos, todos são código que você gostaria de reaproveitar em vários programas. Um programa de verdade tem dezenas de pacotes, centenas de classes e usa bibliotecas de terceiros, cada uma com as suas próprias dependências, e alguém precisa organizar esse código, empacotá-lo em arquivos distribuíveis e explicar à JVM onde encontrar cada classe.

Fontes e leituras recomendadas

Exercícios

Exercício 1

No exemplo com @SemRetencao e @ComRetencao, a reflexão encontrou só uma anotação. Explique por quê, e diga qual retenção cada uma destas anotações deve ter: @Override, uma anotação de validação lida por um framework durante a execução, e uma anotação lida só por um processador de anotações na compilação.

Ver resposta

✓ Resposta: Sem @Retention, a retenção padrão é CLASS: a anotação vai para o arquivo .class, mas a JVM não a disponibiliza para a reflexão. Só @ComRetencao, com RUNTIME, é visível durante a execução. @Override tem retenção SOURCE, porque só serve ao compilador, que confere a sobrescrita e a descarta. Não há motivo para ela existir depois da compilação. Uma anotação de validação lida por um framework durante a execução precisa de RUNTIME, senão o framework não a encontra, e a validação simplesmente não acontece, sem nenhum erro. Uma anotação lida só por um processador de anotações pode ter SOURCE, porque o processador trabalha com o código-fonte durante a compilação. A escolha errada mais perigosa é esquecer RUNTIME numa anotação de framework, porque o sintoma é silencioso.

Exercício 2

Acrescente ao validador uma anotação @Padrao(String regex), que exija que um texto siga uma expressão regular. Use-a para exigir que o código do produto tenha duas letras maiúsculas, um hífen e dois dígitos, como "TK-01".

Ver resposta

✓ Resposta:

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD, ElementType.RECORD_COMPONENT})
@interface Padrao {
    String value();
}

record Produto(
    @NaoVazio String nome,
    @NaoVazio(mensagem = "o código é obrigatório") @Padrao("[A-Z]{2}-\\d{2}") String codigo,
    @Faixa(min = 0.01, max = 100_000) double preco,
    @Faixa(min = 0, max = 9_999) int estoque) { }

E, no laço do validador:

Padrao padrao = componente.getAnnotation(Padrao.class);
if (padrao != null && valor != null && !valor.toString().matches(padrao.value())) {
    erros.add(componente.getName() + ": \"" + valor + "\" não segue o padrão " + padrao.value());
}

Como o elemento se chama value, a anotação pode ser usada sem nome: @Padrao("..."). Um mesmo componente pode ter várias anotações, e cada uma é verificada independentemente. O teste valor != null evita duplicar o erro de @NaoVazio e uma NullPointerException. Uma versão mais eficiente compilaria cada expressão uma única vez, guardando o Pattern num mapa, porque String.matches compila a expressão a cada chamada, o problema discutido em "Tempo, Dinheiro e Padrões".

Exercício 3

A reflexão conseguiu alterar o campo privado saldo da classe Conta, mas não o campo value de String. Explique a diferença, e diga por que é bom que a segunda tentativa falhe.

Ver resposta

✓ Resposta: A classe Conta está no chamado módulo sem nome, o espaço em que fica todo código que não declara um módulo, e esse espaço é aberto: a reflexão pode acessar os membros privados dele com setAccessible(true). String está no módulo java.base, que declara explicitamente quais pacotes abre para acesso reflexivo, e java.lang não é um deles. A JVM recusa o acesso com InaccessibleObjectException. É bom que falhe porque a imutabilidade de String é uma garantia da qual a plataforma inteira depende: o cache de strings, a segurança de nomes de classes e arquivos, e o uso de textos como chaves de mapas. Se qualquer biblioteca pudesse alterar o array interno de um String, nenhuma dessas garantias valeria. Até o Java 15, isso ainda era possível (do 9 ao 15, com um aviso na execução), e algumas bibliotecas faziam; o Java 16 passou a recusar por padrão, e o 17 fechou a porta de vez. Foi o sistema de módulos que tornou isso possível, e "Fronteiras Explícitas" mostra como você pode fazer o mesmo com o seu código.

Exercício 4

Um aluno escreveu o executor de testes num arquivo compacto, declarando class TestesDaCalculadora sem static, e recebeu NoSuchMethodException: MiniTeste$TestesDaCalculadora.<init>(). Explique o erro e dê duas correções.

Ver resposta

✓ Resposta: Num arquivo compacto, as classes declaradas no arquivo são membros da classe implícita e, sem static, são classes internas, como vimos em "Ordem e Critério". O construtor de uma classe interna recebe, como primeiro parâmetro escondido, a instância da classe de fora. Na reflexão, o construtor que parece não ter parâmetros na verdade tem um, e getDeclaredConstructor() sem argumentos procura um construtor que não existe. O <init> na mensagem é o nome dos construtores no bytecode. Primeira correção: declarar a classe como static class TestesDaCalculadora, e aí o construtor é de fato sem parâmetros. Segunda: escrever o programa como classe clássica, com a classe de testes num arquivo próprio ou aninhada como static, como na aula. O erro mostra um custo da reflexão: ela enxerga a estrutura real gerada pelo compilador, que nem sempre é a que o código-fonte aparenta ter. Records e enums aninhados são implicitamente estáticos, e por isso o validador funcionou mesmo num arquivo compacto.

Exercício 5

O executor de testes da aula conta como falha qualquer exceção lançada por um teste. Modifique-o para distinguir falhas (um AssertionError, que significa que o teste encontrou um resultado errado) de erros (qualquer outra exceção, que significa que o teste nem conseguiu verificar o resultado).

Ver resposta

✓ Resposta:

int passaram = 0;
int falharam = 0;
int erros = 0;
for (Method m : classe.getDeclaredMethods()) {
    if (!m.isAnnotationPresent(Teste.class)) {
        continue;
    }
    try {
        m.invoke(instancia);
        IO.println("OK    " + m.getName());
        passaram++;
    } catch (InvocationTargetException e) {
        Throwable causa = e.getCause();
        if (causa instanceof AssertionError) {
            IO.println("FALHA " + m.getName() + ": " + causa.getMessage());
            falharam++;
        } else {
            IO.println("ERRO  " + m.getName() + ": " + causa);
            erros++;
        }
    }
}
IO.println(passaram + " passaram, " + falharam + " falharam, " + erros + " com erro");

A distinção importa para quem lê o resultado. Uma falha diz que o código testado produziu um resultado diferente do esperado: é uma resposta do teste. Um erro, como uma NullPointerException dentro do teste, diz que o teste nem chegou a verificar nada, e o problema pode estar no próprio teste ou na preparação dele. O JUnit faz exatamente essa distinção nos seus relatórios. A exceção original sempre vem embrulhada numa InvocationTargetException, e é a causa que determina a categoria.

Comentários

Mais em Java

Extensão — MySQL: Driver, Dialeto e as Diferenças que Importam
Extensão — MySQL: Driver, Dialeto e as Diferenças que Importam

As Trilhas da Ilha no MySQL 9.7, com ou sem Docker: colação que ignora acentos…

Tempo, Dinheiro e Padrões — java.time, BigDecimal e Expressões Regulares
Tempo, Dinheiro e Padrões — java.time, BigDecimal e Expressões Regulares

Datas, horas e fusos com java.time, dinheiro exato com BigDecimal e padrões de…

Um Milhão de Threads — Virtual Threads
Um Milhão de Threads — Virtual Threads

Threads virtuais esperam quase de graça: dez mil requisições de um segundo…