public 메서드에 public 필드를 두지 마라

아래 클래스는 데이터 필드에 직접 접근할 수 있어 캡슐화의 이점을 갖지 못한다. 내부 표현을 바꾸기 위해 API를 수정해야 하고 불변식도 보장할 수 없다.

class Point {
  public double x;
  public double y;
}

패키지 바깥에서 접근할 수 있도록 의도된 클래스라면 아래와 같이 설계되어야 맞다. 이렇게 접근자가 있다면 내부 구현을 자유롭게 바꿀 수 있다.

class Point {
  private double x;
  private double y;

  public Point(double x, double y) {
    this.x = x;
    this.y = y;
  }

  public double getX() { return x; }
  public double getY() { return y; }

  public void setX(double X) { this.x = x; }
  public void setY(double Y) { this.y = y; }
}
그러나 만약 packege-private이거나 private 중첩 클래스라면 필드를 노출해도 상관없다. 클라이언트 코드가 이 변수에 묶이지만, 어차피 API 내부 코드이기 때문이다.

잘 설계된 소프트웨어는 캡슐화가 잘 되어있어 개발, 테스트, 최적화 등의 과정을 개별적으로 할 수 있게 해준다.

정보 은닉의 장점

  • 여러 컴포넌트를 병렬로 개발할 수 있어, 시스템 개발 속도를 높인다.
  • 컴포넌트별로 기능 파악이 가능해, 디버깅이나 교체가 부담스럽지 않다.
  • 완성된 시스템을 컴포넌트별로 프로파일링할 수 있어 시스템 관리 비용을 낮춘다.
  • 외부에 의존하지 않도록 설계된 컴포넌트는 다른 프로그램에서도 재사용될 수 있다.
  • 개별 컴포넌트 동작을 검증할 수 있어 대규모 시스템 제작 난이도를 낮춘다.

접근 제어

자바는 정보 은닉을 위한 다양한 장치를 제공한다. 특히 접근 제어 메커니즘은 클래스, 인터페이스, 멤버의 접근 허용 범위를 명시한다.

  • private: 멤버를 선언한 톱레벨 클래스에서만 접근
  • package-private: 멤버가 소속된 패키지 안의 모든 클래스에서 접근
  • protected: package-private의 접근 범위를 포함하며, 이 멤버를 선언한 클래스의 하위 클래스에서도 접근할 수 있다.
  • public: 모든 곳에서 접근할 수 있다.

접근제한자 관리

최소한으로 좁혀라

가장 중요한 원칙은 모든 클래스와 멤버의 접근성을 가능한 한 좁혀야 한다는 것이다. 공개 API를 만들 것이 아니라면, 톱레벨 클래스와 인터페이스를 package-private로 선언해야 한다. 이렇게 하면 API가 아닌 내부 구현이라 클라이언트에 아무 피해를 주지 않고 언제든 수정할 수 있다.

  1. 클래스에서 공개 API로 만들 부분을 설계한다.
  2. 나머지 모든 부분을 private로 만든다.
  3. 오직 같은 패키지의 다른 클래스가 접근해야 하는 상황에만 package-private으로 푼다.
만약 3번 상황이 너무 자주 발생하면 컴포넌트를 더 분리해야 하는 것이 아닌지 고려해야 한다.

또한 한 클래스에서만 사용하는 package-private 톱레벨 클래스나 인터페이스는 이를 사용하는 클래스 안에 private static으로 중접시킬 수 있다. 이렇게 하면 꼭 필요한 클래스에서만 접근할 수 있도록 함으로써 클래스 간 긴밀한 관계를 가질 수 있다.

 

