BT

Disseminando conhecimento e inovação em desenvolvimento de software corporativo.

Contribuir

Tópicos

Escolha a região

Início Artigos Anotações de tipos no Java 8: Ferramentas e oportunidades

Anotações de tipos no Java 8: Ferramentas e oportunidades

Nas versões anteriores do Java era possível anotar somente declarações. Com o Java 8 as anotações podem ser escritas em qualquer local que os tipos são usados, como por exemplo: declarações, generics e conversões de tipos (casts), como apresentado no código a seguir:

@Encrypted String data;

List<@NonNull String> strings;

myGraph = (@Immutable Graph) tmpGraph;

À primeira vista, as anotações de tipo não aparentam ser uma das funcionalidades mais atraentes dessa nova versão do Java. Ao contrário, em relação à linguagem em si, as anotações possuem somente uma sintaxe e as ferramentas é que dão a sua semântica, isto é, significado e comportamento.

Como desenvolvedor Java, é provável que as anotações já estejam sendo utilizadas para melhorar a qualidade do software. Considere a anotação @Override, introduzida no Java 1.5. Em projetos com muitas heranças não triviais, torna-se difícil rastrear qual implementação de um método será executado. Nesse contexto, se não forem tomados os devidos cuidados ao modificar a assinatura de um método, isso pode fazer com que o método de uma subclasse deixe de ser executado, pois ele deixará de sobrescrever o método da superclasse recém alterado. Eliminar a chamada de um método dessa forma pode introduzir uma falha ou alguma vulnerabilidade. Em decorrência disso, a anotação @Override foi introduzida. Ela permite que os desenvolvedores deixem explícito os métodos que sobrescrevem outros métodos da superclasse. O compilador Java usa essa informação para advertir o desenvolvedor quando o código não reflete essa intenção. Usando essa abordagem, as anotações agem como um mecanismo para auxiliar na verificação automática do programa.

Além da verificação automática, as anotações têm desempenhado um papel central para aumentar a produtividade através de técnicas de metaprogramação. A ideia é que as anotações podem informar as ferramentas sobre como gerar código auxiliar, fazer transformações no código ou definir como o programa deverá se comportar em tempo de execução. Por exemplo a API de persistência JPA (Java Persistence API), introduzida no Java 1.5, permite que os desenvolvedores especifiquem de forma declarativa uma correspondência entre objetos Java e entidades do banco de dados através de anotações, tal como: @Entity. Ferramentas como o Hibernate podem usar essas anotações para fazer mapeamentos e consultas SQL em tempo de execução.

