Estruturas de dados: Generics

Computação II - Ciência da Computação


Prof.: Danilo S. Carvalho

Nessa aula, aprenderemos sobre um poderoso recurso de sintaxe da linguagem Java: os tipos genéricos.


Ao final da aula, saberemos como definir os nossos próprios tipos genéricos e usar aqueles já existentes na JDK.

Seguindo o conceito de polimorfismo, muitas vezes gostariamos de aplicar uma mesma operação em objetos de tipos diferentes.


Entretanto, nem sempre esses tipos fazem parte de uma mesma hierarquia de classes ou compartilham de uma mesma interface.


Isso é bastante comum quando queremos manipular os objetos em estruturas de dados comuns, como listas, filas e pilhas.

A pergunta que queremos responder é então:

Como criamos uma estrutura de dados comum (ex: lista) ou método (ex: somar) que opere com qualquer tipo definido?

A resposta está em um recurso da linguagem Java chamado Generics (tipos ou métodos genéricos).

Esse recurso nos permite parametrizar um método ou classe para ser aplicado a qualquer tipo, sendo o tipo o parâmetro.

Vejamos um exemplo:

Queremos criar um classe para representar um par ordenado de números.

Uma abordagem generalista razoável seria guardar cada um dos elementos do par em um campo do tipo double.

Dessa forma poderiamos guardar um número qualquer e depois converter conforme necessário para outros tipos numéricos.

Criamos então um construtor e getters para os campos do par.

                        
                            public class ParOrdenado {
                                ...
                            }
                        
                    
                        
                            public class ParOrdenado {
                                private double x;
                                private double y;
                            }
                        
                    
                        
                            public class ParOrdenado {
                                private double x;
                                private double y;

                                public ParOrdenado(double x, double y) {
                                    this.x = x;
                                    this.y = y;
                                }

                                public double getX() {
                                    return x;
                                }

                                public double getY() {
                                    return y;
                                }
                            }
                        
                    

Mas e se quisermos guardar um par ordenado de um tipo qualquer, não apenas de números?

Nesse caso, usamos o recurso de Generics da linguagem Java para definir nossa classe de par em função do tipo que queremos representar.

Isso é feito colocando um parâmetro de tipo após o nome da classe entre <...>.

Tipicamente usamos a letra T ou E maiúscula para representar o parâmetro de tipo.

Todas as outras definições de tipo na classe podem agora ser substituidas pelo parâmetro.

Agora para criar um par de Strings por exemplo, podemos declarar uma variável e instanciar um objeto do tipo ParOrdenado dessa forma:

                        
                            public class ParOrdenado {
                                private double x;
                                private double y;

                                public ParOrdenado(double x, double y) {
                                    this.x = x;
                                    this.y = y;
                                }

                                public double getX() {
                                    return x;
                                }

                                public double getY() {
                                    return y;
                                }
                            }
                        
                    
                        
                        public class ParOrdenado<T> {
                            private double x;
                            private double y;

                            public ParOrdenado(double x, double y) {
                                this.x = x;
                                this.y = y;
                            }

                            public double getX() {
                                return x;
                            }

                            public double getY() {
                                return y;
                            }
                        }
                        
                    
                        
                        public class ParOrdenado<T> {
                            private T x;
                            private T y;

                            public ParOrdenado(T x, T y) {
                                this.x = x;
                                this.y = y;
                            }

                            public T getX() {
                                return x;
                            }

                            public T getY() {
                                return y;
                            }
                        }
                        
                    

Agora para criar um par de Strings por exemplo, podemos declarar uma variável e instanciar um objeto do tipo ParOrdenado dessa forma:

Como podemos observar, o parâmetro agora faz parte do tipo que criamos.

Esse é um ParOrdenado<String>, que podemos ler como: "ParOrdenado de String".

Os métodos vão se comportar conforme o parâmetro de tipo que definimos.

                        
                            ParOrdenado<String> par = new ParOrdenado<String>("cachorro", "quente");
                        
                    
                        
                            // Vai imprimir "cachorro" na saída padrão.
                            System.out.println(par.getX()); 
                        
                    

Também é possível parametrizar em mais de um tipo.

Nesse exemplo, criamos um par ordenado onde o primeiro e o segundo elemento podem ter tipos diferentes.

A declaração de variável e instanciação é feita de forma similar ao caso de um único parâmetro.

Chamamos esse um "ParOrdenado de Integer e String".

                        
                            public class ParOrdenado<T, E> {
                                private T x;
                                private E y;

                                public ParOrdenado(T x, E y) {
                                    this.x = x;
                                    this.y = y;
                                }

                                public T getX() {
                                    return x;
                                }

                                public E getY() {
                                    return y;
                                }
                            }
                        
                    
                        
                            ParOrdenado<Integer, String> par = 
                            new ParOrdenado<Integer, String>(6, "Cheeseburguer com fritas");
                        
                    

Podemos também limitar os parâmetros polimorficamente.

No exemplo abaixo, garantimos que os elementos de um par devem ser veículos.

