본문 바로가기
스터디/이펙티브 자바

[이펙티브 자바] 02. 모든 객체의 공통 메서드

by 디토20 2022. 8. 10.
반응형

 

 

 

 

 

https://be-developer.tistory.com/87

 

[이펙티브 자바] 01. 객체 생성과 파괴

[이펙티브 자바] 01. 객체 생성과 파괴 객체의 생성과 파괴를 다룬다. 객체를 만들어야 할 때와 만들지 말아야 할 때를 구분하는 법, 올바를 객체 생성 방법과 불필요한 생성을 피하는 방법,

be-developer.tistory.com

 

 

[이펙티브 자바] 02. 모든 객체의 공통 메서드

 

Object는 객체를 만들 수 있는 구체 클래스지만 기본적으로는 상속해서 사용하도록 설계되었다. Object에서 final이 아닌 메서드(equals, hashCode, toString, clone, finalize)는 모두 재정의를 염두해 두고 설계된 것이라 재정의 시 지켜야 하는 일반 규약이 명확히 정의되어 있다. 이번에는 해당 메서드들을 언제, 어떻게 정의해야하는지를 다룬다.

 

 

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

equals는 재정의하기 까다로워 아래의 상황 중 하나에 해당한다면 재정의하지 않는것이 좋다.

  1. 각 인스턴스가 본질적으로 고유하다. 값을 표현하는 게 아니라 동작하는 개체를 표현하는 클래스가 여기 해당한다. Thread가 좋은 예로, Object의 equals 메서드는 이러한 클래스에 딱 맞게 구현되었다.
  2. 인스턴스의 '논리적 동치성'을 검사할 일이 없다. 예를들어 regax.Pattern은 equals를 재정의해서 두 Pattern의 인스턴스의 논리적 동치성을 검사한다. 이런 방식이 필요하지 않다면 Object의 기본 equals만으로 해결된다.
  3. 상위 클래스에서 재정의한 equals가 하위 클래스에도 딱 들어맞는다.
  4. 클래스가 private이어서 equals 메서드를 호출할 일이 없다. 

 

equals가 실수라도 호출되는 걸 막고 싶다면 아래처럼 구현해두자.

@Override
public boolean equals(Object o) {
	throw new AssetionError(); // 호출 금지!
}

 

 

10.1 언제 equals를 재정의해야 할까?

두 객체가 물리적으로 같은가가 아니라 논리적으로 같은가를 확인해야 하는데, 상위 클래스의 equals가 논리적 동치성을 비교하도록 재정의되지 않았을 때이다. 주로 Integer나 String같이 값을 표현하는 값 클래스들이 여기에 해당한다. 두 값 객체를 equals로 비교하는 프로그래머는 객체가 같은지가 아니라 값이 같은지가 궁금하다. 값 클래스라 해도, 값이 같은 인스턴스가 둘 이상 만들어지지 않음을 보장하는 인스턴스 통제 클래스라면 equals를 재정의하지 않아도 된다.

 

 

10.2 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(z)도 true면 x.equals(z)도 true다.
  • 일관성: null이 아닌 모든 참조 값 x, y에 대해, x.equals(y)를 반복해서 호출하면 항상 true거나 항상 false이다.
  • null-아님: null이 아닌 모든 참조 값에 x에 대해 x.equals(null)은 false다.

 

 

10.3 양질의 equals 메서드를 구현하는 법

  1. == 연산자를 사용해 입력이 자기 자신의 참조인지 확인한다. 자기 자신이면 true를 반환한다. 이는 단순한 성능 최적화용으로, 비교 작업이 복잡한 상황일 때 값어치를 할 것이다.
  2. instanceof 연산자로 입력이 올바른 타입인지 확인한다. 그렇지 않으면 false를 반환한다.
  3. 입력을 올바른 타입으로 형변환한다. 앞서 2번에서 instanceof 검사를 했기 때문에 이 단계는 100% 성공한다.
  4. 입력 객체와 자기 자신의 대응되는 '핵심' 필드들이 모두 일치하는지 하나씩 검사한다. 모든 필드가 일치하면 true, 하나라도 다르면 false를 반환한다.
  5. 단위테스트를 돌려 일반 규약을 만족하는지 확인한다.

 

 

10.4 equals를 재정의할 때 주의사항

  • equals를 재정의할 땐 hashCode도 반드시 재정의하자
  • 너무 복잡하게 해결하려 들지 말자
  • Object 외의 타입을 매개변수로 받는 equals 메서드는 선언하지 말자

 

 

 

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

equals를 재정의한 클래스 모두에서 hashCode도 재정의해야 한다. 그렇지 않으면 hashCode 일반 규약을 어기게 되어 해당 클래스의 인스턴스를 HashMap이나 HashSet 같은 컬렉션의 원소로 사용할 때 문제를 일으킬 것이다. 다음은 Object 명세에서 발췌한 규약이다.

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

