8. IDENTIDADE & IGUALDADE

“Eu não sou um ótimo programador; sou apenas um bom programador com ótimos hábitos.”

Kent Beck

A cada invocação do construtor (instrução new) um objeto é instanciado. Instância e objeto significam, para todos os efeitos, a mesma coisa. Em um sistema orientado a objetos há centenas, até milhares, de instâncias na memória ao mesmo tempo. Durante todo o tempo de execução são milhões, bilhões de instâncias, objetos que são construídos e destruídos. Neste mar de instâncias, de objetos, como saber diferenciá-los uns dos outros? São várias perguntas que se pretende não deixar sem resposta, tais como: o que faz um objeto ser único? quando dois objetos são considerados iguais? existem casos de objetos maiores ou menores que outros? Estas questões e outros pontos específicos são discutidos aqui, neste capítulo.

8.1 Conceito de Identidade

Cada objeto instanciado é único em um sistema. Para isso, a identidade é a propriedade que garante esta unicidade e que uma instância particular seja distinguida de todas as outras.

Portanto, todos os objetos, ou seja, a cada new executado, um identificador de objeto (object identifier ou oid, em inglês) é criado para representar esta instância particular.

O oid é uma propriedade interna dos objetos. O controle e gerenciamento do identificador é realizado pela linguagem/plataforma de programação usada. Ou seja, este é um problema dos projetistas de linguagens. Enquanto aos programadores que usam a linguagem ou plataforma cabe apenas entender que cada instância recebe um identificador único.

Na maioria da linguagens orientadas a objetos, a identidade está conectada com a referência. Isto é, quando parece que uma variável armazena um objeto ela, de fato, armazena a referência para o objeto. As referências recebem um identificador, dado e gerenciado pelo runtime environment ou runtime system.

Para exemplificar este controle automático da identidade considere o seguinte exemplo escrito na linguagem Ruby:

# identidade.rb
require 'date'
data1 = Date.new 2021, 01, 01
data2 = Date.new 2020, 12, 31
data3 = Date.new 2021, 01, 01
puts data1.object_id # imprime 60
puts data2.object_id # imprime 80
puts data3.object_id # imprime 100
data4 = data2
puts data4.object_id # imprime 80

Considere o código anterior. São 3 instâncias (3 objetos), embora existam 4 variáveis data1, data2, data3 e data4. Os objetos são contados pelas instruções new. Cada um recebeu um oid gerenciado pelo runtime do Ruby (que pode ser diferente na sua máquina!). Importante notar que embora os objetos armazenados em data1 e data3 tenham o mesmo estado, isto é, guardem os mesmos valores, eles não têm o mesmo identificador. Por outro lado, data2 e data4 referenciam o mesmo objeto, que é evidenciado pela identidade.

Objetos na Memória
Objetos na Memória

A imagem anterior apresenta uma possível representação dos objetos demonstrados no código de exemplo. Não pretende-se estender neste livro em como a memória funciona. No entanto, esta explicação dos conceitos básicos pode ajudar a entender o conceito de identidade. A memória, tipicamente, é divida em dois espaços stack (pilha) e heap (monte). Variáveis, valores inteiros, um char, métodos, booleanos e outros não-objetos são armazenados na stack. Os objetos, por outro lado, são armazenados no heap. Algumas variáveis são referências para estes objetos no heap, como pode ser visto na ilustração. No final das contas, elas guardam endereços de memória, no entanto está na figura representado apenas os oid’s. Para visualizar o exemplo em código visualmente, data1 e data3 são referências para objetos diferentes, enquanto data2 e data4 fazem referência ao mesmo objeto.

Na linguagem Java não é diferente, pois o runtime (JRE) também armazena um oid para as instâncias durante a execução. No entanto, diferente de Ruby, não há acesso ao número identificador. Considere o código a seguir:

// Estacao.java
public class Estacao {
  // atributos encapsulados
  private final String[] estacoes = {"verão", "outono", "inverno", "primavera"};
  private int estacao = 0; // verão (primeiro índice de this.estacoes)
  // construtor padrão/inicializador
  public Estacao() {}
  // construtor sobrecarregado
  public Estacao(String estacao) {
    switch (estacao) {
      case "verão":     this.estacao = 0; break;
      case "outono":    this.estacao = 1; break;
      case "inverno":   this.estacao = 2; break;
      case "primavera": this.estacao = 3; break;
      default: throw new IllegalArgumentException("Estação desconhecida " + estacao);
    }
  }
  // getter/estado
  public String getEstacao() {
    return this.estacoes[this.estacao];
  }
  // operação/comportamento
  public void avancar() {
    this.estacao = (this.estacao + 1) % 4;
  }
}
// App.java
class App {
  public static void main(String[] args) {
    Estacao est1 = new Estacao();
    Estacao est2 = new Estacao("inverno");
    Estacao est3 = est1;
    // são três variáveis, mas dois objetos Estação
    System.out.println(est1); // Estacao@6ff3c5b5
    System.out.println(est2); // Estacao@3764951d
    System.out.println(est3); // Estacao@6ff3c5b5
    // seguem os estados:
    System.out.println(est1.getEstacao()); // verão
    System.out.println(est2.getEstacao()); // inverno
    System.out.println(est3.getEstacao()); // verão
    // o objeto @6ff3c5b5 vai mudar de estado
    est1.avancar();
    // est1 e est3 se referem ao mesmo objeto @6ff3c5b5
    System.out.println(est1.getEstacao()); // outono
    System.out.println(est2.getEstacao()); // inverno
    System.out.println(est3.getEstacao()); // outono
  }
}

Para entender bem o exemplo do código anterior é preciso acompanhar os prints. São instanciados dois objetos Estacao, logo são gerados dois oid’s, que aparecem como @6ff3c5b5 e @3764951d. Cada um tem seu estado, que é impresso a seguir como verão, inverno e verão. Importante: não é o caso em que as variáveis est1 e est3 armazenam verão, mas sim que elas referenciam o mesmo objeto que abriga o estado verão. Portanto, quando o objeto @6ff3c5b5 muda de estado através de est1.avancar(), este estado é refletido nos últimos prints quando est1 e est3 imprimem outono. Pode-se pensar nestas variáveis como se fossem apenas controles remotos para o mesmo objeto. Por isso, executando est3.avancar() vai causa mudanças de estado que serão acessíveis por est1 também.

8.2 Conceito de Igualdade

A identidade dos objetos está fora do controle do programador, mas aos cuidados do ambiente de execução. Por outro lado, a igualdade ou equivalência dos objetos pode ser codificada. Isto é, dois objetos, com identidades diferentes, podem ser considerados equivalentes ou iguais segundo um algoritmo de comparação. Por exemplo, considere um ponto no espaço 2D e suas coordenadas X e Y. A seguir uma implementação em Java:

// Ponto.java
public class Ponto {

  private int x;
  private int y;

  public Ponto(int x, int y) {
    this.x = x;
    this.y = y;
  }

  public int getX() { return this.x; }
  public int getY() { return this.y; }
}
// App.java
public class App {
  public static void main(String[] args) {
    Ponto p1 = new Ponto(30, 40);
    Ponto p2 = new Ponto(50, 60);
    Ponto p3 = new Ponto(30, 40);
    Ponto p4 = p2;
    // O operador == compara a identidade, logo
    // false, não são o mesmo objeto:
    System.out.println(p1 == p2);
    // false, não são o mesmo objeto mesmo que guardem os mesmos valores:
    System.out.println(p1 == p3);
    // true, pois as variáveis p2 e p4 referenciam o mesmo objeto:
    System.out.println(p2 == p4);
  }
}

Conforme o código do exemplo anterior, os objetos da classe Ponto possuem duas propriedade, X e Y. No entanto elas não interferem na identidade dos objetos. No exemplo, há três instâncias (três new’s) e quatro variáveis (p1, p2, p3 e p4). A única comparação que retorna true é a de p2 e p4, pois referenciam o mesmo objeto, ou seja, objeto com a mesma identidade. Este é o comportamento esperado. Em Java, o operador == compara a identidade dos objetos. No entanto, se fosse considerada a equivalência, p1 e p3 seriam iguais, embora sejam instâncias diferentes, não apenas porque têm atributos iguais, mas porque no problema em questão ambos possuem a mesma representação, isto é, denotam um mesmo lugar no espaço 2D.