주의할 사항

  • public 클래스 멤버를 가능한 protected로 두지 마라.
    • protected 멤버는 공개 API이므로 영원히 지원되어야 한다.
  • 리스코프 치환 원칙에 주의하라.
    • 상위 클래스 메서드를 재정의할 때는 그 접근 수준을 더 좁게 설정할 수 없다. 리스코프 치환 원칙을 지키기 위해 접근성을 좁히지 못할 수 있다.
  • 테스트 목적으로 접근제한자를 풀지 마라.
    • 테스트 코드를 테스트 대상과 같은 패키지에 둔다면 package-private 멤버에 접근할 수 있다.
  • public 메서드의 필드는 되도록 public이 아니어야 한다.
    • 해당 필드에 담긴 값은 모두 불변식을 보장할 수 없고 스레드 안전하지 않다.
    • 정적 필드에서는 추상 개념을 완성하기 위해 꼭 필요하다면 사용할 수 있다. 그러나 반드시 기본 타입 값이나 불변 객체를 참조해야 한다.
  • 길이가 0이 아닌 배열은 모두 변경 가능하다.
    • 따라서 public static final 배열 필드를 두거나 접근자를 두면 보안 허점이 생길 수 있다.
    • public 불변 리스트를 추가하거나 복사본을 반환하도록 변경할 수 있다.
  • 자바9에서의 모듈 개념이 생김에 따라, public/protected이지만 공개되지 않는 멤버/클래스가 생길 수 있다.

 

 

Comparable 인터페이스

Comparable의 compareTo는 Object의 equals와 유사하다. compareTo는 단순한 동치성 비교에 더해 순서까지 비교할 수 있고, 제네릭하다. 검색, 극단값 계산, 자동 정렬되는 컬렉션도 Array.sort(a);를 사용해 손쉽게 정렬할 수 있다. 자바 플랫폼 라이브러리 대부분 값 클래스와 열거타입이 Comparable을 구현했으므로 알파벳, 숫자와 같이 순서가 명확한 값 클래스를 작성한다면 반드시 Comparable을 구현하자.


compareTo 일반 규약

 

  • 모든 x, y에 대해 sgn(x.compareTo(y)) == -sgn(y.compareTo(x))를 만족해야 한다. -> 두 객체 참조의 순서를 바꿔 비교해도 예상한 결과가 나와야 한다.
  • 추이성을 보장해야 한다. 즉, (x.compareTo(y) > 0 && y.compareTo(z) > 0)이면 x.compareTo(z) > 0이다. -> equals와 같은 추이성에 대한 규약이다.
  • 모든 z에 대해 z.compareTo(y) == 0이면 sgn(x.compareTo(x)) == sgn(y.compareTo(z))다. -> 크기가 같은 객체들끼리는 어떤 객체와 비교하더라도 항상 같아야 한다.
  • (x.compareTo(y) == 0) == (x.equals(y))여야 한다. -> 정렬된 컬렉션들의 동치성을 비교할 때는 equals 대신 compareTo를 사용하므로 둘이 일관된 결과를 내야 한다.

compareTo 메서드 작성 요령

  • 제네릭: Comparable은 타입을 인수로 받는 제네릭 인터페이스이므로 compareTo 메서드의 인수 타입은 컴파일타임에 정해진다. 따라서 인수의 타입이 잘못되었다면 컴파일이 불가능하다.
  • Comparable 커스텀: 인터페이스를 구현하지 않은 필드가 있거나 표준이 아닌 순서로 비교해야 한다면 비교자(Comparator)를 사용한다.
  • 관계 연산자: 박싱된 기본 타입클래스에 관계 연산자인 <, >을 사용하면 오류를 발생시키므로 compare를 사용하는 것이 좋다.
  • 핵심 필드: 핵심 필드가 여러 개라면 가장 핵심적인 필드부터 비교하자.
  • 값의 차: 이따금 값의 차를 기준으로 첫번째 인자가 두번째 인자보다 작으면 음수를, 같으면 0을, 크면 양수를 반환하는 compareTo를 볼 수 있을 것이다. 정수 오버플로를 일으키거나 부동소수점 계산 방식에 따른 오류를 일으키므로 사용하지 않는 것이 좋다. 대신 아래 두 예시 중 하나를 사용하자.
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());

 

비교자 생성 메서드

compareTo에 Java8부터 생긴 비교자 생성 메서드를 사용할 수 있다. 아래 코드에서는 비교자 생성 메서드가 객체 참조를 int 타입 키에 매핑하는 키 추출 함수를 인수로 받아, 그 키를 기준으로 순서를 정하는 비교자를 반환하는 정적 메서드이다.

private static final Comparator<PhoneNumber> COMPARATOR =
  comparingInt((PhoneNumber pn) -> pn.areaCode)
    .thenComparingInt(pn -> pn.prefix)
    .thenComparingINt(pn -> pn.lineNum);

public int compareTo(PhoneNumber pn) {
  return COMPARATOR.compare(this, pn);
}

