equals 메서드는 재정의하기 쉬워보이지만 곳곳에 함정이 도사리고 있어서 자칫하면 끔찍한 결과를 초래한다.
문제를 회피하는 가장 쉬운 길은 아예 재정의를 하지 않는 것이다.
다음 경우에 해당한다면 equals를 재정의 할 필요가 없다.
- 각 인스턴스가 본질적으로 고유하다.
.값을 표현하는게 아니라 동작하는 개체를 표현하는 클래스가 여기에 해당한다. Thread가 좋은 예이다.
- 인스턴스의 논리적 통치성을 검사할 필요가 없다.
.쉽게 설명해 500원 짜리 동전 두개가 있다.
500원 짜리 동전은 두 '개' 로 인스턴스가 두개 있는 것과 같지만 둘 다 동일한 500원의 가치를 지닌다.
물건을 계산을 해야할 때 어떤 500원 짜리인지 구분해야 할 필요는 없을 것이다.
이럴 때 equals를 재정의 할 필요가 없다.
- 상위 클래스에서 재정의한 equals가 하위 클래스에도 딱 들어맞는다.
- 클래스가 private이거나 package-private이고 equals 메서드를 호출할 일이 없다.
그렇다면 equals를 재정의해야 할 때는 언제일까?
1997년과 2007년에 만들어진 두 개의 500원짜리 동전 중 어느 것이 1997년에 만들어진 것인지 논리적 동치성을 확인해야 할 때,
상위 클래스의 equals가 논리적 동치성을 비교하도록 재정의되지 않았을 때다.
주로 값 클래스가 여기 해당하는데, 값 클래스란 Integer와 String과 같이 값을 표현하는 클래스를 말한다.
두 값 객체를 equals로 비교하는 프로그래머는 객체가 같은 지가 아니라 값이 같은지 알고 싶어 할 것이다.
equals가 논리적 동치성을 확인하도록 재정의해두면, 그 인스턴스는 값을 비교하길 원하는 프로그래머의 기대에 부응함은 물론
Map의 키와 Set의 원소로 사용할 수 있게 된다.
정적 팩토리 메서드를 활용한 클래스와 같이 인스턴스를 통제하는 클래스는 equals를 재정의하지 않아도 된다.
Enum도 여기에 해당한다.
이런 클래스에서는 논리적으로 같은 인스턴스가 2개 이상 만들어지지 않으니 논리적 동치성과 객체 식별성이 사실상 똑같은 의미가 된다.
equals 메서드를 재정의할 때는 반드시 일반 규약을 따라야 한다.
다음은 Object 명세에 적힌 규약이다.
- 반사성 - null이 아닌 모든 참조 값 x에 대해, x.equals(x)는 true다.
- 대칭성 - null이 아닌 모든 참조 값 x,y에 대해, x.equals(x)면 y.equals(x)도 true다.
- 추이성- null이 아닌 모든 참조 값 x,y,z에 대해, x.equals(y)가 true이고 y.equals(z)도 true면 x.equals(z)도 true다.
- 일관성- null이 아닌 모든 참조 값 x,y,에 대해 x.equals(y)를 반복해서 호출해도 항상 같은 값을 반환한다.
- Not Null - null이 아닌 모든 참조 값 x에 대해 x.equals(null)은 false다.
제대로 읽어보면 단어가 조금 어려울 뿐 (?) 그다지 어렵진 않고 equals의 특성을 생각하게 하는 규약이다.
하지만 이 규약을 어기면 큰 어려움에 처할 수 있다.
반사성은 객체는 자기 자신과 같아야 한다는 뜻이다.
이 요건을 어긴 클래스의 인스턴스를 컬렉션에 넣은 다음 contains 메서드를 호출하면 인스턴스가 없다고 답할 것 이다.
대칭성은 두 객체는 서로에 대한 동치 여부에 똑같이 답해야 한다는 뜻이다.
// 코드 10-1 잘못된 코드 - 대칭성 위배! (54-55쪽)
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;
}
// 문제 시연 (55쪽)
public static void main(String[] args) {
CaseInsensitiveString cis = new CaseInsensitiveString("Polish");
// CaseInsensitiveString cis2 = new CaseInsensitiveString("polish");
String polish = "polish";
System.out.println(cis.equals(polish));
// System.out.println(cis2.equals(cis));
List<CaseInsensitiveString> list = new ArrayList<>();
list.add(cis);
System.out.println(list.contains(polish));
}
// 수정한 equals 메서드 (56쪽)
// @Override public boolean equals(Object o) {
// return o instanceof CaseInsensitiveString &&
// ((CaseInsensitiveString) o).s.equalsIgnoreCase(s);
// }
}
해당 클래스에서 toString은 원본 문자열의 대소문자를 그대로 돌려주지만 equals는 대소문자를 무시한다.
일관성은 두 객체가 같다면 영원히 같아야 한다는 것 이다.
가변 객체는 비교 시점에 따라 서로 다를 수도 혹은 같을 수도 있지만 불변 객체는 한번 다르면 끝까지 달라야 한다.
클래스가 불변이든 가변이든 equals의 판단에 신뢰할 수 없는 자원이 끼어들게 해서는 안 된다.
not null은 이름 처럼 모든 객체가 Null과 같지 않아야 한다는 뜻 이다.
흔히 명시적 null 검사 시
//메서드의 매개변수 Object o;
if (o == null)
return false;
이러한 패턴을 많이 사용해 입력이 null인지 확인한다.
equals에서 이러한 검사는 필요치 않다. 동치성을 검사하려면 equals는 건네받은 객체를 적절히 형변환 후 필수 필드들의 값을 알아내야 한다.
그러려면 형변환에 앞서 instanceof 연산자로 입력 매개변수가 올바른 타입인지 검사해야 한다
//메서드의 매개변수 Object o;
if (!(o instanceof MyType))
return false;
MyType mt = (MyType) o;
양질의 equals 메서드 구현 방법
1. == 연산자를 사용해 입력이 자기 자신의 참조인지 확인한다.
자기 자신이면 true를 반환한다. 이는 단순한 성능 최적화용으로, 비교 작업이 복잡할 때 값어치를 한다.
2. instanceof 연산자로 입력이 올바른 타입인지 확인한다.
그렇지 않다면 false를 반환한다. 이때의 올바른 타입은 equals가 정의된 클래스인 것이 보통이지만,
가끔 그 클래스가 구현한 특정 인터페이스가 될 수도 있다.
이런 인터페이스를 구현한 클래스라면 equals에서 해당 인터페이스를 사용해야 한다.
Set, List, Map, Map.Entry 등의 컬렉션 인터페이스들이 여기 해당한다.
3. 입력을 올바른 타입으로 형변환한다
4. 입력 객체와 자기 자신의 대응되는 핵심 필드들이 모두 일치하는지 하나씩 검사한다.
모든 필드가 일치하면 true를, 하나라도 다르면 false를 반환한다.
2단계에서 인터페이스를 사용했다면 입력의 필드 값을 가져올 때도 그 인터페이스의 메서드를 사용해야 한다.
타입이 클래스라면 해당 필드에 직접 접근할 수도 있다.
float와 double 필드는 각각 정적 메서드는 compare로 비교한다.
특수한 부동소수값 등을 다뤄야 하기 때문이다.
때론 null도 정상 값으로 취급하는 참조 타입 필드도 있다.
이런 필드는 정적메서드인 Object.equals(Object, Object)로 비교해 NPE를 예방하자.
앞서 CaseInsensitiveString 예처럼 비교하기가 아주 복잡한 필드를 가진 클래스도 있다.
이럴 때는 그 필드의 표준형을 저장해준 후 표준형 끼리 비교하면 훨씬 경제적이다.
어떤 필드를 먼저 비교하느냐가 equals의 성능을 좌우하기도 한다.
최상의 성능을 바란다면 다를 가능성이 더 크거나 비교하는 비용이 싼 필드를 먼저 비교하자.
equals를 다 구현했다면 세 가지만 자문해보자. 대칭적인가? 추이성이 있는가? 일관적인가?
또 equals를 재정의할 땐 hashCode도 반드시 재정의하자.
너무 복잡하게 해결하려 들지 말고 별칭(alias; 심볼릭 링크 등)은 비교하지 않는게 좋다.
Object 외의 타입을 매개변수로 받는 equals 메서드를 선언하지 말자.
이쯤되면 equals를 재정의 할 수 있을까 라는 의문이 든다.
그럴땐 오픈소스인 AutoValue 프레임워크 등의 도구가 도움을 줄 수 있다.
하지만 그럼에도 꼭 필요한 경우가 아니면 equals를 재정의하지 말자.
많은 경우에 Object의 equals가 원하는 비교를 정확히 수행해준다.
재정의할 때는 그 클래스의 핵심 필드 모두를 빠짐없이, 다섯 가지 규약을 확실히 지켜가며 비교해야 한다.
'JAVA > 이펙티브 자바' 카테고리의 다른 글
12. toString을 항상 재정의하라 (0) | 2023.07.01 |
---|---|
11. equals를 재정의하려거든 hashCode도 재정의하라 (0) | 2023.07.01 |
9.try-finally보다는 try-with-resources를 사용하라 (0) | 2023.06.20 |
8. finalizer와 cleaner 사용을 피하라 (0) | 2023.06.20 |
7. 다 쓴 객체 참조를 해제하라 (0) | 2023.06.19 |