[Effective Java] 3장. 모든 객체의 공통 메서드

kkatal_chae·2022년 9월 25일
0

Effective Java

목록 보기
4/11
post-thumbnail

아이템 10. equals 는 일반 규약을 지켜 재정의하라

  • 각 인스턴스가 본질적으로 고유하다

  • 인스턴스의 ‘논리적 동치성' 을 검사할 일이 없다.

  • 상위 클래스에서 재정의한 equals 가 하위 클래스에도 딱 들어맞는다.

  • 클래스가 private 이거나 package-private 이고 equals 메서드를 호출할 일이 없다.

equals를 정의해야하는 때는 언제인가?

💡 객체 식별성 ( 두 객체가 물리적으로 같은가 ) 이 나니라 논리적 동치성을 확인해야 하는데 상위 클래스의 equals 가 논리적 동치성을 비교하도록 재정의되지 않았을 때다.

⇒ 값이 같은 인스턴스가 둘 이상 만들어지지 않음을 보장하는 인스턴스 통제 클래스라면 equals를 재정의하지 않아도 된다. ex) Enum

equals 메서드를 재정의할 때 반드시 따라야하는 일반 규약

equals 메서드는 동치관계를 구현하며, 다음을 만족한다.

  • 반사성 : 단순히 말하면, 객체는 자기 자신과 같아야 한다는 뜻이다.
    null 이 아닌 모든 참조 값 x 에 대해, x.equals( x ) 는 true 다.
  • 대칭성 : 두 객체는 서로에 대한 동치 여부에 똑같이 답해야 한다는 뜻이다.
    null 이 아닌 모든 참조 값 x, y 에 대해, x.equals( y ) 가 true 면 y.equals( x ) 도 true 다.
  • 추이성 : 객체 a, b, c 에 대해 a=b, b=c 라면 a=c 를 만족해야 한다는 뜻이다.
    null 이 아닌 모든 참조 값 x, y 에 x.equals( y ) 가 true 이고 y.equals( z ) 도 true 면 x.equals( z ) 도 true 다.
  • 일관성 : null 이 아닌 모든 참조 값 x, y 에 대해, x.equals( y ) 를 반복해서 호출하면 항상 true 를 반환하거나 항상 false 를 반환한다.
  • null 아님 : null 이 아닌 모든 참조 값 x 에 대해 x.equals( null ) 은 false 다.

equals 규약을 어기면 그 객체를 사용하는 다른 객체들이 어떻게 반응할지 알 수 없다.
구체 클래스를 확장해 새로운 값을 추가하면서 equals 규약을 만족시킬 방법은 존재하지 않는다.

양질의 equals 메서드 구현 방법
1. == 연산자를 사용해 입력이 자기 자신의 참조인지 확인한다.
2. instanceof 연산자로 입력이 올바른 타입인지 확인한다.
3. 입력을 올바른 타입으로 형변환한다.
4. 입력 객체와 자기 자신의 대응되는 '핵심' 필드들이 모두 일치하는지 하나씩 검사한다.

위의 규칙을 고려하여 구현한 equals 예제

public final class PhoneNumber {
	private final short areaCode, prefix, lineNum;
    
    public PhoneNumber( int areaCode, int prefix, lineNum ) {
    	this.areaCode = rangeCheck( areaCode, 999, "areaCode");
        this.prefix = rangeCheck( prefix, 999, "prefix" );
        this.lineNum = rangeCheck( lineNum, 9999, "user Number");
    }
    
    private static short rangeCheck( int val, int max, String arg ) {
    	if ( val < 0 || val > max ) 
        	throw new IllegalArgumentException( arg + ": " + val );
        return ( short ) val;
    }
    
    @Override
    public boolean equals( Object o ) {
    	if ( o == this ) 
        	return true;
        if ( !( o instanceof PhoneNumber) ) 
        	return false;
        PhoneNumber pn = (PhoneNumber) o;
        return pn.lineNum == lineNum && pn.prefix == prefix && pn.areaCode == areaCode;
    }
}

💡 equals 를 재정의할 땐 hashCode 도 반드시 재정의하자

너무 복잡하게 해결하려 하지 말자. 필드들의 동치성만 검사해도 equals 규약을 어렵지 않게 지킬 수 있다.
꼭 필요한 경우가 아니면 equals 를 재정의하지 말자. 많은 경우에 Object 의 equals 가 여러분이 원하는 비교를 정확히 수행해준다. 재정의할 때는 앞서 서술한 다섯 가지 규약을 확실히 지켜가며 비교해야 한다. 이와 같은 작업을 대신해주는 구글의 AutoValue 프레임워크를 사용하거나 IDE 에서도 비슷한 기능을 제공하니 이용하는 것을 권하는 바이다.

