201213 Effective Java 3장 정리

2020-12-13

3. 모든 객체의 공통 메서드

Object에서 final이 아닌 메서드들(equals, hashCode, toString, clone, finalize)은 모두 재정의(overriding)를 염두에 두고 설계된 것

-> 재정의시 지켜야 하는 일반 규약이 명확하게 정의되어 있다.

[item 10] equals는 일반 규약을 지켜 재정의하라

꼭 필요한 경우가 아니면 equals를 재정의하지 말라
재정의해야 하는 경우 규약을 확실히 지켜 비교해야 한다

equals를 재정의하지 않아야 하는 상황들

1. 각 인스턴스가 본질적으로 고유함
  • 값이 아니라 동작하는 개체를 표현하는 클래스
  • 예시: Thread
2. 인스턴스의 ‘논리적 동치(logical equality)’를 검사할 일이 없음
  • 예시: java.util.regex.Pattern
    • 두 Pattern의 인스턴스가 같은 정규표현식을 나타는지를 검사하는 법?
    • 설계자가 위 방법이 필요하지 않다고 여기는 경우 Object의 기본 equals로 해결
3. 상위 클래스에서 재정의한 equals가 하위 클래스에도 들어맞을때
  • 예시: AbstractSet과 대부분의 Set 구현체, AbstractList와 List 구현체, AbstractMap와 Map 구현체
4. 클래스가 private이거나 package-private이고 equals 메서드를 호출할 일이 없을때
  • 다음과 같이 정의하면 equals 호출을 막을 수 있다

  • @Override pubilc boolean equals(Object o){
        throw new AssertionError();
    }
    

equals 재정의가 필요한 경우

-> 논리적 동치성 확인이 필요하지만 상위 클래스의 equals가 논리적 동치성을 비교하도록 재정의되지 않았을 때
  • 값 클래스(Integer, String)
  • 논리적 동치성을 확인하도록 재정의하면, 값을 비교할 수 있고 Map의 키와 Set의 원소로도 사용할 수 있다.
  • 값 클래스라고 해도 값이 같은 인스턴스가 둘 이상 만들어지지 않음을 보장하는 인스턴스 통제 클래스라면 equals의 재정의가 필요하지 않다
    • 예시: Enum
    • 논리적 동치성과 객체 식별성이 같은 의미가 된다(논리적 의미가 같은 인스턴스가 두 개 이상 만들어지지 않음)

Object 명제에 적힌 equals 메서드 재정의 규약

한 클래스의 인스턴스는 다른곳으로 빈번히 전달되며, 컬렉션 클래스를 포함한 수많은 클래스는 전달받은 객체가 equals 규약을 지킨다고 가정하고 동작한다.

1. 반사성(reflexivity): null이 아닌 모든 참조값 x에 대해 x.equals(x)는 true
  • 객체는 자기 자신과 같아야 한다(일부러 어기는 경우가 아니면 만족시키지 못하기가 더 어려워보인다….?)
  • 이 요건을 어긴 클래스는 인스턴스를 컬렉션에 넣은 다음 contains 메서드를 호출하면 방금 넣은 인스턴스가 없다고 답할 것이다.
2. 대칭성(symmetry): nul이 아닌 모든 참조값 x,y에 대해 x.equals(y)가 true면 y.equals(x)도 true이다
  • 두 객체는 서로에 대한 동치 여부에 똑같이 답해야 한다
3. 추이성(transitivity): null이 아닌 모든 참조값 x,y,z에 대해 x.equals(y)가 true이고 y.equals(z)가 true이면 x.equls(z)도 true이다
  • 상위 클래스에는 없는 새로운 필드를 하위 클래스에 추가하는 상황
  • 구체 클래스를 확장해 새값을 추가하면서 equals 규약을 만족시킬 방법은 없다
  • 상속 대신 컴포지션을 사용하라
    • 상속 대신 private 필드로 두고, 뷰 메서드를 public으로 추가한다.
  • 예시: java.sql.Timestamp
    • java.util.Date를 확장한 후 nanoseconds 필드를 추가
    • Timestamp의 equals는 대칭성을 위배하며, Date객체와 함께 섞어 사용하면 엉뚱하게 동작할 수 있다
  • 추상 클래스의 하위 클래스에서라면 equals 규약을 지키면서도 값을 추가할 수 있다
