Likhet i egne klasser

Vi leste i kursnoater om primitive og refererte typer at sammenligning av objekter med == resulterte i true bare hvis objektene var det samme objektet, mens equals sjekket om objektene var like. Vi skal her se på hvordan vi kan få equals til å fungere som vi ønsker for våre egne klasser.


Standardimplementasjonen av equals

Hvis vi ikke selv definerer en metode med signaturen public boolean equals(Object) i en klasse, så vil vi «arve» en standard equals-metode fra Object-klassen. Object en en spesiell klasse i Java, en slags «bestefar-klasse» som inneholder standard-metoder vi arver automatisk i de klassene vi lager selv. Equals-metoden der er definert slik:

public boolean equals(Object obj) {
  return (this == obj);
}

Med andre ord vil et kall til equals i praksis være det samme som å sammenligne objektene med ==. Dersom dette ikke er oppførselen vi ønsker, må vi selv definere en equals-metode i klassen vår.

Egendefinert equals -metode

La oss se på et eksempel hvor sammenligning av to objekter med standard equals-metode ikke gir ønsket resultat:

class Person {
  String name;
  String id;

  public Person(String name, String id) {
    this.name = name;
    this.id = id;
  }
}

public class Main {
  public static void main(String[] args) {
    Person p1 = new Person("Ola", "111111 12345");
    Person p2 = new Person("Ola", "111111 12345");
    System.out.println(p1.equals(p2)); // false
  }
}

I eksempelet over ville det gitt mer mening om to personer med samme personnummer og samme navn ble regnet som like. Vi kan få dette til ved å selv definere en equals-metode i Person-klassen. For eksempel slik:

class Person {
  private String name;
  private String id;

  public Person(String name, String id) {
    this.name = name;
    this.id = id;
  }

  @Override
  public boolean equals(Object obj) {
    if (obj == null) {
      return false;
    }
    if (obj == this) {
      return true;
    }
    if (!(obj instanceof Person)) {
      return false;
    }
    Person other = ((Person) obj);
    return this.name.equals(other.name) && this.id.equals(other.id);
  }

  // TODO: Husk å implementere hashCode() også! Se avsnittet senere!
}

public class Main {
  public static void main(String[] args) {
    Person p1 = new Person("Ola", "111111 12345");
    Person p2 = new Person("Ola", "111111 12345");
    System.out.println(p1.equals(p2)); // true
  }
}

Punkter å merke seg:

Krav til equals

Java tillater oss å implementere equals hvordan vi selv ønsker, men for å skrive god kode er det viktig at vi følger kontrakten som er definert for equals-metoden. Kontrakten finner du ved å finlese javadoc-kommentarene til equals-metoden i Object-klassen, og er sammenfattet her.

Anta at x ikke er null. Da må equals-metoden oppfylle følgende krav:

Dersom kontrakten overholdes, vil equals-metoden fungere som forventet, og den vil samsvare med hva vi kaller en ekvivalens-relasjon i matematikk. Dersom vi ikke følger kontrakten, kan det føre til all slags trøbbel.

I tillegg til det som er nevnt over, er det et siste krav som må innfris:

Dette siste punktet er i eksempelet i forrige avsnitt over ikke innfridd.

hashCode -metoden

hashCode-metoden er på samme måte som equals en metode som hører til Object-klassen. Alle klasser arver derfor en standard hashCode-metode med signaturen public int hashCode() hvis man ikke implementerer den selv. Metoden returnerer en enkelt int som på sin egen måte beskriver objektet. I den standard hashCode-metoden som arves fra Object returneres minneadressen til objektet (PS: dette er egentlig ikke sant, men hvis vi forestiller oss en eventyrverden der objekter aldri endrer minneadresse kunne det vært sant).

Tallet brukes under panseret i datastrukturer som oppslagsverk (map/dictionary) og mengder (set). I INF101 er det egentlig ikke interessant for oss å vite hva dette tallet er, eller hvordan det blir regnet ut; poenget er bare at to objekter som er like som definert av equals-metoden, returnere samme verdi fra hashCode-metoden. Ellers vil ikke oppslagsverk, mengder eller andre datastrukturer basert på hashing fungere.

Av effektivitetshensyn er det ønskelig at to ulike objekter alltid returnerer ulike verdier fra hashCode-metoden; men dette er ikke et krav med tanke på korrekthet, og dessuten teoretisk umulig å oppnå i mange tilfeller. Det vil kun gå ut over kjøretiden dersom vi ikke klarer å oppnå det. Datastrukturer basert på hashing vil være raskere jo større sannsynligheten er for at ulike objekter returnerer ulike hash-verdier.

Først et eksempel på hva som går galt hvis vi ikke følger kontrakten, og bruker Person-klassen fra forrige avsnitt som ikke har implementert hashCode-metoden:

// et sted i koden...
HashSet<Person> persons = new HashSet<>();
persons.add(new Person("Ola", "111111 12345"));
persons.add(new Person("Kari", "222222 12345"));

// mye senere...
Person p = new Person("Ola", "111111 12345");
System.out.println(persons.contains(p)); // false!

import java.util.HashSet;

class Person {
  String name;
  String id;

  public Person(String name, String id) {
    this.name = name;
    this.id = id;
  }

  @Override
  public boolean equals(Object obj) {
    if (obj == null) {
      return false;
    }
    if (obj == this) {
      return true;
    }
    if (!(obj instanceof Person)) {
      return false;
    }
    Person other = ((Person) obj);
    return this.name.equals(other.name) && this.id.equals(other.id);
  }

  // Mangler hashCode()!
}

public class Main {
  public static void main(String[] args) {
    // et sted i koden...
    HashSet<Person> persons = new HashSet<>();
    persons.add(new Person("Ola", "111111 12345"));
    persons.add(new Person("Kari", "222222 12345"));

    // mye senere...
    Person p = new Person("Ola", "111111 12345");
    System.out.println(persons.contains(p)); // false!
  }
}

Svaret over blir feil. Det er fordi vi ikke har implementert hashCode-metoden. Heldigvis er den lett å implementere:

@Override
public int hashCode() {
  return Objects.hash(name, id);
}

import java.util.HashSet;
import java.util.Objects;

class Person {
  String name;
  String id;

  public Person(String name, String id) {
    this.name = name;
    this.id = id;
  }

  @Override
  public boolean equals(Object obj) {
    if (obj == null) {
      return false;
    }
    if (obj == this) {
      return true;
    }
    if (!(obj instanceof Person)) {
      return false;
    }
    Person other = ((Person) obj);
    return this.name.equals(other.name) && this.id.equals(other.id);
  }

  @Override
  public int hashCode() {
    return Objects.hash(name, id);
  }
}

public class Main {
  public static void main(String[] args) {
    // et sted i koden...
    HashSet<Person> persons = new HashSet<>();
    persons.add(new Person("Ola", "111111 12345"));
    persons.add(new Person("Kari", "222222 12345"));

    // mye senere...
    Person p = new Person("Ola", "111111 12345");
    System.out.println(persons.contains(p)); // true
  }
}

Autogenerert equals og hashCode

De fleste kodeeditorer kan generere hashCode og equals-metoder automatisk for deg. Da kan du enkelt og greit markere hvilke feltvariabler du ønsker skal inngå i sammenligningsgrunnlaget. Hvis din editor gir deg valget, må du velge de samme variablene for å generere både hashCode og equals, ellers blir det fort feil.

Husk å generere på nytt hvis du legger til eller fjerner feltvariabler.