BOOK 6 - 이펙티브 자바(3)

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

  • Object 는 기본적으로 상속해서 사용하도록 설계되었다.
    • final 이 아닌 메서드는 모두 오버라이드를 염두에 두고 설계된 것이라 재정의시 지켜야 하는 일반 규약이 명확히 정의되어 있다.
    • equals , hashCode , toString , clone , finalize

equals는 일반 규약을 지켜 재정의하라

  • equals 는 재정의하지 않으면 클래스의 인스턴스는 오직 자기자신과만 같게 된다.

재정의하지 않는 것이 좋은 상황

  • 각 인스턴스가 본질적으로 고유하다.
    • 값을 표현하는 게 아니라 동작하는 개체를 표현하는 클래스가 해당한다.
    • Thread 가 좋은 예이다.
  • 인스턴스의 ‘논리적 동치성’을 검사할 일이 없다.

  • 상위 클래스에서 재정의한 equals 가 하위 클래스에도 들어맞는다.
  • 클래스가 private 이거나 package-private 이고 equals 메서드를 호출할 일이 없다.
  • 값이 같은 인스턴스가 둘 이상 만들어지지 않음을 보장하는 인스턴스 통제 클래스
    • 논리적으로 같은 인스턴스가 2개 이상 만들어지지 않으므로 사실상 논리적 동치성과 객체 식별성(object identity)이 같은 의미가 된다.

재정의해야하는 상황

  • 객체의 물리적 동치성이 아니라 논리적 동치성을 확인해야 하고, 상위 클래스의 equals 가 논리적 동치성을 비교하도록 구현되어있지 않았을 때이다.

재정의할 때 따라야하는 규약

null이 아닌 모든 참조 값 x, y, z에 대해,

  • 반사성(reflexivity) : x.equals(x) == true
  • 대칭성(symmetry) : x.equals(y) == true 이면 y.equals(x) == true
  • 추이성(transitivity) : x.equals(y) == true 이고 y.equals(z) == true 이면 x.equals(z) == true
  • 일관성(consistency) : x.equals(y) 를 반복해서 호출하면 항상 같은 값을 반환한다.
  • null 아님 : x.equals(null) == false

대칭성 / 추이성 위반 예제

  • 2차원에서의 점을 표현하는 클래스가 있다.
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 ColorPoint)) return false;
    return super.equals(o) && ((ColorPoint) o).color == color;
  }
}
  • 위처럼 equals 를 재정의하면 대칭성에 위배된다.
    • PointColorPoint 에 비교한 결과와 그 둘을 바꿔 비교한 결과가 다를 수 있다.
Point p = new Point(1, 2);
ColorPoint cp = new ColorPoint(1, 2, Color.RED);
  • p.equals(cp) == true 이고, cp.equals(p) == false 이다.
  • 그러면 ColorPoint.equals 에서 Point 객체와 비교할 때는 색상을 무시하도록 재정의하면 될까?
@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;
}
  • 이 방식은 대칭성은 지키지만, 추이성에 위배된다.
ColorPoint p1 = new ColorPoint(1, 2, Color.RED);
Point p2 = new Point(1, 2);
ColorPoint p3 = new ColorPoint(1, 2, Color.BLUE);
  • p1.equals(p2) == true 이고 p2.equals(p3) == true 이지만 p1.equals(p3) == false 이다.

  • 이 현상은 모든 객체 지향 언어의 동치관계에 나타나는 근본적인 문제다.
  • 구체 클래스를 확장해 새로운 값을 추가하면서 equals 규약을 만족시킬 방법은 존재하지 않는다.
우회 방법 - 상속 대신 컴포지션 사용
  • 구체 클래스의 하위 클래스에 값을 추가하는 방법 대신 PointColorPoint 의 private 필드로 두고, Point 를 반환하는 view 메서드를 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) o;
    return cp.point.equals(point) && cp.color.equals(color);
  }
}
  • 자바 라이브러리에도 구체 클래스를 확장해 값을 추가한 클래스가 종종 있다.
    • java.sql.Timestampjava.util.Date 를 확장한 후 nanoseconds 필드를 추가했다.
    • 그 결과로 Timestampequals 는 대칭성을 위배하며, Date 객체와 한 컬렉션에 넣거나 섞어 사용하면 엉뚱하게 동작할 수 있다.