hashCode 재정의를 잘못했을 때 크게 문제가 되는 조항은 두번째다. 기본 hashCode는 물리적으로 다른 두 객체는 서로 다른 값을 반환하지만, equals에 의해 논리적으로 같은 객체는 같은 해시코드를 반환해야 한다. 또한 hashCode를 만들때 equals 비교에 사용되지 않은 필드는 '반드시' 제외해야 한다.

 

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

 

 

 

 

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

equals나 hashCode만큼 중요하진 않지만, toString을 잘 구현한 클래스는 사용하기에 좋고 디버깅하기 쉽다. 실전에서 toString은 그 객체가 가진 주요 정보 모두를 반환하는 게 좋다. 그러나 객체가 거대하거나 객체의 상태가 문자열로 표현하기에 적합하지 않다면 주요정보를 요약해서 담아야 한다.

 

 

12.1 toString 포맷의 문서화 장단점

toString을 구현할 때면 반환값의 포맷을 문서화할지 정해야 한다. 전화번호나 행렬 같은 값 클래스라면 문서화하기를 권한다. 포맷을 명시하면 그 객체는 표준적이고, 명확하고, 사람이 읽을 수 있게 된다. 따라서 그 값 그대로 입출력에 사용하거나 CSV 파일처럼 사람이 읽을수 있는 데이터로 저장할 수 있다. 포맷을 명시하기로 했다면, 명시한 포맷에 맞는 문자열과 객체를 상호 전환할 수 있는 정적 팩터리나 생성자를 함께 제공해주면 좋다.

 

그러나 포맷을 한번 명시하면 평생 포맷에 얽매이게 된다. 이를 사용하느 프로그래머들은 포맷에 맞춰 파싱하고 객체를 만들고 저장하는 코드를 작성할 것이다. 만약 향후 릴리즈에서 포맷을 바꾼다면 이전 포맷을 사용하던 데이터들은 엉망이 될것이다. 반대로 포맷을 명시하지 않는다면 향후 릴리즈에서 포맷을 변경할 수 있는 유연성을 얻게 된다.

 

포맷을 명시하든 아니든 개발자의 의도는 명확하게 밝혀야 하고, 포맷 명시 여부와 상관없이 toString이 반환한 값에 포함된 정보를 얻어올 수 있는 API를 제공하자. 그렇지 않으면 정보가 필요한 프로그래머는 toString의 반환값을 파싱할 수 밖에 없다. 이는 성능이 나빠지고 필요하지도 않을 뿐더러 향후 포맷을 바꾸면 시스템이 망가지는 결과를 초래할 수 있다.

 

 

 

 

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

Cloneable은 복제해도 되는 클래스임을 명시하는 용도의 믹스인 인터페이스지만, 아쉽게도 의도한 목적을 제대로 이루지 못했다. 가장 큰 문제는 clone 메서드가 선언된 곳이 Cloneable이 아닌 Object이고, 그 마저도 protected에 있어서 Cloneable을 구현하는 것만으로는 외부 객체에서 clone 메서드를 호출할 수 없다는 것이다. 하지만 여러 문제점에도 불구하고 Cloneable방식은 널리 쓰이고 있어서 잘 알아두는 것이 좋다.

 

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

 

clone 메서드는 다음처럼 구현할 수 있다.

@Override
public PhoneNumber clone() {
  try {
    return (PhoneNumber) super.clone();
  } catch (CloneNotSupportedException e) {
    throw new AssertionsError(); // 일어날 수 없는 일
  }
}

이 메서드가 동작하게 하려면 PhoneNumber의 클래스 선언에 Cloneable을 구현해야한다고 추가해야 한다. Object의 clone()은 Object를 반환하기 때문에 클라이언트가 형변환을 하지 않아도 사용이 가능하게 clone()에서 형변환을 해주자.

 

 

 

13.1 가변 객체 참조시 주의사항

클래스가 가변 객체를 참조하는 순간 재앙이 된다. Stack 클래스를 예로 들어보자.

public class Stack {
    private Object[] elements;
    private int size = 0;
    private static final int DEFAULT_INITIAL_CAPACITY = 16;