Então, a igualdade é a equivalência das instâncias segundo as regras de domínio. Mesmo se instâncias diferentes, os objetos ainda são considerados iguais se representam uma informação equivalente, onde um pode ser usado no lugar do outro sem prejuízo para o programa.

8.3 Implementando a igualdade com equals

O modo como o algoritmo de comparação e equivalência é implementado varia de acordo com a linguagem de programação em uso. Enquanto em algumas linguagens é possível escrever o comportamento diretamente do operador == (Ruby, por exemplo), em outras isto só é possível através da escrita de um método específico, como __eq__ em Python e equals e Equals em Java e C# respectivamente.

Como dito, na linguagem Java, o algoritmo de comparação da equivalência é codificado no método equals. Todos os objetos vêm com o método equals herdado de uma superclasse comum chamada Object (a herança será vista em detalhes no Capítulo 22). No entanto, a classe Object possui a seguinte implementação13:

package java.lang;
public class Object {
  // ...
  /**
   * ...
   * @param   obj   the reference object with which to compare.
   * @return  {@code true} if this object is the same as the obj
   *          argument; {@code false} otherwise.
   */
  public boolean equals(Object obj) {
    return (this == obj);
  }
}

Portanto, mesmo que o App.java seja escrito como a seguir, as comparações seguem considerando apenas a identidade:

// App.java
public class App {
  public static void main(String[] args) {
    Ponto p1 = new Ponto(30, 40);
    Ponto p2 = new Ponto(50, 60);
    Ponto p3 = new Ponto(30, 40);
    Ponto p4 = p2;
    // O método equals compara a identidade internamente,
    // logo os resultados são os mesmos que o operador ==
    System.out.println(p1.equals(p2)); // false
    System.out.println(p1.equals(p3)); // false
    System.out.println(p2.equals(p4)); // true
  }
}

Assim, para os objetos do tipo Ponto serem comparados segundo seus valores, o método equals deve ser sobrescrito (escrito por cima do que foi herdado). Este procedimento varia de linguagem para linguagem. Java, no entanto, possui um contrato específico, descrito na documentação de equals e disponível neste link https://docs.oracle.com/javase/8/docs/api/java/lang/Object.html#equals-java.lang.Object-. Segue minha tradução livre:

public boolean equals(Object obj)

Indica caso algum outro objeto "é igual a" este daqui.
O método equals implementa uma relação de equivalência em referências não-nulas:
  1. Ele é reflexivo: para qualquer referência não-nula X, X.equals(X) deve retornar verdadeiro.
  2. Ele é simétrico: para quaisquer referências não-nulas X e Y, X.equals(Y) deve retornar verdadeiro\
 se e somente se Y.equals(X) retornar verdadeiro.
  3. Ele é transitivo: para quaisquer referências não-nulas X, Y e Z, se X.equals(Y) retorna verdadeir\
o e Y.equals(Z) retorna verdadeiro, então X.equals(Z) deve retornar verdadeiro.
  4. Ele é consistente: para quaisquer referências não-nulas X e Y, múltiplicas chamadas à X.equals(Y)\
 consistentemente retornam verdadeiro ou consistentemente retornam falso, considerando que nenhuma inf\
ormação usada na comparação de ambos objetos foi modificada.
  5. Para qualquer referência não-nula X, X.equals(null) deve retornar falso.

O método equals para a classe Object implementa a equivalência mais possivelmente discriminativa dos o\
bjetos; isto é, para quaisquer referências não-nulas X e Y, este método retorna verdadeiro se e soment\
e se X e Y se referem ao mesmo objeto (X == Y é verdadeiro).

Note que é geralmente necessário sobrescrever o método hashCode sempre quando este método é sobrescrit\
o, de modo a manter o contrato geral para o método hashCode, que declara que objetos iguais devem ter \
os mesmos hashes.

