3장은 모든 Object를 상속받는 객체가 공통적으로 가지는 equals와 hashcode, tostring과 같은 final이 아닌 메서드를 구현할 때 주의할 점에 대해 다룬다.
10. equals 재정의
java에서는 객체 식별성을 확인해 동치성을 확인한다. 이때 논리적인 동치성을 확인해야 할 때 equals를 재정의해야 한다.
equals를 재정의하지 않는 상황
- 각 인스턴스가 본질적으로 고유할 때
- 동작하는 개체를 표현하는 클래스 (Thread)
- 인스턴스의 논리적 동치성을 검사할 일이 없을 때
- 상위 클래스에서 재정의한 equals가 하위 클래스에 딱 맞을 때
- 대부분의 Set 구현체 : AbstractSet이 구현한 equals를 상속받아 사용
- 클래스가 private, package-private이고 equals 메서드 호출할 일이 없을 때
- 인스턴스 통제 클래스일 때
- 값 클래스일 경우에도 값이 같은 인스턴스가 둘 이상 만들어지지 않음을 보장함
- Enum
equals 재정의 시 지켜야 하는 일반 규약
equals 메서드는 동치관계를 구현하고 다음을 만족한다. (이때 모든 참조값들은 null이 아닐 때의 상황)
동치관계는 집합을 서로 같은 원소들로 이뤄진 부분집합으로 나누는 연산이고, 이 부분집합을 동치류라 한다. 동치류에 속한 어떤 원소와도 서로 교환할 수 있어야 equals 메서드를 잘 만든 것이다.
- 반사성(reflexivity) : x.equals(x)는 true
- 객체는 자기 자신과 같아야 한다.
- 대칭성(symmetry) : x.equals(y) true면 y.equals(x) true
- 두 객체는 서로에 대한 동치 여부에 똑같이 답해야 한다.
- 추이성(transitivity) : x.equals(y) true, y.equals(z)면 x.equals(z) true
- 상위 클래스에 없는 새로운 필드를 하위 클래스에 만드는 경우 어기기 쉽다.
- 구체 클래스를 확장해 새로운 값을 추가하면서 equals 규약을 만족할 방법은 존재하지 않는다.
- 상속 대신 컴포지션을 사용해 우회 가능!
- 상위 클래스를 직접 인스턴스로 만드는 것이 불가능하다면 이런 문제는 일어나지 않음 (추상클래스의 하위클래스)
- 상위 클래스에 없는 새로운 필드를 하위 클래스에 만드는 경우 어기기 쉽다.
- 일관성(consistency) : x.equals(y)를 반복 호출 시 항상 true/false 반환
- 두 객체가 같으면 수정되지 않는 한 영원히 같아야 한다.
- equals 판단에 신뢰할 수 없는 자원이 있으면 안된다.
- equals는 메모리에 존재하는 객체만을 사용한 결정적 계산만 수행해야 함
- null 아님 : x.equals(null)은 false
- 모든 객체는 null과 같지 않아야 한다.
equals 구현 방법
- == 연산자를 사용해 입력이 자기 자신의 참조인지 확인
- instanceof 연산자로 입력이 올바른 타입인지 확인
- 자신을 구현한 클래스끼리 비교 가능하도록 equals 수정한 인터페이스 구현한 클래스면 equals에서 해당 인터페이스 사용해야 함 (List, Map, Map.Entry)
- 입력을 올바른 타입으로 형변환
- 입력 객체와 자기 자신의 대응되는 핵심 필드들이 모두 일치하는지 검사
주의사항
- 기본 타입 필드는 ==으로 검사, float와 double은 Float.compare, Double.compare 사용
- 배열 필드는 각각의 원소를 위와 같이 검사
- null이 정상 값으로 취급하는 필드라면 Objects.equals로 비교
- 비교하기 복잡한 필드를 가진 클래스는 필드의 표준형 저장 후 표준형끼리 비교 (불변 클래스에 잘 맞음, 가변 클래스면 값바뀔 때마다 표준형 갱신 필요)
- 다를 가능성 크거나 비교 비용이 싼 필드 먼저 비교
- 동기화용 락 필드와 같이 객체의 논리적 상태와 관련 없는 필교 비교 X
- equals 재정의 시 hashCode 반드시 재정의
- 필드의 동치성만 비교해도 됨 (별칭 비교 X)
- Object 외의 타입을 매개변수로 받는 equals 메서드 선언 X
11. hashCode 재정의
equals 재정의한 클래스 모두에서 hashCode도 재정의 해야 한다. 재정의하지 않으면 HashMap, HashSet과 같은 컬렉션 원소로 사용 시 문제가 발생한다.
Object 명세 규약
- equals 비교에 사용되는 정보 변경되지 않으면 실행 동안 hashCode 메서드는 일관되게 같은 값 반환해야 한다.
- equals(Object)가 같다고 판단했다면 두 객체의 hashCode는 같은 값 반환해야 한다.
- = 논리적으로 같은 객체는 같은 해시코드 반환해야 함
- equals(Object)가 다르다고 판단했어도 hashCode가 다를 필요는 없다. (다른 객체에 대해 다른 값 반환해야 해시테이블 성능 좋아짐)
- 좋은 해시 함수는 서로 다른 인스턴스에 다른 해시코드 반환함
동치인 인스턴스에 대해 똑같은 해시 코드 반환 자문
hashCode 구현
파생 필드는 해시코드 계산에서 제외해도 된다.
equals 비교에 사용되지 않은 필드는 반드시 제외해야 한다.
곱하는 숫자가 짝수이고 오버플로가 발생하면 정보를 잃으니 소수를 곱해야 한다. (전통적으로 소수 곱함)
- int 변수 result 선언 후 값 c로 초기화
- 해당 객체의 나머지 핵심 필드 f 각각에 다음 작업 수행
- 해당 필드의 해시코드 c 계산
- 기본 타입 필드면 Type.hashCode(f) 수행 (Type은 기본 타입의 박싱 클래스)
- 참조 타입 필드면서 이 클래스의 equals 메서드가 이 필드의 equals 재귀적으로 호출해 비교하면 필드의 hashCode를 재귀적으로 호출
- 계산이 복잡해질 것 같으면 표준형 만들어 표준형의 ahshCode 호출
- 필드 값 null이면 0 사용
- 필드가 배열이면 핵심 원소 각각을 별도 필드처럼 다룸
- 모든 원소가 핵심 원소면 Arrays.hashCode 사용
- 2-1 단계에서 계산한 해시코드 c로 result 갱신
result = 31 * result + c;
- 해당 필드의 해시코드 c 계산
- result 반환
이외의 hashCode 구현 방법
- 해시 충돌 더 적은 방법 사용해야 하면 구아바의 com.google.common.hash.Hashing 참고
- 성능에 민감하지 않은 상황이면 Objects 클래스의 hash 사용 (속도는 더 느림)
- 클래스가 불변이고 해시코드 계산 비용 크다면 캐싱 고려
- 해시의 키로 사용되지 않는 경우라면 hashCode 처음 불릴 때 계산하는 지연 초기화 전략 (스레드 안전하게 만들도록 신경써야 함)
핵심 필드 생략하면 안됨, hashCode가 반환하는 값의 생성 규칙을 API 사용자에게 자세히 공표하지 말 것
12. toString 재정의
toString은 간결하면서 사람이 읽기 쉬운 형태의 유익한 정보를 반환해야 한다.
모든 구체 클래스에서 Object의 toString 재정의 해줄 것! (상위에서 이미 재정의한 경우 제외)
- toString을 잘 구현한 클래스는 디버깅하기 쉽다.
- 객체가 가진 주요 정보를 모두 반환하는 것이 좋다.
- 반환값의 포멧을 문서화할지 고려해야 한다.
- 포멧을 명시한다면 명시한 포멧에 맞는 문자열, 객체 상호 전환할 수 있는 정적 팩터리나 생성자 함께 제공하는 것이 좋음
- 포멧을 명시하지 않으면 포멧 개선이 가능하다는 유연성 얻게 됨
- 명시하든 아니든 주석을 작성해야 함
- toString이 반환한 값에 포함된 정보 얻어올 수 있는 API를 제공해야 한다.
- 정적 유틸리티 클래스, 열거 타입은 toString 제공/재정의하지 않아도 된다.
13. clone 재정의 주의
배열을 제외하고는 Cloneable 사용을 지양하는 것이 좋으며 복제 기능은 생성자와 팩터리를 이용하는 것이 좋다.
Cloneable 인터페이스 역할
복제해도 되는 클래스임을 명시하는 용도의 믹스인 인터페이스 (지만 의도한 목적 이루지 못함)
- clone 메서드가 Object에 선언됨, protected
- Cloneable 구현만으로 외부 객체에서 clone 메서드 호출할 수 없음
역할
- Object의 protected 메서드인 clone의 동작 방식을 결정한다.
- Cloneable 구현한 클래스의 인스턴스에서 clone 호출 시 복사한 객체 반환 / CloneNotSupportedException 던짐
clone 메서드의 일반 규약
객체의 복사본을 생성해 반환한다.
- 다음의 식들은 일반적으로 참이지만 필수는 아니다.
- x.clone() != x
- x.clone().getClass() == x.getClass()
- x.clone().equals(x)
- 관례상 메서드가 반환하는 객체는 super.clone 호출해 얻어야 한다. 이 클래스와 모든 상위클래스가 이 관례를 따르면
- x.clone().getClass() == x.getClass() 참이다.
- 관례상, 반환된 객체와 원본 객체는 독립적이어야 한다.
생성자 연쇄와 살짝 비슷
Cloneable 구현
- 제대로 동작하는 clone 메서드 가진 상위 클래스 상속
- super.clone 호출 (원본의 완벽한 복제본)
- 공변 반환 타이핑을 이용해 return (하위 클래스) super.clone();
- super.clone을 try-catch 블록으로 감싸 CloneNotSuppertedException 캐치
- 위와 같은 상황에서 클래스가 가변 객체 참조하면 큰일난다.
- super.clone()을 하게 되면 원본 인스턴스와 같은 곳을 참조해 불변식이 일그러진다. (Stack 클래스)
→생성자를 호출하면 해결 가능 - clone은 원본 객체에 아무런 해를 끼치지 않는 동시에 복제된 객체의 불변식 보장해야 한다.
- clone을 재귀적으로 호출 (배열)
- 원복 객체를 재생성하는 고수준 메서드 호출 (put 등을 이용해 원본과 복사본 내용이 같게 해주기)
→ 느리고 필드 단위 객체 복사를 우회해 Cloneable 아키텍처와 어울리지 않음
- super.clone()을 하게 되면 원본 인스턴스와 같은 곳을 참조해 불변식이 일그러진다. (Stack 클래스)
- 상속용 클래스는 Cloneable 구현하면 안된다.
- Cloneable을 구현한 스레드 안전 클래스를 작성할 때 clone 메서드도 적절하게 동기화해줘야 한다.
복사 생성자, 복사 팩터리
대부분의 상황에서는 복사 생성자, 복사 팩터리 사용해 객체 복사하는 것이 좋다.
- 언어 모순적, 위험한 객체 생성 메커니즘 사용 X
- 엉성하게 문서화된 규약에 기대지 X
- 정상적인 final 용법과 충돌 X
- 불필요한 검사 예외 던지지 X
- 형변환 필요 X
- 해당 클래스가 구현한 인터페이스 타입의 인스턴스 인수로 받기 가능 (변환 생성자, 변환 팩터리)
14. Comparable 구현
단순한 동치성 비교와 순서도 비교할 수 있고 제네릭하다.
Comparable을 구현한 객체 배열은 쉽게 정렬할 수 있다.
Arrays.sort(arr);
java의 모든 값 클래스와 열거 타입은 Comparable을 구현했으며 순서가 명확한 값 클래스 작성 시 Comparable 인터페이스를 구현하는 것이 좋다.
일반 규약
규약을 지키지 못하면 비교 활용하는 클래스를 사용할 수 없다. (TreeSet, TreeMap, Collections, Arrays)
이 객체와 주어진 객체를 비교해 이 객체가 주어진 객체보다 작으면 음의 정수, 같으면 0, 크면 양의 정수를 반환한다. 비교할 수 없는 타입의 객체가 주어지면 ClassCastException을 던진다.
- compareTo 메서드로 수행하는 동치성 검사도 반사성, 대칭성, 추이성 충족한다.
- 모든 x,y에 대해 sgn(x.compareTo(y)) == -sgn(y.compareTo(x))
- sgn은 부호 함수, 표현식 값이 음수/0/양수일 때 각각 -1/0/1 반환
- 추이성 보장
- x.compareTo(y)==0이면 sgn(x.compareTo(z)) == sgn(y.compareTo(z))
- Comparable을 구현한 클래스를 확장해 값 컴포넌트를 추가하고 싶으면 독립된 클래스를 만들고, 원래 클래스의 인스턴스 가리키는 필드를 둔 후 뷰 메서드를 제공하면 된다.
- 모든 x,y에 대해 sgn(x.compareTo(y)) == -sgn(y.compareTo(x))
- (x.compareTo(y)==0) == (x.equals(y))
- 이 권고를 지키지 않는다면 그 사실을 명시해야 한다. (이 클래스의 순서는 equals 메서드와 일관되지 않다.)
- compareTo로 수행한 동치성 테스트 결과가 equals와 같아야 한다.
- 정렬된 컬렉션들은 동치성을 비교할 때 equals 대신 compareTo를 사용하기 때문에 주의해야 한다.
메서드 작성 요령
- Comparable은 타입을 인수로 받는 제네릭 인터페이스여서 컴파일타임에 인수 타입이 정해져 타입 확인, 형변환이 필요 없다.
- Comparable 구현 안한 필드나 표준 아닌 순서로 비교해야 하면 비교자(Comparator) 대신 사용한다.
- 정수 기본타입, 실수 기본타입 필드 비교 시 박싱된 기본 타입 클래스의 정적 메서드인 compare 이용할 것
- 가장 핵심 필드부터 비교
보조 생성 메서드
- long, double은 comparingInt, thenComparingInt 의 변형 메서드
- short는 int용 버전 사용
- float은 double용 이용
- 객체 참조용 비교자 생성 메서드
- comparing 정적 메서드 2개 - 키 추출자 받아 키의 자연적 순서 사용 / 키 추출자, 비교자 사용
- thenComparing 인스턴스 메서드 3개 다중정의
- 비교자 하나만 받아 비교자로 부차 순서 정함
- 키 추출자 인수로 받아 자연적 순서로 보조 순서 정함
- 키 추출자, 비교자 받음
권장 구현 방식
- 값의 차를 기준으로 반환하는 compareTo, compare 메서드 → 오버플로우 일으키거나 부동소수점 계산 방식에 따른 오류 발생 가능함
- 정적 compare 메서드 활용 혹은 비교자 생성 메서드 활용할 것
return Integer.compare(o1.hashCode(), o2.hashCode());
static Comparator<Object> hashCodeOrder = Comparator.comparingInt(o->o.hashCode());