아이템 11.equals 를 재정의하거든 hashCode 도 재정의하라


equals 를 재정의한 클래서 모두에서 hashCode 도 재정의해야 한다.

  • equals 비교에 사용되는 정보가 변경되지 않았다면, 애플리케이션이 실행되는 동안 그 객체의 hashCode 메서드는 몇 번을 호출해도 일관되게 항상 같은 값을 반환해야 한다. 단, 애플리케이션을 다시 실행한다면 이 값이 달라져도 상관없다.

  • equals( Object ) 가 두 객체를 같다고 판단했다면, 두 객체의 hashCode 는 똑같은 값을 반환해야 한다.

  • equals( Object ) 가 두 객체를 다르다고 판단했더라도, 두 객체의 hashCode 가 서로 다른 값을 반환할 필요는 없다. 단, 다른 객체에 대해서는 다른 값을 반환해야 해시테이블의 성능이 좋아진다.


hashCode 재정의를 잘못했을 때 크게 문제가 되는 조항은 두 번째다. 즉, 논리적으로 같은 객체는 같은 해시코드를 반환해야 한다.

예를 들어 다음 코드는 적법하게 구현했지만, 절대 사용해서는 안된다.

@Override
public int hashCode() { return 42; }

이 코드는 동치인 모든 객체에서 똑같은 해시코드를 반환하니 적법하다.

하지만 끔찍하게도 모든 객체에게 똑같은 값만 내어주므로 모든 객체가 해시테이블의 버킷 하나에 담겨 마치 연결 리스트처럼 동작한다.

그 결과 평균 수행 시간이 O(1) 인 해시테이블이 O(n) 으로 느려져서, 객체가 많아지면 도저히 쓸 수 없게 된다.

좋은 해시 함수라면 서로 다른 인스턴스에 다른 해시코드를 반환한다. 이것이 바로 hashCode 의 세 번째 규약이 요구하는 속성이다. 이상적인 해시 함수는 주어진 서로 다른 인스턴스들을 32비트 정수 범위에 균일하게 분배해야 한다.

다음은 좋은 hashCode 를 작성하는 간단한 요령이다.

1. int 변수 result 를 선언한 후 값 c 로 초기화한다. 이때 c 는 해당 객체의 첫번째 핵심 필드를 단계 2.a 방식으로 계산한 해시코드다. ( 여기서 핵심 필드란 equals 에 사용되는 필드를 말한다. ) 

2. 해당 객체의 나머지 핵심 필드 f 각각에 대해 당므 작업을 수행한다. 
	a. 해당 필드의 해시코드 c 를 계산한다. 
    	i. 기본 타입 필드라면, Type.hashCode(f) 를 수행한다. 여기서 Type 은 해당 기본 타입의 박싱 클래스다.
        ii. 참조 타입 필드면서 이 클래스의 equals 메서드가 이 필드의 equals 를 재귀적으로 호출해 비교한다면, 이 필드의 hashCode 를 재귀적으로 호출한다. 계산이 더 복잡해질 것 같으면, 이필드의 표준형을 만들어 그 표준형의 hashCode 를 호출한다. 필드의 값이 null 이면 0 을 사용한다.
        iii. 필드가 배열이라면, 핵심 원소 각각을 별도 필드처럼 다룬다. 이상의 규칙을 재귀적으로 적용해 각 핵심 원소의 해시코드를 계산한 다음, 단계 2.b 방식으로 갱신한다. 배열에 핵심 원소가 하나도 없다면 단순히 상수를 사용한다. 모든 원소가 핵심원소라면 Arrays.hashCode 를 사용한다. 
    b. 단계 2.a 에서 계산한 해시코드 c 로 result 를 갱신한다. 코드로는 다음과 같다. 
    	result = 31. * result + c;
3. result 를 반환한다. 

전형적인 hashCode 메서드

@Override
public int hashCode() {
	int result = Short.hashCode( areaCode );
    result = 31 * result + Short.hashCode( prefix );
    result = 31 * result + Short.hashCode( lineNum );
    return result;
}

단, 해시 충돌이 더욱 적은 방법을 꼭 써야 한다면 구아바의 com.google.common.hash.Hashing 을 참고하라.

Object 클래스는 임의의 개수만큼 객체를 받아 해시코드를 계산해주는 정적 메서드인 hash 를 제공한다.