일관성 위배 예제

  • 클래스가 불변이든 가변이든 equals의 판단에 신뢰할 수 없는 자원이 끼어들게 해서는 안된다.
  • java.net.URLequals 는 주어진 URL과 매핑된 호스트의 IP 주소를 이용해 비교한다.
    • 호스트 이름을 IP 주소로 바꾸려면 네트워크를 통해야 하는데, 그 결과가 항상 같다고 보장할 수 없다.
    • 이는 URLequals 가 일관성을 위배하게 한다.
  • 이런 문제를 피하려면 equals는 메모리에 존재하는 객체만을 사용한 결정적(deterministic) 계산만 수행해야 한다.

null-아님 예제

  • equals의 입력이 null인지를 명시적으로 확인하기보다는 입력받은 객체를 적절히 형변환하는 방식을 사용하는 것이 좋다.
// 명시적 null 검사 
pulblic boolean equals(Object o) {
  if(o == null) return false;
}

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

좋은 재정의 방법

  • == 연산자를 사용해 입력이 자기 자신의 참조인지 확인한다.
    • 자기자신이면 true를 반환한다. (성능 최적화용)
  • instanceof 연산자로 입력이 올바른 타입인지 확인한다.
  • 입력을 올바른 타입으로 형변환한다.
  • 입력 객체와 자기 자신의 대응되는 ‘핵심’ 필드들이 모두 일치하는지 하나씩 검사한다.
    • floatdouble 을 제외한 기본 타입 필드는 == 연산자로 비교한다.
      • float -> Float.compare(float, float) , double -> Double.compare(double, double) 각각 정적 메서드로 비교한다.
      • 특수한 부동소수 값을 다루기 때문이다.
    • 참조 타입 필드는 equals 메서드로 비교한다.
  • 어떤 필드를 먼저 비교하느냐가 성능을 좌우하기도 한다.
    • 다를 가능성이 더 크거나 비교하는 비용이 더 싼 필드를 먼저 비교한다.
    • 핵심 필드로부터 계산해낼 수 있는 파생 필드는 굳이 비교할 필요없지만, 파생 필드를 비교하는 쪽이 더 빠를 때도 있다.
  • 동기화용 lock 필드 같이 객체의 논리적 상태와 관련 없는 필드는 비교하면 안된다.

좋은 재정의 방법을 따른 예제

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, 999, "가입자 번호");
  }
  
  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도 반드시 재정의하자
  • Object 외의 타입을 매개변수로 받는 equals 메서드는 선언하지 말자.
    • 이것은 Object.equals 를 재정의한 것이 아니라 다중정의한 것이다.

equals를 재정의하려거든 hashCode도 재정의하라

  • equals 를 재정의한 모든 클래스에서 hashCode 도 재정의해야한다.
  • 그렇지 않으면 해당 클래스의 인스턴스를 HashMap 이나 HashSet 같은 컬렉션의 원소로 사용할 때 문제를 일으킬 것이다.

hashCode 규약

  • equals 비교에 사용되는 정보가 변경되지 않았다면, 어플리케이션이 실행되는 동안 그 객체의 hashCode 는 항상 같은 값을 반환해야 한다.
    • 단, 어플리케이션을 다시 실행한다면 이 값이 달라져도 상관없다.
  • equals 가 두 객체를 같다고 판단했다면, 두 객체의 hashCode 는 같은 값을 반환해야 한다.
    • equals 는 물리적으로 다른 두 객체를 논리적으로 같다고 할 수 있다.
    • Object 의 기본 hashCode 메서드는 이 두 객체를 전혀 다르다고 판단하여, 재정의하지 않는다면 규약을 위반하게 된다.
  • equals 가 두 객체를 다르다고 판단했더라도, 두 객체의 hashCode 가 서로 다른 값을 반환할 필요는 없다.
    • 단, 다른 객체에 대해서는 다른 값을 반환해야 해시테이블의 성능이 좋아진다.