Cloneable은 무엇인가?

Cloneable은 복제해도 되는 클래스임을 명시하기 위한 용도의 인터페이스지만, clone 메서드가 Cloneable이 아닌 Object이므로 Cloneable을 구현하는 것만으로는 외부 객체에서 clone 메서드를 호출할 수 없다.

Cloneable 인터페이스는 Object의 protected인 메서드인 clone의 동작 방식을 결정한다. Cloneable을 구현한 클래스의 인스턴스에서 clone을 호출하면 내부적으로 객체 필드 하나하나를 복사한 객체를 반환하고, Cloneable을 구현하지 않는 클래스 인스턴스에서 clone을 호출하면 CloneNotSupportException을 던진다.

문제는 clone 메서드의 일반 규약이 허술하다는 것이다. 이 허술한 규약만을 따르면 생성자를 호출하지 않고도 객체를 생성하는, 위험하고 모순적인 매커니즘이 탄생한다.

 

가변 상태를 참조하지 않는 클래스용 clone 메서드

가변 상태를 참조하지 않는 클래스용 clone 메서드를 보자. 이 메서드가 동작하려면 PhoneNumber의 클래스 선언에 Cloneable을 구현하고 clone 메서드가 PhoneNumber를 반환하도록 형 변환을 해주어야 한다.

먼저 super.clone을 호출하면 원본의 완벽한 복제본을 가질 수 있을 것이다. 이때 모든 필드가 기본 타입이거나 불변 객체를 참조한다면 이 객체는 완벽히 clone 메서드에 기대하는 기능을 수행하므로 손볼 것이 없다.

@Override public PhoneNumber clone() {
  try {
    return (PhoneNumber) super.clone();
  } catch (CloneNotSupportedException e) {
    throw new AssertionError();
  }
}

쓸데없는 복사를 지양한다는 관점에서 보면 불변 클래스는 굳이 clone 메서드를 제공하지 않는 것이 좋긴 하다.

 

가변 상태를 참조하는 클래스용 clone 메서드

반대로 가변 객체도 참조하는 Stack 클래스를 생각해보자. clone 메서드가 단순하게 super.clone을 반환하면 원시 타입인 size 필드는 올바른 값을 갖겠지만 element 필드는 원본 Stack 인스턴스와 같은 배열을 참조할 것이다. 결론적으로 clone 메서드는 원본 객체에 아무런 해를 끼치지 않으면서 복제된 객체의 불변식을 보장해야 한다.

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

  public Stack() {
    elements = new Object[DEFAULT_INITIAL_CAPACITY];
  }

  public void push(Object e) {
    ensureCapacity();
    elements[size++] = e;
  }

  public Object pop() {
    if (size == 0) throw new EmptyStacException();
    Object result = elements[--size];
    element[size] = null; // 참조 해제
    return result;
  }

  // 새로 들어올 원소를 위한 공간 확보
  private void ensureCapacity() {
    if (elements.length == size)
      elements = Arrrays.copyOf(elements, 2 * size + 1);
  }
}

 

clone 메서드 재귀 호출

이 문제를 해결하려면 clone 메서드 내부에서 재귀적으로 clone 메서드를 호출해야 한다. 따라서 잘 작성된 Stack의 clone 메서드는 아래와 같다.

그러나 한편, elements 필드가 final이라면 여기에는 새로운 값을 할당할 수 없으므로 아래 코드가 동작하지 않는다. 그래서 클래스를 복제 가능하게 만들기 위해 일부 필드에서 final을 제거해야 할 수도 있다.

따라서 Cloneable 아키텍처는 '가변 객체를 참조하는 필드는 final로 선언하라'는 일반 용법과 충돌한다.
@Override public Stack clone() {
  try {
    Stack result = (Stack) super.clone();
    result elements = elements.clone();
    return result;
  } catch (CloneNotSupportedException e) {
    throw new AssertionError();
  }
}

 

원본 객체의 상태를 다시 생성하는 고수준 메서드 호출

해시테이블에서는 clone을 재귀적으로 호출하는 것만으로 충분하지 않을 수 있다. 해시 테이블 내부는 버킷들의 배열이고, 각 버킷은 키-값 쌍을 담는 연결 리스트의 첫 번째 엔트리를 참조한다. Stack에서처럼 단순히 버킷 배열의 clone을 재귀적으로 호출한다면, 복제본은 자신만의 버킷 배열을 갖지만 원본과 같은 연결 리스트를 참조하므로 원본과 복제본 모두 예상치 못한 방식으로 동작한다.