Diferente do original, eu apenas numerei os tópicos do contrato para demonstrar sua implementação. No código a seguir está a classe Ponto com equals e hashCode implementados:

// Ponto.java
public class Ponto {

  private int x;
  private int y;

  public Ponto(int x, int y) {
    this.x = x;
    this.y = y;
  }

  public int getX() { return this.x; }
  public int getY() { return this.y; }

  // a anotação @Override é recomendada nos métodos sobrescritos
  @Override // o método deve receber Object, SEMPRE, e não Ponto ou outro
  public boolean equals(Object outroObjeto) {
    // primeiro, se é a mesma instância retorna verdadeiro (ver regra 1 do contrato: reflexivo)
    if (this == outroObjeto) return true;
    // segundo, verificar se não é nulo (ver regra 5: equals(null) é sempre falso)
    if (outoObjeto == null) return false;
    // verificar se o outro objeto é um Ponto (ou a classe esperada)
    if (outroObjeto instanceof Ponto) {
      // fazer a coerção segura de outro objeto para o tipo Ponto para acessar seus atributos
      Ponto outroPonto = (Ponto) outroObjeto;
      if (this.x == outroPonto.x && this.y == outroPonto.y) return true;
    }
    // se não for uma instância de Ponto ou não tiver os mesmos valores retorna false
    return false;
  }
  // recomenda-se sobrescrever hashCode quando se sobrescreve equals
  @Override
  public int hashCode() {
    // usar os mesmos atributos usados no algoritmo do equals para calcular o hash
    return this.x * 3 + this.y * 5;
  }
}
// App.java
public class App {
  public static void main(String[] args) {
    Ponto p1 = new Ponto(30, 40);
    Ponto p2 = new Ponto(50, 60);
    Ponto p3 = new Ponto(30, 40);
    Ponto p4 = p2;
    Ponto p5 = new Ponto(30, 40);

    System.out.println(p1.equals(p2)); // false, ok

    System.out.println(p4.equals(p2)); // true
    System.out.println(p1.equals(p1)); // TRUE! É reflexivo.

    System.out.println(p1.equals(p3)); // true
    System.out.println(p3.equals(p1)); // TRUE! É simétrico.

    System.out.println(p1.equals(p3)); // true
    System.out.println(p3.equals(p5)); // true
    System.out.println(p1.equals(p5)); // TRUE! É transitivo

    System.out.println(p1.equals(p3)); // true
    System.out.println(p1.equals(p3)); // true
    System.out.println(p1.equals(p3)); // TRUE! É consistente

    System.out.println(p1.equals(null)); // FALSE! Sempre é falso se comparado com NULL
  }
}

O código anterior demonstra uma maneira muito didática de implementar o equals. É possível usá-la como modelo. Porém, se for buscado no Google algo como “equals Java”, ou a linguagem que preferir, poderão ser vistas várias implementações de equals, todas (ou quase) igualmente válidas, algumas mais enxutas e mais sofisticadas que esta apresentada. Como saber se são válidas? Se passam nas regras do contrato. Nos casos de teste, são verificadas todas as regras do contrato de equals: reflexividade, simetria, transitividade, consistência, o caso excepcional do null. Sabe-se que o equals está ok se estes casos estão cobertos.

8.4 Comparabilidade

Enquanto a igualdade permite definir se dois objetos são (ou não) equivalentes, a comparabilidade permite dizer um objeto é menor ou maior que outro. Em algumas linguagens de programação, inclusive, a igualdade é implementada no mesmo conjunto da comparabilidade, já que igual pode ser inferido de um objeto nem menor e nem maior que outro.

Na linguagem Ruby, por exemplo, há um módulo que pode ser adicionado às classes chamado Comparable (comparável, em Português), que adiciona os operadores <, <=, ==, !=, >=, e >, mais o método between?. No entanto, não são todas as linguagens que oferecem um módulo, interface ou outra construção para tornar um objeto comparável. JavaScript, por exemplo, precisa de uma função de classificação para ordenar um array.