4. 일관성(consisitency): null이 아닌 모든 참조값 x,y에 대해 x.equals(y)를 반복해서 호출하면 항상 true를 반환하거나 항상 false를 반환한다
  • 가변 객체는 비교 시점에 따라 서로 다를 수도, 같을 수도 있지만 불변 객체는 한번 다르면 끝까지 달라야 한다.
  • equals의 판단에 신뢰할 수 없는 자원이 끼어들게 해서는 안 된다.
    • 예시: java.net.URL의 equals
      • 주어진 URL - 매핑된 호스트의 IP주소를 이용해 비교
      • 호스트 이름을 IP주소로 바꾸려면 네트워크를 통해야 하는데, 그 결과가 항상 같다고 보장할 수 없다.
      • URL의 equals가 일반 규약을 어기게 되어 종종 문제가 발생한다
    • equals는 항시 메모리에 존재하는 객체만을 사용한 결정적 계산만 수행해야 한다
5. null-아님: null이 아닌 모든 참조값 x에 대해, x.equals(null)은 false다
  • 모든 객체가 null과 같지 않아야 한다

  • // 묵시적 null 검사
    @Override public boolean equals(Object o){
        if(!(o instanceof MyType)) return false;
        Mytype mt = (MyType) o;
        ...
    }
    
    • 타입을 확인하지 않으면 잘못된 타입이 인수로 주어졌을때 ClassCastException 발생
    • instanceof 는 첫번째 피연산자가 null이면 false를 반환한다.
    • 따라서 입력이 null이면 타입 확인 단계에서 false를 반환하기 때문에 null검사를 명시적으로 하지 않아도 된다

equals 메서드 구현 방법

1. == 연산자를 사용해 입력이 자기 자신의 참조인지 확인한다
2. instanceof 연산자로 입력이 올바른 타입인지 확인한다
  • 올바른 타입: equals가 정의된 클래스 + 그 클래스가 구현한 특정 인터페이스(Set,List,Map,Map.Entry)
3. 입력을 올바른 타입으로 형변환한다
4. 입력 객체와 자기 자신의 대응되는 ‘핵심’필드들이 모두 일치하는지 하나씩 검사한다
  • 2단계에서 인터페이스를 사용했다면 입력의 필드값을 가져올 때도 그 인터페이스의 메서드를 사용해야 한다
  • 기본 타입 필드는 == 연산자로 비교
  • float, double 필드는 Float.compare(float, float) 와 Double.compare(double,double)로 비교한다
    • Float.NaN, -0.0f, 특수한 부동소수값 등을 다루기 위함
    • Float.equals와 Double.equals 사용
      • 오토박싱을 수반할 수 있어 성능상 나쁨
  • null도 정상값으로 취급하는 참조 타입 필드
    • Object.equals(object,object)로 비교, NullPointerException 방지
  • 비교하기 아주 복잡한 필드를 가진 클래스
    • 필드의 표준형(canonical form) 저장, 표준형끼리 비교
    • 불변 클래스인 경우 경제적
  • 다를 가능성이 더 크거나, 비교하는 비용이 싼 필드를 먼저 비교한다
  • 동기화용 락(lock) 필드와 같은 객체의 논리적 상태와 관련 없는 필드는 비교하면 안된다
  • 파생 필드를 비교하는 것이 더 빠른 경우
    • 예시 : Polygon 클래스(자신의 영역 캐싱) -> 모든 변과 정점을 비교할 필요 없이 캐시해둔 영역만 비교하면 결과를 알 수 있다.
