Sabemos que a linguagem Java introduziu, a milhões de programadores, a arquitetura de máquina virtual, um modelo de objetos forte, o garbage collection e a programação concorrente. Mas há outro item importante e menos reconhecido também popularizado pelo Java: a metaprogramação. Neste artigo vamos examinar recursos que, apesar de disponíveis há um bom tempo na plataforma, ainda são pouco explorados por muitos desenvolvedores.

Metaprogramação é uma daquelas coisas que parecem mais complicadas do que são. Muita gente tem receio de mexer com recursos muito dinâmicos, que lembram mais a construção de compiladores do que de aplicações comuns. Mas tudo é questão de prática, até de hábito. Por exemplo, ao construir comandos SQL no código você está criando parte da sua aplicação dinamicamente. Só estamos mais acostumados a fazer isso com SQL do que com Java.

Vamos também mostrar um panorama geral do tópico de metaprogramação. Isso inclui as APIs tradicionais em java.lang.reflection, mas vai além, explorando as possibilidades mais recentes e avançadas do Java, como proxies dinâmicas, além de mostrar coisas que vêm por aí.

Histórico e um pouco de teoria

Programas são dados. Esse axioma da metaprogramação de fato é verdadeiro desde a introdução do conceito de “programa armazenado”. A idéia é atribuída a John Von Neumann (1946), por isso computadores modernos são chamados “máquinas de Von Neumann”, com sua arquitetura abstrata composta por CPU e memória, e o programa armazenado na memória principal da mesma forma que os dados. Essa arquitetura demorou para se tornar predominante, pois o preço da memória era muito alto nos anos 40-50; por isso os programas eram mais freqüentemente lidos instrução por instrução (a partir de cartões perfurados ou fitas magnéticas). Isso acontecia à medida que iam sendo executados, e o computador só mantinha na memória principal uma instrução por vez, ou, no máximo, um cache minúsculo para agilizar loops curtos.

Se os programas são dados e são representados na memória como uma seqüência de bytes, um passo lógico é querer manipulá-los da mesma forma como fazemos com outros dados. Mas um código executável normal (compilado) é muito complexo para ser manipulado corriqueiramente; o hardware antigo não suportaria arquiteturas complexas como nossas JVMs. Interpretar código fonte ou compilá-lo era muito demorado – programadores costumavam iniciar compilações à noite e pegar o resultado somente no dia seguinte, isso para programas de poucos Kb.

A solução exigida era uma linguagem e um runtime planejados para funcionamento dinâmico desde o começo. Essa solução surgiu em 1960 com o Lisp. Além de inovar com as VMs, garbage collection e a programação funcional, o sistema Lisp passou a representar o programa compilado num formato que era um meio-termo entre o código binário da CPU (executável diretamente) e o ASCII do código fonte (mais fácil de manipular). Esse formato intermediário é uma estrutura de dados muito eficiente, que permite um desempenho razoável mesmo por um interpretador. Veja um exemplo na Listagem 1.

O aspecto esquisito do código (para nós) deve-se a essa representação do programa “executável”. A compilação transforma o programa numa “lista de listas” (daí o nome da linguagem, que vem de List Processing – processamento de listas).

A lista-raiz do corpo da função fatorial tem como primeiro elemento o cond (condicional): uma referência para a função que faz o papel do if ou switch. O segundo elemento dessa lista-raiz é uma lista de cláusulas para o cond. Cada cláusula é outra lista, com a estrutura "(condição resultado)". Na primeira cláusula a condição é outra lista, que executa a função "=" com os argumentos (n 0), e o resultado é 1. Na segunda cláusula, que só é executada se as condições anteriores falharem, a condição é o valor t (true), portanto a cláusula sempre executa se o processamento chegar até ela. O resultado é outra lista, com uma expressão mais complexa, envolvendo listas aninhadas.

Você pode imaginar que, com uma representação tão simples do programa executável (listas aninhadas onde cada elemento é uma função, um valor ou outra lista que também pode ser executável) pode ser construída dinamicamente pelo programa de forma muito fácil e eficiente. Não temos espaço para técnicas avançadas em Lisp – nem estamos na Lisp Magazine! –, mas um exemplo modesto já ilustra as possibilidades dessa idéia (veja a Listagem 2).