Já nesse exemplo, garantimos que os elementos de um par devem poder ser comparados entre si.

                        
                            public class ParOrdenado<T extends Veiculo> {
                                private T x;
                                private T y;

                                public ParOrdenado(T x, T y) {
                                    this.x = x;
                                    this.y = y;
                                }

                                public T getX() {
                                    return x;
                                }

                                public T getY() {
                                    return y;
                                }
                            }
                        
                    
                        
                            public class ParOrdenado<T extends Comparable<T>> {
                                private T x;
                                private T y;

                                public ParOrdenado(T x, T y) {
                                    this.x = x;
                                    this.y = y;
                                }

                                public T getX() {
                                    return x;
                                }

                                public T getY() {
                                    return y;
                                }
                            }
                        
                    

Mas nem tudo é alegria. Os parâmetros de tipo aceitam apenas tipos complexos.

Isso quer dizer que para trabalhar com números inteiros, precisamos usar o tipo Integer, e Float ou Double para números reais, assim por diante.

A conversão dos literais primitivos para esses tipos (boxing) é feita automaticamente pelo compilador.

Entretanto, devemos tomar cuidado com os getters e setters.

Como estamos lidando com referências, retornar ou atribuir diretamente a um campo significa que a referência privada ao campo ficará exposta fora do objeto, estragando o encapsulamento.

Para resolver esse problema, primeiro garantimos que os campos são "clonáveis", isto é, que podem gerar cópias de si mesmos, produzindo objetos com estado idêntico, mas diferentes.

Essa garantia é dada pela interface Cloneable da JDK.
Isso deve ser implementado na forma de um método genérico de clonagem (tipicamente é usado uma biblioteca fora da JDK), dado que os campos sejam serializáveis.

Um objeto serializável pode ser convertido em uma sequência de bytes e convertido de volta a um objeto.

Criamos aqui um método de clonagem na classe ParOrdenado, que copia os campos.

Retornamos então uma cópia dos campos nos getters, e atribuímos uma cópia aos campos nos setters.

Dessa forma não há problema de exposição e consequente quebra do encapsulamento.

                        
                            // Conversão de literais primitivos para os tipos complexos correspondentes 
                            // é automática.
                            ParOrdenado<Float, Boolean> par = new ParOrdenado<Float, Boolean>(1.0f, true);
                        
                    
                        
                            public class ParOrdenado<T> {
                                private T x;
                                private T y;

                                public ParOrdenado(T x, T y) {
                                    this.x = x;
                                    this.y = y;
                                }

                                public T getX() {
                                    return x;  // Retorna referência privada, expondo o campo.
                                }

                                public T setX(T x) {
                                    this.x = x;  // Atribui uma referência externa, expondo o campo.
                                }

                                ...
                            }
                        
                    
                        
                            import java.io.*;

                            public class ParOrdenado<T extends Serializable> {
                                private T x;
                                private T y;

                                /** Copia um campo para um novo objeto.
                                 *  

* Realiza a cópia de um campo através de um processo de serialização - deserialização. * Método faz casting explícito, então colocamos essa anotação para evitar * um warning do compilador. *

* @param campo Campo a ser copiado. * @return uma cópia (clone) do argumento campo. */ @SuppressWarnings("unchecked") private static <T> T clone(T campo) { // Parâmetro do método genérico vem antes do tipo. T copia; try { // Cria um array de bytes na memória para guardar o objeto. ByteArrayOutputStream bos = new ByteArrayOutputStream(); ObjectOutputStream out = new ObjectOutputStream(bos); out.writeObject(campo); // Lê os bytes da memória para criar uma cópia do objeto. ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray()); ObjectInputStream in = new ObjectInputStream(bis); copia = (T) in.readObject(); } catch (IOException e) { System.err.println("Não foi possível clonar um objeto: Erro de serialização"); return campo; } catch (ClassNotFoundException e) { System.err.println("Não foi possível clonar um objeto: Erro de tipo."); return campo; } return copia; } public ParOrdenado(T x, T y) { this.x = x; this.y = y; } public T getX() { return clone(x); // Retorna uma cópia do campo. Sem problema. } public void setX(T x) { this.x = clone(x); // Guarda uma cópia no campo. Sem problema. } public T getY() { return clone(y); // Retorna uma cópia do campo. Sem problema. } public void setY(T y) { this.y = clone(y); // Guarda uma cópia no campo. Sem problema. } }

A JDK está repleta de classes parametrizáveis. Um exemplo que já vimos foi a classe ArrayList<E>, que representa uma lista dinâmica de um tipo qualquer.


Outro exemplo é a classe Comparator<T>, que permite comparar ordem entre dois objetos de um mesmo tipo.

Entendemos agora a utilidade de podermos parametrizar o tipo de classes e métodos para implementar estruturas comuns de dados.


Em breve estudaremos um grupo de classes que aplica o recurso de Generics de forma integral e abrangente: as coleções.


Leiam a página https://docs.oracle.com/javase/tutorial/java/generics/ para saber dos detalhes a respeito do uso de Generics.

Perguntas:

  1. Podemos parametrizar interfaces? Qual seria a intenção?
  2. Quando deveriamos usar sobrecarga de métodos em vez de Generics?

Exercício:

  1. Implemente uma classe Java que armazene um número limitado de elementos, mantendo-os sempre ordenados segundo sua "ordem natural" (ex: numérica para números, alfabética para strings). Quando um novo elemento é inserido que excede o tamanho permitido, o elemento mais próximo na lista é descartado para dar espaço ao novo.

Até a próxima aula!