하지만 입력 인수를 담기 위한 배열이 만들어지고, 입력 중 기본 타입이 잇다면 박싱과 언박싱도 거쳐야 하기 때문에 성능이 다소 아쉽기 때문에 성능에 민감한 경우라면 사용하는 것을 지양하자.

hashCode 가 반환하는 값의 생성 규칙을 API 사용자에게 자세히 공표하지 말자. 그래야 클라이언트가 이 값에 의지하지 않게 되고, 추후에 계산 방식을 바꿀 수도 있다.

equals 를 재정의할 때는 hashCode 도 반드시 재정의해야 한다. 그렇지 않으면 프로그램이 제대로 동작하지 ㅇ낳을 것이다. 재정의한 hashCode 는 Object 의 API 문서에 기술된 일반 규약을 따라야 하며, 서로 다른 인스턴스라면 되도록 해시코드도 서로 다르게 구현해야 한다.

아이템 12. toString 을 항상 재정의하라.

toString 의 일반 규약에 따르면 '간결하면서 사람이 읽기 쉬운 형태의 유익한 정보' 를 반환해야 한다.

toString 을 잘 구현한 클래스는 사용하기에 훨씬 즐겁고, 그 클래스를 사용한 시스템은 디버깅하기 쉽다.

실전에서 toString 은 그 객체가 가진 주요 정보 모두를 반환하는게 좋다.

하지만 객체가 거대하거나 객체의 상태가 문자열로 표현하기에 적합하지 않다면 무리가 있다.

이런 상황이라면 "맨해튼 거주자 전화번호부 ( 총 14298371 개 )" 같은 요약 정보를 담아야 한다.

toString 을 구현할 때면 반환값의 포맷을 문서화할지 정해야 한다. 또한, 포맷을 문서화하기로 했다면, 명시한 포맷에 맞는 문자열과 객체를 상호 전환할 수 있는 정적 팩터리나 생성자를 함꼐 제공해주면 좋다.

예시

@Override
public String toString() {
	return String.format("%03d-%03d-%04d", areaCode, prefix, lineNum);
}

하위 클래스들이 공유해야 할 문자열 표현이 있는 추상클래스라면 toString 을 재정의해줘야 한다.

모든 구체 클래스에서 object 의 toString 을 재정의하자. 상위 클래스에서 이미 알맞게 재정의한 경우는 예외다. toString 은 해당 객체에 관한 명확하고 유용한 정보를 읽기 좋은 형태로 반환해야 한다.

아이템 13. clone 재정의는 주의해서 진행하라

메서드 하나 없는 Cloneable 인터페이스는 대체 무슨 일을 할까?

이 인터페이스는 놀랍게도 Object 의 protected 메서드인 clone 의 동작 방식을 결정한다.
Cloneable 을 구현한 클래스의 인스턴스에서 clone 을 호출하면 그 객체의 필드들을 하나하나 복사한 객체를 반환하며, 그렇지 않은 클래스의 인스턴스에서 호출하면 CloneNotSupportedException 을 던진다.

clone 메서드의 일반 규약

이 객체의 복사본을 생성해 반환한다. '복사' 의 정확한 뜻은 그 객체를 구혆나 클래스에 따라 다를 수 있다. 일반적인 의도는 다음과 같다. 어떤 객체 x 에 대해 다음 식은 참이다.

x.clone() != x

또한 다음 식도 참이다.

하지만 이상의 요구를 반드시 만족해야하는 것은 아니다.
한편 다음 식도 일반적으로 참이지만, 역시 필수는 아니다.

x.clone().equals(x)

관례상, 이 메서드가 반환하는 객체는 super.clone 을 호출해 얻어야 한다. 이 클래스와 ( Object 를 제외한 ) 모든 상위 클래스가 이 관례를 따른다면 다음 식은 참이다.

x.clone().getClass() == x.getClass()

관례상, 반환된 객체와 원본 객체는 독립적이어야 한다. 이를 만족하려면 super.clone 으로 얻은 객체의 필드 중 하나 이상을 반환 전에 수정해야 할 수도 있다.

clone 메서드는 사실상 생성자와 같은 효과를 낸다. 즉, clone 은 원본 객체에 아무런 해를 끼치지 않는 동시에 복제된 객체의 불변식을 보장해야 한다.

배열의 clone 은 런타임 타입과 컴파일타임 타입 모두가 원본 배열과 똑같은 배열을 반환한다.

Cloneable 아키텍처는 '가변 객체를 참조하는 필드는 final 로 선언하라'는 일반 용법과 충돌한다.