É preciso dizer, porém, que a comparabilidade é secundária se considerar a igualdade. Os objetos devem ser comparáveis para serem classificados. No entanto, se não for necessário classificá-los ou reordená-los de alguma forma, não há porque torná-los comparáveis.

Na linguagem Java, especificamente, há uma interface chamada Comparable<T> que obriga a implementação de um método compareTo(T): int. O método compareTo deve retornar um número negativo se o objeto em questão for menor que o passado, um número positivo se for maior, ou zero se for igual. No caso da linguagem Java, não há a necessidade de consistência com o equals. Isto é, um objeto pode ser equals outro e seu comparador não retornar zero e vice-e-versa. O contrato de comparable está disponível aqui https://docs.oracle.com/javase/8/docs/api/java/lang/Comparable.html.

O que é considerado para comparação faz parte das regras do domínio. Por exemplo, na comparação de cartões de crédito poderia ser usado o critério de qual tem mais saldo disponível, qual foi usado mais recentemente ou até qual possui maior limite. Enfim, essas regras são implementadas no método de comparação.

Por exemplo, considere um classe para representar Peso, que possui uma implementação bem direta e simples como a seguir:

// Peso.java
public class Peso {
  private int gramas;
  public Peso(int gramas) {
    this.gramas = gramas;
  }
  public int getGramas() {
    return this.gramas;
  }
  public double getKilos() {
    return this.gramas / 1000.0;
  }
}

Para tornar objetos do tipo Peso comparáveis deve-se implementar a interface Comparable<T> como class Peso implements Comparable<Peso>. O exemplo de como isto é feito e os testes estão no código a seguir:

// Peso.java
public class Peso implements Comparable<Peso> { // implementar Comparable<T> onde T é Peso
  private int gramas;
  public Peso(int gramas) {
    this.gramas = gramas;
  }
  public int getGramas() {
    return this.gramas;
  }
  public double getKilos() {
    return this.gramas / 1000.0;
  }
  // método compareTo
  @Override
  public int compareTo(Peso outroPeso) {
    // se este tiver menos gramas retornará negativo,
    // se tiver mais retornará negativo, e zero se os pesos se anularem
    return this.gramas - outroPeso.gramas;
  }
  // para imprimir os objetos
  @Override
  public String toString() {
    return this.gramas + "g";
  }
}
// App.java
import java.util.Arrays; // métodos utilitários para lidar com arrays

public class App {
  public static void main(String[] args) {
    Peso p1 = new Peso(400);
    Peso p2 = new Peso(1200);
    Peso p3 = new Peso(400);
    Peso p4 = new Peso(9600);
    Peso p5 = new Peso(100);

    System.out.println(p1.compareTo(p2)); // -800
    System.out.println(p4.compareTo(p2)); // 8400
    System.out.println(p1.compareTo(p3)); // 0

    System.out.println(p1.compareTo(p2) < 0); // true, p1 é menor que p2
    System.out.println(p4.compareTo(p2) > 0); // true, p4 é maior que p2
    System.out.println(p1.compareTo(p3) == 0); // true, p1 e p3 têm o mesmo valor

    // um array de pesos
    Peso[] pesos = {p1, p2, p3, p4, p5};
    System.out.println(Arrays.toString(pesos)); // imprime [400g, 1200g, 400g, 9600g, 100g]
    Arrays.sort(pesos); // Arrays.sort usa o método compareTo internamente para ordenar
    System.out.println(Arrays.toString(pesos)); // imprime [100g, 400g, 400g, 1200g, 9600g]
  }
}

No exemplo anterior a comparabilidade foi implementada em Peso baseada na quantidade de gramas. É bastante direto, isto é, não aberto a interpretação. Em outras classes pode exigir mais planejamento. Por exemplo, considere novamente a classe Ponto. Quando um ponto é maior ou menor que outro? A sugestão, é que a distância da origem 0, 0 dê aos pontos a noção de maior ou menor. Porém, exige cuidado, um Ponto(-12, -8) é maior que Ponto(10, 5). Uma implementação possível está a seguir:

// Ponto.java
public class Ponto implements Comparable<Ponto> {