이를 해결하려면 각 버킷을 구성하는 연결 리스트를 복사해야 한다. 코드에는 나와있지 않지만 private 클래스인 HashTable.Entry는 deepCOpy를 지원하도록 보강되었다. Entry의 deepCopy가 자신이 가리키는 연결 리스트 전체를 복사하기 위해 자신을 재귀적으로 호출한다면 원본 객체와 완전히 같은 객체를 생성할 수 있을 것이다.

@Override public HashTable clone() {
  try {
    HashTable result = (HashTable) super.clone();
    result.buckets = new Entry[buckets.length];
    for(int i = 0; i < buckets.length; i++){
      if(buckets[i] != null){
        result.buckets[i] = buckets[i].deepCopy();
      }
    }
    return result;
  } catch (CloneNotSupportedException e) {
    throw new AssertionError();
  }
}
Entry deepCopy() {
  return new Entry(key, value, next == null ? null : next.deepCopy());
}

기타 clone 메서드 관련 주의점

재정의될 수 있는 메서드
생성자와 같이, clone 메서드도 재정의될 수 있는 메서드를 호출하지 않아야 한다. 만약 clone이 하위 클래스에서 재정의한 메서드를 호출한다면, 하위 클래스는 복제되는 과정에서 자신의 상태를 올바르게 바꿀 수 있는 기회 없이 잘못된 상태를 가진다.

Exception
Object의 clone은 CloneNotSupportedException을 던지지만 clone을 재정의한 메서드는 throws 절을 없애야 더 편하게 사용할 수 있다.

상속
일반적으로 상속용 클래스는 Cloneable을 구현하면 안 되지만, 하위 클래스가 구현 여부를 선택하도록 하려면 clone 메서드를 구현해 protected로 두고 CloneNotSupportedException을 던지게 할 수 있다.

멀티스레드
스레드 안전한 클래스를 작성하려면, clone 메서드를 또 다른 방식으로 재정의해야 한다.

복사 생성자, 복사 팩토리

객체의 복사가 필요하면 언어 모순적이고 위험한 객체 생성 방법인 Cloneable을 구현하기보다 복사 생성자, 복사 팩토리를 사용하는 것이 좋다. 심지어 이 방법들은 해당 클래스가 구현한 인터페이스 타입 인스턴스를 인수로 받을 수 있다. 이 방법을 이용하면 클라이언트는 원본의 구현 타입에 상관없이 복제본 타입을 선택할 수 있다.

기본 toString 메서드

Object의 기본 toString 메서드는 단순히 클래스이름@해시코드를 반환한다. 클라이언트가 toString을 제대로 재정의하지 않으면 쓸모없는 메시지만 로그에 남을 것이다. 일반 규약에 따르면, toString은 간결하면서 사람이 읽기 쉬운 형태의 유익한 정보를 반환해야 한다. 따라서 toString 일반 규약은 모든 하위 클래스에서 이 메서드를 재정의하는 것을 추천한다.

// 기본 toString
{Jenny=PhoneNumber@abcde}

// 재정의한 toString
{Jenny=010-1234-5678}

재정의한 toString 메서드

toString을 잘 구현한 클래스를 사용한 시스템은 디버깅하기 수월하다.

toString은 클라이언트가 직접 호출하지 않더라도 다른 어딘가에서 사용된다. println, printf, 문자열 연결(+), assert 구문, 디버거가 객체를 출력할 때 등이 대표적인 사용 예시이다.

포맷 문서화 장단점
toString을 구현할 때 반환 값의 포맷을 문서화할지 정해야 한다.

포맷을 명시한 객체는 표준적이고, 사람이 읽을 수 있게 된다. 이 경우 문자열과 객체를 상호 전환할 수 있는 정적 팩토리나 생성자를 함께 제공할 수도 있다. 자바 플랫폼의 BigIntegerBigDecimal 같은 클래스가 포맷을 명시해두었다.