    public Stack(final Object[] elements) {
        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 인스턴스와 똑같은 배열을 참조할 것이다. 원본이나 복제본 중 하나를 수정하면 다른 하나도 수정되어 불변식을 해치게 된다.

 

 

Stack 클래스의 하나뿐인 생성자를 호출한다면 이런 상황은 절대 일어나지 않는다. clone은 사실살 생성자와 같은 효과를 내기 때문에 원본 객체에 아무런 해를 끼치지 않는 동시에 복제된 객체의 불변식을 보장해야 하므로 스택 내부의 값도 복사를 해주어야 한다.

@Override
public Stack clone() {
   try {
      Stack result = (Stack) super.clone();
      result.elements = elements.clone();
      return result;
   } catch (CloneNotSupportedException e) {
      throw new AssertionError();
   }
}

 스택 내부의 값을 복사하는것을 깊은 복사라고 하는데, 버킷이 너무 길지 않다면 재귀 호출이 간단하지만 연결 리스트가 길다면 스택 오버 플로를 일으킬 수 있기 때문에 반복자를 써서 순회하는 것이 좋다.

 

 

13.2 그외 주의사항

  • 상속용 클래스는 Cloneable을 구현해서는 안된다.
  • Cloneable을 구현하는 모든 클래스는 clone을 재정의해야 한다. 접근 제한자는 public으로, 반환 타입은 클래스 자신으로 변경한다.
  • Cloneable 대신 복사 생성자와 복사 팩터리라는 더 나은 객체 복사 방식을 제공할 수도 있다.

 

 

 

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

Comparable의 유일한 메서드인 compareTo를 알아보자. Comparable을 구현했다는 것은 그 클래스의 인스턴스들에는 자연적인 순서가 있음을 뜻한다. 그래서 Compareable을 구현한 객체들의 배열은 다음처럼 손쉽게 구현할 수 있다.

Arrays.sort(a);

 

 

14.1 compareTo 메서드의 일반규약

compareTo 메서드의 일반 규약은 equals의 규약과 비슷하다.

이 객체와 주어진 객체의 순서를 비교한다. 이 객체가 주어진 객체보다 작으면 음의 정수를, 같으면 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.compareTo(y) > 0 && sgn(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(y))여야 한다. Comparable을 구현하고 이 권고를 지키지 않는 모든 클래스는 "주의: 이 클래스의 순서는 equals 메서드와 일관되지 않다" 라는 사실을 명시해야 한다.

 

위의 세가지 규약은 equals의 규약과 똑같이 반사성, 대칭성, 추이성을 충족해야 함을 뜻하고 그에 따라 주의사항도 똑같다. 기존 클래스를 확장한 구체 클래스에서 새로운 값 컴포넌트를 추가 했다면 compareTo 규약을 지킬 방법이 없다.

 

compareTo의 마지막 규약은 필수는 아니지만 지키는 것이 좋다. compareTo의 순서와 equals의 결과가 일관되지 않은 클래스는 여전히 동작을 하지만, 이 클래스의 객체를 정렬된 컬렉션에 넣으면 해당 컬렉션이 구현한 인터페이스(Collection, Set, Map)에 정의된 동작과 엇박자를 낼 것이다. 이 인터페이스들은 equals의 메서드 규약을 따른다고 되어 있지만, 정렬된 컬렉션들은 동치성을 비교할 때 equals 대신 compareTo를 사용하기 때문이다.

 

 

 

14.2 compareTo 메서드 작성 요령

  • Comparable은 타입을 인수로 받는 제네릭 인터페이스이므로 compareTo 메서드 인수 타입은 컴파일타임에 정해진다.
  • 입력 인수의 타입을 확인하거나 형변환할 필요가 없다.
  • 인수 타입이 잘못됐다면 컴파일 자체가 되지 않는다.
  • null을 인수로 넣어 호출하면 NullPointerException을 던져야 한다.
  • 객체 참조 필드를 비교하려면 compareTo 메서드를 재귀적으로 호출해야 한다.
  • 핵심 필드가 여러개라면 가장 핵심적인 필드를 비교하고, 결과가 0이 아니라면 즉시 반환하자
public int compareTo(PhoneNumber pn) {
    int result = Short.compare(areaCode, pn.areaCode); // 가장 중요한 필드
    if(result == 0) {
        result = Short.compare(prefix, pn.prefix); // 두 번째로 중요한 필드
        if(result == 0) {
             result = Short.compare(lineNum, pn.lineNum); // 세 번째로 중요한 필드
        }
    }
    return result;
}

 

 

14.3 Comparator 사용

Comparator 인터페이스가 일련의 비교자 생성 메서드와 팀을 꾸려 메서드 연쇄 방식으로 비교자를 생성할 수 있게 되었다. 이 방식은 간결하지만 약간의 성능 정하가 뒤따른다.

 

비교자 생성 메서드를 활용한 비교자
private static final Comparator<PhoneNumber> COMPARATOR =
    comparingInt((PhoneNumber pn) -> pn.areaCode)
        .thenComparingInt(pn.prefix)
        .thenComparingInt(pn.lineNum);
        
public int compareTo(PhoneNumber pn) {
    return COMPARATOR.compare(this, pn);
}

 

 

 

정적 compare 메서드를 활용한 비교자
static Comparator<Object> hashCodeOrder = new Comparator<>() {
    public int compare(Object o1, Object o2) {
        return Integer.compare(o1.hashCode(), o2.hashCod());
    }
}

 

 

비교자 생성 메서드를 활용한 비교자
static Comparator<Object> hasgCodeOrder =
    Comparator.comparingInt(o -> o.hashCode());
728x90
반응형

댓글