5. 대칭적인가? 추이성이 있는가? 일관적인가?
  • 단위 테스트를 작성하여 돌려본다
전형적인 equals 메서드 예시
public final class PhoneNumber {
    private final short areaCode, prefix, lineNum;
    
    public PhoneNumber(int areaCode,int prefix, int lineNum){
        this.areaCode=rangeCheck(areaCode,999, "지역코드");
        this.prefix=rangeCheck(prefix, 999, "프리픽스");
        this.lineNum=rangeCheck(lineNum, 9999, "가입자 번호");
    }
    
    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 재정의시 주의사항

1. equals를 재정의할때는 hashCode도 반드시 재정의한다
2. 너무 복잡해게 해결하려 들지 말자
  • 필드들의 동치성만 검사해도 equals 규약을 어렵지 않게 지킬 수 있다
  • 예시: File 클래스 - 심볼릭 링크를 비교해 같은 파일 확인 금지
3. Object 외의 타입을 매개변수로 받는 equals 메서드를 선언하지 말자
// 잘못된 예 -  매개변수는 반드시 Object 여야 한다
public boolean equals(MyClass o){
    ...
}
  • 이 메서드는 입력타입이 Object가 아니므로, 재정의(Overriding)가 아닌 다중 정의(Overloading)이다
// 잘못된 예 - 컴파일 오류!
@Override public boolean equals(MyClass o){
    ...
}

equals 작성과 테스트 : AutoValue 프레임워크 이용

lombok으로도 값 클래스 생성 가능 -> @EqualsAndHashCode() 어노테이션


[item11] equals 재정의시 hashCode도 재정의하라

Object 명세 내부 규약

  • equals 비교에 사용되는 정보가 변경되지 않았다면, 애플리케이션이 실행되는 동안 그 객체의 hashCode 메서드는 일관되게 항상 같은 값을 반환해야 한다
  • equals(Object)가 두 객체를 같다고 판단했다면 두 객체의 hashCode는 똑같은 값을 반환해야 한다
  • equals(Object)가 두 객체를 다르다고 판단했더라도, 두 객체의 hashCode가 서로 다른 값을 반환할 필요는 없다. 단, 다른 객체에 대해 다른 값을 반환해야 해시테이블의 성능이 좋아진다.

논리적으로 같은 객체는 같은 해시코드를 반환해야 한다

  • HashMap에 값을 넣고, 논리적 동치인 인스턴스로 get메서드를 사용하는 경우
    • hashCode 클래스를 재정의하지 않았다면, 논리적 동치인 두 객체가 서로 다른 해시코드를 반환하게 된다
최악의 hashCode 구현
@Override public int hashCode(){ return 42; }
  • 모든 객체에게 똑같은 값만 내어준다
  • 해시테이블 버킷이 연결리스트처럼 동작하게 되면서 O(n)의 수행시간이 된다
  • 이상적인 hashCode 구현은 주어진 서로 다른 인스턴스들을 32비트 정수 범위에 균일하게 분배한다.

hashCode 구현 요령

1. int 변수 result를 선언, 값 c로 초기화한다(c: equals 비교에 사용되는 필드(핵심필드) 중 첫번째 필드 계산값)
2. 해당 객체의 나머지 핵심 필드 f 각각에 대해 다음 작업을 수행한다
  • 해당 필드의 해시코드 c를 계산
    • 기본타입 필드라면, Type.hashCode(f)를 수행(박싱 클래스의 hashCode 메서드)
    • 참조타입 필드면서 이 클래스의 equals 메서드가 이 필드의 equals 메서드를 재귀적으로 호출한다면 이 필드의 hashCode를 재귀적으로 호출
      • 계산이 복잡해질 것 같다면 표중형을 만들어 표준형의 hashCode 호출
    • 필드의 값이 null이라면 0을 사용한다
    • 필드가 배열이라면 핵심 원소 각각을 별도 필드처럼 다룬다
      • 모든 원소가 핵심 원소라면 Arrays.hashCode를 사용한다
  • 단계 2.a에서 계산한 해시코드 c로 result 갱신
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;
}
// 한 줄짜리 hashCode 메서드
// hash 메서드에서 배열이 만들어지고, 박싱과 언박싱을 거치므로 느리다
@Override public int hashCode(){
    return Objects.hash(lineNum,prefix,areaCode);
}
  • 해시코드 계산시 핵심 필드를 생략해서는 안 된다
  • hashCode 반환값 생성 규칙을 API사용자에게 공표하지 않는다
    • 클라이언트가 이 값에 의지하지 않게 되고, 결함이나 더 나은 방식을 발견시 계산 방식을 바꿀 수도 있다

