Java Generics: entendendo classes genéricas

Tony Augusto
6 min readSep 8, 2022

--

Todo desenvolvedor Java já teve ou terá contato com generics. A API de Collections por exemplo é fortemente baseada em seu uso, neste artigo busco demonstrar como criar suas próprias classes genéricas e consequentemente entender um pouco mais sobre aquelas que já utilizamos no dia-a-dia.

Como era antes da JDK 5

Todo(a) desenvolvedor(a) já se deparou em algum momento com um trecho de código que realiza uma ação que poderia ser reaproveitada em vários outros lugares, mas como fazer isso?

Até a JDK 5 a melhor opção seria utilizar Object como tipo referência para os parâmetros que se desejasse que fossem genéricos, já que na API do Java, toda classe é, direta ou indiretamente, filha de Object e por isso poderiam ser atribuídas a uma referência do tipo Object.

Com o código acima é possível contruir novas instâncias de BasicObject com parâmetros do tipo Integer, BigDecimal, BigInteger ou até String. Mas, sempre que for feita a atribuição do retorno de getObject a um tipo referência filho de Object, no caso abaixo BigDecimal, uma operação de downcasting explícita será necessária já que o compilador não consegue garantir que essa atribuição é segura.

// Funciona
BigDecimal anyBigDecimal = (BigDecimal) basicObject.getObject();

E qual o problema disso? Simples, segurança de tipo. Operações explícitas de downcasting podem facilmente levar a erros em tempo de execução. Por exemplo, suponha que em algum ponto do código ao invés de fazer o downcasting para BigDecimal, foi feito para BigInteger:

// Lança uma ClassCastException
BigInteger anyBigInteger = (BigInteger) basicObject.getObject();

O código acima lança uma ClassCastException, mas o mais preocupante é que tal erro não acontecerá durante a compilação e sim em tempo de execução, o que faz com que seja relativamente fácil esse problema chegar até o ambiente produtivo de uma aplicação.

Definindo classes genéricas

Para se criar uma classe genérica composta por um tipo parametrizado utlizamos a notação <T> onde o uso do caractere T, do ponto de vista do funcionamento, não é relevante, mas “por convenção, nomes de tipos parametrizados são compostos por uma letra em maiúsculo” (ORACLE). Mais detalhes sobre essa convenção podem ser consultados neste link.

De forma geral a sintaxe para criação de classes genéricas segue o seguinte padrão:

class-name<type-param-list>{}

Assim, podemos definir uma classe genérica com um tipo parametrizado dessa forma:

Exemplo de classe genérica com um tipo parametrizado

E para criar uma variável a partir de uma classe genérica:

BasicGeneric<Integer> basicGenericInt = new BasicGeneric<>();
BasicGeneric<String> basicGenericStr = new BasicGeneric<>();

Dessa forma, apesar da classe BasicGeneric<T> aceitar qualquer tipo referência como parâmetro, a partir do momento que for informado esse tipo, todos os tipos parametrizados T assumirão comportamento do tipo referência informado, incluindo a segurança de tipo que não temos usando Object.

E caso seja preciso criar uma classe genérica com mais de um tipo parametrizado, a sintaxe é a seguinte:

Exemplo de classe genérica com dois tipos parametrizados

Bounded Types ou Tipos Delimitadore em classes genéricas

Até agora usamos o tipo parametrizado que aceita qualquer tipo referência como argumento, mas existem situações onde é necessário restringir esse uso ou mesmo especificar um tipo base para que tenhamos acesso a métodos que estão definidos em classes ou interfaces específicas. Para fazermos isso usamos os chamados tipos delimitadores.

Cenário

Imagine que você precise criar um método genérico para calcular o salário de uma empresa, mas para determinado tipo de funcionário há o acréscimo de um adicional. Atualmente esse adicional é obtido a partir de um método chamado additional() que está definido na interface KeyPosition e somente funcionários que possuem o adicional implementam essa interface, algo assim:

Diagrama simplificado do cenário

Dado o diagrama, devemos criar uma classe genérica que tem a função de calcular os adicionais, mas um tipo <T> por padrão só contém métodos herdados de Object e additional() definitivamente não está entre eles.

public class AdditionalCalc<T> {
private final T employee;

public AdditionalCalc(T employee) {
this.employee = employee;
}

public void calcSalary() {
// Não compila:
employee.additional();
}
}

Como resolver esse problema?

Solução

Podemos utilizar um tipo delimitador como, por exemplo, a interface KeyPosition. Assim, somente classes que implementam essa interface serão aceitas como argumentos de tipo para a classe AdditionalCalc, o que nos dará acesso a todos os métodos que porventura existam na interface KeyPosition. O diagrama ficará assim:

E a classe genérica ficará assim:

public class AdditionalCalc<T extends KeyPosition> {
private final T employee;

public AdditionalCalc(T employee) {
this.employee = employee;
}

public void calcSalary() {
employee.additional();
}
}

Ainda sobre delimitadores…

Podemos utilizar mais de um delimitador, mas somente um delimitador pode ser uma classe e ele sempre deve ser o primeiro da lista logo após o extends. Os demais tipos delimitadores devem ser interfaces e separados por um &

public class MyGeneric<T extends MyClass & Interface1 & Interface2>

No caso acima, ao criar uma nova instância de MyGeneric o argumento T poderá ser a própria MyClass caso ela implemente Interface1 e Interface2 ou deverá ser uma classe derivada de MyClass e que implemente, obrigatoriamente, Interface1 e Interface2.

Type Erasure

De forma simples, type erasure é um processo onde o compilador Java remove todas as informações de tipos parametrizados dos Generics, isso é necessário principalmente para manter a compatibilidade entre códigos que usam Generics e códigos “não-genéricos”. Durante esse processo de remoção a segurança de tipo é garantida pelo compilador.

Type Erasure em classes genéricas sem tipos delimitadores

Um parâmetro de tipo genérico que não tenha delimitador será substituído por Object após a compilação. Observe a classe genérica antes de ser compilada:

public class ExampleGeneric<T> {
private final T genericParameter;

public ExampleGeneric(T genericParameter) {
this.genericParameter = genericParameter;
}

public T getGenericParameter() {
return genericParameter;
}
}

Agora, ao decompilar o código gerado, podemos observar que não há mais o <T> na assinatura de nome da classe e todas as referências para o tipo T foram substituídas por Object:

public class ExampleGeneric {
private final Object genericParameter;

public ExampleGeneric(Object genericParameter) {
this.genericParameter = genericParameter;
}

public Object getGenericParameter() {
return this.genericParameter;
}
}

Type Erasure em classes genéricas com tipos delimitadores

Em se tratando de classes genéricas que usam tipos delimitadores, o processo de erasure substituirá o tipo parametrizado pelo tipo do delimitador. Exemplo antes da compilação:

public class AdditionalCalc<T extends KeyPosition> {
private final T employee;

public AdditionalCalc(T employee) {
this.employee = employee;
}

public void calcSalary() {
employee.additional();
}
}

Após a compilação, diferente do cenário onde não há delimitador, o tipo parametrizado não é convertido para Object e sim para o próprio tipo do delimitador, que nesse caso é a interface KeyPosition:

public class AdditionalCalc {
private final KeyPosition employee;

public AdditionalCalc(KeyPosition employee) {
this.employee = employee;
}

public void calcSalary() {
this.employee.additional();
}
}

Nos próximos artigos trataremos sobre os métodos genéricos e suas caracterísicas.

Referências:

--

--

Tony Augusto

Desenvolvedor back-end Java e uma pessoa que acredita na propagação do conhecimento