A “meta-função” ind-f implementa qualquer função recursiva que gera um resultado a partir de computações para uma seqüência 0,1,2,...,N, daí o nome ind-f (do “método indutivo” de prova de teoremas). Observe que um dos parâmetros adicionais dessa meta-função (n0) é um valor comum, mas o outro argumento (op) é outra função. Se você editar o código de ind-f, substituindo todas as ocorrências de n0 por 1 e todas ocorrências de op por "*", o resultado será igual ao fatorial do primeiro exemplo. Por isso nossa nova definição de fatorial funciona. E podemos fazer outras definições, como a função somatoria que implementa um algoritmo diferente.

A metaprogramação nos permite reutilizar a estrutura de um algoritmo: várias funções que têm o mesmo “jeitão”, mas variando apenas em algumas operações, podem ser implementadas uma única vez e reutilizadas.

Seria possível fazer um programa semelhante em Java usando reflection, conforme a Listagem 3. Da mesma forma que em Lisp, o Java permite passar métodos como parâmetros. O código Java é um pouco mais confuso, mesmo tendo sido ignorado o tratamento de exceções. É que a linguagem faz uma forte distinção entre programação e metaprogramação, e não inclui uma sintaxe especial para esta última, obrigando-nos a usar APIs como getMethod() e invoke(), em vez de permitir a manipulação direta de elementos do programa, como classes e métodos. Essa distinção é importante porque o Java não “mascara” as peculiaridades de reflection (veja o quadro "Linguagens dinâmicas versus estáticas").

Poderíamos também tentar contornar a metaprogramação, e buscar uma solução alternativa em Java, como a mostrada na Listagem 4. Esse código também é relativamente longo, dessa vez mais por causa da verificação estática de tipos de Java (que exige uma interface como BinOp criada no exemplo). E também por causa da sintaxe um pouco pesada das inner classes. Mas o código é menos confuso que a versão com reflection. É também mais robusto, pois elimina a possibilidade de exceções.

O estilo de programação usado na Listagem 4 é comum em algumas APIs Java. Por exemplo, Collections.sort(List, Comparator) também exige uma função passada como parâmetro: Comparator.compare(Object, Object).

Mesmo em casos simples, como no uso de inner classes para tratamento de eventos do Swing/AWT, pode-se dizer que o código fornecido pelas inner classes customiza a implementação dos componentes, “injetando” código que será executado quando os eventos acontecerem. Mas isso não é realmente metaprogramação, pois quando adicionamos um listener de eventos a um componente, modificamos apenas uma instância dele (por exemplo, um botão específico) mas não a definição do componente em si (no caso, a própria classe JButton). E se os listeners fossem static, afetando o comportamento de todas as instâncias? Para responder isso, vamos precisar investigar mais a fundo esse conceito de metaprogramação, o que faremos adiante.

Objetos e metaprogramação

Lisp é uma linguagem funcional, mas logo no final dos anos 70 surgiu o Smalltalk, que, além de inventar a Orientação a Objetos como a conhecemos hoje, incluiu uma meta-arquitetura avançada; a ideia radical de que “tudo é um objeto” aplica-se ao próprio programa e a todas as operações executadas por ele. Se todas as coisas são objetos, isso deve incluir também as classes. Mas se uma classe é representada por um objeto, enquanto objeto será também instância de outra classe. Explicando: em Java, Class c = “string qualquer”.getClass() retorna uma instância de java.lang.Class, sendo que a própria classe (c) é um objeto (c.getName() retorna “java.lang.String”), e a “classe da classe”, obtida com Class cc = c.getClass(), também é uma java.lang.Class (cc.getName() retorna “java.lang.Class”). Isso pode parecer óbvio, mas em Java é simples porque só há um tipo de classe.

O prefixo “meta” designa relações reflexivas, portanto a classe de uma classe (nosso cc no exemplo anterior), numa linguagem como Smalltalk, não é uma classe comum, e sim uma metaclasse. Metaclasses são especiais porque definem a estrutura da linguagem. Por exemplo, se a linguagem tem herança simples, então toda classe possui um atributo que aponta para a sua superclasse. Isso é refletido na metaclasse, que deve definir esse atributo, seu getter e talvez outros métodos – por exemplo, isSubclass(outraClasse).

Na linguagem Java, todas as classes são instâncias de java.lang.Class, que combina os papéis de classe e metaclasse numa única entidade. Uma das conseqüências disso é que não há polimorfismo para os métodos static de Java, embora isso seja possível em Smalltalk, cuja estrutura de metaclasses cria uma hierarquia de herança paralela à das classes. Se Java fosse como Smalltalk, e se: Cliente extends Pessoa, cc = Client.class.getClass(), pc = Pessoa.class.getClass(), então cc e pc ...

Quer ler esse conteúdo completo? Tenha acesso completo