불변 클래스, 해시코드 계산 비용이 큰 경우

  • 인스턴스가 만들어 질 때 해시코드를 계산

  • 해시의 키로 이용되지 않는 경우

    • hashCode가 처음 불릴때 계산하는 지연 초기화 방식 사용

    • private int hashCode;// 자동으로 0으로 초기화
          
      @Override public int hashCode(){
          int result=hashCode;
          if(result==0){
              int result=Short.hashCode(areaCode);
              result=31 * result+Short.hashCode(prefix);
              result=31*result+Short.hashCode(lineNum);
              hashCode=result;
          }
          return result;
      }
      

[item12] toString을 항상 재정의하라

toString을 잘 구현한 클래스는 사용하기 좋고, 디버깅하기 쉽다.

  • toString 메서드는 객체를 println,printf,문자열 연결 연산자(+),assert구문에 넘길때,디버거가 객체를 출력할 때 자동으로 불린다
  • toString은 그 객체가 가진 주요 정보 모두를 반환하는 게 좋다
  • 반환값의 포맷을 문서화할지 정해야 한다
    • 전화번호와 행렬 같은 값 클래스: 문서화
    • 그 값 그대로 입출력에 사용하거나 csv파일처럼 사람이 읽을 수 이쓴 데이터 객체로 저장
    • 포맷을 명시할 때: 명시한 포맷에 맞는 문자열과 객체를 상호 전환 가능한 정적 팩터리나 생성자를 함께 제공
      • 예시: BigInteger, BigDecimal, 대부분의 기본 타입 클래스
    • 하지만 포맷을 한번 명시하면 계속 그 포맷에 얽매이게 된다
      • 향후 릴리스에서 포맷을 바꾼다면 이를 사용하던 코드들과 데이터들은 엉망이 된다
  • 포맷을 명시하든 아니든 의도는 명확하게 밝혀야 한다
  • 포맷 명시 여부와 관계없이 toString이 반환한 값에 포함된 정보를 얻어올 수 있는 API를 제공해야 한다
  • 정적 유틸리티 클래스는 toString을 제공할 이유가 없다
    • 대부분의 열거 타입도 따로 재정의 필요 X
    • 하위클래스들이 공유해야 할 문자열 표현이 있는 추상 클래스라면 toString 재정의 필요
      • 예시: 대다수의 컬렉션 구현체 - 추상 컬렉션 클래스의 toString 메서드 상속

[item13] clone 재정의는 주의해서 진행하라

새로운 인터페이스를 만들 때는 Cloneable을 확장해서는 안 되며, 새로운 클래스도 이를 구현해서는 안 된다. final 클래스에서는 구현해도 위험이 크지 않으나 성능 최적화 관점에서 검토한 후 별다른 문제가 없을 때만 허용해야 한다.
기본 원칙은 '복제 기능은 생성자와 팩터리를 이용'이다. 단, 배열만은 clone 메서드 방식이 가장 깔끔하다.

clone 메서드의 일반 규약

이 객체의 복사본을 생성해 반환한다.

‘복사’의 정확한 뜻은 그 객체를 구현한 클래스에 따라 다를 수 있다.

일반적인 의도는 다음과 같다. 어떤 객체 x에 대해 다음 식은 참이다.

