[Why]
- equals 규약을 어기면 그 객체를 사용하는 다른 객체들이 어떻게 반응할지 알 수 없다.
Object 명세에 적힌 규약
equals 메서드는 동치관계(equivalence relation)을 구현하며, 다음을 만족한다.
- 반사성(reflexivity): null이 아닌 모든 참조 값 x에 대해, x.equals(x)는 true다.
- 대칭성(symmetry): null이 아닌 모든 참조 값 x, y에 대해, x.equals(y)가 true면 y.equals(x)도 true다.
- 추이성(transitivity): null이 아닌 모든 참조 값 x, y, z에 대해, x.equals(y)가 true이고 y.equals(z)도 true면 x.equals(z)도 true다.
- 일관성(consistency): null이 아닌 모든 참조 값 x, y에 대해, x.equals(y)를 반복해서 호출하면 항상 true를 반환하거나 항상 false를 반환한다.
- null-아님: null이 아닌 모든 참조 값 x에 대해, x.equals(null)은 false다.
[When]
※ equals 메서드는 곳곳에 함정이 있으므로 웬만하면 재정의하지 마라.
equals 메서드 재정의 하면 안 될 때
- 각 인스턴스가 본질적으로 고유할 때. 값 표현이 아니라 동작하는 개체를 표현하는 클래스. 예) Thread
- 인스턴스의 '논리적 동치성(logical equality)'을 검사할 일이 없을 때.
- 상위 클래스에서 재정의한 equals가 하위 클래스에도 딱 들어맞을 때.
- 클래스가 private이거나 package-private이고 equals 메서드를 호출할 일이 없을 때.
- 값 클래스이지만, 값이 같은 인스턴스가 둘 이상 만들어지지 않음을 보장하는 인스턴스 통제 클래스
equals 메서드 재정의 해도 될 때
- 객체 식별성(object identity; 두 객체가 물리적으로 같은가)이 아니라 논리적 동치성을 확인해야 하는데, 상위 클래스의 equals가 논리적 동치성을 비교하도록 재정의되지 않았을 때. 주로 값 클래스들. 값 클래스란 Integer와 String처럼 값을 표현하는 클래스.
[How]
1. == 연산자를 사용해 입력이 자기 자신의 참조인지 확인한다. 성능 최적화 목적
2. instanceof 연산자로 입력이 올바른 타입인지 확인한다.
3. 입력을 올바른 타입으로 형변환한다. 2번에서 instanceof 검사를 했기 때문에 이 단계는 100% 성공한다.
4. 입력 객체와 자기 자신의 대응되는 '핵심' 필드들이 모두 일치하는지 하나씩 검사한다.
5. equals를 재정의할 땐 hashCode도 반드시 재정의하자아이템11
6. 입력 타입은 반드시 Object여야 한다.
※ equals, hashCode를 작성하고 테스트하는 일은 지루하고 테스트 코드도 항상 뻔하다. 구글이 만든 AutoValue 프레임워크는 이 작업을 대신해준다.
예시1) 대칭성 위배
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 클래스를 알지만, String 클래스는 CaseInsensitiveString을 모른다.
// 옳은 예
@Override
public boolean equals(Object o) {
return o instanceof CaseInsensitiveString &&
((CaseInsensitiveString) o).s.equalsIgnoreCase(s);
}
- String과 CaseInsensitiveString 간 equals 비교를 true로 만들 생각하지 말고, CaseInsensitiveString 간의 비교만 구현한다.
예시2) 대칭성, 추이성 위배
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 final Color color;
public ColorPoint(int x, int y, Color color) {
super(x, y);
this.color = color;
}
... // 나머지 코드는 생략
}
- equals 메서드를 그대로 둔다면 Point의 구현이 상속되어 색상 정보는 무시한 채 비교를 수행한다.
// 대칭성 위배!
@Override
public boolean equals(Object o) {
if (!(o instanceof ColorPoint)) {
return false;
}
return super.equals(o) && ((ColorPoint) o).color == color;
}
- Point와 ColorPoint를 비교하면, Point의 equals는 색상을 무시하고 true를 반환하고, ColorPoint의 equals는 입력 매개변수의 클래스 종류가 다르다며 매번 false만 반환할 것이다.
// 추이성 위배!
@Override
public boolean equals(object o) {
if (!(o instanceof Point)) {
return false;
}
// o가 일반 Point면 색상을 무시하고 비교한다.
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 p3 = new ColorPoint(1, 2, Color.BLUE); // p1.equals(p2), p2.equals(p3) -> true // p1.equals(p3) -> false
- 무한 재귀에 빠질 위험도 있다. Point의 또 다른 하위 클래스로 SmellPoint를 만들고, equals를 같은 방식으로 구현한 후, myColorPoint.equals(mySmellPoint)를 호출하면 StackOverflowError를 일으킨다.
구체 클래스를 확장해 새로운 값을 추가하면서 equals 규약을 만족시킬 방법은 존재하지 않는다.
- 이 말은 얼핏, equals 안의 instanceof 검사를 getClass 검사로 바꾸면 규약도 지키고 값도 추가하면서 구체 클래스를 상속할 수 있는 것처럼 들린다. 하지만...
// 리스코프 치환 원칙 위배!
@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;
}
- 위 equals는 구현 클래스끼리 비교할 때만 true를 반환한다.
- Point의 하위 클래스는 정의상 여전히 Point이므로 어디서든 Point로써 활용될 수 있어야 한다. 그런데 이 방식에서는 그렇지 못하다.
구체 클래스의 하위 클래스에서 값을 추가하는 괜찮은 우회 방법
- "상속 대신 컴포지션을 사용하라"는 아이템18의 조언을 따르면 된다.
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.requiredNonNull(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);
}
... // 나머지 코드는 생략
}
- Point를 상속하는 대신 Point를 ColorPoint의 private 필드로 두고, ColorPoint와 같은 위치의 일반 Point를 반환하는 뷰(view) 메서드를 public으로 추가하는 식이다.
equals의 판단에 신뢰할 수 없는 자원이 끼어들게 해서는 안 된다.
- java.net.URL의 equals는 주어진 URL과 매핑된 호스트의 IP 주소를 이용해 비교한다.
- 호스트 이름을 IP 주소로 바꾸려면 네트워크를 통해야 하는데, 그 결과가 항상 같다고 보장할 수 없다.
- 이는 URL의 equals가 일반 규약을 어기게 하고, 실무에서도 종종 문제를 일으킨다.
- URL의 equals를 이렇게 구현한 것은 커다란 실수였으니 절대 따라해서는 안 된다.
'독서찰기(讀書札記) > 이펙티브 자바' 카테고리의 다른 글
[아이템 12] toString을 항상 재정의하라 (0) | 2022.01.08 |
---|---|
[아이템 11] equals를 재정의하려거든 hashCode도 재정의하라 (0) | 2022.01.08 |
[아이템 9] try-finally보다는 try-with-resources를 사용하라 (0) | 2022.01.08 |
[아이템 8] finalizer와 cleaner 사용을 피하라 (0) | 2022.01.08 |
[아이템 7] 다 쓴 객체 참조를 해제하라 (0) | 2022.01.08 |