두번째 규약 위배 예제

  • 위에서 봤던 PhoneNumber 클래스에 hashCode 메서드가 재정의되지 않았다면, 두번째 규약에 위배된다.
Map<PhoneNumber, String> m = new HashMap<>();
m.put(new PhoneNumber(707, 876, 5309), "제니");
m.get(new PhoneNumber(707, 876, 5309));  // => "제니"가 나오길 기대하지만, 실제로 null을 반환한다.
  • 여기에는 2개의 인스턴스가 사용되었다.
    • 하나는 put 할 때 사용되었고, 하나는 get 할 때 사용되었다.
    • 두 객체는 논리적으로 동치이지만, hashCode 를 재정의하지 않았기 때문에 서로 다른 해시코드를 반환하여 규약을 지키지 못한다.

좋은 hashCode 작성

  • 좋은 해시 함수라면 서로 다른 인스턴스에 다른 해시코드를 반환한다.
    • 이것이 세번째 규약이 요구하는 속성이다.
  • 이상적인 해시 함수는 서로 다른 인스턴스들을 32비트 정수 범위에 균일하게 분배해야 한다.
  1. int result 를 선언한 후, 값 c로 초기화한다.

    • 이 때 c는 해당 객체의 첫번째 핵심 필드를 2.a 방식으로 계산한 해시코드다.
  2. 해당 객체의 나머지 핵심 필드 f 각각에 대해 다음 작업을 수행한다.

    a. 해당 필드의 해시코드 c를 계산한다.

    • 기본 타입 필드 : Type.hashCode(f)
    • 참조 타입 필드 : 이 클래스의 equals 메서드가 이 필드의 equals 를 재귀적으로 호출해 비교한다면, 이 필드의 hashCode 를 재귀적으로 호출한다.
      • 필드의 값이 null 이라면 0을 사용한다.
    • 배열 : 핵심 원소 각각을 별도 필드처럼 다룬다.
      • 이상의 규칙을 재귀적으로 적용해 각 핵심 원소의 해시코드를 계산한 다음, 2.b 방식으로 갱신한다.
      • 모든 원소가 핵심 원소라면 Arrays.hashCode 를 사용한다.

    b. 2.a에서 계산한 해시코드 c로 result 를 갱신한다.

    • result = 31 * result + c
  3. result 를 반환한다.

  • 파생 필드는 해시코드 계산에서 제외해도 된다.
  • equals 비교에 사용되지 않은 필드는 반드시 제외해야 한다.

  • result 값에 31을 곱하는 이유는 31이 홀수이면서 소수이기 때문이다.
    • 31을 이용하면, 이 곱셈을 시프트 연산과 뺄셈으로 대체해 최적화할 수 있다. ((i << 5) - i)

적용 예제

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

Objects.hash

  • Objects 클래스는 임의의 개수만큼 객체를 받아 해시코드를 계산해주는 정적 메서드인 hash 를 제공한다.
  • 이 메서드를 사용하면 앞서 구현한 코드와 비슷한 수준의 hashCode 함수를 한 줄로 작성할 수 있다.
    • 속도는 더 느리다.
    • 성능에 민감하지 않은 상황에서 사용하자.
@Override
public int hashCode() {
  return Objects.hash(lineNum, prefix, areaCode);
}

lazy initialization

  • 클래스가 불변이고 해시코드를 계산하는 비용이 크다면, 매번 새로 계산하기 보다 캐싱하는 방식을 고려하는 것이 좋다.
  • 이 타입의 객체가 주로 해시의 키로 사용될 것 같다면, 인스턴스가 만들어질 때 해시코드를 계산해둬야 한다.
  • 해시의 키로 사용되지 않는 경우라면, hashCode 가 처음 불릴 때 계산하는 지연 초기화 전략을 사용할 수 있다.
