본문 바로가기
개발 공부/Java

이펙티브 자바 아이템 14 - Comparable 을 구현할지 고민하라 - 핵심 정리

by 개발인생 2022. 10. 31.
반응형

아이템 14 - Comparable 을 구현할지 고민하라 - 핵심 정리

이 글은 백기선 님의 이펙티브 자바 강의와 이펙티브 자바 3 / E 편을 참고하여 작성하였습니다.

Comparable 은 Object 가 제공하는 메서드는 아니지만 일반적으로 널리 사용할 수 있는 인터페이스 이다.

Comparable 은 엘리먼트가 지닌 자연적인 순서(natural order) 를 정해줄 때 사용하는 인터페이스이다.

Comparable 은 우리가 비교해주고 싶은 순서가 있는 경우에 그 비교 방법을 구현할 수 있다.

Comparable 은 제네릭 타입 을 가지고 있기 때문에 컴파일 타임 에 체크가 가능한 장점이 있다.

compareTo 를 재정의 할때는 몇가지 규약이 있다.

compareTo 규약

compareTo 메서드는 구현시 양수, 0 , 음수 를 return 해야한다.

내 자신이 넘겸받은 값보다 크다면 양수, 넘겨받은 객체와 같다면 0 , 작다면 음수 를 return 한다.

public class CompareToConvention {

   public static void main(String[] args) {
      BigDecimal n1 = BigDecimal.valueOf(23134134);
      BigDecimal n2 = BigDecimal.valueOf(11231230);
      BigDecimal n3 = BigDecimal.valueOf(53534552);
      BigDecimal n4 = BigDecimal.valueOf(11231230);

      // p88, 반사성
      System.out.println(n1.compareTo(n1));
   }
}

자기자신과 compareTo 를 했을 때 같다 고 나와야한다.

이걸 반사성이라고 한다.

public class CompareToConvention {

   public static void main(String[] args) {
      BigDecimal n1 = BigDecimal.valueOf(23134134);
      BigDecimal n2 = BigDecimal.valueOf(11231230);
      BigDecimal n3 = BigDecimal.valueOf(53534552);
      BigDecimal n4 = BigDecimal.valueOf(11231230);

      // p88, 대칭성
      System.out.println(n1.compareTo(n2));
      System.out.println(n2.compareTo(n1));
   }
}

대칭성을 맞추어 주어야한다.

n1이 n2 보다 크다면 n2 는 n1 보다 작다고 나와야한다.

public class CompareToConvention {

   public static void main(String[] args) {
      BigDecimal n1 = BigDecimal.valueOf(23134134);
      BigDecimal n2 = BigDecimal.valueOf(11231230);
      BigDecimal n3 = BigDecimal.valueOf(53534552);
      BigDecimal n4 = BigDecimal.valueOf(11231230);

      // p89, 추이성
      System.out.println(n3.compareTo(n1) > 0); //true
      System.out.println(n1.compareTo(n2) > 0); //true
      System.out.println(n3.compareTo(n2) > 0); //true
   }
}

추이성을 맞추어 주야한다.

n3 이 n1 보다 크고, n1 이 n2 보다 크다면 n3 은 n2 보다 크다는 결과가 나와야한다.

public class CompareToConvention {

   public static void main(String[] args) {
      BigDecimal n1 = BigDecimal.valueOf(23134134);
      BigDecimal n2 = BigDecimal.valueOf(11231230);
      BigDecimal n3 = BigDecimal.valueOf(53534552);
      BigDecimal n4 = BigDecimal.valueOf(11231230);

      // p89, 일관성
      System.out.println(n4.compareTo(n2));
      System.out.println(n2.compareTo(n1));
      System.out.println(n4.compareTo(n1));
   }
}

일관성을 맞추어 주어야한다.

만약 어떤 두개의 수가 같다면 다른 어떤 수와 비교해도 결과가 두 수 모두 동일해야한다.

n4 와 n2 가 같다면 n2 와 n1 을 비교한 값과 n4 와 n1 을 비교한 값이 동일해야한다.

public class CompareToConvention {

   public static void main(String[] args) {
      BigDecimal n1 = BigDecimal.valueOf(23134134);
      BigDecimal n2 = BigDecimal.valueOf(11231230);
      BigDecimal n3 = BigDecimal.valueOf(53534552);
      BigDecimal n4 = BigDecimal.valueOf(11231230);

      // p89, compareTo가 0이라면 equals는 true여야 한다. (아닐 수도 있고..)
      BigDecimal oneZero = new BigDecimal("1.0");
      BigDecimal oneZeroZero = new BigDecimal("1.00");
      System.out.println(oneZero.compareTo(oneZeroZero)); // Tree, TreeMap
      System.out.println(oneZero.equals(oneZeroZero)); // 순서가 없는 콜렉션
   }
}

compareTo가 0 (같다) 이라면 equals 는 true 여야 한다.