그러나 포맷을 한번 명시하면 해당 시스템은 평생 그 포맷에 얽매이게 된다. 클라이언트는 모두 포맷에 맞추어 파싱 하고, 객체를 만들고 저장하는 코드를 작성하게 되는데, 만약 다음 릴리즈에서 포맷이 바뀐다면 기존 코드는 엉망이 될 것이다. 따라서 포맷을 명시하지 않으면 향후 릴리즈에서 정보를 더 넣거나 포맷을 개선할 수 있어 비교적 유연하다.

개별 값 접근 API
toString이 반환하는 객체 인스턴스를 얻어올 수 있는 API를 제공해야 한다. 예를 들어 Address 클래스에 시, 군, 구, 우편번호 등이 있다면 각각을 위한 접근자를 제공해야 한다. 그렇지 않으면 클라이언트는 각 정보가 필요할 때 toString 반환 값을 파싱 해서 각 값을 얻어야 하고 이는 성능 저하로 이어진다.

hashCode와 equals에 대한 Object 명세

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

hashCode를 재정의하지 않을 때 생기는 문제

Item 10에서 보았듯이 equals는 물리적으로 다른 두 객체를 논리적으로 같다고 할 수 있다. 그러나 Object에 정의된 기본 hashCode 메서드는 물리적으로 다르나, 논리적으로는 같은 이 객체를 전혀 다르다고 판단해 다른 값을 반환한다.

이전 Item 예시인 PhoneNumber 클래스를 생각해보면, 이 클래스는 hashCode를 재정의하지 않았기 때문에 논리적으로 같은 객체에 다른 해시코드를 반환한다. 예를 들어, 아래 코드에서 m.get()은 "제니"를 반환하지 않는다.

Map<PhoneNumber, String> m = new Hashmap<>();
m.put(new PhoneNumber(707, 867, 5309), "제니");
// 아래 값은 "제니"를 반환하지 않는다.
m.get(new PhoneNumber(707, 867, 5309));

 

좋은 해시 함수

좋은 해시 함수는 서로 다른 인스턴스에 다른 해시코드를 반환한다.

hashCode 작성 순서

  1. 결과값을 c로 초기화한다. (c는 객체의 첫 번째 핵심 필드를 2(1) 방식으로 계산한 해시 코드이다.)
  2. 해당 객체의 나머지 핵심 필드 각각에 대해 다음 작업을 수행한다.
    • 핵심 필드의 해시코드를 계산한다.
      • 기본 타입이면, Type.hashCode(f)를 수행한다.
      • 참조 타입이면서 클래스의 equals가 필드의 equals를 재귀적으로 호출한다면, 이 필드의 hashCode를 재귀적으로 호출한다.
      • 배열이라면, 배열 내 핵심 원소 각각을 별도의 필드처럼 다뤄 계산한다. 배열 해시 코드를 계산하면 2(2) 방법대로 갱신한다. 모든 원소가 핵심 원소라면 Arrays.hashCode를 사용한다.
    • 해시 코드로 reault를 갱신한다.
  3. result를 반환한다.

 

전형적인 hashCode 메서드

아래 메서드는 인스턴스의 핵심 필드만을 사용해 간단하게 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 작성 시 주의할 점

  • 동치인 인스턴스에 똑같은 해시 코드를 반환해야 한다.
    • AutoValue로 생성한 것이 아니면 단위 테스트를 작성해 이 사실을 검증해야 한다.
  • 파생 필드는 해시코드 계산에서 제외해도 된다.
  • equals 비교에 사용되지 않는 필드는 반드시 제외해야 한다.
  • Object에서 제공하는 hash 메서드를 사용할 수 있다.
    • 속도가 느리므로 성능에 민감하지 않을 때만 사용해야 한다.
  • 성능을 높이기 위해 핵심 필드를 생략하면 안 된다.
    • 해시 테이블 속도가 느려질 수 있다.
  • hashCode가 반환하는 값의 생성 규칙을 API 사용자에게 자세히 공표하지 않아야 한다.
    클라이언트가 값에 의존하지 않아야 추후에 계산 방식을 바꿀 수 있다.

equals는 주의 깊게 재정의해야 한다.

 

equals를 재정의하지 않아야 하는 경우

각 인스턴스가 본질적으로 고유할 때

Thread와 같이 값을 표현하는 게 아니라 동작하는 개체를 표현하는 클래스는 각 인스턴스가 고유할 때는 equals를 재정의하지 않아야 한다.