private int hashCode;

@Override
public int hashCode() {
  int result = hashCode;
  if (result == 0) {
    result = Short.hashCode(areaCode);
  	result = 31 * result + Short.hashCode(prefix);
  	result = 31 * result + Short.hashCode(lineNum);
    hashCode = result;
  }
  return result;
}
  • 필드를 지연 초기화하려면 그 클래스를 Thread-Safe 하게 만들도록 신경써야 한다.

toString을 항상 재정의하라

  • toString의 일반 규약에 따르면 ‘간결하면서 사람이 읽기 쉬운 형태의 유익한 정보’를 반환해야 한다.
  • toString을 잘 구현한 클래스는 디버깅하기 쉽다.
  • toString은 그 객체가 가진 주요 정보를 모두 반환하는게 좋다.
    • 객체가 거대하거나 객체의 상태가 문자열로 표현하기에 적합하지 않다면 무리가 있다.

toString 포맷

  • toString을 구현할 때면 반환값의 포맷을 문서화할지 정해야 한다.
  • 포맷을 명시하기로 했다면, 명시한 포맷에 맞는 문자열과 객체를 상호 전환할 수 있는 정적 팩터리나 생성자를 함께 제공해주면 좋다.
  • 포맷을 한번 명시하면 평생 그 포맷에 얽매이게 된다.
    • 만약 다음 릴리즈에서 포맷을 바꾼다면, 이전에 이를 사용하던 코드들과 데이터들은 유효하지 않게 될 것이다.
  • 포맷 명시 여부와 관계없이 toString이 반환한 값에 포함된 정보를 얻어올 수 있는 API를 제공하는 것이 좋다.

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

  • Cloneable : 복제해도 되는 클래스임을 명시하는 mixin interface

Cloneable 인터페이스

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

clone 규약

  • 이 객체의 복사본을 생성해 반환한다.
    • ‘복사’의 정확한 뜻은 그 객체를 구현한 클래스에 따라 다를 수 있다.
  • 일반적인 ‘복사’의 의도는 다음과 같다.
  • 일반적으로 다음 식은 참이지만, 반드시 만족해야 하는 것은 아니다.
    • x.clone() != x
    • x.clone().getClass() == x.getClass()
    • x.clone().equals(x)
  • 관례상, 이 메서드가 반환하는 객체는 super.clone() 를 호출해 얻어야 한다.
  • 관례상, 반환된 객체와 원본 객체는 독립적이어야 한다.

super.clone()

  • 강제성이 없다면 점만 빼면 생성자 연쇄와 비슷하다.
  • clone 메서드가 super.clone() 이 아닌 생성자를 호출해 얻은 인스턴스를 반환해도 컴파일러는 오류를 내지는 않을 것이다.
    • 그러나 이 클래스의 하위 클래스에서 super.clone() 을 호출한다면 잘못된 클래스의 객체가 만들어져 하위 클래스의 clone 메서드가 제대로 동작하지 않을 것이다.
예제
  • 제대로 동작하는 clone 메서드를 가진 상위 클래스를 상속해 Cloneable 을 구현하고 싶다고 하자.
public class PhoneNumber implements Cloneable {
  ...
  
  @Override
  public PhoneNumber clone() {
    try {
      return (PhoneNumber) super.clone();
    } catch (CloneNotSupportedException e) {
      throw new AssertionError();
    }
  }
}
  • 먼저 super.clone() 을 호출한다.
    • 이렇게 얻은 객체는 완벽한 복제본이다.
    • 모든 필드가 기본 타입이거나 불변 객체를 참조한다면 이 객체는 완벽히 우리가 원하는 상태라 손볼 것이 없다.
    • 그러나 쓸데없는 복사를 지양하는 관점에서 불변 클래스는 굳이 clone 메서드를 제공하지 않는 것이 좋다.
  • Object 의 clone 메서드는 Object 를 반환하지만, PhoneNumber 의 clone 메서드는 PhoneNumber 를 반환하게 했다.
    • 재정의한 메서드의 반환 타입은 상위 클래스의 메서드의 반환 타입의 하위 타입일 수 있다.
    • 이 경우 클라이언트가 형변환하지 않아도 된다.
    • 이를 위해 super.clone() 으로 얻은 객체를 반환하기 전에 PhoneNumber 로 형변환 하였다.
  • try-catch 블록으로 감싸준 이유는 Object의 clone 메서드가 checked exception인 CloneNotSupportedException 을 던지도록 선언되었기 때문이다.

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

  • 클래스가 가변 객체를 참조하는 순간 앞서의 구현은 프로그램을 망칠 수 있다.
