equals 메서드는 몇 가지 규약을 제대로 지키지 않을 경우 의도와 다르게 동작하여 프로그램에 오류를 발생시킬 수 있다. 재정의하지 않고 그냥 두면 그 클래스의 인스턴스는 오직 자기 자신과만 같게 된다.
@Override
public boolean equals(Object o) {
throw new AssertionError(); // 호출 금지
}
equals를 재정의 해야하는 경우는 객체 식별성(두 객체가 물리적으로 같은가)이 아니라 논리적 동치성을 확인해야 하는데 상위 클래스의 equals가 논리적 동치성을 비교하도록 재정의 되지 않았을 때이다.
주로 값 클래스들이 여기 해당한다. (Integer나 String 같은) 두 값 객체를 equals로 비교하는 프로그래머는 객체가 같은지가 아니라 값이 같은지를 알고 싶어할 것이다.
이렇게 논리적 동치성을 확인하도록 재정의해두면 Map의 키와 Set의 원소로 사용할 수 있게 된다. Enum과 같이 값 클래스라 해도, 값이 같은 인스턴스가 둘 이상 만들어지지 않음을 보장하는 인스턴스 통제 클래스라면 굳이 재정의할 필요가 없다. 어차피 논리적으로 같은 인스턴스가 2개 이상 만들어지지 않으니 논리적 동치성 = 객체 식별성이 되는 것이다.
반사성(reflexivity)
null이 아닌 모든 참조 값 x에 대해, x.equals(x)는 true이다.
대칭성(symmetry)
null이 아닌 모든 참조 값 x,y에 대해, x.equals(y)가 true면 y.equals(x)도 true다.
/* 대칭성 위배의 경우 */
// 대소문자를 구별하지 않는 문자열을 구현한 다음 살펴보자
public final class CaseInsensitiveString() {
private final String s;
public CaseInsensitiveString() {
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";
cis.equals(s); // true
s.equals(cis); // false
/* 한 방향으로만 작용한다. String의 equals는 일반 String을 알고 있지만 String의 equals는 Case InsensitiveString의 존재를 모르기 때문이다. */
List<CaseInsensitiveString> list = new ArrayList<>();
list.add(cis);
list.contains(s); // false
/* 위 예제는 equals 규약을 어기면 그 객체를 사용하는 다른 객체들이 어떻게 반응할지 알 수 없음을 보여준다. */
// CaseInsensitiveString의 eqauls를 String과 연동하겠다는 허황된 꿈을 버려야한다.
// 고치기
@Override
public boolean equals(Object o) {
return o instanceof CaseInsensitiveString && ((CaseInsensitiveString) o).s.equalsIgnoreCase(s);
}
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;
}
}
.
.
public class ColorPoint extends Point {
private fianl Color color;
public ColorPoint(int x, int y, Color color) {
super(x, y);
this.color = color;
}
}
// 위 경우라면 색상 정보는 무시한 채 비교하게 될 것이다. 규약을 어긴건 아니지만 중요한 정보를 놓치게 될 것이다.
// 대칭성 위배
// 다음 코드는 Point.equals 시 색상 비교를 무시하게 되고 ColorPoint 객체에서는 Point 객체를 받지 않기 때문에 항상 false를 반환하게 될 것이다.
@Override
public boolean equals(Object o) {
if(!(o instanceof ColorPoint)) return false;
return super.equals(o) && ((ColorPoint) o).color == color;
// 예제
Point p = new Point(1, 2);
ColorPoint cp = new ColorPoint(1, 2, Color.RED);
p.equals(cp); // true
cp.equals(p); //false
// ColorPoint.equals가 Point와 비교할 때 색상을 무시하도록 하면 해결 될까?
// 추이성 위배
@Override
public boolean equals(Object o) {
if(!(o instanceof Point)) return false;
if(!(o instanceof ColorPoint)) return o.equals(this);
// o가 ColorPoint면 색까지 비교하기
return super.equals(o) && ((ColorPoint) o).color == color;
// 예제
ColorPoint p1 = new ColorPoint(1, 2, Color.RED);
Point p2 = new Point(1,2);
ColorPoint p2 = new ColorPoint(1, 2, Color.BLUE);
p1.equals(p2); // true
p2.equals(p3); // true
p1.equals(p3); // false
// 그렇다면 해결방법은?
// 사실상 구체 클래스를 확장해 새로운 값을 추가하면서 equals 규약을 만족시킬 수는 없다.
// 그렇다면 getClass 검사로 바꾸면 어떻게 될까
// 리스코프 치환 원칙 위배
@Override
public boolean equals(Object o) {
// Point 클래스의 equlas
if(o == null || o.getClass != getClass()) return false;
Point p = (Point) o;
return p.x == x && p.y = y;
}
// 위 코드는 같은 구현 클래스의 객체와 비교할 때만 true를 반환한다. 하지만, 실제로 활용할 수는 없다. Point의 하위 클래스는 정의상 여전히 Point이므로 어디서든 Point로써 활용될 수 있어야하는데 그렇지 못하기 때문이다.
//단위 원 안의 모든 점을 포함하도록 unitCircle을 초기화한다.
private static final Set<Point> unitCircle = Set.of(
new Point(1, 0), new Point(0, 1),
new Point(-1, 0), new Point(0, -1));
public static boolean onUnitCircle(Point p) {
return unitCircle.contains(p);
}
.
.
public class CounterPoint extends Point {
private static final AtomicInteger counter = new AtomicInteger();
public ConterPoint(int x, int y) {
super(x, y);
counter.incrementAndGet();
}
public static int numberCreated() {return counter.get();
}
/* 리스코프 치환 원칙에 따르면 어떤 타입에 있어 중요한 속성이라면 그 하위 타입에서도 마찬가지로 중요하다. 따라서 그 타입의 모든 메서드가 하위 타입에서도 똑같이 잘 작동해야한다. -> 이는 Point의 하위 클래스는 정의상 여전히 Point이므로 어디서든 Point로써 활용될 수 있어야 한다는 말
그런데 CounterPoint의 인스턴스를 onUnitCircle에 넘기게되면 onUnitCircle은 x,y값과 무관하게 false를 반환할 것이다. 어쨌든 CounterPoint의 인스턴스는 Point와 본질적으로 다르기 때문 */
결국 구체 클래스의 하위 클래스에서 값을 추가할 방법은 없지만 “상속 대신 컴포지션을 사용하라(아이템 18)” 라는 조언을 이용할 수 있다. (Point를 상속하는 대신 Point를 ColorPoint의 private 필드로 두고, ColorPoint와 같은 위치의 일반 Point를 반환하는 뷰 메서드를 public으로 추가)
public class ColorPoint {
private final Point point;
private final Color color;
public ColorPoint(int x, int y, Color color) {
point = new Point(x, y);
this.color = Objects.requireNonNull(color);
}
// 이 ColorPoint의 Point 뷰를 반환한다.
public Point asPoint() {
return point;
}
@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);
}
.. 생략
}
참고로, Java 라이브러리인 Timestamp와 Date가 이런 이슈를 가지고 있다.
가변 객체는 비교 시점에 따라 서로 다를 수도 혹은 같을 수도 있지만, 불변 객체는 그렇지 않다. 하지만, 클래스가 불변이든 가변이든 equals의 판단에 신뢰할 수 없는 자원이 끼어들게 해서는 안된다. 예컨대 java.net.URL의 equals는 주어진 URL과 매핑된 호스트의 IP 주소를 이용해 비교한다. 호스트 이름을 IP 주소로 바꾸려면 네트워크를 통해야 하는데 그 결과가 항상 같다고 보장할 순 없다. (이 역시 실수)
이런 문제를 피하려면 항시 메모리에 존재하는 객체만 사용한 ‘결정적 계산’만을 수행해야 한다.
동치성 검사 시 equals는 건네받은 객체를 적절히 형변환하여 필수 필드들의 값을 알아내야하며, 그 전에 instanceof
로 연산자 매개변수가 올바른 타입인지 검사한다.
이 instanceof
는 첫 번째 피연산자가 null이면 flase를 반환한다. 때문에 null 검사를 명시적으로 하지 않아도 된다.
@Override
public boolean equals(Object o) {
if(!(o instanceof MyType)) {
return false;
MyType my = (MyType) o
}
}
AutoValue를 사용하면 깔끔하고 보기 좋게 메서드들을 작성해준다. 물론, 테스트 코드를 잘 작성해둬야 한다.