Programming/JAVA
[Effective JAVA] 모든 객체의 공통 메서드 (1) Equals 메소드의 재정의
sky Jeong
2021. 6. 27. 01:24
서론
- Object 객체는 만들 수 있는 구체 클래스이나, 기본적으로 상속해 사용하도록 설계됨
- Object에서 final이 아닌 메서드(equals, hashCode, toString, clone, finalize)는 모두 재정의(overriding)을 염두하고 설계됨
즉, 재정의를 위해 지켜야하는 일반 규약이 명확함 - 모든 클래스는 일반 규약에 맞게 재정의해야 함
- 만약 메서드 구현을 잘못 할 경우, HashMap, HashSet 등 이 규약을 준수한다고 가정하는 클래스에서 오동작이 발동함
고로 이번 장에서는 Object 메서드들을 언제 어떻게 재정의해야 하는지 다룸
Item 10. equals는 일반 규약을 지켜 재정의 해야 함
equals는 기본적으로 본인과 객체가 동일한지 여부를 판단해 true OR false로 값을 리턴하며, 재정의하지 않으면 그 클래스는 오직 자기 자신과 동일하게 판단함
아래 열거한 상황 중 하나라도 해당한다면 재정의하지 않는 것이 좋음
재정의를 피해야하는 경우
- 각 인스턴스가 본질적으로 고유한 경우
- 이 경우는 값을 표현하는 것이 아닌 동작하는 개체를 표현하는 클래스가 해당됨
=> 이해하기에 VO와 같은 값을 표현하는(Only read) 객체가 아닌 경우 - Thread가 좋은 예로 Object의 equals 메서드는 이러한 클래스에 맞게 구현됨
=> 활성 객체로 이루어진 Thread는 실행단위이기에 equals의 메소드를 그대로 사용하여도 문제 없음 - 값 클래스라 하여도 값이 같은 인스터스가 둘 이상 만들어지지 않음을 보장하는 '인스턴스 통제 클래스'인 경우
- Enum
=> 1-3, 1-4는 어차피 인스턴스가 두 개 이상 만들어지지 않아 논리적 동치성과 객체 식별성(Object identity: 두 객체가 물리적으로 동일한가)이 동일한 의미이기 때문에 Object의 equals가 논리적 동치성까지 확인해준다고 볼 수 있음
- 이 경우는 값을 표현하는 것이 아닌 동작하는 개체를 표현하는 클래스가 해당됨
- 인스턴스의 '논리적 동치성(logical equlity)'를 검사할 일이 없는 경우
java.util.regex.Pattern은 equals를 재정의해 두 패턴의 인스턴스가 같은 정규표현식을 나타내는지 검사함
즉, 논리적 동치성을 검사함
하지만 클라이언트가 이 방식을 원하지 않거나 애초에 필요하지 않다고 판단한 경우 Object의 equals만으로 해결할 수 있음 - 상위 클래스에서 재정의한 equals가 하위 클래스에도 딱 들어 맞는 경우
- 다시 한 번 더 말을 풀자면, 상위 클래스에서 정의한 equals가 하위 클래스에도 알맞게 사용된다면 재정의하지 않아도 됨
- 구현체 예시를 살피자면..
- 대부분의 Set 구현체는 AbastractSet이 구현한 equals를 상속 받음
- List 구현체는 AbstractList로,
- Map 구현체는 AbstractMap으로 부터
- private class이거나 package-private이고 equals 메서드를 호출할 일이 없는 경우
- 내부 클래스일 경우 equals를 사용하는 일이 없다고 봐도 무방
- 만약 이를 사전에 차단하고 싶다면 아래 코드처럼 막을 수 있음
@Override
public boolean equlas(Object o) {
throw new AssertionError( );
}
equals를 재정의해야 하는 경우
- 두 개체의 메모리 주소가 동일한지 비교하는 것이 아닌 논리적 동치성을 비교해야 할 때
- 컴퓨터의 개념으로 접근해보자.
메모리 주소 = 실체가 있는 물리적 개념 // 할당된 값 = 해당 메모리 주소가 갖고 있는 실체가 없는 논리적 개념
이와 같이 생각하고 보면 좋음 - 주로 값을 나타내는 VO(Value Object)가 이에 해당됨
- 개체 자체가 동일한지 판단하는 것이 아닌 개체가 가진 값을 비교하는 것이 중요할 때 재정의해야 함
- 위 말을 종합하자면, 논리적 동치성을 비교해야 하지만 구현된 equals 메소드는 논리적 동치성을 비교하도록 재정의 되지 않았다면 재정의해야 함
- 컴퓨터의 개념으로 접근해보자.
- Map의 키, Set의 원소로 사용하고자 할 경우
equals 재정의 규약
- 반사성(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면, z.equals(x)도 true임
- 일관성(consistency): null이 아닌 모든 참조 값 x, y에 대해 x.equals(y)를 반복해 호출하면 항상 true를 반환하거나 false를 반환함
- null-아님: null이 아닌 모든 참조 값 x에 대해 x.equals(null)은 항상 false임
- 위 규약을 어길 경우 서론에 명시되어 있던 '당연히 이 규약을 준수한다고 생각하는 메서드'에서 오작동이 발생함
- 수많은 객체들은 전달 받은 객체가 당연히 equals 규약을 지킬 것으로 가정하고 동작하기에 앞서 언급된 재정의의 규칙과 규약을 명확히 따라야 함
Object 명세에서의 동치관계
- 집합을 서로 같은 원소들로 이루어진 부분집합으로 나누는 연산
- 부분집합을 동치 클래스(equivalence class == 동치류)라고 함
- equals 메소드가 쓸모 있으려면 모든 원소가 같은 동치류에 속한 어떤 원소와도 서로 교환할 수 있어야 함
동치관계 만족을 위한 다섯 요건 [재정의 규약과 연결]
- 반사성
- 객체는 자기 자신과 같아야 함
- 반사성 요건을 어긴 클래스의 인스턴스를 컬렉션에 넣고 contains 메서드를 호출한다면 false가 반환될 것임
- 대칭성
- 두 객체는 서로 동치 여부에 똑같이 답해야 함
- x == y --> y == x
- 예시
- 대소문자를 구별하지 않는 문자열
- 위 코드에 한 방향으로만 작동해 대칭성이 위배되고 있다는 것은
x.equals(y) && y.equals(x) 를 만족하지 못한다는 뜻 - 실행 결과
- CaseInsensitiveString은 String의 존재를 알고 재정의를 하였지만,
일반 String의 equals는 CaseInsensitiveString의 존재를 모름
- 위 코드에 한 방향으로만 작동해 대칭성이 위배되고 있다는 것은
- 이 예시처럼 equals 규약을 어기면 해당 객체를 사용하는 다른 객체들이 어떻게 반응하는지 알 수 없음
- 위 코드의 문제를 해결하려면 CaseInsensitiveString의 equals를 String과 연동하는 생각을 버려야 함
=> equalsIgnoreCase(문자열) : 대소문자 구분없이 비교 / equals(문자열) : 대소문자를 구분해 비교
- 대소문자를 구별하지 않는 문자열
- 추이성
- x == y 이고, y == z라면, x == z가 성립된다는 것
- 이 요건 또한 자칫하면 어기기 쉬움
- 예시. 상위 클래스에 없는 새로운 필드를 하위 클래스에 추가하는 상황
- 먼저 x와 y를 지정하는 Point class가 존재함
- 그리고 Color를 가진 color와 해당 포인트에 색을 입히는 PointColor가 존재함
- 이때 재정의된 equals 메서드는 어떻게 해야 할까?
- 만약 상속을 무시하고 그대로 둔다면 Point의 equals 메소드는 PointColor의 정보를 무시한 채 비교를 수행함
- equals 규약을 어긴 것은 아니나, 중요한 정보를 놓친 것이니 올바른 사용이라 할 수 없음
- 먼저 x와 y를 지정하는 Point class가 존재함
- 위배 예시
- 아래 코드는 비교 대상이 PointColor이고, 위치와 색상이 같을 때만 true를 반환함
위 코드는 비교의 결과가 다를 수 있음
- Point의 equals는 색상을 무시하며
- PointColor의 equals는 입력 매개변수의 클래스 종류가 달라 매번 false를 반환할 것임
- 그렇다면 PointColor의 equals가 Point와 비교할 때 색상을 무시한다면 위 문제가 해결되는가?
- 위 방식은 대칭성은 지켜지지만 추이성은 위배됨
- 결과는 아래와 같음
- 위 코드는 추이성에 위배됨
- 왜냐, PointColor와 Point의 비교에서는 색상이 무시되나, PointColor간의 비교는 색상까지 고려되었기 때문임
- 위 방식은 재귀에 빠지는 위험이 도사림.
이는 모든 객체 지향 언어의 동치 관계에서 나타나는 근본적 문제로 구체 클래스를 확장해 새로운 값을 추가하며 equals 규약을 만족시킬 방법은 없음 - 리스코프 치환 원칙에 따르면 어떤 타입에 있어 중요한 속성이라면 그 하위 타입에서도 마찬가지로 중요함.
이 말은 예시 코드에 적용하자면..
"Point의 하위 클래스는 정의상 여전히 Point이기에 어디서든 Point로써 활용될 수 있어야 함"이라는 뜻
- 아래 코드는 비교 대상이 PointColor이고, 위치와 색상이 같을 때만 true를 반환함
위 코드는 비교의 결과가 다를 수 있음
- 그렇다면 값을 추가하며 문제를 해결할 방법은 없는가?
- 우회 방법이 존재함. "상속 대신 컴포지션을 사용하라(item 18)"를 따르면 됨.
- Point를 상속하는 대신 Point를 PointColor의 private 필드로 두고, ColorPoint와 같은 위치의 일반 Point를 반환하는 View 메서드를 public으로 추가
- 해석하자면..
- (지역변수) pointColor가 가진 point와 이를 호출한 객체의 point가 동일한가
- (지역변수) pointColor가 가진 color와 이를 호출한 객체의 color가 동일한가
- 해석하자면..
- 조금 정리하자면..
- 추상 클래스의 하위 클래스에서라면 equals 규약을 지키며 값 추가가 가능함
- item 23조언인 태그 달린 클래스보다 클래스 계층 구조를 활용하라는 것은 계층구조에서 아주 중요한 사실!
- 일관성
- 두 객체가 같다면 앞으로도 영원히 같아야 함
- 가변 객체는 비교 시점에 따라 결정된다면, 불변 객체는 한 번 다르면 끝까지 다르고 한 번 같다면 끝까지 같아야 함
- 가변을 할지 불변을 할지는 item 17 참고
- 불변 클래스로 만들기로 결정했다면 equals가 한 번 같다고 한 객체는 영원히 같게, 한 번 다르다고 한 객체도 영원히 다르게 답하도록 만들어야 함
- 어찌되었든 중요한 것은 equals의 판단에 신뢰할 수 없는 자원이 끼어서는 안 됨
- 일관성의 제약을 어기는 예시 - java.net.URL
- 해당 equals는 주어진 URL과 매핑된 호스트의 IP 주소를 이용해 비교함
- 호스트 이름을 IP 주소로 바꾸려면 네트워크를 통해야 하는데 결과가 항상 같을 수 없음
- 이러한 equals 구현은 절대 따라해서는 안되는데,
이 문제를 피하려면 equals는 항시 메모리에 존재하는 객체만을 사용해 결정적 계산만 수행해야 함
- null-아님
- 말 그대로 모든 객체가 null이 아니어야 한다는 뜻
- 구현시 명시적 검사를 할 필요 없음
- 이 말은 무엇이냐. 묵시적 검사가 더 좋다는 뜻.
- 어차피 받은 Object가 null이라면 비교하고자 하는 클래스와 다름
양질의 Equals 메서드 구현 단계별 정리
- == 연산자를 사용해 입력이 자기 자신 참조인지 확인
- 만약 자기 자신이라면 true 반환
- 성능 최적화를 위한 것으로 비교 작업이 복잡한 상황에서 유용
- instanceof 연산자로 입력이 올바른 타입인지 확인
- 만약 올바른 타입이 아니라면 false 반환
- 여기서 정의하는 올바른 타입은 equals가 정의된 클래스.
가끔은 그 클래스가 구현한 특정 인터페이스가 될 수도 있음. (????????) - 인터페이스는 자신을 구현한 클래스 끼리 비교할 수 있도록 equals 규약을 수정하기도 함
이러한 인터페이스라면 equals에서 클래스가 아닌 해당 인터페이스로 비교해야 함 - Set, List, Map, Map.Entry 등의 컬렉션 인터페이스가 여기에 해당함
- 입력을 올바른 타입으로 형변환 함
앞서 instanceof 검사를 했기에 이 단계는 100% 성공함 - 입력 객체와 자기 자신의 대응되는 핵심 필드들이 모두 일치하는지 하나씩 검사함
- 모든 필드가 일치하면 true
- 하나라도 다르면 false 반환
- 2단계에서 인터페이스를 사용했다면 입력의 필드 값을 가져올 때도 인터페이스의 메서드를 사용해야 함
- float와 double을 제외한 기본 타입 필드는 == 연산자로 비교하며, 참조 타입 필드는 각각의 equals 필드로 비교
- float와 double 필드는 각각 정적 메서드인 Float.compare(float, float)와 Double.compare(double, double)로 비교
- 다른 방법으로 비교하는 이유는 Float.NaN, -0.0f, 특수 부동소수 값 등을 다뤄야 하기 때문
- Float.equals와 Double.equals 사용도 가능하나 성능상 좋지 않음
- 배열 필드는 앞선 지침대로 비교하며, 배열의 모든 원소가 핵심 원소라면 Arrays.equals 메서드들 중 하나 사용
- Null도 정상 값으로 취급하는 참조 필드도 존재함
- 이는 정적 메서드인 Objects.equals(Object, Object)로 비교해 NullPointerException 발생을 예방할 것
- CaseInsensitiveString의 예시처럼 비교하기 복잡한 필드의 클래스는?
- 그 필드의 표준형을 저장한 후 표준형끼리 비교하는 것이 좋음
- 이는 불변 클래스에 적합함
- 가변 객체일 경우 값이 바뀔 때마다 표준형을 최신 상태로 갱신해줘야 함
- 어떤 필드를 먼저 비교하는지에 따라 성능이 좌우됨
- 최상의 성능
- 다를 가능성이 더 크거나 비교 비용이 저렴한 필드를 먼저 비교
- 동기화용 락(Lock) 필드와 같이 객체의 논리적 상태와 관련 없는 필드는 비교 X
- 핵심 필드로부터 계산할 수 있는 파생 필드는 굳이 비교하지 않아도 됨
- 하지만 간혹 파생 필드를 비교하는게 더 빠를 때도 있음 (이 경우는 파생 필드가 객체 전체 상태를 대표하는 경우)
- 최상의 성능
Equals를 모두 구현했다면 세 가지만 자문해보자. 대칭성? 추이성? 일관적?
- 자문 후 단위 테스트를 진행해보는 것이 좋음
- 위에 언급에 따른 equals 메소드 재정의
주의사항
- equals를 재정의할 때 hashCode도 반드시 재정의해야 함(item 11)
- 너무 복잡하게 해결하려 하면 안 됨
- 필드의 동치성만 검사해도 equals 규약을 어렵지 않게 지킬 수 있음
- 별칭(alias)는 비교하지 않는게 좋음
- Filter 클래스라면 심볼릭 링크를 비교해 같은 파일을 가리키는지 확인하면 안됨
- Object 외의 타입을 매개변수로 받는 equals 메서드는 선언하지 말 것.
즉, 입력 타입은 반드시 Object이어야 함 - @Override를 일관되게 사용하므로 긍정 오류를 내게 하며, 보안 측면에서 잘못된 정보를 알려줌
심볼릭 링크
컴퓨팅에서 심볼릭 링크 또는 기호화된 링크는 절대 경로 또는 상대 경로의 형태로 된 다른 파일이나 디렉터리에 대한 참조를 포함하고 있는 특별한 종류의 파일
- equals(hashCode도 마찬가지)를 작성하고 테스트하는 일은 거의 반복됨
- 이를 대신하는 오픈소스가 존재하는데 이는 구글이 만든 AutoValue 프레임워크임
- 클래스에 Annotation을 하나 추가하면 AutoValue가 이 메서드들을 알아서 작성하며, 직접 작성하는 것과 근본적으로 같은 코드를 만듦
핵심 정리
- 꼭 필요한 경우가 아니면 equals를 재정의하지 말 것
- 많은 경우 Object의 equals가 원하는 비교를 정확히 수행함
- 재정의해야 할 때는 그 클래스의 핵심 필드 모두를 빠짐없이, 다섯 가지 규약을 확실히 지켜 비교해야 함