Cloneable 이 몰고 온 모든 문제를 되짚어봤을 때, 새로운 인터페이스를 만들 때는 절대 Cloneable 을 확장해서는 안 되며, 새로운 클래스도 이를 구현해서는 안 된다. final 클래스라면 Cloneable 을 구현해도 위험이 크지 않지만, 성능 최적화 관점에서 검토한 후 별다른 문제가 없을 때만 드물게 허용해야 한다. 기본 원칙은 복제 기능은 생성자와 팩터리를 이용하는게 최고라는 것이다. 단, 배열만은 clone 메서드 방식이 가장 깔끔한, 이 규칙의 합당한 예외라 할 수 있다.

아이템 14. Comparable 을 구현할지 고려하라

Comparable 인터페이스는 compareTo 메서드를 가지고 있다.

compareTo 는 단순 동치성 비교에 더해 순서까지 비교할 수 있으며, 제네릭하다.

Comparable 을 구현했다는 것은 그 클래스의 인스턴스들에는 자연적인 순서가 있음을 뜻한다.

compareTo 메서드의 일반 규약

이 객체와 주어진 객체의 순서를 비교한다. 이 객체가 주어진 객체보다 작으면 음의 정수를, 같으면 0을, 크면 양의 정수를 반환한다. 이 객체와 비교할 수 없는 타입의 객체가 주어지면 ClassCastException 을 던진다.

다음 설명에서 sgn( 표현식 ) 표기는 수학에서 말하는 부호 함수를 뜻하며, 표현식의 값이 음수, 0, 양수일 때 -1, 0, 1 을 반환하도록 정의 했다.

  • Comparable 을 구현한 클래스는 모든 x, y 에 대해 sgn( x.compareTo( y ) ) == - sgn( y.compareTo( x ) ) 여야 한다. ( 따라서 x.compareTo( y ) 는 y.compareTo( x ) 가 예외를 던질 때에 한해 예외를 던져야 한다)

  • Comparable 을 구현한 클래스는 추이성을 보장해야 한다. 즉, ( x.comapreTo( y ) > - && compareTo( z ) > 0 ) 이면 x.compareTo( z ) > 0 이다.

  • Comparable 을 구현한 클래스는 모든 z 에 대해 x.compareTo( y ) == 0 이면 sgn( x.compareTo( z )) == sgn( y.compareTo( z ))다.

  • 이번 권고가 필수는 아니지만 꼭 지키는 게 좋다. ( x.compareTo( y ) == 0 ) == ( x.equals( z )) 여야 한다. Comparable 을 구현하고 이 권고를 지키지 않는 모든 클래스는 그 사실을 명시해야 한다. 다음과 같이 명시하면 적당할 것이다.

"주의 : 이 클래스의 순서는 equals 메서드와 일관되지 않다."

compareTo 메서드 작성 요령은 equals 와 비슷하다.

Comparable 은 타입을 인수로 받는 제네릭 인터페이스이므로 compareTo 메서드의 인수 타입은 컴파일타임에 정해진다.

입력 인수의 타입을 확인하거나 형변환할 필요가 없다는 뜻이다.

compareTO 메서드에서 관계 연산자< 와 > 를 사용하는 이전 방식은 거추장스럽고 오류를 유발하니, 이제는 추천하지 않는다.

클래스에 핵심 필드가 여러 개라면 어느 것을 먼저 비교하느냐가 중요하다. 가장 핵심적인 필드부터 비교해 나가며 순서가 결정되면 그 결과를 바로 반환하면 된다.

정적 compare 메서드를 활용한 비교자

static Comparator< Object > hashCodeOrder = new Comparator<>() {
	public int compare( Object o1, Object o2 ) {
    	return Integer.compare( o1.hashCode(), o2.hashCode());
    }
}

비교자 생성 메서드를 활용한 비교자

static Comparator< Object > hashCodeOrder = 
	Comparator.comparingInt( o -> o.hashCode());

순서를 고려해야 하는 값 클래스를 작성한다면 꼭 Comparable 인터페이스를 구현하여, 그 인스턴스들을 쉽게 정렬하고, 검색하고, 비교 기능을 제공하는 컬렉션과 어우러지도록 해야 한다. compareTo 메서드에서 필드의 값을 비교할 떄 < 와 > 연산자는 쓰지 말아야 한다. 그 대신 박싱된 기본 타입 클래스가 제공하는 정적 compare 메서드나 Comparator 인터페이스가 제공하는 비교자 생성 메서드를 사용하자.

0개의 댓글