Effective Java

[Effective Java] 아이템14 - Comparable을 구현할 지 고려하라

돌에 2022. 1. 25. 23:08

Comparable 인터페이스의 메서드, compareTo

 

단순 동치성 비교에 더해 순서까지 비교할 수 있으며, 제네릭함

→ 이 두 가지 성격만 빼면 Object의 equals와 같음

 

Comparable을 구현했다는 것은 그 클래스의 인스턴스들에는 자연적인 순서(natural order)가 있음을 뜻함

그래서 Comparable을 구현한 객체들의 배열은 Arrays.sort(a); 처럼 쉽게 정렬할 수 있음

 

compareTo 메서드의 일반 규약

 

이 객체와 주어진 객체의 순서를 비교한다. 이 객체가 주어진 객체보다 작으면 음의 정수를, 같으면 0을, 크면 양의 정수를 반환한다. 이 객체와 비교할 수 없는 타입의 객체가 주어지면 ClassCastException을 던진다.
다음 설명에서 sgn(표현식) 표기는 수학에서 말하는 부호 함수(signum function)! 뜻 하며, 표현식의 값이 음수, 0, 양수일 때 -1, 0,1을 반환하도록 정의했다.

 

#1

Comparable을 구현한 클래스는 모든 x, y에 대해 sgn(x.compareTo(y)) == -sgn(y. compareTo(x))여야 한다. (따라서 x.compareTo(y)는 y.compareTo(x)가 예외를 던질 때에 한해 예외를 던져야 한다)

 

→ 두 객체 참조의 순서를 바꿔 비교해도 예상한 결과가 나와야 함

 

 

#2

Comparable을 구현한 클래스는 추이성을 보장해야 한다. 즉, (x.compareTo(y) > 0 && y.compareTo(z) > 0)이면 x.compareTo(z) > 0이다.

 

 

#3

Comparable을 구현한 클래스는 모든 z에 대해 x.compareTo(y) == 0이면 sgn(x. compareTo(z)) == sgn(y.compareTo(z))다.

 

→ 크기가 같은 객체들끼리는 어떤 객체와 비교하더라도 항상 같아야 함

 

 

#4

이번 권고가 필수는 아니지만 꼭 지키는 게 좋다. (x.compareTo(y) == 0) == (x. equals(y))여야 한다. Comparable을 구현하고 이 권고를 지키지 않는 모든 클래스는 그 사실을 명시해야 한다. 다음과 같이 명시하면 적당할 것이다.

주의: 이 클래스의 순서는 equals 메서드와 일관되지 않다.

 

→ compareTo 메서도로 수행한 동치성 테스트의 결과가 equals와 같아야 함

위의 권고를 잘 지키면 compareTo로 줄지은 순서와 equals 의 결과가 일관되게 됨

 

compareTo의 순서와 equals의 결과가 일관되지 않은 클래스도 동작하지만,

이 클래스의 객체를 정렬된 컬렉션에 넣으면 해당 컬렉션이 구현한 인터페이스(Collection, Set, 혹은 Map)에 정의된 동 작과 맞지 않을 것임

→ 이 인터페이스들은 equals 메서드의 규약을 따른다고 되어 있지만, 놀랍게도 정렬된 컬렉션들은 동치성을 비교할 때 equals 대신 compareTo를 사용하기 때문

 

ex) compareTo와 equals가 일관되지 않는 BigDecimal 클래스

빈 HashSet 인스턴스에 new BigDecimal("1.0")과 new BigDecimal("1.00")을 차례로 추가

 

1. HashSet을 사용할 경우: 2개의 원소 (equals 메서드로 비교)

2. HashSet 대신 TreeSet을 사용할 경우: 1개의 원소 (compareTo 메서드로 비교)

 

[2번 참고]

Compares this {@code BigDecimal} with the specified {@code BigDecimal}. Two {@code BigDecimal} objects that are equal in value but have a different scale (like 2.0 and 2.00) are considered equal by this method.

 

 

✖️

compareTo 메서드는 각 필드가 동치인지를 비교하는 게 아니라 그 순서를 비교

객체 참조 필드를 비교하려면 compareTo 메서드를 재귀적으로 호출해야 함

Comparable을 구현하지 않은 필드나 표준이 아닌 순서로 비교해야 한다면, 비교자(Comparator)를 대신 사용

 

public final class CaselnsensitiveString
		implements Comparable<CaseInsensitiveString> {
	public int compareTo(CaseinsensitiveString cis) {
		return String.CASE_INSENSITIVE_ORDER.compare(s, cis.s);
	}
	...// 나머지 코드 생략
}

 

→ CaselnsensitiveString의 참조는 CaselnsensitiveString 참조와만 비교할 수 있음

 

숫자용 기본 타입 필드 비교

 

compareTo 메서드에서 관계 연산자 <와 >를 사용하는 이전 방식은 오류를 유발하기 때문에 추천하지 않음

박싱된 기본 타입 클래스들에 새로 추가된 정적 메서드인 compare를 이용하면 됨!

 

클래스에 핵심 필드가 여러 개일 경우 → 가장 핵심적인 필드부터 비교해야 함

 

1. 비교 결과가 0이 아니라면, 즉 순서가 결정되면 결과 반환

