Escreva uma Vez para Todo Tipo — Genéricos

Escreva uma Vez para Todo Tipo — Genéricos

Genéricos em Java: classes, records e métodos genéricos, o operador diamante, limites com extends Comparable, a restrição aos tipos de referência, autoboxing e suas três armadilhas, uma lista genérica construída do zero sobre um array de Object e os tipos crus.
Java

• • 17 min de leitura

Como escrever classes e métodos que funcionam com qualquer tipo sem abrir mão da verificação do compilador, e como construir a sua própria lista genérica.

A Fase 2 terminou com uma promessa: explicar os sinais de menor e maior que apareceram em HashSet<Ponto> e EnumMap<Dia, Integer>. Eles marcam os genéricos, e esta aula abre a Fase 3 com eles. Você vai ver o problema que os genéricos resolvem, escrever classes e métodos genéricos, aprender a restringir os tipos aceitos, entender como os tipos primitivos entram nessa história e, no fim, construir uma lista que cresce sozinha e funciona com qualquer tipo de elemento, uma versão simplificada de uma das classes mais usadas do Java.

O problema: código que serve para tudo e não protege nada

Suponha que você queira uma classe Caixa, capaz de guardar um objeto qualquer. Antes dos genéricos, a única forma de aceitar "qualquer coisa" era usar Object, a raiz de todos os tipos, de "Do Geral ao Específico":

class Caixa {
    private Object conteudo;

    void guardar(Object o) { conteudo = o; }
    Object abrir() { return conteudo; }
}
Caixa caixa = new Caixa();
caixa.guardar("um presente");
String presente = (String) caixa.abrir();
IO.println(presente.toUpperCase());        // UM PRESENTE

caixa.guardar(42);
String outro = (String) caixa.abrir();     // compila...
Exception in thread "main" java.lang.ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String

A classe aceita qualquer coisa, e por isso não garante nada. Quem abre a caixa precisa lembrar o que guardou e fazer um cast, e um erro só aparece na execução, possivelmente muito longe de onde o objeto errado entrou. É exatamente o tipo de erro que a tipagem estática de "Cada Valor em sua Caixa" deveria evitar. Até o Java 5, de 2004, todas as coleções da biblioteca funcionavam assim.

Classes genéricas

Um parâmetro de tipo transforma a classe num molde que recebe um tipo como argumento:

class Caixa<T> {
    private T conteudo;

    void guardar(T conteudo) {
        this.conteudo = conteudo;
    }

    T abrir() {
        return conteudo;
    }
}

T é uma variável que representa um tipo. Dentro da classe, ele é usado como qualquer outro tipo. O valor de T é escolhido por quem usa a classe:

Caixa<String> caixa = new Caixa<>();
caixa.guardar("um presente");
String presente = caixa.abrir();           // sem cast
IO.println(presente.toUpperCase());        // UM PRESENTE

Caixa<Integer> numeros = new Caixa<>();
numeros.guardar(42);
int n = numeros.abrir();
IO.println(n + 1);                         // 43

Caixa<String> é uma caixa que só aceita textos. O cast desapareceu, e o erro que antes aparecia na execução agora é pego pelo compilador:

Erro.java:8: error: incompatible types: int cannot be converted to String
    caixa.guardar(42);
                  ^

O <> vazio do lado direito é o operador diamante: o compilador deduz o tipo pela declaração à esquerda, e não é preciso repetir new Caixa<String>().

Por convenção, os parâmetros de tipo têm nomes de uma letra maiúscula, o que os distingue de nomes de classe: T para um tipo qualquer, E para elementos de coleções, K e V para chaves e valores de mapas, e R para tipos de retorno. Uma classe pode ter vários parâmetros. Records também podem ser genéricos:

record Par<A, B>(A primeiro, B segundo) { }
Par<String, Integer> idade = new Par<>("Ana", 30);
IO.println(idade);                                           // Par[primeiro=Ana, segundo=30]
IO.println(idade.primeiro().length() + idade.segundo());     // 33

O compilador sabe que primeiro() devolve um String, e por isso permite chamar length(). E sabe que segundo() devolve um Integer, e por isso permite a soma. Um Par é útil, por exemplo, para um método que precisa devolver dois valores:

Par<Integer, Integer> minMax(int[] valores) {
    int min = valores[0];
    int max = valores[0];
    for (int v : valores) {
        min = Math.min(min, v);
        max = Math.max(max, v);
    }
    return new Par<>(min, max);
}
var extremos = minMax(new int[] {7, 3, 9, 1});
IO.println("mínimo " + extremos.primeiro() + ", máximo " + extremos.segundo());
// mínimo 1, máximo 9

Um aviso de projeto: um Par genérico é conveniente, mas nomes como primeiro e segundo não dizem nada. Num código real, um record próprio, como record Extremos(int minimo, int maximo), custa uma linha e é muito mais claro. Os genéricos são para código que realmente não se importa com o que está guardando, como caixas, listas e mapas.

Métodos genéricos

Um método também pode ter parâmetros de tipo próprios, declarados antes do tipo de retorno:

<T> T primeiroOuPadrao(T[] valores, T padrao) {
    return valores.length > 0 ? valores[0] : padrao;
}
String[] nomes = {"Bia", "Caio"};
IO.println(primeiroOuPadrao(nomes, "ninguém"));          // Bia
IO.println(primeiroOuPadrao(new String[0], "ninguém"));  // ninguém

Na chamada, o compilador deduz T a partir dos argumentos. Essa é a inferência de tipos. O método é escrito uma vez e funciona com arrays de qualquer tipo de objeto, sempre com a garantia de que o valor devolvido tem o tipo dos elementos. Um record ou uma classe também podem ter métodos genéricos com parâmetros próprios, independentes dos da classe:

record Par<A, B>(A primeiro, B segundo) {
    <C> Par<A, C> comSegundo(C novo) {
        return new Par<>(primeiro, novo);
    }
}

idade.comSegundo(true) devolve um Par<String, Boolean>.

Limitando o tipo: bounded types

Às vezes o método precisa de algo do tipo além de "ser um objeto". Para encontrar o maior elemento de um array, é preciso saber comparar os elementos. A biblioteca tem uma interface para isso, Comparable<T>, a interface "sei me comparar" mencionada em "Contratos sem Implementação". O método compareTo devolve um número negativo, zero ou positivo, conforme o objeto seja menor, igual ou maior que o outro:

IO.println("pera".compareTo("uva"));              // -5: "pera" vem antes
IO.println(Integer.valueOf(3).compareTo(17));     // -1: 3 é menor

Um limite superior (upper bound) restringe os tipos aceitos por T:

<T extends Comparable<T>> T maior(T[] valores) {
    T resultado = valores[0];
    for (T v : valores) {
        if (v.compareTo(resultado) > 0) {
            resultado = v;
        }
    }
    return resultado;
}
IO.println(maior(new Integer[] {3, 17, 8}));                  // 17
IO.println(maior(new String[] {"pera", "uva", "abacaxi"}));   // uva

<T extends Comparable<T>> se lê: "T é qualquer tipo que saiba se comparar com outro T". Dentro do método, o compilador permite chamar compareTo porque todo T o tem. Fora, ele recusa tipos que não se comparam: um array de Object dá o erro inference variable T has incompatible bounds. Repare que, nos limites, extends vale tanto para classes quanto para interfaces. Um limite com uma classe funciona do mesmo jeito: <T extends Number> aceita Integer, Double e os demais números, e permite chamar doubleValue():

<T extends Number> double somar(T[] valores) {
    double total = 0;
    for (T v : valores) {
        total += v.doubleValue();
    }
    return total;
}

A comparação de objetos, com Comparable e com a sua irmã Comparator, tem uma aula dedicada, "Ordem e Critério".

Genéricos e tipos primitivos

Há uma restrição importante: parâmetros de tipo só aceitam tipos de referência. Caixa<int> não compila:

Prim.java:3: error: unexpected type
    Caixa<int> c = new Caixa<>();
          ^
  required: reference

O motivo está na próxima aula: por baixo, um T é tratado como Object, e um int não é um objeto. Para cada tipo primitivo existe uma classe embrulho (wrapper) correspondente: Integer para int, Long para long, Double para double, Character para char, Boolean para boolean, e assim por diante. Você já conhece algumas delas pelos métodos estáticos Integer.parseInt e Double.parseDouble.

A conversão entre primitivo e embrulho é automática. De primitivo para objeto é o autoboxing, e de objeto para primitivo é o unboxing:

Integer embrulhado = 5;           // autoboxing: int -> Integer
int desembrulhado = embrulhado;   // unboxing: Integer -> int

Foi o autoboxing que permitiu Object numero = 42 em "Do Geral ao Específico" e Caixa<Integer> acima. A conversão é conveniente, mas não é gratuita, e esconde três armadilhas.

A identidade. Um Integer é um objeto, e == compara referências. O cache de −128 a 127 de "Iguais ou o Mesmo?" faz o == funcionar com números pequenos e falhar com números maiores. Com embrulhos, use sempre equals.

O unboxing de null. Um Integer pode ser null. Um int não pode. Desembrulhar um null lança exceção, numa linha que parece não ter nenhum objeto:

Integer nada = null;
int x = nada;
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "java.lang.Integer.intValue()" because "<local3>" is null

A mensagem revela o que o compilador escreveu por baixo: uma chamada a intValue().

O custo. Cada autoboxing de um valor fora do cache cria um objeto no heap. Num laço intenso, a diferença é grande:

long soma = 0;
for (int i = 0; i < 100_000_000; i++) soma += i;       // primitivo

Long somaObj = 0L;
for (int i = 0; i < 100_000_000; i++) somaObj += i;    // embrulho

Na máquina em que este artigo foi testado, a versão primitiva levou cerca de 50 ms, e a versão com Long levou quase 700 ms, mais de dez vezes mais. Cada somaObj += i desembrulha, soma e embrulha um objeto novo. Use primitivos em cálculos e embrulhos só onde objetos são necessários, como dentro de coleções genéricas. O Projeto Valhalla, em desenvolvimento no OpenJDK, trabalha para reduzir esse custo no futuro, e você vai ouvir falar dele em "O Preço da Compatibilidade".

Construindo uma lista genérica

Agora, o exemplo principal. Em "O que Fica Dentro, o que Fica Fora", a ContaBancaria guardava os movimentos num array que dobrava de tamanho quando enchia. Em "Contratos sem Implementação", o NotificadorRegistrador repetiu o mesmo código para textos. Com genéricos, dá para escrever essa lógica uma vez, para qualquer tipo:

import java.util.Arrays;

public class Lista<E> {
    private Object[] elementos = new Object[4];
    private int tamanho = 0;

    public void adicionar(E elemento) {
        if (tamanho == elementos.length) {
            elementos = Arrays.copyOf(elementos, elementos.length * 2);
        }
        elementos[tamanho++] = elemento;
    }

    @SuppressWarnings("unchecked")
    public E obter(int indice) {
        if (indice < 0 || indice >= tamanho) {
            throw new IndexOutOfBoundsException("Índice " + indice + ", tamanho " + tamanho);
        }
        return (E) elementos[indice];
    }

    public int tamanho() {
        return tamanho;
    }

    public boolean contem(E procurado) {
        for (int i = 0; i < tamanho; i++) {
            if (elementos[i].equals(procurado)) {
                return true;
            }
        }
        return false;
    }

    @Override
    public String toString() {
        return Arrays.toString(Arrays.copyOf(elementos, tamanho));
    }
}

Alguns detalhes:

  • O array interno é Object[], e não E[]. Não é possível criar um array de um tipo genérico com new E[4], por uma razão que a próxima aula explica. O array guarda Object, e a classe garante que só entram elementos do tipo E, porque adicionar só aceita E.
  • O cast em obter é seguro por essa razão, mas o compilador não consegue provar isso e emitiria um aviso de "unchecked cast". A anotação @SuppressWarnings("unchecked") silencia o aviso. Ela deve ficar no menor escopo possível, aqui só no método, e deve ser usada só quando você tem certeza de que o cast é seguro.
  • A validação do índice usa a exceção padrão da biblioteca para esse caso, IndexOutOfBoundsException, com uma mensagem clara.
  • contem usa equals, e não ==, pela lição de "Iguais ou o Mesmo?". Por isso, procurar um Ponto com as mesmas coordenadas funciona, desde que o Ponto tenha equals.