x.clone()!=x 또한 다음 식도 참이다. x.clone().getClass() == x.getClass()

하지만 이상의 요구를 반드시 만족해야 하는 것은 아니다.

한편 다음 식도 일반적으로 참이지만, 역시 필수는 아니다.

x.clone().equals(x)

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

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

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

클래스가 가변 객체를 참조하는 경우

  • 예시: 스택 클래스

  • public class Stack {
        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 e){
            ensureCapacity();
            elements[size++]=e;
        }
        public Object pop(){
            if (size==0)
                throw new EmptyStackException();
            Object result=elements[--size];
            elements[size]=null;
            return result;
        }
          
        private void ensureCapacity(){
            if(elements.length==size)
                elements= Arrays.copyOf(elements,2*size+1);
        }
    }
      
    
    • clone메서드가 super.clone의 결과를 그대로 반환한다면 Stack 인스턴스의 size필드는 올바른 값을 가지나, elements 필드는 원본 Stack 인스턴스와 똑같은 배열을 참조하게 된다
    • 원본이나 복제본 중 하나를 수정하면 다른 하나도 수정되어 불변식을 해친다
  • clone은 원본 객체에 아무런 해를 끼치지 않으면서 동시에 복제된 객체의 불변식을 보장해야 한다

  • elements 필드에 대해 재귀적 clone 호출

  • @Override public Stack clone(){
            try{
                Stack result=(Stack) super.clone();
                result.elements=elements.clone();
                return result;
            } catch (CloneNotSupportedException e){
                throw new AssertionError();
            }
        }
    
  • ‘깊은 복사’를 지원하도록 보강

  • 객체의 모든 필드를 초기 상태로 설정한 다음, 원본 객체의 상태를 다시 생성하는 고수준 메서드 호출

    • 저수준 복사보다 느리다
    • 필드 단위 객체 복사를 우회하기 때문에 전체 Cloneable 아키텍처와는 어울리지 않는다

[item14] Comparable을 구현할지 고려하라

compareTo는 Object의 메서드가 아니지만, Object의 equals와 성격이 유사하다.

  • 단순 동치성 비교, 순서 비교, 제네릭
  • Comparable을 구현했다
    • 그 클래스의 인스턴스들에는 자연적인 순서가 있다
    • Arrays.sort(a); 로 정렬 가능하다

compareTo 메서드 일반 규약

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

이 객체와 비교할 수 없는 타입의 객체가 주어지면 ClassCastException을 던진다.

Comparable을 구현한 클래스는

  • 모든 x,y에 대해 sgn(x.compareTo(y))==-sgn(y.compareTo(x))여야 한다
    • 따라서 x.compareTo(y)는 y.compareTo(x)가 예외를 던질때에 한해 예외를 던져야 한다
  • 추이성을 보장해야 한다
    • x.compareTo(y) >0이고 y.comparaTo(z)>0이면 x.compareTo(z)>0이다.
  • 모든 z에 대해 x.compareTo(y)==0이면 sgn(x.compareTo(z))==sgn(y.compareTo(z))이다
  • 필수는 아니지만 지켜져야 하는 것
    • (x.compareTo(y)==0)==(x.equals(y))여야 한다
    • Comparable을 구현하였으나 이 권고를 지키지 않는 모든 클래스는 그 사실을 명시해야 한다

compareTo 메서드

  • 각 필드가 동치인지를 비교하는 것이 아닌, 순서를 비교한다

  • 자바7부터 compareTo 메서드에서 관계 연산자 <와>를 사용하는 이전 방식은 오류를 유발한다

    • 박싱된 기본 타입 클래스들에 새로 추가된 compare 메서드를 사용해야 한다

      • 이 방식은 간결하지만 성능 저하가 있다
    • 비교자 생성 메서드를 사용한다

  • ‘값의 차’ 비교자는 사용하면 안 된다(추이성 위배)

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

정수 오버플로우를 일으키거나, 부동소수점 계산방식에 따라 오류 발생

// 정적 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);