인스턴스의 논리적 동치성을 검사할 일이 없을 때

각 인스턴스가 논리적으로 동일한지 검사할 필요가 없을 수 있다. 예를 들어 java.util.regex.Pattern은 equals를 재정의해서 두 Pattern 인스턴스가 같은 정규표현식을 나타내는지 알아볼 수도 있겠지만, 굳이 그러한 종류의 검사가 필요하지 않을 수 있다. 이 경우는 Object의 기본 equals로도 충분하다.

상위 클래스에서 재정의한 equals가 하위 클래스에도 딱 들어맞을 때

상위 클래스의 equals가 하위 클래스에 알맞을 때, 하위 클래스의 equals를 재정의할 필요가 없다. 예를 들어 Set 구현체는 AbstractSet이 구현한 eqauls를 상속받아 쓴다.

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

어느 곳에서도 equals가 호출될 일이 없다고 가정하고, 재정의할 필요가 없다. 만약에라도 위험을 피하고 싶다면 아래와 같이 재정의할 수 있다.

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

같은 인스턴스가 둘 이상 만들어지지 않을 때

인스턴스 통제 클래스, Enum 클래스는 값이 같은 인스턴스가 2개 이상 만들어지지 않으므로 객체 식별성을 확인하는 것이 논리적 식별성을 확인하는 것과 같은 의미이다.

 

equals를 재정의해야 하는 경우

객체가 물리적으로 다른지 식별할 때가 아니라, 논리적으로 다른지 식별해야 하는데 상위 클래스의 equals가 논리적 동치성을 비교하도록 재정의되지 않았을 때 재정의가 필요하다. 주로 Integer, String 같은 값 클래스가 여기에 해당되는데, 일반적으로 클라이언트는 값 클래스 객체가 동일한 객체인지가 아니라 같은 값을 갖는지 검사하고 싶어 할 것이다.

equals가 논리적 동치성을 확인하도록 재정의하면 우리가 equals 메서드를 쓸 때 예상하는 결과에 부응함은 물론 Map, Set의 원소로도 사용할 수 있을 것이다.

 

equals 재정의 시 따라야 하는 일반 규약

equals 메서드는 동치관계를 구현한다.

Object 명세에서 말하는 동치관계란 집합을 서로 같은 원소로 이뤄진 부분집합으로 나누는 연산이다. 올바르게 구현된 equals 메서드는 모든 원소를 같은 동치류에 속한 어떤 원소와 교환해도 만족해야 한다. 아래 다섯 가지 성질을 만족하면 동치 관계를 만족한다.

 

반사성

null이 아닌 모든 참조 값 x에 대해, x.equals(x)는 true이다.

객체는 자기 자신과 같아야 한다.

 

대칭성

null이 아닌 모든 참조 값 x, y에 대해, x.equals(y)가 true면 y.equals(x)도 true이다.

아래 코드에서 CaseInsensitiveString은 String 클래스 객체와도 비교하기 위해 equals를 재정의했다. 그러나 String 클래스의 equals는 CaseInsensitiveString의 존재를 모르기 때문에 대칭성을 위반한다.

따라서 CaseInsensitiveString과 String을 equals로 비교할 생각을 하지 말고, CaseInsensitiveString의 equals를 좋은 예시처럼 분리해야 한다.

// 나쁜 예시
public final class CaseInsensitiveString {
  private final String s;

  public CaseInsentiveString(String s) {
    this.s = Objects.requireNonNull(s);
  }

