이펙티브 자바 -2-
모든 객체의 공통 메서드
equals는 일반 규약을 지켜 재정의하라
equals를 재정의 하지 않으면 그 클래스 인스턴스는 오직 자기 자신과만 같게 된다.
다음과 같은 상황이면 재정의 X
- 각 인스턴스가 본질적으로 고유한 객체 : 값을 표현하는 게 아닌 동작하는 클래스(ex Thread 클래스)
- 인스턴스의 '논리적 동치성' (논리적으로 같음)을 검사할 일 없을 때(equals 호출할 일 없을 때)
- 상위 클래스에서 재정의한 equals가 하위 클래스에도 딱 들어맞을 때
equals 재정의 할 때 : 객체의 식별성(두 객체가 물리적으로 같은 가)이 아니라 논리적 동치성을 확인해야 하는데 상위 클래스의 equals가 논리적 동치성을 비교하도록 재정의되지 않았을 때
equals 메서드 일반 규약 (재정의시 지켜야할 규약)
- 반사성 : null이 아닌 모든 참조 값 x에 대해, x.equals(x)는 true다
- 대칭성 : null이 아닌 모든 참조 값 x,y에 대해, x.equals(y)가 true면 y.equals(x)도 true다
- 추이성 : null이 아닌 모든 참조 값 x,y,z에 대해, x.equals(y)가 true 이고 y.equals(x)가 true면, x.equals(z)도 true다
- 일관성 : null이 아닌 모든 참조 값 x,y에 대해, x.equals(y)를 반복해서 호출하면 항상 true거나 항상 false이다
- null-아님 : null이 아닌 모든 참조 값 x에 대해, x.equals(null)은 false다.
equals 규약을 어기면 그 객체를 사용하는 다른 객체들이 어떻게 반응할지 알 수 없다.
equals 구현 방법
- == 연산자를 사용해 입력이 자기 자신의 참조인지 확인한다
- instanceof 연산자로 입력이 올바를 타입인지 확인한다
- 입력을 올바른 타입으로 형변환한다
- 입력 객체와 자기 자신의 대응되는 '핵심' 필드들이 모두 일치하는 지(논리적 동치성) 검사한다.
tip
- equals를 다 구현했다면 세 가지 자문하자 : 대칭적인가? 추이성이 있는가? 일관적인가?
- equals를 재정의 할 때 hashCode도 반드시 재정의하자
- 너무 복잡하게 해결하려 들지 말자
- Object 외의 타입을 매개변수로 받는 equals 메서드 선언하지 말자 - ex) 잘못된 예 : equals(MyClass o) 입력 타입은 반드시 Object여야 한다 @Override로 컴파일 타임에 검사하는 것도 좋다.
정리
꼭 필요한 경우가 아니면 equals 정의하지 말고, 정의해야 한다면 equals 규약을 모두 지켜야 한다
추가
* float과 double은 Float.NaN, -0.0f, 특수한 부동소수 값을 다루어야 하기 때문에 내장 compare함수로 비교
*클래스가 불변이든 가변이든 equals의 판단에 신뢰할 수 없는 자원이 끼어들게 해서는 안된다.
(일관성을 만족해야한다)
*equals는 자기 자신의 type만 비교하도록 재정의 할 것
* 구체 클래스를 확장해 새로운 값을 추가하면서 equals 규약을 만족시킬 방법은 존재하지 않는다.
(단, 상위 클래스를 직접 인스턴스로 만들 수 있어야 성립된다.)
ex) Point : 좌표 비교, ColorPoint : Ponit 비교 + 색깔 비교
좌표는 같고 색은 틀릴 때, Point.equals(ColorPoint) => true, ColorPoint.equals(Point) => false
Point의 하위 객체가 ColorPoint 이기 때문에 만족시킬수가 없음
equals를 재정의하려거든 hashCode도 재정의하라
equals를 재정의한 클래스 모두에서 hashCode도 재정의해야 한다.
hashcode의 규약
- equals비교에 사용되는 정보가 변경되지 않았다면, 객체의 hashcode 메서드는 몇번을 호출해도 항상 일관된 값을 반환해야 한다.(단, Application을 다시 실행한다면 값이 달라져도 상관없다. (메모리 주소가 달라지기 때문))
- equals메서드 통해 두 개의 객체가 같다고 판단했다면, 두 객체는 똑같은 hashcode 값을 반환해야 한다.
- equals메서드가 두 개의 객체를 다르다고 판단했다 하더라도, 두 객체의 hashcode가 서로 다른 값을 가질 필요는 없다. (Hash Collision) 단, 다른 객체에 대해서는 다른 값을 반환해야 해시테이블의 성능이 좋아진다.
논리적으로 같은 객체는 같은 해시코드를 반환해야한다.
class Pair{
private int t1;
private int t2;
public Pair(int t1, int t2) {
this.t1 = t1;
this.t2 = t2;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Pair pair = (Pair) o;
return t1 == pair.t1 && t2 == pair.t2;
}
}
public class Main {
public static void main(String[] args) throws IOException{
HashMap<Pair,Integer> hashMap = new HashMap<>();
hashMap.put(new Pair(1,3),4);
Integer integer = hashMap.get(new Pair(1, 3));
System.out.println(integer); // null이 나온다
}
}
Pair 클래스는 hashCode를 재정의 하지 않았기 때문에 논리적인 동치인 두 객체가 서로 다른 해시 코드를 반환하여 두번째 규약을 지키지 못한다. 적절한 hashCode 메서드를 작성해 주어야 한다
좋은 해시 함수라면 서로 다른 인스턴스에 다른 해시코드를 반환한다
좋은 hashCode 작성하는 간단한 요령
- int 변수인 result를 선언한 후 값을 c로 초기화한다.
- 이 때, c는 해당 객체의 첫번째 핵심 필드를 단계 2.1 방식으로 계산한 해시코드이다.
- 여기서 핵심 필드는 equals 비교에 사용되는 필드를 말한다.
- 해당 객체의 나머지 핵심 필드인 f 각각에 대해 다음 작업을 수행한다.
- 해당 필드의 해시코드 c 를 계산한다.
- 기본 타입 필드라면, Type.hashCode(f)를 수행한다. 여기서 Type은 해당 기본타입의 박싱 클래스다.
- 참조 타입 필드면서, 이 클래스의 equals 메소드가 이 필드의 equals를 재귀적으로 호출하여 비교한다면, 이 필드의 hashCode를 재귀적으로 호출한다.
- 필드가 배열이라면, 핵심 원소 각각을 별도 필드처럼 다룬다.
모든 원소가 핵심 원소라면 Arrays.hashCode를 사용한다.
- 단계 2.1에서 계산한 해시코드 c로 result를 갱신한다.
- result = 31 * result + c;
- 해당 필드의 해시코드 c 를 계산한다.
- result를 반환한다.
hashcode를 편하게 만들어 주는 모듈
- Objects.hash()
- 내부적으로 AutoBoxing이 일어나 성능이 떨어진다.
- Lombok의 @EqualsAndHashCode
- Google의 @AutoValue
hashcode를 재정의 할 때 주의 할 점!
- 불변 객체에 대해서는 hashcode 생성비용이 많이 든다면, hashcode를 캐싱하는 것도 고려하자
- 스레드 안전성까지 고려해야 한다.
- 성능을 높인답시고 hashcode를 계산할 떄 핵심필드를 생략해서는 안된다.
- 속도는 빨라지겠지만, hash품질이 나빠져 해시테이블 성능을 떨어뜨릴 수 있다 (Hashing Collision)
- hashcode 생성규칙을 API사용자에게 공표하지 말자
- 그래야 클라이언트가 hashcode값에 의지한 코드를 짜지 않는다.
- 다음 릴리즈 시, 성능을 개선할 여지가 있다.
정리
equals를 재정의할 때는 hashCode도 반드시 재정의해야 한다. 그렇지 않으면 프로그램이 제대로 동작하지 않을 것이다. 좋은 hashCode를 만들어 hashCode 메서드를 재정의 하자
toString을 항상 재정의하라
toString의 일반 규약에 따라 '간결하면서 사람이 읽기 쉬운 형태의 유익한 정보'를 반환해야 한다
toString을 잘 구현한 클래스는 사용하기에 좋고, 그 클래스를 사용한 시스템은 디버깅하기 쉽다.
toString 재정의 요령
- 실전에서 toString은 그 객체가 가진 주요 정보 모두를 반환하는 것이 좋다
- toString을 의도대로 명확하게 작성을 해야한다.
- toString이 반환한 값에 포함된 정보를 얻어올 수 있는 API를 제공해라 - toString을 파싱하는 것은 비효율적이다
toString을 따로 재정의 안해도 되는 경우
- 정적 Utils 클래스는 따로 재정의 하지 않아도 된다. (객체의 상태(state)를 가지는 클래스가 아니기 떄문)
- enum 타입 또한 이미 완벽한 toString을 제공한다.
- 대다수의 컬렉션 구현체는 추상 컬렉션 클래스(AbstractMap, AbstractSet등)의 toString 메서드를 상속하여 쓴다.
- 라이브러리를 통해 자동생성하자
- 구글의 @Autovalue
- Lombok의 @ToString
- 위의 라이브러리들을 이용해 자동생성하는 편이 더 간편하다.
clone 재정의는 주의해서 진행하라
Cloneable은 복제해도 되는 클래스임을 명시하는 용도의 믹스인 인터페이스이다.
Cloneable 인터페이스는 메서드가 하나도 없다. 아무것도 없지만, 사실 Object의 protected 메서드인 clone의 동작 방식을 결정한다.
Cloneable을 구현한 클래스의 인스턴스에서 clone을 호출하면 그 객체의 필드들을 복사한 객체가 반환되며, 구현하지 않은 클래스에서 호출하면 CloneNotSupportedException을 던진다.
Object에 명시된 clone 규약
- x.clone() != x은 참이다.
복사한 객체와 원본 객체는 서로 다른 객체이다. - x.clone() .getClass() == x.getClass()은 일반적으로 참이다. 하지만 반드시 만족해야 하는 것은 아니다.
- x.clone.equals(x) 은 참이다.
복사한 객체와 원본객체는 논리적 동치성이 같다. - x.clone().getClass() == x.getClass()은 참이다.
관례상, 이 방법으로 반환된 객체는 독립성이 있어야 한다.
이를 만족하려면 super.clone으로 얻은 객체의 필드 중 하나 이상을 반환 전에 수정해야 할 수도 있다. - Cloneable을 구현하지 않은 클래스의 경우, CloneNotSupportedException이 throw된다.
- 모든 Array는 Cloneable을 구현하도록 고려되었다. clone 메서드는 T[]를 리턴하도록 설계
- T는 기본타입 또는 참조타입으로 설계
- 기본적으로 Object.clone은 clone대상 클래스에 대해 새로운 객체를 new로 생성
- 모든 필드들에 대해 초기화를 진행
- 하지만 필드에 대한 객체를 다시 생성하지 않는 Shallow copy 방식으로 수행한다 (deep copy아님)
- 클래스에 대한 복제본을 원치 않는 경우 clone메서드를 재정의해 CloneNotSupportedException을 throw하도록 함
기본적인 clone 메서드 재정의
class PhoneNumber implements Cloneable {
@Override
public PhoneNumber clone() {
try {
return (PhoneNumber) super.clone();
} catch(ClassNotSupportedException e) {
//아무처리를 하지 않거나, RuntimeException으로 감싸는 것이 사용하기 편하다.
}
}
}
- super.clone()을 실행하면 PhoneNumber에 대한 완벽한 복제가 이루어진다.
- super.clone()의 리턴 타입은 Object이지만, 자바의 공변 반환타이핑 기능을 통해 PhoneNumber 타입으로 캐스팅하여 리턴하는 것이 가능하다.
가변 상태를 참조하는 클래스용 clone 메서드
public class Stack implements Cloneable{
private Object[] elements;
private int size = 0;
private static final int DEFAULT_INITIAL_CAPACITY = 16;
public Stack() {
this.elements = new Object[DEFAULT_INITIAL_CAPACITY];
}
public void push(Object o) {}
}
...
@Override
public Stack clone() {
try {
Stack result = (Stack) super.clone();
result.elements = elements.clone();
} catch(CloneNotSupportedException e) {
}
}
}
super.clone()으로 원본 stack과 동일한 Stack인스턴스가 생성된다. 하지만 Stack의 내부 Obejct[] 배열은 참조 type으로 원본의 값을 복사하는 것이 아닌 참조를 복사한다(Shallow copy). 이는 원본 객체에 아무런 해를 끼치지 않는 동시에 복제된 객체의 불변식(원본이나 복사본을 수정하면 다른 하나도 수정된다)을 보장할 수 없다. 따라서 배열의 clone 메서드를 사용해서 내부 값을 복사해야 한다.
배열 복사
배열의 clone은 런타임 타입과 컴파일타입 모두가 원본 배열과 똑같은 배열을 반환한다. 배열 필드가 final이 적용되어 있다면 clone을 통한 초기화는 할 수 없다. (final이기 때문에 객체 생성 이후 초기화 불가) Cloneable 아키텍처는 가변객체를 참조하는 필드는 final로 선언하라 라는 일반 용법과 충돌한다.그래서 복제 할 수 있는 클래스를 만들기 위해 일부 필드에서 final 한정자를 제거해야 할 수도 있다
스레드 안전성을 고려한다면 적절히 동기화해야 한다.
@Override
public synchronized Object clone() {
try {
Object result = super.clone();
} catch(CloneNotSupportedException e) {
}
}
스레드 안정성을 고려한다면 clone 메서드에 대해 적절히 동기화 처리 해야 한다.
복사 생성자와 복사 팩터리 메서드 - 더 나은 객체 복사 방식
Clonable 이미 구현한 클래스에서 확장한다면 clone을 잘 작동하도록 구현해야하지만, 그렇지 않은 상황에서는 복사 생성자와 복사 팩터리 메서드가 유용하다
// 복사 생성자
public Yum(Yum yum) {}
// 복사 팩토리
public static Yum newInstance(Yum yum) {}
복사 생성자와 복사 팩터리 메서드는 Cloneable/clone 방식보다 나은 면이 많다.
- 언어 모순적이고 위험한 객체 생성 메커니즘을 사용하지 않는다. (super.clone())
- clone 규약에 기대지 않는다.
- 정상적인 final필드 용법과도 충돌하지 않는다.
- 불필요한 check exception 처리가 필요없다.
- 형변환도 필요없다.
- 복사 생성자와 복사 팩터리는 인터페이스 타입의 인스턴스를 인수로 받을 수 있다.
정리
새로운 인터페이스를 만들 때 절대 Cloneable을 확장해서는 안 되며, 새로운 클래스도 이를 구현해서는 안된다. 배열의 clone 매서드를 제외하고, Cloneable 보다는 복제 기능은 생성자와 팩터리를 이용하는 것이 더 좋다.
Comparable을 구현할지 고려하라
Comparable을 구현했다는 것은 그 클래스 인스턴들에는 자연적인 순서가 있음을 뜻한다. 따라서 Comparable을 구현한 클래스에 대한 배열은 손쉽게 정렬할 수 있다.
Arrays.sort(arr);
알파벳, 숫자, 연대 같이 순서가 명확한 값 클래스를 정의한다면 반드시 Comparable을 구현하자
// Comparable 인터페이스
public interface Comparable<T>{
int compareTo(T t);
}
compareTo 메서드 규약
이 객체와 주어진 객체의 순서를 비교한다.
이 객체가 주어진 객체보다 작으면 음의 정수를, 같으면 0을, 크면 양의 정수를 리턴한다.
이 객체와 비교할 수 없는 타입이 주어지면 ClassCaseException을 던진다.
- 대칭성
- Comparable을 구현한 클래스는 모든 x, y에 대해 x.compareTo(y) == (y.compareTo(x)) * (-1)을 만족해야 한다.
- 따라서 x.compareTo(y)가 예외를 던지는 경우, y.compareTo(x)도 예외를 던져야 한다.
- 추이성
- Comparable을 구현한 클래스는 모든 x, y, z에 대해 x.compareTo(y) > 0 이고 y.compareTo(z) >0이면, x.compareTo(z)>0를 만족해야 한다.
- 반사성
- Comparable을 구현한 클래스 z는 x.compareTo(y) == 0 이면, sgn(x.compareTo(z)) == sgn(y.compareTo(z))를 만족해야 한다.
- equals
- Comparable을 구현한 클래스는 모든 x, y에 대해 x.compareTo(y) == 0 이면, x.equals(y)를 만족하는 것이 좋다. (권고사항은 아니다) 이 권고를 지키지 않으려면, "주의: 이 클래스의 순서는 equals 메서드와 일관되지 않다."라고 명시해 주자.
compareTo 와 equals가 일관되지 않을 때 나타나는 문제점
예시 : BigDecimal 클래스가 있다.(comparteTo와 equals 일관되지 않음)
new Decimal("1.0")과 new Decimal("1.00")이 있다고 할 때 두 객체를 HashSet<Decimal>에 담게 되면 size는 2개가 된다. 하지만 TreeSet<Decimal>에 담게 되면 size는 1개가 된다.
HashSet에서는 equals를 기반으로 비교하기 때문에 new Decimal("1.0")과 new Decimal("1.00")은 서로 다른 객체이다. 그렇기 때문에 size가 2개가 된다. 하지만 TreeSet에서는 객체에 대한 동치성 비교를 compareTo로 하기 때문에 new Decimal("1.0")과 new Decimal("1.00")의 compareTo는 0을 리턴한다. 따라서 같은 객체로 인식하여 size가 1개가 된다.
compareTo 사용
자바 7부터 박싱된 기본 타입 클래스에 새로 추가된 정적 메서드 compare가 생겼다. 이제 compareTo 메서드에서 관계 연산자 < 와 > 를 사용하는 이전 방식은 거추장스럽고 오류를 유발하니 추천하지 않는다.
compareTo 안티 패턴
static Comparator<Object> hashCodeOrder = new Comparator<>() {
(Object o1, Object o2) -> o1.hashCode() - o2.hashCode();
}
개선된 compareTo
// 정적 compare 메서드를 활용한 비교자
static Comparator<Object> hashCodeOrder = new Comparator<>() {
(Object o1, Object o2) -> Integer.compare(o1.hashCode(), o2.hashCode())
}
// 비교자 생성 메서드를 활용한 비교자
static Comparator<Object> hashCodeOrder = Comparator.comparingInt(o -> o.hashCode());
정리
순서를 고려해야 하는 값 클래스를 작성한다면 꼭 Comparable 인터페이스를 구현하여야 한다. compareTo와 equals를 일관되게 하는 것이 좋다. 또한 compareTo 메서드에서 필드의 값을 비교했을 때 < 와 > 연산자 보다 정적 compare 메서드나 Comparator 안에 있는 비교자 생성 메서드를 사용하자.
*Comparable vs Comparator
Comparable
정렬 수행 시 기본적으로 적용되는 정렬 기준이 되는 메서드를 정의하는 인터페이스 (compareTo 메서드)
Comparator
객체 비교를 할 수 있게 만들어 주는 인터페이스, 기본 정렬 기준과 다르게 정렬 하고 싶을 때 사용한다(compare 메서드)