아이템 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);
Comparator 는 Comparator 인터페이스가 제공하는 static
한 메서드를 사용해 Comparator 인스턴스를 만들 수 있다.
Comparator 인터페이스에는 default
메서드와 static
메서드들이 정의되어 있다.
이런식으로 Comparator 의 인스턴스를 만들게 되면 Comparator 인스턴스에 있는 default
메서드를 사용할 수 있다.
default
메서드들은 체이닝 하는 데 사용할 수 있다.
static
메서드와 default
메소드의 매개변수로는 람다 표현식 또는 메서드 레퍼런스를 사용할 수 있다.
Comparator 인터페이스를 사용하면 읽기가 편하다는 장점이 있다.
단점으로는 성능이 약 10% 정도 느리다고 하지만 크게 신경쓰지 않아도 될 것 같다.
'개발 공부 > Java' 카테고리의 다른 글
이펙티브 자바 아이템 15 - 클래스와 멤버의 접근 권한을 최소화하라 - 핵심 정리 (0) | 2022.11.08 |
---|---|
이펙티브 자바 아이템 14 - Comparable 을 구현할지 고민하라 - 완벽 공략 (0) | 2022.10.31 |
이펙티브 자바 아이템 13 - clone 재정의는 주의해서 진행하라 - 완벽 공략 (0) | 2022.10.28 |
이펙티브 자바 아이템 13 - clone 재정의는 주의해서 진행하라 - 핵심 정리 (0) | 2022.10.27 |
이펙티브 자바 아이템 12 - toString 을 항상 재정의하라 - 핵심 정리 (0) | 2022.10.26 |
댓글