✔️ 모든 객체의 공통 메서드
Object는 객체를 만들 수 있는 구체 클래스지만 기본적으로 상속해서 사용하도록 설계되었다.
Object에서 final이 아닌 메서드(equals, hashCode, toString, clone, finalize)는
모두 재정의를 염두에 두고 설걔된 것이라 재정의 시 지켜야 하는 일반 규약이 명확히 정의되어 있다.
그래서 Object를 상속하는 클래스, 즉 모든 클래스는 이 메서드들을 일반 규약에 맞게 재정의해야 하므로 이를 다뤄보자.
10. equals는 일반 규약을 지켜 재정의하라
- equals 메서드는 재정의하기 쉬워 보이지만 자칫하면 끔찍한 결과를 초래한다.
그냥 둘 경우 그 클래스의 인스턴스는 오직 자기 자신과만 같게 된다.
그러니 다음에서 열거한 상황 중 하나에 해당한다면 재정의하지 않는 것이 최선이다.- 각 인스턴스가 본질적으로 고유하다.
- 인스턴스의 '논리적 동치성'을 검사할 일이 없다.
- 상위 클래스에서 재정의한 equals가 하위 클래스에도 딱 들어맞는다.
- 클래스가 private이거나 package-private이고 equals 메서드를 호출할 일이 없다.
- 그렇다면 equals를 재정의해야 할 때는 언제일까?
- 객체 식별성이 아니라 논리적 동치성을 확인해야 하는데,
상위 클래스의 equals가 논리적 동치성을 비교하도록 재정의되지 않았을 때다. - 주로 값 클래스(Integer, String처럼 값을 표현하는 클래스)들이 여기 해당한다.
- 반면 값이 같은 인스턴스가 둘 이상 만들어지지 않음을 보장하는 인스턴스 통제 클래스라면 재정의하지 않아도 된다.
- 객체 식별성이 아니라 논리적 동치성을 확인해야 하는데,
- equals 메서드를 재정의할 때는 반드시 일반 규약을 따라야 한다.
다음은 Object 명세에 적힌 규약이다.- equals 메서드는 동치관계를 구현하며, 반사성, 대칭성, 추이성, 일관성, null-아님을 만족한다.
이때 동치관계란 집합을 서로 같은 원소들로 이뤄진 부분집합으로 나누는 연산으로 이 부분집합을 동치류라 하며,
equals 메서드가 쓸모 있으려면 모든 원소가 같은 동치류에 속한 어떤 원소와도 서로 교환할 수 있어야 한다. - 반사성 : null이 아닌 모든 참조 값 x에 대해, x.equals(x)는 true다.
즉, 객체는 자기 자신과 같아야 한다는 뜻이다. - 대칭성 : null이 아닌 모든 참조 값 x, y에 대해 x.equals(y)가 true면 y.equals(x)도 true다.
즉, 두 객체는 서로에 대한 동치 여부에 똑같이 답해야 한다는 뜻이다. - 추이성 : null이 아닌 모든 참조 값 x, y, z에 대해 x.equals(y)가 true이고, y.equals(x)도 true면 x.equals(z)도 true다.
즉, 첫 번째 객체와 두 번째 객체가 같고, 두 번째 객체와 세 번째 객체가 같다면,
첫 번째 객체와 세 번째 객체도 같아야 한다는 뜻이다.
하지만 이때 구체 클래스를 확장해 새로운 값을 추가할 경우 equals 규약을 만족시킬 방법은 존재하지 않는다.
대신 상속 대신 컴포지션을 사용해 뷰 메서드를 추가하여 사용하는 괜찮은 우회 방법이 있다. - 일관성 : null이 아닌 모든 참조 값 x, y에 대해, x.equals(y)를 반복 호출하면 항상 true를 반환하거나 false를 반환한다.
즉, 두 객체가 같다면 앞으로도 영원히 같아야 한다는 뜻이다.
클래스가 불변이든 가변이든 equals의 판단에 신뢰할 수 없는 자원이 끼어들게 해서는 안 된다.
이 제약을 어기면 일관성 조건을 만족시키기가 아주 어려우므로
equals는 항상 메모리에 존재하는 객체만을 사용한 결정적 계산만 수행해야 한다. - null-아님 : null이 아닌 모든 참조 값 x에 대해, x.equals(null)은 false다.
즉, 모든 객체가 null과 같지 않아야 한다는 뜻이다.
- equals 메서드는 동치관계를 구현하며, 반사성, 대칭성, 추이성, 일관성, null-아님을 만족한다.
- 양질의 equals 메서드 구현 방법을 단계별로 정리해보자.
- == 연산자를 사용해 입력이 자기 자신의 참조인지 확인한다.
이때 올바른 타입은 equals가 정의된 클래스인 것이 보통이지만, 가끔은 그 클래스가 구현한 특정 인터페이스가 될 수 있다.
이런 인터페이스를 구현한 클래스라면 equalsa에서 해당 인터페이스를 사용해야 한다. - instanceof 연산자로 입력이 올바른 타입인지 확인한다.
- 입력을 올바른 타입으로 형변환한다.
- 입력 객체와 자기 자신의 대응되는 핵심 필드들이 모두 일치하는지 하나씩 검사한다.
앞에서 인터페이스를 사용했다면 입력의 필드 값을 가져올 때도 그 인터페이스의 메서드를 사용해야 한다.
타입이 클래스라면 해당 필드에 직접 접근할 수도 있다.
- == 연산자를 사용해 입력이 자기 자신의 참조인지 확인한다.
// 전형적인 equals 메서드
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, 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;
}
// 나머지 코드는 생략 - hashCode 메서드는 꼭 필요하다
}
11. equals를 재정의하려거든 hashCode도 재정의하라
- equals를 재정의한 클래스 모두에서 hashCode도 재정의해야 한다.
그렇지 않으면 hashCode 일반 규약을 어기게 되어
해당 클래스의 인스턴스를 HashMap이나 HashSet 같은 컬렉션의 원소로 사용할 때 문제를 일으킬 것이다. - Object 명세에서 발췌한 규약이다.
- equals 비교에 사용되는 정보가 변경되지 않았다면,
애플리케이션이 실행되는 동안 그 객체의 hashCode 메서드는 몇 번을 호출해도 일관되게 항상 같은 값을 반환해야 한다. - equals(Object)가 두 객체를 같다고 판단했다면, 두 객체의 hashCode는 똑같은 값을 반환해야 한다.
- equals(Object)가 두 객체를 다르다고 판단했더라도, 두 객체의 hashCode가 서로 다른 값을 반환할 필요는 없다.
하지만 다른 객체에 대해서는 다른 값을 반환해야 해시테이블의 성능이 좋아진다.
- equals 비교에 사용되는 정보가 변경되지 않았다면,
- equals는 물리적으로 다른 두 객체를 논리적으로 같다고 할 수 있다.
하지만 Object의 기본 hashCode는 이 둘이 전혀 다르다고 판단하여, 규약과 달리 서로 다른 값을 반환한다.
그러므로 hashCode를 재정의하여 논리적으로 같은 객체가 같은 해시코드를 반환하도록 해야 한다. - 좋은 hashCode를 작성하는 간단한 요령이다.
- int 변수 result를 선언한 후 값 c로 초기화한다. 이때 c는 해당 객체의 첫 번째 핵심 필드를 계산한 해시 코드다.
- 해당 객체의 나머지 핵심 필드 f 각각에 대해 작업을 수행한다.
기본 타입 필드라면 Type.hashCode(f)를 수행한다.
참조 타입 필드면서 이 클래스의 equals 메서드가 이 필드의 equals를 재귀적으로 호출해 비교한다면
이 필드의 hashCode를 재귀적으로 호출한다.
필드가 배열이라면, 핵심 원소 각각을 별도 필드처럼 다룬다. 배열에 핵심 원소가 하나도 없다면 단순히 상수를 사용한다.
이를 통해 계산한 해시코드 c로 result를 갱신해 나간다. - result를 반환한다.
- 이때 파생 필드는 해시코드 계산에서 제외해도 되며, 또한 equals 비교에 사용되지 않는 필드는 반드시 제외해야 한다.
- 또한 Objects 클래스는 임의의 개수만큼 객체를 받아 해시코드를 계산해주는 정적 메서드인 hash를 제공해주므로
앞서의 요령대로 구현한 코드와 비슷한 수준의 hashCode 함수를 단 한 줄로 작성할 수 있다. 대신 속도는 더 느리다. - 클래스가 불변이며 해시코드를 계산하는 비용이 크다면 매번 새로 계산하기 보다는 캐싱하는 방식을 고려한다.
public final class PhoneNumber {
private final short areaCode, prefix, lineNum;
public PhoneNumber(int areaCode, int prefix, int lineNum) {
this.areaCode = rangeCheck(areaCode, 999, "area code");
this.prefix = rangeCheck(prefix, 999, "prefix");
this.lineNum = rangeCheck(lineNum, 9999, "line num");
}
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;
}
// 전형적인 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 메서드
@Override
public int hashCode() {
return Objects.hash(lineNum, prefix, areaCode);
}
// 해시코드를 지연 초기화하는 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;
}
}
12. toString을 항상 재정의하라
- Object의 기본 toString 메서드가 우리가 작성한 클래스에 적합한 문자열을 반환하는 경우는 거의 없다.
- 하지만 toString의 일반 규약에 따르면 간결하면서 사람이 읽기 쉬운 형태의 유익한 정보를 반환해야 한다.
toString을 잘 구현한 클래스는 사용하기에 훨씬 즐겁고, 그 클래스를 사용한 시스템은 디버깅하기 쉽다. - 실전에서 toString은 그 객체가 가진 주요 정보를 모두 반환하는 게 좋다.
하지만 객체가 거대하거나 객체의 상태가 문자열로 표현하기에 적합하지 않다면 요약 정보를 담아야 한다.
이상적으로는 스스로를 완벽히 설명하는 문자열이어야 한다. - 이때 반환값의 포맷을 문서화할지 정해야 한다. 전화번호나 행렬 같은 값 클래스라면 문서화하기를 권한다.
포맷을 명시하면 그 객체는 표준적이고, 명확하고, 사람이 읽을 수 있게 된다.
이때 포맷을 명시하기로 했다면,
명시한 포맷에 맞는 문자열과 객체를 상호 전환할 수 있는 정적 팩터리나 생성자를 함께 제공해주면 좋다.
반면 포맷을 한 번 명시하면 평생 그 포맷에 얽매이게 되므로 주의해야 한다. - 포맷 명시 여부와 상관없이 toString이 반환한 값에 포함된 정보를 얻어올 수 있는 API를 제공하자.
예를 들어 PhoneNumber 클래스는 지역 코드, 프리픽스, 가입자 번호용 접근자를 제공해야 한다.
그렇지 않으면 이 정보가 필요한 프로그래머는 toString의 반환값을 파싱할 수 밖에 없어 성능이 나빠진다. - 정적 유틸리티 클래스는 toString을 제공할 이유가 없다.
또한, 대부분의 열거 타입도 자바가 이미 완벽한 toString을 제공하니 따로 재정의하지 않아도 된다.
// 포맷을 명시한 toString
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, 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);
}
}
13. clone 재정의는 주의해서 진행하라
- Cloneable은 복제해도 되는 클래스임을 명시하는 용도인 인터페이스로
Object의 protected 메서드인 clone의 동작 방식을 결정한다.
Cloneable을 구현한 클래스의 인스턴스에서 clone을 호출하면 그 객체의 필드들을 하나하나 복사한 객체를 반환하며,
그렇지 않은 클래스의 인스턴스에서 호출하면 CloneNotSupportedException을 던진다. - 이는 인터페이스를 상당히 이례적으로 사용하는 예이다.
인터페이스를 구현한다는 것은 일반적으로 해당 클래스가 그 인터페이스에서 정의한 기능을 제공한다고 선언하는 행위다.
그런데 Cloneable의 경우에는 상위 클래스에 정의된 protected 메서드의 동작 방식을 변경한 것이다. - clone 메서드의 일반 규약을 허술하다. Object 명세에서 가져온 다음 설명을 보자.
- clone 메서드는 객체의 복사본을 생성해 반환한다.
일반적인 의도로는 x.clone() != x, x.clone().getClass() == x.getClass()의 식에 대한 참을 뜻한다.
하지만 이상의 요구를 반드시 만족해야 하는 것은 아니다. - 한편 다음 x.clone().equals(x) 식도 일반적으로 참이지만, 역시 필수는 아니다.
- 관례상, 이 메서드가 반환하는 객체는 super.clone을 호출해 얻어야 한다.
이 클래스와 모든 상위 클래스가 이 관계를 따른다면 x.clone().getClass() == x.getClass() 식은 참이다. - 관례상, 반환된 객체와 원본 객체는 독립적이어야 한다.
이를 만족하려면 super.clone으로 얻은 객체의 필드 중 하나 이상을 반환 전에 수정해야 할 수도 있다.
- clone 메서드는 객체의 복사본을 생성해 반환한다.
- 즉, 강제성이 없으므로 clone가 super.clone이 아닌, 생성자를 호출해 얻은 인스턴스를 반환해도 컴파일러는 불평하지 않는다.
하지만 이 클래스의 하위 클래스에서 super.clone을 호출한다면 잘못된 클래스의 객체가 만들어져,
결국 하위 클래스의 clone 메서드가 제대로 동작하지 않게 된다. - 제대로 동작하는 clone 메서드를 가진 상위 클래스를 상속해 Cloneable을 구현하고 싶을 경우를 생각해보자.
먼저 불변 클래스의 clone 메서드를 살펴보자.- super.clone을 호출하여 얻은 객체는 원본의 완벽한 복제본일 것이다.
클래스에 정의된 모든 필드는 원본 필드와 똑같은 값을 갖는다. - 이때 재정의한 메서드의 반환 타입은 상위 클래스의 메서드가 반환하는 타입의 하위 타입일 수 있으므로
이 방식으로 클라이언트가 형변환하지 않아도 되도록 만든다. - 그리고 super.close 호출을 try-catch 블록으로 감싸 Object의 clone 메서드가 검사 예외인
CloneNotSupportedException을 던지도록 선언한다. - 그런데 쓸때없는 복사를 지양한다는 관점에서 보면 불변 클래스는 굳이 clone 메서드를 제공하지 않는 게 좋다.
- super.clone을 호출하여 얻은 객체는 원본의 완벽한 복제본일 것이다.
public final class PhoneNumber implements Cloneable {
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;
}
@Override
public String toString() {
return String.format("%03d-%03d-%04d",
areaCode, prefix, lineNum);
}
// 가변 상태를 참조하지 않는 클래스용 clone 메서드 (불변 클래스)
@Override
public PhoneNumber clone() {
try {
return (PhoneNumber) super.clone();
} catch (CloneNotSupportedException e) {
throw new AssertionError();
}
}
}
- 반면 클래스가 가변 객체를 참조하는 순간 재앙으로 돌변한다.
가변 클래스인 Stack 클래스를 예로 들어보자.- 이때는 단순히 super.clone의 결과를 그대로 반환할 경우 반환된 Stack 인스턴스의 size 필드는 올바른 값을 갖겠지만,
elements 필드는 원본 Stack 인스턴스와 똑같은 배열을 참조할 것이다.
그러므로 원본이나 복제본 중 하나를 수정하면 다른 하나도 수정되어 불변식을 해치게 된다. - 그래서 Stack의 clone 메서드는 제대로 동작하려면 스택 내부 정보를 복사해야 하는데,
가장 쉬운 방법은 elements 배열의 clone을 재귀적으로 호출해주는 것이다. - 한편, elements 필드가 final이었다면 새로운 값을 할당할 수 없어 앞서의 방식은 작동하지 않는다.
이는 근본적인 문제로, Cloneable 아키텍처는 가변 객체를 참조하는 필드는 final로 선언하라는 일반 용법과 충돌한다.
그래서 복제할 수 있는 클래스를 만들기 위해 일부 필드에서 final 한정자를 제거해야 할 수도 있다. - 또한 clone을 재귀적으로 호출하는 것만으로는 충분하지 않을 때도 있다.
해시테이블 내부는 버킷들의 배열이고, 각 버킷은 키-값 쌍을 담는 연결 리스트의 첫 번째 엔트리를 참조한다.
이때 복제본은 자신만의 버킷 배열을 갖지만,
이 배열은 원본과 같은 연결 리스트를 참조하여 원본과 복제본 모두 예기치 않게 동작할 가능성이 생긴다.
이를 해결하려면 각 버킷을 구성하는 연결 리스트를 복사해야 한다.
그러므로 해시테이블의 clone 메서드는 먼저 적절한 크기의 새로운 버킷 배열을 할당한 다음
버킷 배열을 순회하며 비지 않은 각 버킷에 대해 깊은 복사를 수행하고
Entry의 deepCopy 메서드는 자신이 가리키는 연결 리스트 전체를 복사하기 위해 자신을 재귀적으로 호출한다.
하지만 이렇게 연결 리스트를 복제하는 방법은 스택 오버플로를 일으킬 위험이 있어 좋지 않다.
이 문제를 피하려면 deepCopy 재귀 호출 대신 반복자를 써서 순회하는 방향으로 수정해야 한다. - 또 다른 가변 객체를 복제하는 방법으로는 super.clone을 호출하여 얻은 객체의 모든 필드를 초기 상태로 설정한 다음,
원본 객체의 상태를 다시 생성하는 고수준 메서드들을 사용하는 것이다.
이를 사용하여 복제하면 보통은 간단하고 제법 우아한 코드를 얻게 되지만, 저수준에서 바로 처리할 때보다는 느리다.
- 이때는 단순히 super.clone의 결과를 그대로 반환할 경우 반환된 Stack 인스턴스의 size 필드는 올바른 값을 갖겠지만,
public class Stack implements Cloneable {
private Object[] elements;
private int size = 0;
private static final int DEFAULT_INITIAL_CAPACITY = 16;
public Stack() {
this.elements = new Object[DEFAULT_INITIAL_CAPACITY];
}
public void push(Object e) {
ensureCapacity();
elements[size++] = e;
}
public Object pop() {
if (size == 0)
throw new EmptyStackException();
Object result = elements[--size];
elements[size] = null; // 다 쓴 참조 해제
return result;
}
public boolean isEmpty() {
return size ==0;
}
// 가변 상태를 참조하는 클래스용 clone 메서드
@Override
public Stack clone() {
try {
Stack result = (Stack) super.clone();
result.elements = elements.clone();
return result;
} catch (CloneNotSupportedException e) {
throw new AssertionError();
}
}
private void ensureCapacity() {
if (elements.length == size)
elements = Arrays.copyOf(elements, 2 * size + 1);
}
}
- 그런데 이 모든 작업이 꼭 필요한 걸까?
- 다행히도 이처럼 복잡한 경우는 드물다.
- Cloneable을 이미 구현한 클래스를 확장한다면 어쩔 수 없이 clone을 잘 작동하도록 구현해야 한다.
- 하지만 그렇지 않은 상황에서는 복사 생성자와 복사 팩터리라는 더 나은 객체 복사 방식을 제공할 수 있다.
복사 생성자란 단순히 자신과 같은 클래스의 인스턴스를 인수로 받는 생성자를 말한다.
복사 팩터리는 복사 생성자를 모방한 정적 팩터리다. - 또한 이들은 해당 클래스가 구현한 인터페이스 타입의 인스턴스를 인수로 받을 수 있으므로
원본의 구현 타입에 얽매이지 않고 복제본의 타입을 직접 선택할 수 있다.
// 복사 생성자
public Yum(Yum yum) {
...
}
// 복사 팩터리
public static Yum newInstance(Yum yum) {
...
}
14. Comparable을 구현할지 고려하라
- compareTo는 Object의 메서드가 아니라 Comparable 인터페이스의 유일무이한 메서드이다.
- compareTo는 단순 동치성 비교에 더해 순서까지 비교할 수 있으며, 제네릭하다.
또한 Comparable을 구현한 객체들의 배열은 손쉽게 정렬이 가능하며
검색, 극단값 계산, 자동 정렬되는 컬렉션 관리도 쉽게할 수 있다. - 사실상 자바 플랫폼 라이브러리의 모든 값 클래스와 열거 타입은 Comparable을 구현하므로
알파벳, 숫자, 연대 같이 순서가 명확한 값 클래스를 작성한다면 반드시 Comparable 인터페이스를 구현하자. - compareTo 메서드의 일반 규약은 equals의 규약과 비슷하다.
- 이 객체와 주어진 객체의 순서를 비교한다.
이 객체가 주어진 객체보다 작으면 음의 정수를, 같으면 0을, 크면 양의 정수를 반환한다.
이 객체와 비교할 수 없는 타입의 객체가 주어지면 ClassCastException을 던진다. - Comparable을 구현한 클래스는 모든 x, y에 대하여 대칭성을 보장해야 하므로
두 객체 참조의 순서를 바꿔 비교해도 예상한 결과가 나와야 한다.
sgn(x.compareTo(y)) = -sgn(y.compareTo(x)) - Comparable을 구현한 클래스는 추이성을 보장해야 하므로
첫 번째가 두 번째보다 크고 두 번째가 세 번째보다 크면, 첫 번째는 세 번째보다 커야 한다.
x.compareTo(y) > 0 && y.compareTo(z) > 0 이면 x.compareTo(z) > - Comparable을 구현한 클래스는 모든 z에 대해 반사성을 보장해야 하므로
크기가 같은 객체들끼리는 어떤 객체와 비교하더라도 항상 같아야 한다.
x.compareTo(y) == 0이면 sgn(x.compareTo(z)) == sgn(y.compareTo(z)) - (x.compareTo(y) == 0) == (x.equals(y))여야 한다.
이는 compareTo 메서드로 수핸항 동치성 테스트의 결과가 equals와 같아야 한다는 것으로
필수는 아니지만 이를 잘 지키면 compareTo로 줄 지은 순서와 equals의 결과가 일관되게 되므로 꼭 지키길 권한다.
- 이 객체와 주어진 객체의 순서를 비교한다.
- compareTo 메서드 작성 요령은 equals와 비슷하다. 몇 가지 차이점만 주의하면 된다.
- Comparable은 타입을 인수로 받는 제네릭 인터페이스이므로 compareTo 메서드의 인수 타입은 컴파일타임에 정해진다.
그러므로 입력 인수의 타입을 확인하거나 형변환할 필요가 없으며 인수 타입이 잘못됐다면 컴파일 자체가 되지 않는다.
또한 null을 인수로 넣어 호출하면 NullPointerException이 던져지게 된다. - Comparable을 구현하지 않은 필드나 표준이 아닌 순서로 비교해야 한다면 비교자 Comparator를 대신 사용한다.
비교자는 직접 만들거나 자바가 제공하는 것 중에 골라 쓰면 된다. - 또한 기존에 정수 기본 타입 필드를 비교할 때 관계 연산자인 <와 >를,
실수 기본 타입 필드를 비교할 때는 정적 메서드 Double.compare, Float.compare를 사용하라고 권했다.
그런데 자바 7부터는 compareTo 메서드에서 관계 연산자 <와 >를 사용하는 이전 방식은 거추장스럽고 오류를 유발하므로
박싱된 기본 타입 클래스들에 새로 추가된 정적 메서드인 compare를 이용하도록 하면 된다. - 클래스에 핵심 필드가 여러 개라면 어느 것을 먼저 비교하느냐가 중요하므로 가장 핵심적인 필드부터 비교해나가자.
가장 핵심이 되는 필드가 똑같다면, 똑같지 않은 필드를 찾을 때까지 그다음으로 중요한 필드를 비교해나간다. - 자바 8에서는 Comparator 인터페이스가 일련의 비교자 생성 메서드와 팀을 꾸려
메서드 연쇄 방식으로 비교자를 생성할 수 있게 되었으므로
이 비교자들을 Comparable 인터페이스가 원하는 compareTo 메서드를 구현하는데 멋지게 활용할 수 있다.
이 방식은 간결한 반면, 약간의 성능 저하가 뒤따른다. - 만약 값의 차를 기준으로 순서를 비교해야 할 경우에 단순히 해시코드 값의 차를 기준으로 비교할 경우
정수 오버플로를 일으키거나 부동소수점 계산 방식에 따른 오류가 발생하여 추이성을 위반하므로
정적 compare 메서드 또는 비교자 생성 메서드를 활용하자.
- Comparable은 타입을 인수로 받는 제네릭 인터페이스이므로 compareTo 메서드의 인수 타입은 컴파일타임에 정해진다.
// 자바가 제공하는 비교자를 사용할 때의 비교
public final class CaseInsensitiveString implements Comparable<CaseInsensitiveString> {
private final String s;
public CaseInsensitiveString(String s) {
this.s = Objects.requireNonNull(s);
}
@Override
public boolean equals(Object o) {
return o instanceof CaseInsensitiveString && ((CaseInsensitiveString) o).s.equalsIgnoreCase(s);
}
@Override
public int hashCode() {
return s.hashCode();
}
@Override
public String toString() {
return s;
}
// 자바가 제공하는 비교자를 사용해 클래스를 비교
public int compareTo(CaseInsensitiveString cis) {
return String.CASE_INSENSITIVE_ORDER.compare(s, cis.s);
}
}
public class Test {
public static void main(String[] args) {
Set<CaseInsensitiveString> s = new TreeSet<>();
for (String arg : args)
s.add(new CaseInsensitiveString(arg));
System.out.println(s);
}
}
// 기본 타입 필드가 여럿일 때의 비교
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);
}
// // 1. 기본 타입 필드가 여럿일 때의 비교자
// 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;
// }
// 2. 비교자 생성 메서드를 활용한 비교자
private static final Comparator<PhoneNumber> COMPARATOR =
/* 첫 번째 비교자 생성 메서드인 comparingInt는
객체 참조를 int 타입 키에 매핑하는 키 추출 함수를 인수로 받아,
그 키를 기준으로 순서를 정하는 비교자를 반환
이는 람다를 인수로 받으며, 이 람다는 PhoneNumber에서 추출한 지역 코드를 기준으로
전화번호의 순서를 정하는 Comparator(PhoneNumber>를 반환
*/
comparingInt((PhoneNumber pn) -> pn.areaCode)
/* 두 번재 비교자 생성 메서드인 thenComparingInt는
int 키 추출자 함수를 입력받아 다시 비교자를 반환
thenComparingInt는 원하는 만큼 연달아 호출할 수 있음
*/
.thenComparingInt(pn -> pn.prefix)
.thenComparingInt(pn -> pn.lineNum);
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 class Test {
public static void main(String[] args) {
NavigableSet<PhoneNumber> s = new TreeSet<PhoneNumber>();
for (int i = 0; i < 10; i++)
s.add(randomPhoneNumber());
System.out.println(s);
}
}
// 정적 compare 메서드를 활용한 비교자
static Comparator<Object> hashCodeOrder = new Compararot<>() {
public int compare(Object o1, Object o2) {
return Integer.compare(o1.hashCode(), o2.hashCode());
// return o1.hashCode() - o2.hashCode(); 이 방식은 사용하면 안 됨
}
}
// 비교자 생성 메서드를 활용한 비교자
static Comparator<Object> hashCodeOrder = Comparator.comparingInt(o -> o.hashCode());
'Java-Spring > 이펙티브 자바' 카테고리의 다른 글
[이펙티브 자바] 객체 생성과 파괴 (0) | 2024.05.30 |
---|
✔️ 모든 객체의 공통 메서드
Object는 객체를 만들 수 있는 구체 클래스지만 기본적으로 상속해서 사용하도록 설계되었다.
Object에서 final이 아닌 메서드(equals, hashCode, toString, clone, finalize)는
모두 재정의를 염두에 두고 설걔된 것이라 재정의 시 지켜야 하는 일반 규약이 명확히 정의되어 있다.
그래서 Object를 상속하는 클래스, 즉 모든 클래스는 이 메서드들을 일반 규약에 맞게 재정의해야 하므로 이를 다뤄보자.
10. equals는 일반 규약을 지켜 재정의하라
- equals 메서드는 재정의하기 쉬워 보이지만 자칫하면 끔찍한 결과를 초래한다.
그냥 둘 경우 그 클래스의 인스턴스는 오직 자기 자신과만 같게 된다.
그러니 다음에서 열거한 상황 중 하나에 해당한다면 재정의하지 않는 것이 최선이다.- 각 인스턴스가 본질적으로 고유하다.
- 인스턴스의 '논리적 동치성'을 검사할 일이 없다.
- 상위 클래스에서 재정의한 equals가 하위 클래스에도 딱 들어맞는다.
- 클래스가 private이거나 package-private이고 equals 메서드를 호출할 일이 없다.
- 그렇다면 equals를 재정의해야 할 때는 언제일까?
- 객체 식별성이 아니라 논리적 동치성을 확인해야 하는데,
상위 클래스의 equals가 논리적 동치성을 비교하도록 재정의되지 않았을 때다. - 주로 값 클래스(Integer, String처럼 값을 표현하는 클래스)들이 여기 해당한다.
- 반면 값이 같은 인스턴스가 둘 이상 만들어지지 않음을 보장하는 인스턴스 통제 클래스라면 재정의하지 않아도 된다.
- 객체 식별성이 아니라 논리적 동치성을 확인해야 하는데,
- equals 메서드를 재정의할 때는 반드시 일반 규약을 따라야 한다.
다음은 Object 명세에 적힌 규약이다.- equals 메서드는 동치관계를 구현하며, 반사성, 대칭성, 추이성, 일관성, null-아님을 만족한다.
이때 동치관계란 집합을 서로 같은 원소들로 이뤄진 부분집합으로 나누는 연산으로 이 부분집합을 동치류라 하며,
equals 메서드가 쓸모 있으려면 모든 원소가 같은 동치류에 속한 어떤 원소와도 서로 교환할 수 있어야 한다. - 반사성 : null이 아닌 모든 참조 값 x에 대해, x.equals(x)는 true다.
즉, 객체는 자기 자신과 같아야 한다는 뜻이다. - 대칭성 : null이 아닌 모든 참조 값 x, y에 대해 x.equals(y)가 true면 y.equals(x)도 true다.
즉, 두 객체는 서로에 대한 동치 여부에 똑같이 답해야 한다는 뜻이다. - 추이성 : null이 아닌 모든 참조 값 x, y, z에 대해 x.equals(y)가 true이고, y.equals(x)도 true면 x.equals(z)도 true다.
즉, 첫 번째 객체와 두 번째 객체가 같고, 두 번째 객체와 세 번째 객체가 같다면,
첫 번째 객체와 세 번째 객체도 같아야 한다는 뜻이다.
하지만 이때 구체 클래스를 확장해 새로운 값을 추가할 경우 equals 규약을 만족시킬 방법은 존재하지 않는다.
대신 상속 대신 컴포지션을 사용해 뷰 메서드를 추가하여 사용하는 괜찮은 우회 방법이 있다. - 일관성 : null이 아닌 모든 참조 값 x, y에 대해, x.equals(y)를 반복 호출하면 항상 true를 반환하거나 false를 반환한다.
즉, 두 객체가 같다면 앞으로도 영원히 같아야 한다는 뜻이다.
클래스가 불변이든 가변이든 equals의 판단에 신뢰할 수 없는 자원이 끼어들게 해서는 안 된다.
이 제약을 어기면 일관성 조건을 만족시키기가 아주 어려우므로
equals는 항상 메모리에 존재하는 객체만을 사용한 결정적 계산만 수행해야 한다. - null-아님 : null이 아닌 모든 참조 값 x에 대해, x.equals(null)은 false다.
즉, 모든 객체가 null과 같지 않아야 한다는 뜻이다.
- equals 메서드는 동치관계를 구현하며, 반사성, 대칭성, 추이성, 일관성, null-아님을 만족한다.
- 양질의 equals 메서드 구현 방법을 단계별로 정리해보자.
- == 연산자를 사용해 입력이 자기 자신의 참조인지 확인한다.
이때 올바른 타입은 equals가 정의된 클래스인 것이 보통이지만, 가끔은 그 클래스가 구현한 특정 인터페이스가 될 수 있다.
이런 인터페이스를 구현한 클래스라면 equalsa에서 해당 인터페이스를 사용해야 한다. - instanceof 연산자로 입력이 올바른 타입인지 확인한다.
- 입력을 올바른 타입으로 형변환한다.
- 입력 객체와 자기 자신의 대응되는 핵심 필드들이 모두 일치하는지 하나씩 검사한다.
앞에서 인터페이스를 사용했다면 입력의 필드 값을 가져올 때도 그 인터페이스의 메서드를 사용해야 한다.
타입이 클래스라면 해당 필드에 직접 접근할 수도 있다.
- == 연산자를 사용해 입력이 자기 자신의 참조인지 확인한다.
// 전형적인 equals 메서드
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, 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;
}
// 나머지 코드는 생략 - hashCode 메서드는 꼭 필요하다
}
11. equals를 재정의하려거든 hashCode도 재정의하라
- equals를 재정의한 클래스 모두에서 hashCode도 재정의해야 한다.
그렇지 않으면 hashCode 일반 규약을 어기게 되어
해당 클래스의 인스턴스를 HashMap이나 HashSet 같은 컬렉션의 원소로 사용할 때 문제를 일으킬 것이다. - Object 명세에서 발췌한 규약이다.
- equals 비교에 사용되는 정보가 변경되지 않았다면,
애플리케이션이 실행되는 동안 그 객체의 hashCode 메서드는 몇 번을 호출해도 일관되게 항상 같은 값을 반환해야 한다. - equals(Object)가 두 객체를 같다고 판단했다면, 두 객체의 hashCode는 똑같은 값을 반환해야 한다.
- equals(Object)가 두 객체를 다르다고 판단했더라도, 두 객체의 hashCode가 서로 다른 값을 반환할 필요는 없다.
하지만 다른 객체에 대해서는 다른 값을 반환해야 해시테이블의 성능이 좋아진다.
- equals 비교에 사용되는 정보가 변경되지 않았다면,
- equals는 물리적으로 다른 두 객체를 논리적으로 같다고 할 수 있다.
하지만 Object의 기본 hashCode는 이 둘이 전혀 다르다고 판단하여, 규약과 달리 서로 다른 값을 반환한다.
그러므로 hashCode를 재정의하여 논리적으로 같은 객체가 같은 해시코드를 반환하도록 해야 한다. - 좋은 hashCode를 작성하는 간단한 요령이다.
- int 변수 result를 선언한 후 값 c로 초기화한다. 이때 c는 해당 객체의 첫 번째 핵심 필드를 계산한 해시 코드다.
- 해당 객체의 나머지 핵심 필드 f 각각에 대해 작업을 수행한다.
기본 타입 필드라면 Type.hashCode(f)를 수행한다.
참조 타입 필드면서 이 클래스의 equals 메서드가 이 필드의 equals를 재귀적으로 호출해 비교한다면
이 필드의 hashCode를 재귀적으로 호출한다.
필드가 배열이라면, 핵심 원소 각각을 별도 필드처럼 다룬다. 배열에 핵심 원소가 하나도 없다면 단순히 상수를 사용한다.
이를 통해 계산한 해시코드 c로 result를 갱신해 나간다. - result를 반환한다.
- 이때 파생 필드는 해시코드 계산에서 제외해도 되며, 또한 equals 비교에 사용되지 않는 필드는 반드시 제외해야 한다.
- 또한 Objects 클래스는 임의의 개수만큼 객체를 받아 해시코드를 계산해주는 정적 메서드인 hash를 제공해주므로
앞서의 요령대로 구현한 코드와 비슷한 수준의 hashCode 함수를 단 한 줄로 작성할 수 있다. 대신 속도는 더 느리다. - 클래스가 불변이며 해시코드를 계산하는 비용이 크다면 매번 새로 계산하기 보다는 캐싱하는 방식을 고려한다.
public final class PhoneNumber {
private final short areaCode, prefix, lineNum;
public PhoneNumber(int areaCode, int prefix, int lineNum) {
this.areaCode = rangeCheck(areaCode, 999, "area code");
this.prefix = rangeCheck(prefix, 999, "prefix");
this.lineNum = rangeCheck(lineNum, 9999, "line num");
}
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;
}
// 전형적인 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 메서드
@Override
public int hashCode() {
return Objects.hash(lineNum, prefix, areaCode);
}
// 해시코드를 지연 초기화하는 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;
}
}
12. toString을 항상 재정의하라
- Object의 기본 toString 메서드가 우리가 작성한 클래스에 적합한 문자열을 반환하는 경우는 거의 없다.
- 하지만 toString의 일반 규약에 따르면 간결하면서 사람이 읽기 쉬운 형태의 유익한 정보를 반환해야 한다.
toString을 잘 구현한 클래스는 사용하기에 훨씬 즐겁고, 그 클래스를 사용한 시스템은 디버깅하기 쉽다. - 실전에서 toString은 그 객체가 가진 주요 정보를 모두 반환하는 게 좋다.
하지만 객체가 거대하거나 객체의 상태가 문자열로 표현하기에 적합하지 않다면 요약 정보를 담아야 한다.
이상적으로는 스스로를 완벽히 설명하는 문자열이어야 한다. - 이때 반환값의 포맷을 문서화할지 정해야 한다. 전화번호나 행렬 같은 값 클래스라면 문서화하기를 권한다.
포맷을 명시하면 그 객체는 표준적이고, 명확하고, 사람이 읽을 수 있게 된다.
이때 포맷을 명시하기로 했다면,
명시한 포맷에 맞는 문자열과 객체를 상호 전환할 수 있는 정적 팩터리나 생성자를 함께 제공해주면 좋다.
반면 포맷을 한 번 명시하면 평생 그 포맷에 얽매이게 되므로 주의해야 한다. - 포맷 명시 여부와 상관없이 toString이 반환한 값에 포함된 정보를 얻어올 수 있는 API를 제공하자.
예를 들어 PhoneNumber 클래스는 지역 코드, 프리픽스, 가입자 번호용 접근자를 제공해야 한다.
그렇지 않으면 이 정보가 필요한 프로그래머는 toString의 반환값을 파싱할 수 밖에 없어 성능이 나빠진다. - 정적 유틸리티 클래스는 toString을 제공할 이유가 없다.
또한, 대부분의 열거 타입도 자바가 이미 완벽한 toString을 제공하니 따로 재정의하지 않아도 된다.
// 포맷을 명시한 toString
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, 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);
}
}
13. clone 재정의는 주의해서 진행하라
- Cloneable은 복제해도 되는 클래스임을 명시하는 용도인 인터페이스로
Object의 protected 메서드인 clone의 동작 방식을 결정한다.
Cloneable을 구현한 클래스의 인스턴스에서 clone을 호출하면 그 객체의 필드들을 하나하나 복사한 객체를 반환하며,
그렇지 않은 클래스의 인스턴스에서 호출하면 CloneNotSupportedException을 던진다. - 이는 인터페이스를 상당히 이례적으로 사용하는 예이다.
인터페이스를 구현한다는 것은 일반적으로 해당 클래스가 그 인터페이스에서 정의한 기능을 제공한다고 선언하는 행위다.
그런데 Cloneable의 경우에는 상위 클래스에 정의된 protected 메서드의 동작 방식을 변경한 것이다. - clone 메서드의 일반 규약을 허술하다. Object 명세에서 가져온 다음 설명을 보자.
- clone 메서드는 객체의 복사본을 생성해 반환한다.
일반적인 의도로는 x.clone() != x, x.clone().getClass() == x.getClass()의 식에 대한 참을 뜻한다.
하지만 이상의 요구를 반드시 만족해야 하는 것은 아니다. - 한편 다음 x.clone().equals(x) 식도 일반적으로 참이지만, 역시 필수는 아니다.
- 관례상, 이 메서드가 반환하는 객체는 super.clone을 호출해 얻어야 한다.
이 클래스와 모든 상위 클래스가 이 관계를 따른다면 x.clone().getClass() == x.getClass() 식은 참이다. - 관례상, 반환된 객체와 원본 객체는 독립적이어야 한다.
이를 만족하려면 super.clone으로 얻은 객체의 필드 중 하나 이상을 반환 전에 수정해야 할 수도 있다.
- clone 메서드는 객체의 복사본을 생성해 반환한다.
- 즉, 강제성이 없으므로 clone가 super.clone이 아닌, 생성자를 호출해 얻은 인스턴스를 반환해도 컴파일러는 불평하지 않는다.
하지만 이 클래스의 하위 클래스에서 super.clone을 호출한다면 잘못된 클래스의 객체가 만들어져,
결국 하위 클래스의 clone 메서드가 제대로 동작하지 않게 된다. - 제대로 동작하는 clone 메서드를 가진 상위 클래스를 상속해 Cloneable을 구현하고 싶을 경우를 생각해보자.
먼저 불변 클래스의 clone 메서드를 살펴보자.- super.clone을 호출하여 얻은 객체는 원본의 완벽한 복제본일 것이다.
클래스에 정의된 모든 필드는 원본 필드와 똑같은 값을 갖는다. - 이때 재정의한 메서드의 반환 타입은 상위 클래스의 메서드가 반환하는 타입의 하위 타입일 수 있으므로
이 방식으로 클라이언트가 형변환하지 않아도 되도록 만든다. - 그리고 super.close 호출을 try-catch 블록으로 감싸 Object의 clone 메서드가 검사 예외인
CloneNotSupportedException을 던지도록 선언한다. - 그런데 쓸때없는 복사를 지양한다는 관점에서 보면 불변 클래스는 굳이 clone 메서드를 제공하지 않는 게 좋다.
- super.clone을 호출하여 얻은 객체는 원본의 완벽한 복제본일 것이다.
public final class PhoneNumber implements Cloneable {
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;
}
@Override
public String toString() {
return String.format("%03d-%03d-%04d",
areaCode, prefix, lineNum);
}
// 가변 상태를 참조하지 않는 클래스용 clone 메서드 (불변 클래스)
@Override
public PhoneNumber clone() {
try {
return (PhoneNumber) super.clone();
} catch (CloneNotSupportedException e) {
throw new AssertionError();
}
}
}
- 반면 클래스가 가변 객체를 참조하는 순간 재앙으로 돌변한다.
가변 클래스인 Stack 클래스를 예로 들어보자.- 이때는 단순히 super.clone의 결과를 그대로 반환할 경우 반환된 Stack 인스턴스의 size 필드는 올바른 값을 갖겠지만,
elements 필드는 원본 Stack 인스턴스와 똑같은 배열을 참조할 것이다.
그러므로 원본이나 복제본 중 하나를 수정하면 다른 하나도 수정되어 불변식을 해치게 된다. - 그래서 Stack의 clone 메서드는 제대로 동작하려면 스택 내부 정보를 복사해야 하는데,
가장 쉬운 방법은 elements 배열의 clone을 재귀적으로 호출해주는 것이다. - 한편, elements 필드가 final이었다면 새로운 값을 할당할 수 없어 앞서의 방식은 작동하지 않는다.
이는 근본적인 문제로, Cloneable 아키텍처는 가변 객체를 참조하는 필드는 final로 선언하라는 일반 용법과 충돌한다.
그래서 복제할 수 있는 클래스를 만들기 위해 일부 필드에서 final 한정자를 제거해야 할 수도 있다. - 또한 clone을 재귀적으로 호출하는 것만으로는 충분하지 않을 때도 있다.
해시테이블 내부는 버킷들의 배열이고, 각 버킷은 키-값 쌍을 담는 연결 리스트의 첫 번째 엔트리를 참조한다.
이때 복제본은 자신만의 버킷 배열을 갖지만,
이 배열은 원본과 같은 연결 리스트를 참조하여 원본과 복제본 모두 예기치 않게 동작할 가능성이 생긴다.
이를 해결하려면 각 버킷을 구성하는 연결 리스트를 복사해야 한다.
그러므로 해시테이블의 clone 메서드는 먼저 적절한 크기의 새로운 버킷 배열을 할당한 다음
버킷 배열을 순회하며 비지 않은 각 버킷에 대해 깊은 복사를 수행하고
Entry의 deepCopy 메서드는 자신이 가리키는 연결 리스트 전체를 복사하기 위해 자신을 재귀적으로 호출한다.
하지만 이렇게 연결 리스트를 복제하는 방법은 스택 오버플로를 일으킬 위험이 있어 좋지 않다.
이 문제를 피하려면 deepCopy 재귀 호출 대신 반복자를 써서 순회하는 방향으로 수정해야 한다. - 또 다른 가변 객체를 복제하는 방법으로는 super.clone을 호출하여 얻은 객체의 모든 필드를 초기 상태로 설정한 다음,
원본 객체의 상태를 다시 생성하는 고수준 메서드들을 사용하는 것이다.
이를 사용하여 복제하면 보통은 간단하고 제법 우아한 코드를 얻게 되지만, 저수준에서 바로 처리할 때보다는 느리다.
- 이때는 단순히 super.clone의 결과를 그대로 반환할 경우 반환된 Stack 인스턴스의 size 필드는 올바른 값을 갖겠지만,
public class Stack implements Cloneable {
private Object[] elements;
private int size = 0;
private static final int DEFAULT_INITIAL_CAPACITY = 16;
public Stack() {
this.elements = new Object[DEFAULT_INITIAL_CAPACITY];
}
public void push(Object e) {
ensureCapacity();
elements[size++] = e;
}
public Object pop() {
if (size == 0)
throw new EmptyStackException();
Object result = elements[--size];
elements[size] = null; // 다 쓴 참조 해제
return result;
}
public boolean isEmpty() {
return size ==0;
}
// 가변 상태를 참조하는 클래스용 clone 메서드
@Override
public Stack clone() {
try {
Stack result = (Stack) super.clone();
result.elements = elements.clone();
return result;
} catch (CloneNotSupportedException e) {
throw new AssertionError();
}
}
private void ensureCapacity() {
if (elements.length == size)
elements = Arrays.copyOf(elements, 2 * size + 1);
}
}
- 그런데 이 모든 작업이 꼭 필요한 걸까?
- 다행히도 이처럼 복잡한 경우는 드물다.
- Cloneable을 이미 구현한 클래스를 확장한다면 어쩔 수 없이 clone을 잘 작동하도록 구현해야 한다.
- 하지만 그렇지 않은 상황에서는 복사 생성자와 복사 팩터리라는 더 나은 객체 복사 방식을 제공할 수 있다.
복사 생성자란 단순히 자신과 같은 클래스의 인스턴스를 인수로 받는 생성자를 말한다.
복사 팩터리는 복사 생성자를 모방한 정적 팩터리다. - 또한 이들은 해당 클래스가 구현한 인터페이스 타입의 인스턴스를 인수로 받을 수 있으므로
원본의 구현 타입에 얽매이지 않고 복제본의 타입을 직접 선택할 수 있다.
// 복사 생성자
public Yum(Yum yum) {
...
}
// 복사 팩터리
public static Yum newInstance(Yum yum) {
...
}
14. Comparable을 구현할지 고려하라
- compareTo는 Object의 메서드가 아니라 Comparable 인터페이스의 유일무이한 메서드이다.
- compareTo는 단순 동치성 비교에 더해 순서까지 비교할 수 있으며, 제네릭하다.
또한 Comparable을 구현한 객체들의 배열은 손쉽게 정렬이 가능하며
검색, 극단값 계산, 자동 정렬되는 컬렉션 관리도 쉽게할 수 있다. - 사실상 자바 플랫폼 라이브러리의 모든 값 클래스와 열거 타입은 Comparable을 구현하므로
알파벳, 숫자, 연대 같이 순서가 명확한 값 클래스를 작성한다면 반드시 Comparable 인터페이스를 구현하자. - compareTo 메서드의 일반 규약은 equals의 규약과 비슷하다.
- 이 객체와 주어진 객체의 순서를 비교한다.
이 객체가 주어진 객체보다 작으면 음의 정수를, 같으면 0을, 크면 양의 정수를 반환한다.
이 객체와 비교할 수 없는 타입의 객체가 주어지면 ClassCastException을 던진다. - Comparable을 구현한 클래스는 모든 x, y에 대하여 대칭성을 보장해야 하므로
두 객체 참조의 순서를 바꿔 비교해도 예상한 결과가 나와야 한다.
sgn(x.compareTo(y)) = -sgn(y.compareTo(x)) - Comparable을 구현한 클래스는 추이성을 보장해야 하므로
첫 번째가 두 번째보다 크고 두 번째가 세 번째보다 크면, 첫 번째는 세 번째보다 커야 한다.
x.compareTo(y) > 0 && y.compareTo(z) > 0 이면 x.compareTo(z) > - Comparable을 구현한 클래스는 모든 z에 대해 반사성을 보장해야 하므로
크기가 같은 객체들끼리는 어떤 객체와 비교하더라도 항상 같아야 한다.
x.compareTo(y) == 0이면 sgn(x.compareTo(z)) == sgn(y.compareTo(z)) - (x.compareTo(y) == 0) == (x.equals(y))여야 한다.
이는 compareTo 메서드로 수핸항 동치성 테스트의 결과가 equals와 같아야 한다는 것으로
필수는 아니지만 이를 잘 지키면 compareTo로 줄 지은 순서와 equals의 결과가 일관되게 되므로 꼭 지키길 권한다.
- 이 객체와 주어진 객체의 순서를 비교한다.
- compareTo 메서드 작성 요령은 equals와 비슷하다. 몇 가지 차이점만 주의하면 된다.
- Comparable은 타입을 인수로 받는 제네릭 인터페이스이므로 compareTo 메서드의 인수 타입은 컴파일타임에 정해진다.
그러므로 입력 인수의 타입을 확인하거나 형변환할 필요가 없으며 인수 타입이 잘못됐다면 컴파일 자체가 되지 않는다.
또한 null을 인수로 넣어 호출하면 NullPointerException이 던져지게 된다. - Comparable을 구현하지 않은 필드나 표준이 아닌 순서로 비교해야 한다면 비교자 Comparator를 대신 사용한다.
비교자는 직접 만들거나 자바가 제공하는 것 중에 골라 쓰면 된다. - 또한 기존에 정수 기본 타입 필드를 비교할 때 관계 연산자인 <와 >를,
실수 기본 타입 필드를 비교할 때는 정적 메서드 Double.compare, Float.compare를 사용하라고 권했다.
그런데 자바 7부터는 compareTo 메서드에서 관계 연산자 <와 >를 사용하는 이전 방식은 거추장스럽고 오류를 유발하므로
박싱된 기본 타입 클래스들에 새로 추가된 정적 메서드인 compare를 이용하도록 하면 된다. - 클래스에 핵심 필드가 여러 개라면 어느 것을 먼저 비교하느냐가 중요하므로 가장 핵심적인 필드부터 비교해나가자.
가장 핵심이 되는 필드가 똑같다면, 똑같지 않은 필드를 찾을 때까지 그다음으로 중요한 필드를 비교해나간다. - 자바 8에서는 Comparator 인터페이스가 일련의 비교자 생성 메서드와 팀을 꾸려
메서드 연쇄 방식으로 비교자를 생성할 수 있게 되었으므로
이 비교자들을 Comparable 인터페이스가 원하는 compareTo 메서드를 구현하는데 멋지게 활용할 수 있다.
이 방식은 간결한 반면, 약간의 성능 저하가 뒤따른다. - 만약 값의 차를 기준으로 순서를 비교해야 할 경우에 단순히 해시코드 값의 차를 기준으로 비교할 경우
정수 오버플로를 일으키거나 부동소수점 계산 방식에 따른 오류가 발생하여 추이성을 위반하므로
정적 compare 메서드 또는 비교자 생성 메서드를 활용하자.
- Comparable은 타입을 인수로 받는 제네릭 인터페이스이므로 compareTo 메서드의 인수 타입은 컴파일타임에 정해진다.
// 자바가 제공하는 비교자를 사용할 때의 비교
public final class CaseInsensitiveString implements Comparable<CaseInsensitiveString> {
private final String s;
public CaseInsensitiveString(String s) {
this.s = Objects.requireNonNull(s);
}
@Override
public boolean equals(Object o) {
return o instanceof CaseInsensitiveString && ((CaseInsensitiveString) o).s.equalsIgnoreCase(s);
}
@Override
public int hashCode() {
return s.hashCode();
}
@Override
public String toString() {
return s;
}
// 자바가 제공하는 비교자를 사용해 클래스를 비교
public int compareTo(CaseInsensitiveString cis) {
return String.CASE_INSENSITIVE_ORDER.compare(s, cis.s);
}
}
public class Test {
public static void main(String[] args) {
Set<CaseInsensitiveString> s = new TreeSet<>();
for (String arg : args)
s.add(new CaseInsensitiveString(arg));
System.out.println(s);
}
}
// 기본 타입 필드가 여럿일 때의 비교
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);
}
// // 1. 기본 타입 필드가 여럿일 때의 비교자
// 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;
// }
// 2. 비교자 생성 메서드를 활용한 비교자
private static final Comparator<PhoneNumber> COMPARATOR =
/* 첫 번째 비교자 생성 메서드인 comparingInt는
객체 참조를 int 타입 키에 매핑하는 키 추출 함수를 인수로 받아,
그 키를 기준으로 순서를 정하는 비교자를 반환
이는 람다를 인수로 받으며, 이 람다는 PhoneNumber에서 추출한 지역 코드를 기준으로
전화번호의 순서를 정하는 Comparator(PhoneNumber>를 반환
*/
comparingInt((PhoneNumber pn) -> pn.areaCode)
/* 두 번재 비교자 생성 메서드인 thenComparingInt는
int 키 추출자 함수를 입력받아 다시 비교자를 반환
thenComparingInt는 원하는 만큼 연달아 호출할 수 있음
*/
.thenComparingInt(pn -> pn.prefix)
.thenComparingInt(pn -> pn.lineNum);
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 class Test {
public static void main(String[] args) {
NavigableSet<PhoneNumber> s = new TreeSet<PhoneNumber>();
for (int i = 0; i < 10; i++)
s.add(randomPhoneNumber());
System.out.println(s);
}
}
// 정적 compare 메서드를 활용한 비교자
static Comparator<Object> hashCodeOrder = new Compararot<>() {
public int compare(Object o1, Object o2) {
return Integer.compare(o1.hashCode(), o2.hashCode());
// return o1.hashCode() - o2.hashCode(); 이 방식은 사용하면 안 됨
}
}
// 비교자 생성 메서드를 활용한 비교자
static Comparator<Object> hashCodeOrder = Comparator.comparingInt(o -> o.hashCode());
'Java-Spring > 이펙티브 자바' 카테고리의 다른 글
[이펙티브 자바] 객체 생성과 파괴 (0) | 2024.05.30 |
---|