public class Stack {
  private Object[] elements;
  private int size;
  private static final int DEFAULT_INITIAL_CAPACITY = 16;
  
  ...
}
  • 이 클래스의 clone 메서드가 단순히 super.clone 의 결과를 그대로 반환한다면 어떻게 될까?
    • 반환된 인스턴스의 size 필드는 올바른 값을 가질 것이다.
    • elements 필드는 원본 인스턴스와 똑같은 배열을 참조할 것이다.
    • 즉, 원본이나 복제본 중 하나를 수정하면 다른 하나도 같이 수정되어 불변식을 해친다는 의미이다.
      • 따라서 프로그램이 이상하게 동작할 수 있다.
  • clone 메서드는 사실상 생성자와 같은 효과를 내기 때문에 원본 객체에 아무런 해를 끼치지 않는 동시에 복제된 객체의 불변식을 보장해야 한다.

  • Stackclone 메서드가 제대로 동작하려면 스택 내부 정보를 복사해야 하는데, 가장 쉬운 방법은 elements 배열의 clone 을 재귀적으로 호출하는 것이다.
@Override
public Stack clone() {
  try {
    Stack result = (Stack) super.clone();
    result.elements = elements.clone();
    return result;
  } catch (CloneNotSupportedException e) {
    throw new AssertionError();
  }
}
  • 만약 elements 필드가 final 이었다면 이 방식은 동작하지 않는다.
    • 그래서 복제할 수 있는 클래스를 만들기 위해 일부 필드에서 final 한정자를 제거해야 할 수도 있다.

deepCopy

  • clone을 재귀적으로 호출하는 것만으로 충분하지 않을 때도 있다.
public class HashTable implements Cloneable {
  private Entry[] buckets = ...;
  
  private static class Entry {
    final Object key;
    Object value;
    Entry next;
    
    Entry(Object key, Object value, Entry next) {
      this.key = key;
      this.value = value;
      this.next = next;
    }
  }
  ...
}
  • Stack 에서처럼 단순히 버킷 배열의 clone 을 재귀적으로 호출한다면, 복제본은 자신만의 버킷 배열을 가질 것이다.
  • 그러나 이 배열은 원본과 같은 연결 리스트를 참조하여, 원본과 복제본 모두 예기치 않게 동작할 가능성이 크다.
  • 이를 해결하려면 각 버킷을 구성하는 연결 리스트를 복사해야 한다.
public class HashTable implements Cloneable {
  private Entry[] buckets = ...;
  
  private static class Entry {
    final Object key;
    Object value;
    Entry next;
    
    Entry(Object key, Object value, Entry next) {
      this.key = key;
      this.value = value;
      this.next = next;
    }
    
    Entry deepCopy() {
      return new Entry(key, value, next == null ? null : next.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();
    }
  }
}
  • HashTable.Entry 가 deep copy를 지원하도록 보강되었다.
  • HashTable.clone 은 먼저 적절한 크기의 새로운 버킷 배열을 생성한 다음, 원래의 버킷 배열을 순회하며 deep copy를 수행한다.