이 규약은 지키면 좋지만 안지켜질 수도 있다.

TreeSet 이나 TreeMap 에 값을 넣을 때는 compareTo 메서드로 비교해 값이 들어간다.

위의 코드에서 oneZero 와 oneZeroZero 는 compareTo 에서 같다고 나온다.

반면 순서가 없는 컬렉션의 경우에는 equals 를 통해 비교하게 되는데

oneZero 와 oneZeroZero 는 equals 에서 다르다고 나온다.

만약 compareTo 가 같지만 equals 에서는 다르다면 문서화 를 해놓는 것을 권장한다.


compareTo 구현 방법 1.

우리가 만든 클래스에 엘리먼트가 지닌 자연적인 순서(natural order) 를 정의하려면 다음과 같다.

// PhoneNumber를 비교할 수 있게 만든다. (91-92쪽)
public final class PhoneNumber implements Cloneable, Comparable<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;
   }

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

   /**
    * 이 전화번호의 문자열 표현을 반환한다.
    * 이 문자열은 "XXX-YYY-ZZZZ" 형태의 12글자로 구성된다.
    * XXX는 지역 코드, YYY는 프리픽스, ZZZZ는 가입자 번호다.
    * 각각의 대문자는 10진수 숫자 하나를 나타낸다.
    *
    * 전화번호의 각 부분의 값이 너무 작아서 자릿수를 채울 수 없다면,
    * 앞에서부터 0으로 채워나간다. 예컨대 가입자 번호가 123이라면
    * 전화번호의 마지막 네 문자는 "0123"이 된다.
    */
   @Override public String toString() {
      return String.format("%03d-%03d-%04d",
            areaCode, prefix, lineNum);
   }

   // 코드 14-2 기본 타입 필드가 여럿일 때의 비교자 (91쪽)
   @Override
   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;
   }


   private static PhoneNumber randomPhoneNumber() {
      Random rnd = ThreadLocalRandom.current();
      return new PhoneNumber((short) rnd.nextInt(1000),
            (short) rnd.nextInt(1000),
            (short) rnd.nextInt(10000));
   }
}

클래스에 Comparable 인터페이스를 implements 해준다.

Comparable 은 제네릭 인터페이스이다.

제네릭 타입에 자신의 클래스 를 넘겨준다.

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

compareTo 메서드 를 오버라이딩한다.

제네릭 타입이기 때문에 컴파일 시점에 어떤 타입이 들어올지 알 수 있다.

비교할 값이 프리미티브 타입이라면 해당 타입의 박싱 타입 이 가지고 있는 compare 메서드를 사용해 비교한다.

compare 메서드는 compareTo 메서드와 흡사하게 음수, 양수, 0 의 결과값을 리턴해준다.

값이 여러개인 경우에는 정렬해야하는 순서대로 비교하면 된다.

만약 상속을 사용했을 때는 어떻게 해야할까?

public class Point implements Comparable<Point>{

   final int x, y;

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

   public int getX() {
      return x;
   }

   public int getY() {
      return y;
   }

   @Override
   public int compareTo(Point point) {
      int result = Integer.compare(this.x, point.x);
      if (result == 0) {
         result = Integer.compare(this.y, point.y);
      }
      return result;
   }
}
public class NamedPoint extends Point {

   final private String name;

   public NamedPoint(int x, int y, String name) {
      super(x, y);
      this.name = name;
   }

   @Override
   public String toString() {
      return "NamedPoint{" +
            "name='" + name + '\'' +
            ", x=" + x +
            ", y=" + y +
            '}';
   }
}

상속받은 클래스에서 compareTo 를 통해 비교하는 방법이 있지만

하위 클래스에서 implements 를 사용하여 Comparable 를 구현해서는 안된다.

public class NamedPoint extends Point implements Comparable<NamedPoint> {

   final private String name;

   public NamedPoint(int x, int y, String name) {
      super(x, y);
      this.name = name;
   }

   @Override
   public String toString() {
      return "NamedPoint{" +
            "name='" + name + '\'' +
            ", x=" + x +
            ", y=" + y +
            '}';
   }
}

위처럼 하위타입에서 부모클래스의 compareTo 메서드를 오버라이딩 하려 하겠지만

파라미터 타입이 달라지기 때문에 오버라이딩이 아니라 오버로딩 이 된다.

상속에서 쓰이는 다형성이 적용이 되지 않기 때문이다.

상위 클래스에서 지정한 제네릭 타입이 있기 때문에 하위 클래스에서 다시 오버라이딩 할 수 없다.