public class TestaLista {
    public static void main(String[] args) {
        Lista<String> nomes = new Lista<>();
        for (String n : new String[] {"Ana", "Bia", "Caio", "Davi", "Eva"}) {
            nomes.adicionar(n);
        }
        IO.println(nomes + " tamanho " + nomes.tamanho());
        IO.println(nomes.obter(4).toUpperCase());
        IO.println(nomes.contem("Caio") + " " + nomes.contem("Zeca"));

        Lista<Integer> quadrados = new Lista<>();
        for (int i = 1; i <= 5; i++) {
            quadrados.adicionar(i * i);
        }
        int soma = 0;
        for (int i = 0; i < quadrados.tamanho(); i++) {
            soma += quadrados.obter(i);
        }
        IO.println(quadrados + " soma " + soma);
    }
}
[Ana, Bia, Caio, Davi, Eva] tamanho 5
EVA
true false
[1, 4, 9, 16, 25] soma 55

O quinto nome passou da capacidade inicial de 4, e o array interno dobrou sem que ninguém de fora percebesse. nomes.obter(4).toUpperCase() compila sem cast, porque o compilador sabe que obter devolve String numa Lista<String>. Na lista de inteiros, i * i é embrulhado automaticamente ao entrar, e soma += quadrados.obter(i) é desembrulhado ao sair. nomes.obter(5) lança IndexOutOfBoundsException: Índice 5, tamanho 5.

Você acabou de escrever uma versão simplificada do ArrayList, a lista mais usada da biblioteca padrão. Ela é, essencialmente, isto: um array de Object que cresce, com uma interface genérica por cima. A versão real tem muito mais métodos e cuidados, mas o princípio é o mesmo.

Tipos crus: o legado de 2004

Por compatibilidade com o código anterior ao Java 5, ainda é possível usar uma classe genérica sem o parâmetro de tipo, como ArrayList lista = new ArrayList();. Isso é um tipo cru (raw type), que desliga a verificação de tipos e volta ao comportamento da Caixa com Object. O compilador avisa:

Note: Raw.java uses unchecked or unsafe operations.
Note: Recompile with -Xlint:unchecked for details.

Tipos crus existem só para que o código antigo continue compilando. Nunca os use em código novo. E não ignore esse aviso quando ele aparecer: ele significa que algum ponto do programa pode ter um ClassCastException escondido.

Os genéricos parecem simples na superfície, mas escondem duas perguntas incômodas. A primeira: se Integer é um Number, uma Lista<Integer> é uma Lista<Number>? A intuição diz que sim, e o compilador diz que não. A segunda: por que não é possível criar um new E[4], se E é um tipo como outro qualquer? As duas respostas vêm de uma decisão tomada em 2004 para que o código antigo continuasse funcionando.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Na versão da Caixa com Object, o erro aparecia na linha do cast, durante a execução. Na versão genérica, onde e quando o mesmo erro aparece? Por que essa mudança de lugar é tão valiosa?

Ver resposta

✓ Resposta: Na versão genérica, o erro aparece na linha caixa.guardar(42), e na compilação: o compilador recusa guardar um Integer numa Caixa<String>. Na versão com Object, o erro aparecia só na execução, na linha que abre a caixa, e só se aquele caminho do programa fosse executado. O valor disso está em duas coisas. A primeira é o momento: um erro de compilação aparece para o programador, antes do programa existir, e não para o usuário, em produção. A segunda é o lugar: o erro aponta onde o objeto errado entrou, que é onde está o defeito, e não onde ele foi usado, que pode ser em outro arquivo, dias depois. Encontrar a origem de um ClassCastException exige descobrir quem colocou o objeto errado ali, e o genérico elimina essa investigação.

Exercício 2

Escreva um método genérico <T> void trocar(T[] valores, int i, int j) que troque dois elementos de um array de posição. Teste com um array de textos e outro de inteiros. Por que o método pode ser void?

Ver resposta

✓ Resposta:

<T> void trocar(T[] valores, int i, int j) {
    T temp = valores[i];
    valores[i] = valores[j];
    valores[j] = temp;
}

Com String[] s = {"a", "b", "c"}, trocar(s, 0, 2) deixa [c, b, a]. Com Integer[] n = {1, 2, 3}, trocar(n, 0, 1) deixa [2, 1, 3]. A variável temporária é do tipo T, e o compilador garante que só elementos do mesmo tipo são trocados entre si. O método pode ser void pela razão de "Muitos Valores sob um Nome": ele recebe uma cópia da referência que aponta para o mesmo array de quem chamou, e as trocas acontecem nesse array. Não funciona com int[], porque T só aceita tipos de referência, e seria preciso um array Integer[] ou uma versão específica para int. É por isso que a classe Arrays tem versões de sort e fill para cada tipo primitivo.