  • Entry.deepCopy 는 자신을 재귀적으로 호출한다.
    • 이 방식은 간단하며, 버킷이 너무 길지 않다면 잘 작동한다.
    • 재귀 호출때문에 리스트의 원소 수만큼 스택 프레임을 소비하기 때문에 리스트가 길면 스택 오버플로우를 일으킬 위험이 있다.
    • 재귀 호출 대신 반복자를 써서 순회하는 방향으로 수정할 수도 있다.
Entry deepCopy() {
  Entry result = new Entry(key, value, next);
  for (Entry p = result; p.next != null; p = p.next) {
    p.next = new Entry(p.next.key, p.next.value, p.next.next);
  }
  return result;
}

요약 및 기타 주의사항

  • Cloneable 을 구현한 Thread-safe한 클래스를 작성할 때는 clone 메서드 역시 적절히 동기화 해줘야 한다.
  • Cloneable 을 구현하는 모든 클래스는 clone 을 재정의해야 한다.
    • 접근 제한자는 public 으로, 반환 타입은 클래스 자신으로 변경한다.
  • Cloneable 을 구현한 클래스를 확장한다면 어쩔 수 없이 clone 메서드를 잘 구현해야 하지만, 그렇지 않은 경우는 복사 생성자와 복사 팩터리라는 더 나은 객체 복사 방식을 제공할 수 있다.
public Yum(Yum yum) {...}
public static Yum newInstance(Yum yum) {...}
  • 복사 생성자와 복사 팩터리는 clone 방식보다 나은 면이 많다.
    • 언어 모순적이고 위험한 객체 생성 매커니즘(생성자를 쓰지 않는 방식)을 사용하지 않는다.
    • 정상적인 final 필드 용법과도 충돌하지 않는다.
    • 불필요한 checked exception을 던지지 않는다.
    • 형변환이 필요치 않다.
  • 또한 복사 생성자와 복사 팩터리는 해당 클래스가 구현한 인터페이스 타입의 인스턴스를 인수로 받을 수 있다.
    • 이를 이용하면 클라이언트는 원본의 구현 타입에 얽매이지 않고 복제본의 타입을 직접 선택할 수 있다.
    • 예를 들어 컬렉션 구현체들은 Collection 이나 Map 타입을 받는 생성자를 제공한다.

Comparable을 구현할지 고려하라

  • compareToComparable 인터페이스의 메서드이다.
    • 두가지를 제외하고 equals 와 성격이 같다.
  • compareTo 는 단순 동치성 비교에 더해 순서까지 비교할 수 있으며, 제네릭하다.

  • Comparable 을 구현했다는 것은 그 클래스의 인스턴스들에는 자연적인 순서가 있음을 의미한다.
    • 따라서 Comparable 을 구현한 객체들의 배열은 다음처럼 정렬할 수 있다.
Arrays.sort(a);
  • 알파벳, 숫자, 연대 같이 순서가 명확한 값 클래스를 작성한다면 반드시 Comparable 인터페이스를 구현하자.

compareTo 규약

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

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

  • sgn(x.compareTo(y)) == -sgn(y.compareTo(x))

  • 추이성 : x.compareTo(y) > 0 && y.compareTo(z) > 0 이면 x.compareTo(z) > 0
  • x.compareTo(y) == 0 이면 sgn(x.compareTo(z)) == sgn(y.compareTo(z))
  • (x.compareTo(y) == 0) == (x.equals(y))
    • 이 사항은 필수는 아니지만 지키는 것이 좋다.
    • 이 규약을 지키지 않은 클래스의 객체를 정렬된 컬렉션에 넣으면 해당 컬렉션이 구현한 인터페이스에 정의된 동작과 엇박자를 낼 것이다.
  • compareTo 는 타입이 다른 객체를 신경쓰지 않아도 된다.
    • 타입이 다르면 ClassCastException 을 던져도 된다.
    • 다른 타입 사이의 비교도 허용하지만, 보통은 비교할 객체들이 구현한 공통 인터페이스를 매개로 이뤄진다.
    • compareTo 규약을 지키지 못하면 비교를 활용하는 클래스와 어울리지 못한다.
  • equals 와 비슷하게, 기존 클래스를 확장한 구체 클래스에서 새로운 필드를 추가했다면 compareTo 규약을 지킬 방법이 없다.
    • 우회법도 equals 와 동일하게 Comparable 을 구현한 클래스를 확장하는 대신, 독립된 클래스를 만들고 이 클래스에 원래 클래스의 인스턴스를 가리키는 필드를 두도록 한다.
    • 그 다음 내부 인스턴스를 반환하는 view 메서드를 제공한다.

compareTo 작성 요령