public class main {
   public static void main(String[] args) {
      NamedPoint p1 = new NamedPoint(1, 0, "keesun");
      NamedPoint p2 = new NamedPoint(1, 0, "whiteship");

      Set<NamedPoint> points = new TreeSet<>(new Comparator<NamedPoint>() {
         @Override
         public int compare(NamedPoint p1, NamedPoint p2) {
            int result = Integer.compare(p1.getX(), p2.getX());
            if (result == 0) {
               result = Integer.compare(p1.getY(), p2.getY());
            }
            if (result == 0) {
               result = p1.name.compareTo(p2.name);
            }
            return result;
         }
      });

      points.add(p1);
      points.add(p2);

      System.out.println(points);
   }
}

대신 별도의 Comparator 를 제공하는 방법이 있다.

하지만 이 방법은 추천하지 않는 방법이다.

이 방법 대신 컴포지션을 사용하도록 하자.

상속을 사용해 필드를 추가하면 equals 규약이 깨지기 때문에

equals 규약을 지키면서 확장을 하기위해서는 컴포지션을 사용해야한다.

compareTo 역시 마찬가지이다.

public class Point implements Comparable<Point>{

   final int x, y;

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

   public int getX() {
      return x;
   }

   public int getY() {
      return y;
   }

   @Override
   public int compareTo(Point point) {
      int result = Integer.compare(this.x, point.x);
      if (result == 0) {
         result = Integer.compare(this.y, point.y);
      }
      return result;
   }
}
public class NamedPoint implements Comparable<NamedPoint> {

   private final Point point;
   private final String name;

   public NamedPoint(Point point, String name) {
      this.point = point;
      this.name = name;
   }

   // 뷰 제공
   public Point getPoint() {
      return this.point;
   }

   @Override
   public int compareTo(NamedPoint namedPoint) {
      int result = this.point.compareTo(namedPoint.point);
      if (result == 0) {
         result = this.name.compareTo(namedPoint.name);
      }
      return result;
   }
}

상속대신 상속하려했던 클래스를 필드값으로 만든다.

이렇게하면 implements Comparable 을 사용할 수 있다.


compareTo 구현 방법 2

자바 8버전 부터는 Comparator 인터페이스를 통해 구현할 수 있다.

// PhoneNumber를 비교할 수 있게 만든다. (91-92쪽)
public final class PhoneNumber implements Cloneable {
   private final short areaCode, prefix, lineNum;

   public short getAreaCode() {
      return areaCode;
   }

   public short getPrefix() {
      return prefix;
   }

   public short getLineNum() {
      return 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;
   }

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

   /**
    * 이 전화번호의 문자열 표현을 반환한다.
    * 이 문자열은 "XXX-YYY-ZZZZ" 형태의 12글자로 구성된다.
    * XXX는 지역 코드, YYY는 프리픽스, ZZZZ는 가입자 번호다.
    * 각각의 대문자는 10진수 숫자 하나를 나타낸다.
    *
    * 전화번호의 각 부분의 값이 너무 작아서 자릿수를 채울 수 없다면,
    * 앞에서부터 0으로 채워나간다. 예컨대 가입자 번호가 123이라면
    * 전화번호의 마지막 네 문자는 "0123"이 된다.
    */
   @Override public String toString() {
      return String.format("%03d-%03d-%04d",
            areaCode, prefix, lineNum);
   }

   // 코드 14-3 비교자 생성 메서드를 활용한 비교자 (92쪽)
   private static final Comparator<PhoneNumber> COMPARATOR =
         comparingInt((PhoneNumber pn) -> pn.areaCode)
               .thenComparingInt(pn -> pn.prefix)
               .thenComparingInt(pn -> pn.lineNum);

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

   private static PhoneNumber randomPhoneNumber() {
      Random rnd = ThreadLocalRandom.current();
      return new PhoneNumber((short) rnd.nextInt(1000),
            (short) rnd.nextInt(1000),
            (short) rnd.nextInt(10000));
   }

   public static void main(String[] args) {
      Set<PhoneNumber> s = new TreeSet<>();
      for (int i = 0; i < 10; i++)
         s.add(randomPhoneNumber());
      System.out.println(s);
   }

}

Comparator 를 만든 후 compareTo 를 사용하면된다.

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

COMPARATOR 를 만든 후 COMPARATOR 에 있는 compare 를 호출한다.

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

ComparatorComparator 인터페이스가 제공하는 static 한 메서드를 사용해 Comparator 인스턴스를 만들 수 있다.

Comparator 인터페이스에는 default 메서드와 static 메서드들이 정의되어 있다.

이런식으로 Comparator 의 인스턴스를 만들게 되면 Comparator 인스턴스에 있는 default 메서드를 사용할 수 있다.

default 메서드들은 체이닝 하는 데 사용할 수 있다.

static 메서드와 default 메소드의 매개변수로는 람다 표현식 또는 메서드 레퍼런스를 사용할 수 있다.

Comparator 인터페이스를 사용하면 읽기가 편하다는 장점이 있다.

단점으로는 성능이 약 10% 정도 느리다고 하지만 크게 신경쓰지 않아도 될 것 같다.

반응형

댓글