  @Override public boolean eqauls(Object o) {
    if (o instanceof CaseInsensitiveString)
      return s.equalsIgnoreCase(((CaseInsensitiveString) o).s);
    if (o instanceof String)
      return s.equalsIgnoreCase((String) o);
    return false;
  }  
}
// 좋은 예시
@Override public boolean equals(Object o) {
  return o instanceof CaseInsensitiveString && ((CaseInseneitiveString o).s.equalsIgnoreCase(s);
}

 

추이성

null이 아닌 모든 참조 값 x, y, z에 대해, x.equals(y)가 true이고 y.equals(z)도 true이면 x.equals(z)도 true이다.

점의 좌표를 멤버로 가지는 Point 클래스와 색깔 정보를 더해 확장한 ColorPoint 클래스가 있다고 할 때, 아래 예시 상황은 추이성을 만족하지 않는다. p1.equals(p2)와 p2.equals(p3)는 true이나 p1.equals(p3)는 false를 반환하기 때문이다.

// 예시 상황
ColorPoint p1 = new ColorPoint(1, 2, Color.RED);
Point p2 = new Point(1, 2);
ColorPoint p3 = new ColorPoint(1, 2, Color.BLUE);
public class Point {
  private final int x;
  private final int y;

  public Point(int x, int y) {
    this.x = x;
    this.y = y;
  }

  @Override public boolean equals(Object o) {
    if (!(o instanceof Point))
      return false;
    Point p = (Point)o;
    return p.x == x && p.y == y;
  }
}
public class ColorPoint extends Point {
  private final Color color;

  public ColorPoint(int x, int y, Color color) {
    super(x, y);
    this.color = color;
  }

  @Override public boolean equals(Object o) {
    if (!(o instanceof Point))
      return false;

    if(!(o instanceof ColorPoint))
      return o.equals(this);
    
    return super.equals(o) && ((ColorPoint) o).color == color;
  }
}

 

문제는 구체 클래스를 확장해 새로운 값을 추가하며 equals 규약을 만족시킬 방법은 존재하지 않는다는 것이다.

만약 추이성을 만족시키기 위해 instanceof가 아니라 getClass를 사용한다면, 특정 상황에서 리스코프 치환 원칙을 위배한다. Set과 같은 컬렉션 구현체에서는 contains는 equals를 기반으로 구현되어 있다. 따라서 주어진 원소를 가지는지를 확인하는 contains를 호출했을 때 getClass를 사용해 재정의된 equals를 사용한다면 하위 클래스는 여전히 상위 클래스처럼 사용되어야 함에도 항상 false를 반환할 것이다.

구체 클래스의 하위 클래스에서 값을 추가하는 대신, 아래 코드와 같이 컴포지션을 사용할 수 있다. Point를 ColorPoint의 private 필드로 두고, ColorPoint와 같은 위치의 Point를 반환하는 뷰 메서드를 public으로 추가하는 것이다.

public class ColorPoint {
  private final Point point;
  private final Color color;

  public ColorPoint(int x, int y, Color color) {
    point = new Point(x, y);
    this.color = Objects.requireNonNull(color);
  }

  // 뷰를 반환하는 메서드
  public Point asPoint() {
    return point;
  }

  @Override public boolean equals(Object o) {
    if (!(o instanceof ColorPoint))
      return false;
    ColorPoint cp = (ColorPoint) 0;
    return cp.point.equals(point) && cp.color.equals(color);
  }
}

 

일관성

null이 아닌 모든 참조 값 x, y에 대해, x.equals(y)를 반복해서 호출하면 항상 true를 반환하거나 항상 false를 반환한다.

일관성은 두 객체가 같다면 앞으로도 영원히 같아야 한다는 뜻이다. 가변 객체는 비교 시점에 따라 비교 결과가 달라질 수 있지만, 불변 객체는 항상 같은 결과를 반환해야 한다.

특히 일관성을 지키기 위해서는, equals의 판단에 신뢰할 수 없는 자원이 끼어들면 안 된다. java.net.URL의 equals는 주어진 url과 매핑된 호스트의 IP 주소를 이용해 비교하는데, 호스트 이름은 네트워크를 통해야 변경할 수 있으므로 equals의 결과가 항상 같다고 보장할 수 없다. 이런 문제를 피하려면 equals는 항상 메모리에 존재하는 객체만을 사용해 결정적인 계산만 수행해야 한다.

 

null 아님

null이 아닌 모든 참조 값 x에 대해, x.equals(null)은 false이다.

이 규약은 모든 객체가 null과 같이 않아야 한다는 뜻이다. 아래 코드처럼 명시적으로 null을 검사할 수도 있겠지만, equals에 사용되는 instanceof는 첫 번째 인자가 null이면 항상 false를 반환하므로 null을 명시적으로 검사하지 않아도 된다.

@Override public boolean equals(Object o) {
  if (o == null)
    return false;
}

 

양질의 equals 메서드 구현법

  1. == 연산자를 사용해 입력이 자기 자신의 참조인지 확인하면 성능이 좋아질 것이다.
    float과 double은 부동 소수 값을 다뤄야 하므로 == 대신 compare()를 사용한다.
  2. instanceof 연산자로 입력이 올바른 타입인지 확인한다.
    이때 올바른 타입은 equals가 정의된 클래스일 수도, 그 클래스가 구현한 인터페이스일 수도 있다.
  3. 입력을 올바른 타입으로 형 변환한다.
  4. 입력 객체와 자기 자신에서 핵심 필드가 모두 일치하는지 확인한다.
    어떤 필드를 먼저 비교하느냐가 equals의 성능을 좌우하기도 한다.
    높은 성능을 요한다면 다를 가능성이 크거나 비교 비용이 싼 필드를 먼저 비교하자.
    파생 필드가 객체의 상태를 대표한다면 파생 필드를 사용하는 것도 좋다.
  5. equals를 재정의했으면 hashCode도 재정의하자.
  6. Object 외의 타입을 매개변수로 받는 equals 메서드는 작성하지 말아야 한다.
    Object 외의 타입을 받으면 그것은 Object.equals를 재정의한 것이 아니라 새로운 메서드를 생성해 equals를 다중 정의한 것이다.

AutoValue를 사용하지 않고 equals를 구현했다면 해당 메서드가 대칭적인지, 추이성이 있는지, 일관적인지 단위 테스트를 돌려보자. 아래 코드는 잘 작성된 equals의 예시이다.

@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;
}