No caso do JPA e do Hibernate, as anotações são usadas para evitar a escrita de código repetitivo, reduzindo assim a duplicidade de código - princípio conhecido como Não Se Repita (Don't Repeat Yourself - DRY). Curiosamente, sempre que se olha para as ferramentas que auxiliam na aplicação de boas práticas, não é difícil encontrar o uso de anotações. Alguns exemplos notáveis ajudam na redução do acoplamento através da Injeção de Dependência e também na separação de responsabilidades com a Programação Orientada a Aspectos

Mas se as anotações já estão sendo usadas para melhorar a qualidade do código, por que usar anotações de tipos?

Uma resposta simples é que as anotações de tipos trazem mais possibilidades. Elas permitem, por exemplo, que mais tipos de falhas sejam detectados automaticamente e dão mais controle às ferramentas de produtividade.

Sintaxe das anotações de tipo

As anotações de tipo no Java 8 podem ser escritas em qualquer local em que um tipo é usado, como por exemplo:


@Encrypted String data

List<@NonNull String> strings

MyGraph = (@Immutable Graph) tmpGraph;

Criar uma anotação de tipo é muito simples, basta usar o ElementType.TYPE_PARAMETER, ElementType.TYPE_USE ou ambos, na definição do alvo (target) da anotação.

@Target({ElementType.TYPE_PARAMETER, ElementType.TYPE_USE})

public @interface Encrypted { }

O ElementType.TYPE_PARAMETER indica que a anotação pode ser usada na declaração de tipos, tais como: classes ou métodos com generics ou ainda junto de caracteres coringa em declarações de tipos anônimos. O ElementType.TYPE_USE indica que a anotação pode ser aplicada em qualquer ponto que um tipo é usado, por exemplo em declarações, generics e conversões de tipos (casts). O código a seguir exemplifica alguns dos usos desses tipos de anotações:


@TypeUse class MyClass<@TypeParameter T extends @TypeUse Integer> {

@TypeUse <@TypeParameter S> MyClass(String s) {

...

}

public static void main(String ... args) {

Object o = ( @TypeUse String ) new String( "Test" );

if( o instanceof @TypeUse String ){

...

}

}

}

Assim como as anotações em declarações, as anotações de tipos podem ser salvas junto com o arquivo de bytecode gerado e acessadas em tempo de execução via Reflection através das políticas de retenção: RetentionPolicy.CLASS ou RetentionPolicy.RUNTIME, usadas na definição da anotação. Existem duas diferenças fundamentais entre as anotações de tipo e as suas predecessoras. Primeiro, as anotações de tipo aplicadas a variáveis locais podem ser salvas junto com o bytecode. Segundo, o tipo usado com generics também é salvo e fica acessível em tempo de execução.

Embora as anotações fiquem disponíveis em tempo de execução, elas não afetam a execução do programa. Por exemplo, um desenvolvedor pode declarar duas variáveis do tipo File e uma variável do tipo Connection no corpo de um método, da seguinte forma


File file = new File("aFile");

@Encrypted File encryptedFile = new File("anEncryptedFile");

@Open Connection connection = getConnection();

Supondo que a classe Connection tenha um método send(File), é possível executar esse método passando como parâmetro qualquer uma das duas variáveis do tipo File:


connection.send(file);

connection.send(encryptedFile);

Como era de se esperar, a ausência de efeito em tempo de execução implica que, embora os tipos possam ser anotados, os métodos não podem ser sobrecarregados com base nas anotações dos tipos.


public class Connection{

void send(@Encrypted File file) { ... }

// A tentativa de sobrecarga a seguir resulta em um erro de compilação

// void send( File file) { ... }

. . .

}

A intuição por trás dessa limitação é que o compilador não tem conhecimento sobre a relação entre tipos anotados e não anotados ou entre tipos com diferentes anotações.

Repare que há uma anotação @Encrypted na variável encryptedFile coincidindo com o parâmetro file na assinatura do método send, mas perceba que não há nada nesse método que corresponda à anotação @Open na variável connection. Quando é feita a chamada connection.send(...), a variável connection é conhecida como uma receptora (receiver) do método (o termo receiver é uma analogia clássica à troca de mensagens entre objetos na teoria de Orientação a Objetos). O Java 8 introduz uma nova sintaxe para as declarações de métodos de forma que anotações de tipos possam ser escritas em um receptor no método.


void send(@Open Connection this, @Encrypted File file)

Observe nesse último exemplo a sintaxe para definir o método send. O primeiro parâmetro na verdade faz referência á instância receptora, por isso o nome desse parâmetro é "this".

Como citado anteriormente, as anotações não afetam a execução do programa. Assim, um método declarado com a nova sintaxe do parâmetro receptor tem o mesmo comportamento àquele utilizando a sintaxe tradicional. Na prática, o uso da nova sintaxe só permite que anotações de tipos sejam escritas no tipo do receptor.

Uma explicação completa da sintaxe de anotações de tipos, incluindo sintaxe para arrays multi-dimensionais, pode ser encontrada na especificação JSR (Java Specification Request) 308.

Detectando erros com anotações

Escrever anotações no código serve para enfatizar erros quando o código tem algum problema:


@Closed Connection connection = ...;

File file = ...;

…

connection.send(file); // Problema!: fechada e não criptografada!

Contudo, esse último código de exemplo pode ser compilado, executado e causará erro durante a execução. O compilador Java não faz verificações de anotações definidas pelo usuário. Apesar disso, a plataforma Java expõe duas APIs que auxiliam nessa tarefa, uma para a criação de plugins para o Compilador e outra para o processamento de anotações, de forma que terceiros possam construir seus próprios métodos de análise.

Nos exemplos anteriores, as anotações tinham a função de qualificar os valores que as variáveis poderiam conter. Porém, podem ser pensadas outras formas de qualificar o tipo File: @Open, @Localized, @NonNull, etc; também pode ser pensada na aplicação dessas anotações qualificando outros tipos, como por exemplo: @Encrypted String. Devido ao fato das anotações serem independentes do sistema de tipos do Java, os conceitos expressos através delas podem ser reutilizadas de muitas formas.

Mas como essas anotações poderiam ser automaticamente verificadas? Intuitivamente, algumas anotações são subtipos de outras anotações e suas aplicações podem ser verificadas em relação aos tipos. Considere a vulnerabilidade ao ataque de Injeção de SQL, causado pela execução de sentenças SQL modificadas maliciosamente pelo usuário. Pode-se pensar em um tipo de dados classificado como @MaybeTainted, indicando que o dado pode ter sido adulterado, ou @Untainted, indicando que o dado está garantidamente livre de adulteração, pois não foi diretamente informado pelo usuário.


@MaybeTainted String userInput;

@Untainted String dbQuery;

A anotação @MaybeTainted pode ser vista como um supertipo da anotação @Untainted. Existem várias maneiras de pensar nessa relação. Primeiro, o conjunto de valores que podem ter sido adulterados podem ser um superconjunto de valores que sabidamente não foram adulterados (um valor que não foi adulterado pode ser um elemento desse superconjunto). Dessa forma, a anotação @Untainted fornece uma garantia maior que @MaybeTainted. Na prática, essa tipagem poderia funcionar da seguinte forma:


userInput = dbQuery; // OK

dbQuery = "SELECT FROM * WHERE " + userInput; // erro de tipo!

Nesse exemplo, a primeira linha não contém nenhum problema, pois userInput supõe que o valor atribuído pode ter sido adulterado, mesmo que esse não seja o caso. Por outro lado, essa tipagem revela um erro na segunda linha, pois está atribuindo um valor que pode ter sido adulterado a uma variável que contém essa restrição.

O framework Checker

O Checker é um framework que faz verificações de anotações em Java. Lançado em 2007, esse framework é um projeto de código aberto encabeçado pelo sub-lider da especificação JSR 308, professor Michael Ernst. O Checker vem com um conjunto padrão de anotações e verificadores de falhas, tais como: NullPointerException, incompatibilidade no uso de unidades de medidas, vulnerabilidades de segurança, problemas de concorrência, entre outros. Por ser um verificador que utiliza mecanismos formais de verificação de tipagem baseado em lógica, ele é capaz de reconhecer falhas potenciais que verificadores baseados em heurísticas não detectam. O framework usa uma API de compilador que detecta as falhas durante a compilação. Outra característica é que ele é estensível, o próprio desenvolvedor pode criar rapidamente os seus verificadores de anotações para detectar problemas específicos da aplicação em questão.

A meta do Checker é detectar falhas sem que o desenvolvedor tenha que escrever muitas anotações. Isso é feito principalmente através de duas funcionalidades apelidadas como: padrões mais inteligente e controle de fluxo sensíveis. Por exemplo, durante o processo de verificação de falhas por ponteiros nulos, o verificador assume que os parâmetros de um método não são nulos por padrão. O verificador pode também utilizar condicionais para determinar quando uma atribuição de nulo é uma expressão válida.


void nullSafe(Object nonNullByDefault, @Nullable Object mightBeNull){

nonNullByDefault.hashCode(); // OK, devido ao padrão

mightBeNull.hashCode(); // Falha!

if (mightBeNull != null){

mightBeBull.hashCode(); // OK, devido ao padrão

}

}

Na prática, os padrões e o controle de fluxo sensíveis significam que o desenvolvedor raramente terá que escrever anotações no corpo dos métodos, pois o verificador é capaz de inferir e verificar as anotações automaticamente. Mantendo a semântica das anotações fora do compilador oficial do Java garante que ferramentas de terceiros e os próprios desenvolvedores tomem as suas decisões. Isso permite que a verificação de erros possa ser personalizada conforme as necessidades de cada projeto.

A habilidade de definir suas próprias anotações também permite que seja considerada a verificação de subtipos específicos do domínio de negócio. Por exemplo, em um sistema financeiro, as taxas de juros usam frequentemente porcentagens enquanto a diferença entre taxas utiliza ponto base (1/100 de 1%). Com um framework de verificação de unidades podem ser definidas as anotações @Percent e @BasisPoint para garantir que não há confusão no uso dos dois tipos:


BigDecimal pct = returnsPct(...); // anotado para devolver @Percent

requiresBps(pct); // erro: requer @BasisPoints

No exemplo apresentado, devido ao fato do Checker ser sensível ao fluxo de controle, sabe-se que pct é um @Percent BigDecimal quando é feita a chamada para requiresBps(pct) com base em dois fatos: returnsPct(...) é anotado para devolver um @Percent BigDecimal e pct não foi novamente atribuído antes de chamar requireBps(...). Frequentemente, desenvolvedores usam convenções de nomes para tentar prevenir esses tipos de falha. O que o Checker faz é dar ao desenvolvedor a garantia de que essas falhas não existam, mesmo que o código mude e evolua.

O Checker já foi executado em milhões de linhas de código, apontando centenas de falhas, mesmo em código bem testado. Um dos exemplos mais interessante aconteceu ao executá-lo com a biblioteca muito utilizada Google Collections, agora Google Guava, que revelou um possível NullPointerException que não tinha sido detectado após vários testes e nem após utilizar ferramentas heurísticas de análise estática de falhas.

Resultados assim foram obtidos sem fazer muitas alterações no código existente. Na prática, verificar uma propriedade com o Checker requer somente 2 ou 3 anotações para uma centena de linhas de código.

Para aqueles que utilizam o Java 6 ou 7, também é possível usar o Checker para melhorar a qualidade do código. As anotações podem ser escritas em comentários, tal como:


/* @NonNull */ String aString

Históricamente, a razão disso é que o Checker foi desenvolvido junto com a JSR 308, que teve início em 2006.

Apesar do Checker ser o melhor framework para fazer verificação de falhas utilizando anotações, ele não é o único atualmente. Tanto o Eclipse quanto o IntelliJ dão suporte a esse tipo de anotação:

Possuem suporte

 

Checker Framework

Suporte completo, incluindo anotações em comentários.

Eclipse

Suporte à verificação de valores não nulos.

IntelliJ IDEA

Podem ser escritos inspecionadores personalizados, suporte à verificação de valores não nulos.

Não possuem suporte

 

PMD

 

Coverity

 

Check Style

Sem suporte para Java 8.

Find Bugs

Sem suporte para Java 8.

Aumentando a produtividade com anotações de tipo

A ideia inicial por trás das anotações de tipo era a verificação de falhas. Contudo, ainda haviam muitas aplicações convincentes desse tipo de anotação em ferramentas de produtividade. Para entender um pouco melhor o porquê, considere os seguintes exemplos de como as anotações são usadas:

Programação Orientada a Aspectos

@Aspect, @Pointcut, etc.

Injeção de Dependência

@Autowired, @Inject, etc.

Persistência

@Entity, @Id, etc.

As anotações são especificações declarativas para informar como as ferramentas devem gerar código ou arquivos auxiliares e como as ferramentas devem se comportar durante a execução do programa. O uso das anotações desse modo pode ser considerado uma forma de metaprogramação. Alguns frameworks, tal como o Lombok, tiram vantagem da metaprogramação com anotações ao extremo, resultando em um código que mal parece Java.

Considere a Programação Orientada a Aspectos (Aspect Oriented Programming - AOP). A AOP ajuda na separação de propriedades ortogonais de uma aplicação, tais como log e autenticação, daquelas relacionadas às regras de negócio. Com a AOP é possível executar um aplicativo em tempo de compilação que adiciona código ao programa com base em um conjunto de regras. Por exemplo, pode ser definida uma regra que automaticamente faz autenticação com base no tipo anotado:


void showSecrets(@Authenticated User user){

// Inserido automaticamente pela AOP:

if (!AuthHelper.EnsureAuth(user)) throw . . .;

}

Como antes, a anotação está qualificando o tipo. Ao invés de verificar as anotações em tempo de compilação, a AOP é usada para fazer a verificação em tempo de execução automaticamente. Esse último exemplo mostra a anotação de tipo sendo usada para dar um controle maior sobre como e quando a AOP modifica o programa.

O Java 8 também dá suporte às anotações de tipo em declarações locais que são persistidas nos arquivos de bytecode. Isso cria novas oportunidades para se ter uma AOP mais granular. Por exemplo, para incluir código de rastreamento de uma forma mais granular:


// Faz o rastreamento de todas as chamadas ao objeto ar

@Trace AuthorizationRequest ar = . . .;

Novamente, as anotações de tipo dão maior controle ao se fazer metraprogramação com AOP. A Injeção de Dependência é uma história similar. Com o Spring 4, é finalmente possível usar generics como qualificador:


@Autowired private Store s1;

@Autowired private Store s2;

Com generics é possível eliminar a necessidade introduzir classes como ProductStore e ServiceStore ou usar regras mais frágeis de injeção baseada em nomes.

Com anotações de tipo, não é difícil imaginar que um dia o Spring permita usá-las para controlar a injeção da seguinte forma:


@Autowired private Store<@Grocery Product> s3;

Esse exemplo mostra um tipo anotado servindo como um mecanismo de separação de interesses, favorecendo para que a hierarquia de tipos do projeto seja mais concisa. Essa separação só é possível porque as anotações de tipo são independentes do sistema de tipagem do Java.

A estrada à frente

Como apresentado, as anotações de tipo podem ser usadas tanto para detectar como prevenir erros em programas e também para aumentar a produtividade. Contudo, o potencial real das anotações de tipos está em combinar a verificação de erros e a metaprogramação para dar suporte aos novos paradigmas de desenvolvimento.

A ideia básica é construir ambientes de execução e bibliotecas que alavanquem a abordagem de criar programas automaticamente mais eficientes, paralelos ou seguros e que façam com que os desenvolvedores usem as anotações corretamente.

Um bom exemplo dessa abordagem é o framewok EnerJ, de Adrian Sampson, para computação com eficiência energética utilizando a computação aproximada. EnerJ baseia-se na observação de que em certas ocasiões, tal como o processamento de imagens em dispositivos móveis, faz sentido reduzir a precisão para poupar energia. Um desenvolvedor usando o EnerJ pode anotar um dado que não é crítico usando a anotação @Approx. Com base nessa anotação, o ambiente de execução do EnerJ usa vários atalhos ao manipular esse dado. Por exemplo, pode ser utilizado um hardware de cálculos aproximados de baixo consumo de energia para armazenar e fazer cálculos. Contudo, tendo dados aproximados se movendo pelo programa pode ser perigoso, e nenhum desenvolvedor vai querer controlar o fluxo afetado por esse dado aproximado. Portanto, o EnerJ usa o Checker para garantir que os dados aproximados não sejam utilizados em sentenças de controle de fluxo, por exemplo, em sentenças condicionais como ifs.

Mas as aplicações dessa abordagem não são limitadas à dispositivos móveis. Em finanças, frequentemente há um conflito entre precisão e velocidade. Nesses casos, o ambiente de execução pode decidir entre usar um algoritmo de Monte Carlo para o cálculo de caminhos, um critério de convergência ou mesmo processar algo em um hardware especializado com base nas demandas atuais ou na disponibilidade de recursos.

A beleza desse abordagem é que a preocupação de como uma execução deve ser feita fica fora da lógica de negócio central da aplicação, que descreve o que deve ser feito.

Conclusão

No Java 8, as anotações podem ser usadas em qualquer tipo, incrementando a capacidade de escrever anotações em declarações. As anotações por sí próprias não afetam o comportamento do programa. Contudo, utilizando ferramentas como o Checker, é possível utilizá-las para verificar automaticamente a ausência de falhas e aumentar a produtividade com metaprogramação. Enquanto ainda levará algum tempo para que as ferramentas existentes obtenham total vantagem das anotações de tipos, agora é hora de começar a explorar como as anotações de tipos podem melhorar tanto a qualidade do software desenvolvido quanto a produtividade.

Sobre o Autor

Todd Schiller é diretor da FinLingua, uma companhia de consultoria e desenvolvimento de software para a área financeira. A prática de consultoria da FinLingua ajuda equipes de desenvolvimento a adotar uma linguagem específica de domínio, metraprogramação e técnicas de análise de programas. Todd é um membro ativo da comunidade de pesquisa em engenharia de software; sua pesquisa em especificação e verificação tem sido apresentada nas principais conferências internacionais, incluindo ICSE e OOPSLA.

Avalie esse artigo

Relevância
Estilo/Redação

Conteúdo educacional

BT