  • equals 와 비슷하다.
  • Comparable 은 타입을 인수로 받는 제네릭 인터페이스이므로, compareTo 메서드의 인수 타입은 컴파일 타임에 정해진다.
    • 입력 인수의 타입을 확인하거나 형변환 할 필요가 없다.
  • 각 필드의 순서를 비교한다.
    • 기본 타입 필드 : 박싱된 기본 타입 클래스의 정적 메서드 compare 메서드 호출
    • 객체 참조 필드 : compareTo 메서드를 재귀적으로 호출
      • Comparable 을 구현하지 않은 필드나 표준이 아닌 순서로 비교해야한다면 Comparator 를 사용한다.
      • Comparator 는 직접 만들거나 자바가 제공하는 것 중 골라쓰면 된다.
public final class CaseInsensitiveString implements Comparable<CaseInsensitiveString> {
  public int compareTo(CaseInsensitiveString cis) {
    return String.CASE_INSENSITIVE_ORDER.compare(s, cis.s);
  }
  ...
}
  • CaseInsensitiveStringComparable<CaseInsensitiveString> 을 구현했다.
    • 이는 CaseInsensitiveString 참조는 CaseInsensitiveString 참조와만 비교할 수 있다는 의미이다.
  • 클래스에 핵심 필드가 여러개라면 가장 핵심적인 필드부터 비교한다.
    • 비교 결과가 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;
}

비교자 생성 메서드

  • 자바 8에서는 메서드 연쇄 방식으로 비교자를 생성할 수 있게 되었다.
    • 이 방식은 코드가 간결해지지만, 약간의 성능 저하가 발생한다.
public 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);
}
  • comparingInt
    • 입력 인수 : 객체 참조를 int 타입 키에 매핑하는 키 추출 함수(key extractor function)
    • 추출된 키를 기준으로 순서를 정하는 Comparator를 반환하는 정적 메서드이다.
    • 예제에서는 PhoneNumber 에서 지역코드를 추출하는 lambda를 인수로 받아 추출한 지역코드를 기준으로 순서를 정하는 Comparator<PhoneNumber> 를 반환한다.
  • thenComparingInt
    • Comparator 의 인스턴스 메서드
    • int 키 추출 함수를 입력 받아 다시 Comparator를 반환한다.

객체 참조용 비교자 생성 메서드

  • comparing 이라는 정적 메서드 2개가 다중정의 되어 있다.
    1. 키 추출자를 받아서 그 키의 자연적 순서를 사용한다.
    2. 키 추출자 하나와 추출된 키를 비교할 비교자까지 2개의 인수를 받아 순서를 비교한다.
  • thenComparing 이란 인스턴스 메서드 3개가 다중정의 되어 있다.
    1. 비교자 하나만 인수로 받아 그 비교자로 순서를 비교한다.
    2. 키 추출자를 인수로 받아 그 키의 자연적 순서로 비교한다.
    3. 키 추출자 하나와 추출된 키를 비교할 비교자까지 2개의 인수를 받아 순서를 비교한다.

‘값의 차’로 비교하는 방식

  • 이따금씩 값의 차를 기준으로 첫번째 값이 두번째 값보다 작으면 음수를, 같으면 0을, 크면 양수를 반환하는 compareTocompare 메서드를 발견할 것이다.
static Comparator<Object> hashCodeOrder = new Comparator<>() {
  public int compare(Object o1, Obejct o2) {
    return o1.hashCode() - o2.hashCode();
  }
};
  • 이 방식은 정수 오버플로우를 일으키거나, 부동소수점 계산 방식에 따른 오류를 낼 수 있어 바람직하지 않다.
  • 대신 다음 두 방식 중 하나를 사용하자.
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());