자바 라이브러리에는 InputStreamOutputStream, java.sql.Connection 등 close() 메서드를 이용해 닫아야 하는 자원이 많다. 그러나 자원 닫기는 예측할 수 없는 예외가 발생하면 클라이언트가 놓치기 쉽다. 이런 자원 중 대부분이 finalizer를 사용하지만 믿을만하지 못하다.

 

자원을 닫기 위해 try-finally를 사용하는 경우

아래 코드에서는 자원이 제대로 닫히는 것을 보장하는 수단으로 항상 실행됨을 보장하는 try-finally를 사용했다. 그러나 예외는 try문과 finally 문에서 모두 발생할 수 있다.

만약 기기에 물리적인 문제가 생긴다면 readLine 메서드에 문제가 생겨 예외를 던지고 close()도 실패할 것이다. 이 경우 스택 추적 내역은 두 번째 예외만을 기록할 것이고 디버깅을 어렵게 한다.

// 자원을 한 개 사용하는 경우
static String firstLineOfFile(String path) throws IOException {
  BufferReader bt = new BufferReader(new FileReader(path));
  try {
    return br.readLine();
  } finally {
    br.close();
  }
}
// 자원을 두 개 사용하는 경우
static void copy(String src, String dst) throws IOException {
  InputStream in = new FileInputStream(src);
  try {
    OutputStream out = new FileOutputStrieam(dst);
    try {
      byte[] buf = new byte[BUFFER_SIZE];
      int n;
      while ((n = in.read(buf)) >= 0)
        out.write(buf, 0, n);
    } finally {
      cout.close();
    }
  }
}

 

자원을 닫기 위해 try-with-resources를 사용하는 경우

자바 7은 리소스를 닫아야만 하는 자원은 AutoCloseable 인터페이스를 사용하게끔 했다. 아래 코드처럼 AutoClosable을 구현한 리소스에 try-with-resources를 사용하면 길이가 짧아져서 읽기 수월할 뿐 아니라 문제를 진단하기도 훨씬 쉽다.

이전 상황과 달리, 만약 close() 호출 양쪽에서 예외가 발생하면, close()에서 발생한 예외는 숨겨지고 readLine에서 발생한 예외만 기록된다.

try-finally에서처럼 try-with-resources에도 catch 절을 쓸 수 있으므로 try문을 중첩하지 않고도 다수의 예외를 처리할 수 있다.

static String firstLineOfFile(String path) throws IOException {
  try (BufferReader br = new BufferedReader(
    new FileReader(path))) {
      return br.readLine();
    }
}
static void copy(String src, String dst) throws IOException {
  try (InputStream in = new FileInputStream(src);
      OutputStream out = new FileOutputStream(dst)) {
        byte[] buf = new byte[BUFFER_SIZE];
        int n;
        while ((n = in.read(buf)) >= 0)
          out.write(buf, 0, n);
      }
}

+ Recent posts