Java 개발자라면 한번쯤 들어봤고 사용해봤을 두 메서드가 있다.
두 메서드가 어떤 역할을 하는지 알고는 있었지만 나보다 똑똑한 IDE의 도움을 받아 필요할 때 자동생성하는 식으로 구현하는 바람에 어떤 상황에서 커스텀 할 수 있는 능력과 최적화 포인트 등을 전혀 몰랐다.
Object
가 제공하는 몇 안 되는 메서드이기 때문에 이번 기회에 잘 알아보고자 한다.
eqauls
는 두 객체의 논리적 동치성을 비교하기 위해 사용한다.
여기서 논리적 동치성이란 분명 다른 클래스 인스턴스이지만 핵심 필드를 비교했을 때 동일한 인스턴스로 판별하는 것을 말한다.
만약 equals
를 재정의하지 않는다면 상위 클래스의 eqauls
를 사용하게 되고 끝까지 올라간다면 Object
의 equals
를 사용하게 된다.
Object
의 equals
의 경우 단순히 자기 자신과 같은 지를 비교한다.
public boolean equals(Object obj) {
return (this == obj);
}
이제 직접 테스트 클래스를 작성해보고 equals
를 구현해보자.
static class Point {
public final int x;
public final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
}
x
와 y
를 필드로 갖는 클래스이다.
아직 equals
메서드를 재정의하지 않아서 x와 y의 값이 같은 인스턴스더라도 equals
의 결과는 false
가 나올 것이다.
public static void main(String[] args) {
Point p1 = new Point(1, 2);
Point p2 = new Point(1, 2);
System.out.println(p1.equals(p2)); // false
System.out.println(p2.equals(p1)); // false
}
물리적으로 다른 인스턴스이지만 논리적 동치성을 확인하기 위해 equals
를 재정의한다. equals
재정의 시 무조건 모든 필드의 동치를 비교할 필요는 없다. 도메인의 요구사항에 따라 핵심필드를 뽑아 비교한다.
테스트를 위한 클래스인 Point
의 경우 두 개 필드 뿐이고 두 필드 모두 핵심필드라고 판단하고 equals
메서드를 재정의한다.
static class Point {
public final int x;
public final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
@Override
public boolean equals(Object o) {
if (o == this) return true; // 1
if (!(o instanceof Point)) return false; // 2
Point p = (Point) o; // 3
return p.x == this.x && p.y == this.y;
}
}
각 핵심필드의 동치를 비교하기 전에 3가지 작업을 필요로 한다.
true
를 반환한다.if (o == this) return true;
instanceof
를 사용해서 타입을 확인하면 묵시적으로 null
에 대한 체크도 수행한다. instanceof
의 앞 쪽에 위치한 객체가 null
이라면 false
를 반환한다.if (!(o instanceof Point)) return false;
Point p = (Point) p;
이제 물리적으로 다른 인스턴스에 대해 논리적 동치성을 판별할 수 있게 되었다.
equals 메서드를 재정의 할 때 다음 3가지 원칙을 고려해야 한다.
대칭성
null이 아닌 모든 참조 값 x,y에 대해 x.equals(y)가 true라면 y.equals(x)도 true 이다.
추이성 (3단 논법 느낌)
null이 아닌 모든 참조 값 x,y,z에 대해 x.equals(y)가 true이고, y.equals(z)라면 x.equals(z)는 true 이다.
일관성
null이 아닌 모든 참조 값 x,y에 대해 x.equals(y)를 반복해도 항상 같은 결과를 반환한다.
equals
를 제대로 재정의 했다면 반드시 hashCode
를 재정의 해야 한다.
이유는 hashCode
를 재정의 하면서 알아보자.
equals
메서드를 통해 객체의 핵심필드를 비교할 수 있게 되었다.
아직 충분하지 않다.
충분하지 않은 상황은 다음과 같다.
public static void main(String[] args) {
Map<Point, String> map = new HashMap<>();
Point p1 = new Point(1, 2);
Point p2 = new Point(1, 2);
System.out.println(p1.equals(p2));
map.put(p1, "test");
System.out.println(map.get(p2)); // null
}
Point
클래스는 이전 equals
를 재정의 했을 때와 동일하다.
논리적 동치를 보장받은 두 인스턴스이지만 Map
의 key로 두 인스턴스를 사용했을 때 서로 다르다는 결과가 나온다.
두 인스턴스의 equals
결과가 true
라면 "test"
가 map에서 조회됐어야 했다.
위 코드를 수행했을 때 "test"
가 조회되기 위해 hashCode를 재정의하는 것이다.
equals
에 사용된 핵심필드의 해시 값을 계산해서 반환하는 것이 hashCode
의 역할이다.
Point
클래스에 hashCode
를 재정의해보자.
static class Point {
public final int x;
public final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
@Override
public boolean equals(Object o) {
if (o == this) return true;
if (!(o instanceof Point)) return false;
Point p = (Point) o;
return p.x == this.x && p.y == this.y;
}
@Override
public int hashCode() {
int result = Integer.hashCode(this.x);
result += 31 * result + Integer.hashCode(this.y);
return result;
}
}
hashCode
를 재정의하는 일반적인 과정은 다음과 같다.
equals
에 사용된 핵심필드 중 첫번째 필드의 hash 값을 int 타입 변수 result에 초기화 한다.31
을 곱하는데 이것은 해시충돌을 피해서 해시성능을 높이기 위함이다.위 과정을 보다 간결하게 구현하기 Objects
의 hash()
메서드를 사용할 수 있다.
@Override
public int hashCode() {
return Objects.hash(x, y);
}
hashCode
를 구현하고 map을 조회하면 동일한 key값으로 판정되어 값을 받아올 수 있다.
클래스가 불변이고, 핵심필드가 너무 많아 해시코드 계산에 너무 많은 시간이 소요된다면 캐싱을 고려할 수 있다.
static class Point {
public final int x;
public final int y;
public int hashCode;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
@Override
public boolean equals(Object o) {
if (o == this) return true;
if (!(o instanceof Point)) return false;
Point p = (Point) o;
return p.x == this.x && p.y == this.y;
}
@Override
public int hashCode() {
int result = this.hashCode;
if (result == 0) {
result = Integer.hashCode(this.x);
result += 31 * result + Integer.hashCode(this.y);
}
return result;
}
}
캐싱을 위한 필드를 추가하고 해당 필드에 캐싱된 결가가 있다면 캐싱된 패시코드를 반환하는 방식이다.
(캐싱을 위한 필드는 쓰레드 세이프하게 만들어야 한다. ThreadLocal
같은 ??)
equals
를 재정의 할 때 반드시hashCode
를 재정의하고hashCode
에는equals
에 사용된 핵심필드만을 사용해서 해시 값을 계산해야 한다.