equals 메소드는 굉장히 중요하다! 대부분의 경우에는 우리가 원하는 비교를 수행해준다. 정 그래도 재정의를 해야겠다면 조심해야 한다. 재정의를 할 때는 아래의 체크리스트를 확인해보자.
이를 모두 통과했다면 어느정도 재정의를 해도 되는 것이다. 그럼 다음의 조건들을 만족해서 equals 메소드를 구현해야 한다.
이를 지키게 되면 동치관계를 구현하게 되는 것이다.
왠만하면 만족하는 속성이다. 말도 안되는 예시를 한번 보자.
public final class NonReflect {
@Override
public boolean equals(Object o) {
if (o == this) return false;
return true;
}
}
이렇게 코드를 짜면 인스턴스를 넣어줘도 없다고 뜨는 기이한 현상이 발생할 것이다.
대칭성은 서로 다른 두 객체가 동치 여부에 똑같이 답을 해야 한다는 것이다. 대칭성은 자칫 잘못하면 규칙에서 벗어날 수 있을 것 같다. 다음의 예시를 한번 보자.
public 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과 String의 비교에서 문제가 발생한다.
CaseInsensitiveString인 cis가 있다고 하면 cis.equals(string)은 문제가 없지만,
string.equals(cis)에서 문제가 발생한다. 문자열과 문자열 클래스가 아닌 두 인스턴스를 비교하게 되면 toString을 가져와서 비교할텐데 이 과정에선 equlasIgnoreCase()(대소문자를 구별 안함)를 호출하지 않으므로 문제가 발생한다!
이런식으로 기본 클래스와의 직접적인 비교는 생각치 않은 버그가 발생할 수 있으니 유념해야 한다.
1 == 2이고, 2 == 3이면 1 == 3 이어야 한다는 뜻이다. 이 요건도 자칫하면 어기기 쉬우니 유념해야 한다. 다음의 예시를 보자.
public class Point {
private final int x;
private final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
@Override public boolean equals(Object o) {
if (!(o instanceof Point))
return false;
Point p = (Point) o;
return p.x == x && p.y == y;
}
}
이 코드에 색상을 더해서 ColorPoint를 만들어보자.
public class ColorPoint extends Point{
private final Color color;
public ColorPoint(int x, int y, Color color) {
super(x, y);
this.color = color;
}
}
equals를 상속하지 않았는데 그러면 Point의 equals를 그대로 상속되어 색상을 제외하고 비교하게 된다.
이는 받아들일 수 없는 상황이다. 그래서 위치와 색상이 같을 때만 true를 반환하는 equals를 생각해보자.
@Override public boolean equals(Object o) {
if (!(o instanceof ColorPoint))
return false;
return super.equals(o) && ((ColorPoint) o).color == color;
}
Point와 ColorPoint와 비교는 서로 결과가 다를 것이다. Point는 색상을 무시하고 ColorPoint는 매개변수의 클래스가 다르다고 매번 false를 반환할 것이다.
@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.equals가 Point와의 비교할 때 색상을 무시하고 비교한다면 대칭성은 지켜주지만 추이성을 깨버린다.
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), p2.equals(p3)는 true를 반환하는데 p1.equals(p3)는 false를 반환한다. 이는 추이성을 깨버린 것이다. 또한 무한 재귀를 일으킬 위험도 상존한다.
@Override public boolean equals(Object o) {
if (o == null || o.getClass() != getClass())
return false;
Point p = (Point) o;
return p.x == x && p.y == y;
}
ColorPoint는 여전히 Point로써 활용되지 못하게 되는 코드이다.
리스코프 치환 원칙은 어떤 타입에 있어서 중요한 속성이라면 하위 타입에서도 중요하다는 원칙
이다.
우리의 상황에 빗대어 표현하면 Point의 속성인 위치는 ColorPoint에서도 중요하다는 것이다.
위의 문제들을 우회할 방법이 하나 있는데 그것은 ColorPoint 내에 Point를 private 필드로 선언하여 일반 Point를 반환하는 뷰 메소드를 public으로 추가하는 것이다.
두 객체가 같다면 불변이라면 계속 쭉~~ 같거나 달라야 한다는 특성이다.
다만 equals 구현에 신뢰할 수 없는 자원이 들어가서는 안된다.
java.net.URL에서는 URL과 호스트 IP를 활용하는데 고정IP가 아니라 유동IP라면 문제가 생긴다. 신뢰할 수 없는 자원은 메모리에 존재하는 객체만을 사용하는 결정적(deterministic) 계산만 수행해야 한다는 것이고 외부의 개입이 있는 자원간의 비교는 지양하는 것이다!
이름처럼 모든 객체가 null과 같지 않아야 한다는 것이다. 그런데 이를 지키기 위해서 임의적으로 null과의 비교로 return false;를 할 필요는 없다. instanceof로 묵시적 null 검사를 할 수 있기 때문이다.
단위 테스트
를 작성해서 돌려보자.핵심 정리
꼭 필요한 경우가 아니면 equals를 재정의하지 말자. 대부분은 잘 비교해주고 재정의할거면 위의 다섯 가지 규칙을 잘 지켜서 정의하자.