equals 메서드는 재정의하기 쉬워 보이지만 곳곳에서 문제가 발생할 가능성이 크기 때문에 필요하지 않다면 재정의하지 않는 것이 가장 좋다.
다음 조건에 해당한다면 굳이 재정의하지 말자.
@Override
public boolean equals(Object o) {
throw new AssertionError(); // 호출 금지
}
이 규약을 어기면 찾기 어려운 버그를 양산하게 될 수 있다.
단순히 객체는 자기 자신과 같아야 한다는 뜻이다.
Point point = new Point(1, 2);
List<Point> list = new ArrayList<>();
list.add(point);
list.contains(point); // true가 나와야함.
서로에 대한 동치 여부에 똑같이 답해야 한다는 뜻이다.
final class CaseInsensitiveString {
private final String s;
public CaseInsensitiveString(String s) {
this.s = Objects.requireNonNull(s);
}
// 대칭성 위배
@Override
public boolean equals(Object o) {
if (o instanceof CaseInsensitiveString) {
return s.equalsIgnoreCase(((CaseInsensitiveString) o).s);
}
if (o instanceof String) {
return s.equalsIgnoreCase((String) o);
}
return false;
}
}
CaseInsensitiveString cis = new CaseInsensitiveString("Polish");
String s = "Polish";
System.out.println(cis.equals(s)); // true
System.out.println(s.equals(cis)); // false
참고
instanceof 대신 getClass 검사로 바꾸는 것은 리스 코프 치환 원칙(하위 클래스는 상위 클래스를 대신할 수 있어야 한다.)을 위배한다.
equals 메 서드는 반대의 경우에도 똑같은 응답을 해야 하는데 String 클래스는 CaseInsensitiveString 클래스를 알지 못하기 때문에 s.equals(cis)는 false가 나온다. (대칭성 위배)
첫 번째 객체와 두 번째 객체가 같고 두 번째 객체와 세 번째 객체가 같다면 첫 번째 객체와 세 번째 객체 또한 같아야 한다.
class Point {
private int y;
private int x;
public Point(int x, int y) {
this.y = y;
this.x = x;
}
@Override
public boolean equals(Object o) {
if (!(o instanceof Point)) {
return false;
}
Point p = (Point) o;
return p.x == x && p.y == y;
}
}
class ColorPoint extends Point {
private final Color color;
ColorPoint(int x, int y, Color color) {
super(x, y);
this.color = color;
}
}
ColorPoint는 Point 클래스를 상속받아 Color 필드를 추가했다. 이때 ColorPoint에서는 equals를 재정의하지 않았기 때문에 Point로부터 상속받은 equals를 사용하게 되는데 그럼 Color 정보는 무시한 채 비교를 수행한다.
그래서 ColorPoint에서 equals 메 서드를 재정의했다.
class ColorPoint extends Point {
private final Color color;
ColorPoint(int x, int y, Color color) {
super(x, y);
this.color = color;
}
// 재정의
@Override
public boolean equals(Object o) {
if (!(o instanceof ColorPoint)) {
return false;
}
return super.equals(o) && ((ColorPoint) o).color == color;
}
}
하지만 이방식의 경우는 대칭성이 지켜지지 못한다.
Point point = new Point(1, 2);
ColorPoint cp = new ColorPoint(1, 2, Color.RED);
point.equals(cp); // true
cp.equals(point); // false
대칭성을 지켜주기 위해 equals를 수정해 보았다.
@Override
public boolean equals(Object o) {
if (!(o instanceof ColorPoint)) {
return false;
}
if (!(o instanceof ColorPoint)) {
return o.equals(this);
}
return super.equals(o) && ((ColorPoint) o).color == color;
}
이 방식은 대칭성은 지켜주지만 추이성을 깨버린다.
ColorPoint p1 = new ColorPoint(1, 2, Color.RED);
Point p2 = new Point(1, 2);
ColorPoint p3 = new ColorPoint(1, 2, Color.BLUE);
p1.equals(p2); // true
p2.equals(p3) // true
p1.equals(p3) // false
구체 클래스를 확장해 새로운 값을 추가하면서 equals 규약을 만족시킬 방법은 존재하지 않는다.
대신 컴포지션을 사용해서 상위 클래스에 값을 추가할 수는 없지만 상속으로 지켜지지 못했던 규약들을 지킬 수 있다. (물론 상속에서도 하위 클래스에서 필드를 추가하지 않는다면 규약 지킬 수 있음.)
class ColorPoint {
private final Point point;
private final Color color;
ColorPoint(int x, int y, Color color) {
this.point = new Point(x, y);
this.color = Objects.requireNonNull(color);
}
@Override
public boolean equals(Object o) {
if (!(o instanceof ColorPoint)) {
return false;
}
ColorPoint cp = (ColorPoint) o;
return cp.point.equals(point) && cp.color.equals(color);
}
}
추상 클래스의 하위 클래스라면 equals 규약을 지키면서도 값을 추가할 수도 있다. 상위 클래스를 직접 인스턴스로 만들 수 없다면 문제가 발생하지 않는다.
두 객체가 같다면(둘 다 모두 수정되지 않는 한) 앞으로도 영원히 같아야 한다는 뜻이다.
즉 클래스가 불변이든 가변이든 equals의 판단에 신뢰할 수 없는 자원이 끼어들게 해서는 안 된다.
ex) 외부 API 이용해서 equals 구현, 외부 API 결과에 따라 equals 결과도 달라지게 됨.
NullPointerException을 던지는 것도 허용하지 않는다.
그리고 명시적 null 검사보다는 묵시적 null 검사가 낫다.
// bad
if (o == null) {
return false;
}
...
// good
if (!(o instanceof ColorPoint)) {
return false; // null일 경우 false 반환, 이 부분이 없으면 아래 코드에서 CaseException이 발생하기 때문에 이 코드가 반드시 필요하다.
}
ColorPoint cp = (ColorPoint) o; // null일 경우 CaseException이
return cp.point.equals(point) && cp.color.equals(color);