Exercício 3

Escreva um método <T extends Comparable<T>> boolean estaOrdenado(T[] valores) que diga se o array está em ordem crescente. Teste com {1, 3, 3, 7}, {5, 2} e {"ana", "bia", "caio"}.

Ver resposta

✓ Resposta:

<T extends Comparable<T>> boolean estaOrdenado(T[] valores) {
    for (int i = 1; i < valores.length; i++) {
        if (valores[i - 1].compareTo(valores[i]) > 0) {
            return false;
        }
    }
    return true;
}

Os resultados são true, false e true. O laço compara cada elemento com o anterior e devolve false no primeiro par fora de ordem, com retorno antecipado. Elementos iguais, como o 3 repetido, não violam a ordem, porque o teste é > 0, e não >= 0. Arrays vazios ou de um elemento estão ordenados por definição: o laço não executa nenhuma vez e o método devolve true. O limite extends Comparable<T> é o que permite chamar compareTo. Sem ele, o compilador não saberia que os elementos se comparam.

Exercício 4

O método contem da Lista usa elementos[i].equals(procurado). Explique o que aconteceria numa Lista<Integer> se ele usasse ==, e o que acontece hoje se a lista contiver um elemento null.

Ver resposta

✓ Resposta: Com ==, a comparação seria de referências. Numa Lista<Integer>, contem(100) funcionaria por coincidência, porque os valores entre −128 e 127 vêm do cache e o objeto guardado e o procurado seriam o mesmo. contem(1000) devolveria false mesmo com 1000 na lista, porque o autoboxing criaria objetos diferentes. É a armadilha de "Iguais ou o Mesmo?". Com equals, o problema não existe. Quanto ao null: se a lista contiver um elemento null, a chamada elementos[i].equals(...) sobre ele lança NullPointerException. A correção é usar Objects.equals(elementos[i], procurado), que trata null dos dois lados com segurança e ainda permite procurar o próprio null. É o que o ArrayList real faz.

Exercício 5

Acrescente à Lista um método E remover(int indice) que remova o elemento da posição, desloque os seguintes uma posição para trás e devolva o elemento removido. Por que é importante atribuir null à última posição ocupada depois do deslocamento?

Ver resposta

✓ Resposta:

@SuppressWarnings("unchecked")
public E remover(int indice) {
    E removido = obter(indice);    // reaproveita a validação do índice
    for (int i = indice; i < tamanho - 1; i++) {
        elementos[i] = elementos[i + 1];
    }
    elementos[--tamanho] = null;
    return removido;
}

Numa lista [Ana, Bia, Caio], remover(0) devolve "Ana" e deixa [Bia, Caio]. O null na última posição é importante por causa do coletor de lixo. Depois do deslocamento, a última posição ocupada ainda guarda uma referência ao último elemento, que agora também está uma posição antes. A lista nunca mais vai ler essa posição, porque tamanho diminuiu, mas o array continua segurando a referência. Se o elemento removido fosse o último da lista, ele seria um objeto que ninguém mais usa, e que o coletor não pode recolher, porque ainda existe uma referência a ele. Numa lista que recebe e remove muitos objetos grandes, isso vira um vazamento de memória. Essas referências esquecidas são um dos poucos jeitos de vazar memória numa linguagem com coletor de lixo, e o tema volta em "Quem Recolhe o Lixo". Repare também no reaproveitamento de obter: a validação do índice fica num lugar só.

Comentários

Mais em Java

O Projeto Final Começa — Arquitetando um Servidor HTTP sobre Sockets
O Projeto Final Começa — Arquitetando um Servidor HTTP sobre Sockets

Começa o projeto final: o que trafega entre navegador e servidor no HTTP/1.1…

Uma Linguagem para Torradeiras que Conquistou o Mundo — a História do Java
Uma Linguagem para Torradeiras que Conquistou o Mundo — a História do Java

Como um projeto da Sun para eletrodomésticos fracassou, foi salvo pela web e…

A Máquina por Trás da Máquina — Class Loading, Pilha, Heap e JIT
A Máquina por Trás da Máquina — Class Loading, Pilha, Heap e JIT

O que a JVM faz com o seu programa: class loaders e delegação, a inicialização…