  private int x;
  private int y;

  public Ponto(int x, int y) {
    this.x = x;
    this.y = y;
  }

  public int getX() { return this.x; }
  public int getY() { return this.y; }

  @Override
  public int compareTo(Ponto outroPonto) {
    // considerando as posições sem sinal (também poderia ser calculada )
    return (Math.abs(this.x) + Math.abs(this.y)) -
      (Math.abs(outroPonto.x) + Math.abs(outroPonto.y));
    // Ou usando o Teorema de Pitágoras
    // return Math.sqrt(Math.pow(Math.abs(this.x), 2) + Math.pow(Math.abs(this.y), 2)) -
      // Math.sqrt(Math.pow(Math.abs(outroPonto.x), 2) + Math.pow(Math.abs(outroPonto.y), 2));
  }

  // o método toString permite imprimir objetos Ponto
  @Override
  public String toString() {
    return "(" + this.x + ", " + this.y + ")";
  }
}
// App.java
public class App {
  public static void main(String[] args) {
    Ponto p1 = new Ponto(-12, -8);
    Ponto p2 = new Ponto(10, 5);
    Ponto p3 = new Ponto(2, -3);
    // um array de pontos
    Ponto pontos = {p1, p2, p3};
    // testes:
    System.out.println(Arrays.toString(pontos)); // imprime [(-12, -8), (10, 5), (2, -3)]
    Arrays.sort(pontos); // Arrays.sort usa o método compareTo internamente para ordenar
    System.out.println(Arrays.toString(pontos)); // imprime [(2, -3), (10, 5), (-12, -8)]
  }
}

Este último exemplo pode esclarecer que o algoritmo de comparação depende das regras do domínio que se está implementando, isto é, dos critérios estabelecidos para o objeto no mundo real.

8.5 Considerações

Este é o primeiro capítulo que não trata o projeto dos objetos individualmente. Em outras palavras, é a primeira vez que se trata de um conjunto de instâncias e da comunicação entre objetos. De fato, objetos não vivem sozinhos. A questão de que eles são identificáveis e que podem ser comparados são os conceitos que devem ser levados deste capítulo. Finalmente, é preciso lembrar que embora ambos conceitos tenham sido exemplificados em Java, eles são implementáveis em quase qualquer linguagem orientada a objetos.

8.6 Exercícios

A seguir alguns exercícios para entender e praticar identidade, igualdade e comparabilidade.

Revisitando Time

Resgatando o exercício do capítulo anterior, sobrescreva equals, hashCode, e implemente Comparable em Time para que passe nos seguintes testes:

// App.java
import java.util.Arrays;

public class App {
  public static void main(String[] args) {
    Time t1 = new Time(18, 40, 20);
    Time t2 = new Time(1, 10, 50);
    Time t3 = new Time();
    Time t4 = new Time(1, 10, 51);
    Time t5 = new Time(18, 40, 20);

    System.out.println(t1.equals(t2) == false);

    System.out.println(t1.equals(null) == false);

    System.out.println(t2.equals(t4) == false);

    System.out.println(t1.equals(t1) == true);

    System.out.println(t1.equals(t5) == true);
    System.out.println(t5.equals(t1) == true);

    System.out.println(t5.hashCode() == t1.hashCode()); // true
    System.out.println(t5.hashCode() != t4.hashCode()); // true

    System.out.println(t1.compareTo(t2) > 0); // true
    System.out.println(t1.compareTo(t5) == 0); // true
    System.out.println(t2.compareTo(t4) < 0); // true

    Time moments = {t1, t2, t3, t4, t5};
    Arrays.sort(moments);
    System.out.println(
      Arrays.toString(moments).equals("[00:00:00, 01:10:50, 01:10:51, 18:40:20, 18:40:20]")
    );

  }
}

Implementar Previsão do Tempo

Projetar uma classe para representar uma previsão do tempo. Alguns requisitos sugeridos são as temperaturas mínima e máxima esperada e as condições como se chove, sol, nublado, etc. Sobrescrever e implementar os métodos para igualdade e comparação, respectivamente. Escrever testes.