아이템 10. equals는 일반 규약을 지켜 재정의하라
equals 메서드는 다음에서 열거한 상황 중 하나에 해당한다면 재정의하지 않는 것이 최선이다.
- 각 인스턴스가 본질적으로 고유하다.
- 값을 표현하는 게 아니라 동작하는 개체를 표현하는 클래스가 여기 해당한다.
- 인스턴스의 '논리적 동치성(logical equality)'을 검사할 일이 없다.
- 논리적 동치성을 검사할 필요가 없다면, Object의 기본 equals만으로 충분하다.
- 상위 클래스에서 재정의한 equals가 하위 클래스에도 딱 들어맞는다.
- ex. Set 구현체와 AbstractSet, List 구현체와 AbstractList, Map 구현체와 AbstractMap
- 클래스가 private이거나 package-private이고 equals 메서드를 호출할 일이 없다.
equals 메서드를 재정의해야 할 때는 언제일까?
- 객체 식별성(object identity, 두 객체가 물리적으로 같은가)이 아니라 논리적 동치성을 확인해야 하는데, 상위 클래스의 equals가 논리적 동치성을 비교하도록 재정의되지 않았을 때이다.
- 주로 값 클래스들이 여기 해당하며, 값 클래스란 Integer와 String처럼 값을 표현하는 클래스를 말한다.
- equals가 논리적 동치성을 확인하도록 재정의해두면, 그 인스턴스는 값을 비교할 수 있음과 동시에 Map의 키와 Set의 원소로 사용할 수 있게 된다.
인스턴스 통제 클래스에서의 equals 메서드 재정의
- 값 클래스라 해도, 값이 같은 인스턴스가 둘 이상 만들어지지 않음을 보장하는 인스턴스 통제 클래스라면 equals를 재정의하지 않아도 된다.
- Enum이 여기에 해당한다.
- 이런 클래스에서는 논리적으로 같은 인스턴스가 2개 이상 만들어지지 않으니 논리적 동치성과 객체 식별성이 사실상 똑같은 의미가 된다.
- 따라서 Object의 equals가 논리적 동치성까지 확인해준다고 볼 수 있다.
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다.
equals 규약을 어기면 그 객체를 사용하는 다른 객체들이 어떻게 반응할지 알 수 없다.
- 반사성은 단순히 말하면 객체는 자기 자신과 같아야 한다는 뜻이다.
- 대칭성은 두 객체는 서로에 대한 동치 여부에 똑같이 답해야 한다는 뜻이다.
- 추이성은 첫 번째 객체와 두 번째 객체가 같고, 두 번째 객체와 세 번째 객체가 같다면, 첫 번째 객체와 세 번째 객체도 같아야 한다는 뜻이다.
- 구체 클래스를 확장해 새로운 값을 추가하면서 equals 규약을 만족시킬 방법은 존재하지 않는다(객체 지향적 추상화의 이점을 포기하지 않는 한).
- 리스코프 치환 원칙(Liskov substitution principle)에 따르면, 어떤 타입에 있어 중요한 속성이라면 그 하위 타입에서도 마찬가지로 중요하다.
- 따라서 그 타입의 모든 메서드가 하위 타입에서도 똑같이 잘 작동해야 한다.
Set을 포함하여 대부분의 컬렉션은 주어진 원소를 담고 있는지를 확인할 때, equals 메서드를 이용한다.
- 일관성은 두 객체가 같다면 (어느 하나 혹은 두 객체 모두가 수정되지 않는 한) 앞으로도 영원히 같아야 한다는 뜻이다.
- 클래스가 불변이든 가변이든 equals의 판단에 신뢰할 수 없는 자원이 끼어들게 해서는 안 된다.
- 이 제약을 어기면 일관성 조건을 만족시키기가 아주 어렵다.
- equals는 항시 메모리에 존재하는 객체만을 사용한 결정적(deterministic) 계산만 수행해야 한다.
- null-아님은 이름처럼 모든 객체가 null과 같지 않아야 한다는 뜻이다.
instanceof 연산자
- instanceof는 (두 번째 피연산자와 무관하게) 첫 번째 피연산자가 null이면 false를 반환한다.
- 따라서 입력이 null이면 타입 확인 단계에서 false를 반환하기 때문에 null 검사를 명시적으로 하지 않아도 된다.
단계별 equals 메서드 구현 방법
- == 연산자를 사용해 입력이 자기 자신의 참조인지 확인한다.
- instanceof 연산자로 입력이 올바른 타입인지 확인한다.
- 입력을 올바른 타입으로 형변환한다.
- 입력 객체와 자기 자신의 대응되는 '핵심' 필드들이 모두 일치하는지 하나씩 검사한다.
기본 타입과 참조 타입에서의 비교
- float와 double을 제외한 기본 타입 필드는 == 연산자로 비교한다.
- 참조 타입 필드는 각각의 equals 메서드로 비교한다.
- float와 double 필드는 각각 정적 메서드인 Float.compare(float, float)와 Double.compare(double, double)로 비교한다.
- float와 double을 특별 취급하는 이유는 Float.NaN, -0.0f, 특수한 부동소수 값 등을 다뤄야 하기 때문이다.
- Float.equals와 Double.equals 메서드를 대신 사용할 수도 있지만, 이 메서드들은 오토박싱을 수반할 수 있으니 성능상 좋지 않다.
배열에서의 비교
- 배열 필드는 원소 각각을 앞서의 지침대로 비교한다.
- 배열의 모든 원소가 핵심 필드라면 Arrays.equals 메서드들 중 하나를 사용하자.
null을 정상 값으로 취급하는 참조 타입에서의 비교
- 이런 필드는 정적 메서드인 Objects.equals(Object, Object)로 비교해 NullPointerException의 발생을 예방해야 한다.
equals와 필드의 비교 순서
- 최상의 성능을 바란다면 다를 가능성이 크거나 비교하는 비용이 싼 (혹은 둘 다 해당하는) 필드를 먼저 비교하자.
- 동기화용 락(lock) 필드 값이 객체의 논리적 상태와 관련 없는 필드는 비교하면 안 된다.
- 핵심 필드로부터 계산해낼 수 있는 파생 필드 역시 굳이 비교할 필요는 없다.
- 경우에 따라서는 파생 필드를 비교하는 쪽이 더 빠를 수도 있다. 파생 필드가 객체 전체의 상태를 대표하는 상황이 그렇다.
equals를 재정의할 때의 주의사항
- equals를 재정의할 땐 hashCode도 반드시 재정의해야 한다.
- 너무 복잡하게 해결하려 들지 마라. 필드들의 동치성만 검사해도 equals 규약을 어렵지 않게 지킬 수 있다.
- 일반적으로 별칭(alias)은 비교하지 않는 게 좋다.
- Object 외의 타입을 매개변수로 받는 equals 메서드는 선언하지 말아야 한다.
- equals를 재정의할 때 입력 타입은 반드시 Object여야 한다.
- 입력 타입이 Object가 아닐 경우, 이는 재정의가 아니라 다중정의한 것이다.
- 만약 다중정의를 할 경우, 하위 클래스에서의 @Override 애너테이션이 긍정 오류(false positive; 거짓 양성)를 내게 하고 보안 측면에서도 잘못된 정보를 준다.
- 이 경우, @Override 애너테이션을 일관되게 사용하면 이런 실수를 예방할 수 있다.
핵심 정리
꼭 필요한 경우가 아니면 equals를 재정의하지 말자. 많은 경우에 Object의 equals가 여러분이 원하는 비교를 정확히 수행해준다. 재정의해야 할 때는 그 클래스의 핵심 필드 모두를 빠짐없이, 다섯 가지
규약을 확실히 지켜가며 비교해야 한다.
참고자료