A maioria das minhas entrevistas em posições técnicas tem uma tarefa na qual o candidato precisa implementar 2 interfaces muito semelhantes na mesma classe:
Implemente as duas interfaces em uma classe, se possível. Explique por que isso é possível ou não.
interface WithPrimitiveInt { void m(int i); } interface WithInteger { void m(Integer i); }
De um tradutor: este artigo não incentiva você a fazer as mesmas perguntas em uma entrevista. Mas se você quiser estar totalmente preparado quando essa pergunta for feita, seja bem-vindo ao gato.
Às vezes, os candidatos que não têm muita certeza da resposta, preferem resolver esse problema com a seguinte condição (posteriormente, em qualquer caso, peço que você o resolva):
interface S { String m(int i); } interface V { void m(int i); }
De fato, a segunda tarefa parece muito mais simples, e a maioria dos candidatos responde que é impossível incluir os dois métodos na mesma classe, porque as assinaturas Sm(int)
e Vm(int)
mesmas, enquanto o tipo do valor de retorno é diferente. E isso é absolutamente verdade.
No entanto, às vezes faço outra pergunta relacionada a este tópico:
Você acha que faz sentido permitir a implementação de métodos com a mesma assinatura, mas com tipos diferentes na mesma classe? Por exemplo, em alguma linguagem hipotética baseada na JVM, ou pelo menos no nível da JVM?
Esta é uma pergunta cuja resposta é ambígua. Mas, apesar de não esperar uma resposta, a resposta correta existe. Uma pessoa que lida com a API de reflexão, manipula o bytecode ou está familiarizada com a especificação da JVM pode respondê-la.
Assinatura do método Java e identificador do método JVM
A assinatura do método Java (ou seja, o nome do método e os tipos de parâmetros) é usada apenas pelo compilador Java no momento da compilação. Por sua vez, a JVM separa os métodos na classe usando um nome de método não qualificado (ou seja, apenas um nome de método) e um identificador de método , ou seja, uma lista de parâmetros do descritor e um descritor de retorno.
Por exemplo, se quisermos chamar o método String m(int i)
diretamente na classe foo.Bar
, o seguinte bytecode será necessário:
INVOKEVIRTUAL foo/Bar.m (I)Ljava/lang/String;
e para void m(int i)
seguinte:
INVOKEVIRTUAL foo/Bar.m (I)V
Portanto, a JVM é bastante confortável com as String m(int i)
e void m(int i)
da mesma classe. Tudo o que é necessário é gerar o bytecode correspondente.
Kung fu com bytecode
Temos interfaces S e V, agora criaremos uma classe SV que inclui as duas interfaces. Em Java, se fosse permitido, deveria ficar assim:
public class SV implements S, V { public void m(int i) { System.out.println("void m(int i)"); } public String m(int i) { System.out.println("String m(int i)"); return null; } }
Para gerar o bytecode, usamos a biblioteca Objectweb ASM , uma biblioteca de nível inferior o suficiente para ter uma idéia da JVM do bytecode.
O código fonte completo é carregado no GitHub, aqui darei e explicarei apenas os fragmentos mais importantes.
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES);
Vamos começar criando um ClassWriter
para gerar bytecode.
Agora vamos declarar uma classe que inclui as interfaces S e V.
Embora nosso código pseudo-java de referência para SV não tenha construtores, ainda precisamos gerar código para ele. Se não descrevermos construtores em Java, o compilador gera implicitamente um construtor vazio.
No corpo dos métodos, começamos obtendo o campo System.out
do tipo java.io.PrintStream
e adicionando-o à pilha de operandos. Em seguida, carregamos a constante ( String
ou void
) na pilha e chamamos o comando println
na variável resultante com uma constante de string como argumento.
Finalmente, para a String m(int i)
adicionamos a constante do tipo de referência com o valor null
à pilha e usamos a return
tipo correspondente, ou ARETURN
, ARETURN
, para retornar o valor ao iniciador da chamada do método. Para void m(int i)
você precisa usar RETURN
apenas para retornar ao iniciador da chamada do método sem retornar um valor. Para garantir que o bytecode esteja correto (o que eu faço constantemente, corrigindo erros muitas vezes), escrevemos a classe gerada no disco.
Files.write(new File("/tmp/SV.class").toPath(), cw.toByteArray());
e use jad
(descompilador Java) para converter o bytecode de volta no código-fonte Java:
$ jad -p /tmp/SV.class The class file version is 51.0 (only 45.3, 46.0 and 47.0 are supported) // Decompiled by Jad v1.5.8e. Copyright 2001 Pavel Kouznetsov. // Jad home page: http://www.geocities.com/kpdus/jad.html // Decompiler options: packimports(3) package edio.java.experiments; import java.io.PrintStream;
Nada mal na minha opinião.
Usando a classe gerada
A descompilação bem-sucedida do jad
basicamente não garante nada para nós. O utilitário jad
alerta apenas para problemas comuns no código de bytes, do tamanho do quadro às incompatibilidades de variáveis locais ou declarações de retorno ausentes.
Para usar a classe gerada em tempo de execução, precisamos carregá-la de alguma forma na JVM e instancia-la.
Vamos implementar nosso próprio AsmClassLoader
. Este é apenas um invólucro útil para ClassLoader.defineClass
:
public class AsmClassLoader extends ClassLoader { public Class defineAsmClass(String name, ClassWriter classWriter) { byte[] bytes = classWriter.toByteArray(); return defineClass(name, bytes, 0, bytes.length); } }
Agora use este carregador de classes e instancie a classe:
ClassWriter cw = SVGenerator.generateClass(); AsmClassLoader classLoader = new AsmClassLoader(); Class<?> generatedClazz = classLoader.defineAsmClass(SVGenerator.SV_FQCN, cw); Object o = generatedClazz.newInstance();
Como nossa classe é gerada em tempo de execução, não podemos usá-la no código fonte. Mas podemos converter esse tipo para interfaces implementadas. Uma chamada sem reflexão pode ser feita assim:
((S)o).m(1); ((V)o).m(1);
Ao executar o código, obtemos a seguinte saída:
String void
Para alguns, essa conclusão pode parecer inesperada: nos referimos ao mesmo método (do ponto de vista Java) na classe, mas os resultados diferem dependendo da interface para a qual trouxemos o objeto. Impressionante, certo?
Tudo ficará claro se levarmos em conta o bytecode subjacente. Para nossa chamada, o compilador gera uma instrução INVOKEINTERFACE, e o identificador do método não vem da classe, mas da interface.
Assim, a primeira chamada que recebemos:
INVOKEINTERFACE edio/java/experiments/Sm (I)Ljava/lang/String
e no segundo:
INVOKEINTERFACE edio/java/experiments/Vm (I)V
O objeto no qual fizemos a chamada pode ser obtido da pilha. Este é o poder do polimorfismo inerente ao Java.
O nome dele é o método bridge
Alguém perguntará: "Então, qual é o sentido de tudo isso? Será que algum dia será útil?"
O ponto é que usamos a mesma coisa (implicitamente) ao escrever código Java regular. Por exemplo, tipos de retorno covariantes, genéricos e acesso a campos particulares de classes internas são implementados usando a mesma mágica do bytecode.
Dê uma olhada nesta interface:
public interface ZeroProvider { Number getZero(); }
e sua implementação com o retorno do tipo covariante:
public class IntegerZero implements ZeroProvider { public Integer getZero() { return 0; } }
Agora vamos pensar sobre este código:
IntegerZero iz = new IntegerZero(); iz.getZero(); ZeroProvider zp = iz; zp.getZero();
Para iz.getZero()
, o compilador de chamadas gerará INVOKEVIRTUAL
com o ()Ljava/lang/Integer;
descritor ()Ljava/lang/Integer;
, enquanto que para zp.getZero()
ele gerará INVOKEINTERFACE com o descritor de método ()Ljava/lang/Number;
. Já sabemos que a JVM despacha uma chamada de objeto usando o descritor de nome e método. Como os descritores são diferentes, essas 2 chamadas não podem ser roteadas para o mesmo método em uma instância IntegerZero
.
De fato, o compilador gera um método adicional que atua como uma ponte entre o método real especificado na classe e o método usado ao chamar pela interface. Portanto, o nome é o método da ponte. Se isso fosse possível em Java, o código final ficaria assim:
public class IntegerZero implements ZeroProvider { public Integer getZero() { return 0; }
Posfácio
A linguagem de programação Java e a máquina virtual Java não são a mesma coisa: embora tenham uma palavra comum no nome e Java seja a linguagem principal da JVM, seus recursos e limitações estão longe de ser sempre os mesmos. Conhecer a JVM ajuda a entender melhor Java ou qualquer outra linguagem baseada em JVM, mas, por outro lado, conhecer Java e seu histórico ajuda a entender certas decisões no design da JVM.
Do tradutor
Os problemas de compatibilidade, mais cedo ou mais tarde, começam a preocupar qualquer desenvolvedor. O artigo original abordou a questão importante do comportamento implícito do compilador Java e o efeito de sua mágica nos aplicativos, que nós, como desenvolvedores da estrutura da Plataforma CUBA, nos preocupamos bastante, isso afeta diretamente a compatibilidade das bibliotecas. Mais recentemente, falamos sobre compatibilidade em aplicativos da vida real no JUG em Ecaterimburgo no relatório “APIs não mudam no cruzamento - como criar uma API estável”, o vídeo da reunião pode ser encontrado aqui.