2. 가장 핵심이 되는 필드가 똑같다면, 똑같지 않은 필드를 찾을 때까지 비교

 

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(lineNumf pn.lineNum); // 세 번째로 중요한 필드
	}
	return result;
}

 

→ Comparator 인터페이스의 비교자 생성 메서드(comparator construction method)들을 Comparable 인터페이스가 원하는 compareTo 메서드를 구현하는 데 활용 가능

(약간의 성능 저하가 올 수 있음)

 

↓ 자바의 정적 임포트 기능을 이용해 정적 비교자 생성 메서드들을 이름만으로 사용했을 경우

 

private static final Comparator<PhoneNumber> COMPARATOR =
	comparinglnt((PhoneNumber pn) -> pn.areaCode)
		.thenComparinglnt(pn -> pn.prefix)
		.thenComparinglnt(pn -> pn.lineNum);
        
public int compareTo(PhoneNumber pn) {
	return COMPARATOR.compare(this, pn);
}

 

위 코드는 클래스를 초기화할 때 비교자 생성 메서드 2개를 이용해 비교자를 생성함

 

1. comparingInt

: 객체 참조를 int 타입 키에 매핑하는 키 추출 함수(key extractor function)를 인수로 받아, 그 키를 기준으로 순서를 정하는 비교자를 반환하는 정적 메서드

 

위의 코드에서 comparinglnt는 람다(lambda)를 인수로 받으며, 이 람다는 PhoneNumber에서 추출한 지역 코드를 기준으로 전화번호의 순서를 정하는 Comparator<PhoneNumber>를 반환

 

 

2. thenComparingInt

: Comparator의 인스턴스 메서드로, int 키 추출자 함수를 입력 받아 다시 비교자를 반환하는 메서드

(이 비교자는 첫 번째 비교자를 적용한 다음 새 로 추출한 키로 추가 비교 수행)

 

 

long과 double: comparinglnt와 thenComparinglnt의 변형 메서드 사용

short: int용 버전 메서드 사용

float: double용 버전 메서드 사용

 

객체 참조용 타입 필드 비교

 

1. comparing

: 정적 메서드 2개가 다중정의되어 있음

 

1. 키 추출자를 받아서 그 키의 자연적 순서를 사용

2. 키 추출자 하나와 추출된 키를 비교할 비교자까지 총 2개의 인수를 받아 사용

 

 

2. thenComparing

: 인스턴스 메서드 3개가 다중정의되어 있음

 

1. 비교자 하나만 인수로 받아 그 비교자로 부차 순서(comparing 이후의 순서)를 정함

2. 키 추출자를 인수로 받아 그 키의 자연적 순서로 보조 순서를 정함

3. 키 추출자 하나와 추출된 키를 비교 할 비교자까지 총 2개의 인수를 받아 사용

 

‘값의 차’를 기준으로 비교할 경우

 

static Comparator<Object> hashCodeOrder = new Comparator<>() {
	public int compare(Object ol, Object o2) {
		return ol.hashCode() - o2.hashCode();
	}
};

 

위 방식은 정수 오버플로를 일으키거나 부동소수점 계산 방식에 따른 오류를 낼 수 있으므로 사용하면 안 됨

→ 밑의 두 방식 중 하나를 사용해야 함

 

1. 정적 compare 메서드를 활용한 비교자

 

static Comparator<0bject> hashCodeOrder = new Comparator<>() {
	public int compare(Object ol, Object o2) {
		return Integer.compare(ol.hashCode(), o2.hashCode());
};

 

 

2. 비교자 생성 메서드를 활용한 비교자

 

static Comparator<Object> hashCodeOrder =
	Comparator.comparinglnt(o -> o.hashCode());

 

 

순서를 고려해야 하는 값 클래스를 작성한다면 꼭 Comparable 인터페이스를 구현하여, 그 인스턴스들을 쉽게 정렬하고, 검색하고, 비교 기능을 제공하는 컬렉션과 어우러 지도록 해야 한다. compareTo 메서드에서 필드의 값을 비교할 때 <와 > 연산자는 쓰지 말아야 한다. 그 대신 박싱된 기본 타입 클래스가 제공하는 정적 compare 메서드나 Comparator 인터페이스가 제공하는 비교자 생성 메서드를 사용하자.

 

 

✖️ Comparable vs Comparator

 

1. 비교 대상

Comparable/compareTo(): 자기 자신과 매개변수 객체를 비교

Comparator/compare(): 두 매개변수 객체를 비교함

 

2. 패키지 위치

Comparable: lang (import 해줄 필요 X)

Comparator: util

 

어떤 클래스가 이미 Comparable 인터페이스를 구현한 경우

이 클래스의 정렬 방식을 정의할 때 Comparator 인터페이스를 사용할 수 있음

 

ex) String 클래스는 Comparable 인터페이스가 구현되어 있으며, compareTo 메서드는 오름차순 정렬을 구현하고 있음

if 정렬 방식을 내림차순으로 변경하려면?

 

String 클래스는 final로 선언되어 있어 compareTo 메서드를 재정의할 수 없음

→ Comparator 사용 (compare 메서드 내부에 compareTo 메서드 사용)