Object는 객체를 만들 수 있는 구체 클래스지만 기본적으로는 상속해서 사용하도록 설계되었다
Object에서 final이 아닌 메서드(equals, hashCode, toString, clone, finalize)는
모두 재정의(overriding)를 염두에 두고 설계된 것이라 재정의 시 지켜야 하는 일반 규약이 명확히 정의되어 있다
이번 장에서는 언제 어떻게 재정의해야 하는지 다룬다
1. equals는 일반 규약을 지켜 재정의하라
1.1 equals를 재정의할 필요가 없는 경우
equals 재정의 시 문제가 발생하는 일을 회피하는 가장 쉬운 길은 아예 재정의 하지 않는 것이다
다음 상황 중 하나에 해당한다면 재정의하지 않는 것이 최선이다
- 각 인스턴스가 본질적으로 고유하다
값을 표현하는 게 아니라 동작하는 개체를 표현하는 클래스가 여기 해당한다
Thread가 좋은 예로, Object의 equals 메서드는 이러한 클래스에 딱 맞게 구현되었다 - 인스턴스의 논리적 동치성을 검사할 일이 없다
java.util.regex.Pattern은 equals를 재정의해서 두 Pattern의 인스턴스가 같은 정규표현식을 나타내는지를 검사하는 방법
즉, 논리적 동치성을 검사하는 방법도 있다
하지만 설계자는 클라이언트가 이 방식을 원하지 않거나 애초에 필요하지 않다고 판단했다면 Object의 기본 equals만으로 해결된다 - 상위 클래스에서 재정의한 equals가 하위 클래스에도 딱 들어맞는다
대부분의 set 구현체는 AbstractSet이 구현한 equals를 상속받아 쓰고,
List 구현체들은 AbstractList이 구현한 equals를 상속받아 쓰고,
Map 구현체들은 AbstractMap이 구현한 equals를 상속받아 쓴다 - 클래스가 private이거나 package-private이고 equals 메서드를 호출할 일이 없다
equals가 실수로라도 호출되는 걸 막고 싶다면 다음처럼 구현해두자
@Override public boolean equals(Object o) {
throw new AssertionError(); // 호출 금지!
}
1.2 equals를 재정의해야 하는 경우
객체 식별성(두 객체가 물리적으로 같은가)이 아니라 논리적 동치성을 확인해야할 경우
즉, 상위 클래스의 equals가 논리적 동치성을 비교하도록 재정의되지 않았을 때다
주로 값 클래스들이 여기 해당한다 (Integer, String 등)
두 값 객체를 equals로 비교하는 프로그래머는 객체가 같은지가 아니라 값이 같은지를 알고 싶어 할 것 이다
값 비교 뿐 아니라 Map의 키와 Set의 원소로 사용할 수 있게 된다
Enum도 여기에 해당한다
equals 메서드를 재정의할 때는 반드시 일반 규약을 따라야 한다
일반 규약은 다음과 같다
(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(z)도 true면 x.equals(z)도 true다 - 일관성
null이 아닌 모든 참조 값 x, y에 대해
x.equals(y)를 반복해서 호출하면 항상 true를 반환하거나 항상 false를 반환한다 - null-아님
null이 아닌 모든 참조 값 x에 대해
x.equals(null)은 false다
1.3 대칭성이 위배되는 사례
대소문자를 구별하지 않는 문자열을 구현한 다음 클래스를 예로 살펴보자
이 클래스에서 toString 메서드는 원본 문자열의 대소문자를 그대로 돌려주지만 equals에서는 대소문자를 무시한다
public class CaseInsensitiveString {
private final String s;
public CaseInsensitiveString(String s) {
this.s = Object.requireNonNull(s);
}
// 대칭성 위배!
@Override
public boolean equals(Object o) {
if (o instanceof CaseInsensitiveString) {
return s.equalsIgnoreCase(((CaseInsensitiveString) o).s);
}
if (o instanceof String) { // 한 방향으로만 작동한다!
return s.equalsIgnoreCase((String) o);
}
return false;
}
...
...
}
CaseInsensitiveString의 equals는 일반 문자열과도 비교를 시도한다
다음처럼 CaseInsensitiveString과 일반 String 객체가 하나씩 있다고 해보자
CaseInsensitiveString cis = new CaseInsensitiveString("Polish");
String s = "Polish";
cis.equals(s)는 true를 반환한다
하지만 s.equals(cis)는 false를 반환한다
대칭성을 명백히 위반한다
이 문제를 해결하려면 CaseInsensitiveString의 equals를 String과도 연동하겠다는 생각을 버려야 한다
@Override
public boolean equals(Object o) {
return o instanceof CaseInsensitiveString &&
((CaseInsensitiveString) o).s.equalsIgnoreCase(s);
}
1.4 동치관계에서 나타나는 근본적인 문제
간단한 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 obj) {
if(!(obj instanceof Point))
return false;
Point p = (Point) obj;
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 = Object.requireNonNull(color);
}
}
equals 메서드는 어떻게 해야 할까?
그대로 둔다면 Point의 구현이 상속되어 색상 정보는 무시한 채 비교를 수행한다
equals 규약을 어긴 것은 아니지만, 중요한 정보를 놓치게 되니 받아들일 수 없는 상황이다
다음 코드처럼 비교 대상이 또 다른 ColorPoint이고 위치와 색상이 같을 때만 true를 반환하는 equals를 생각해보자
// 대칭성 위배!
@Override
public boolean equals(Object o) {
if(!(o instanceof ColorPoint)) {
return false;
}
return super.equals(o) && ((ColorPoint) o).color == color;
}
Point의 equals는 색상을 무시하고, ColorPoint의 equals는 입력 매개변수의 클래스 종류가 다르다며 매번 false만 반환할 것이다
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와 비교할 때 색상을 무시하도록 한다면?
Point p = new Point(1, 2);
ColorPoint cp1 = new ColorPoint(1, 2, Color.RED);
ColorPoint cp2 = new ColorPoint(1, 2, Color.BLUE);
cp1.equals(p)와 cp2.equals(p)는 true를 반환하지만
cp1.equals(cp2)는 false를 반환하게 된다
이 경우 대칭성은 지켜주지만, 추이성이 깨지게 된다
구체 클래스를 확장해 새로운 값을 추가하면서 equals 규약을 만족시킬 방법은 존재하지 않는다
객체 지향적 추상화의 이점을 포기하지 않는 한은 말이다
이 말은 얼핏, equals 안의 instanceof 검사를 getClass 검사로 바꾸면 규약도 지키고 값도 추가하면서 구체 클래스를 상속할 수 있다는 뜻으로 들린다
// 리스코프 치환 원칙 위배!
@Override
public boolean equals(Object o) {
if (o == null || o.getClass() != getClass()) {
return false;
}
Point p = (Point) o;
return p.x == x && p.y == y;
}
이번 equals는 같은 구현 클래스의 객체와 비교할 때만 true를 반환한다
괜찮아 보이지만 실제로는 활용할 수 없다
Point의 하위 클래스는 정의상 여전히 Point 이므로 어디서든 Point로써 활용될 수 있어야 한다
그런데 이 방식에서는 그렇지 못하다
리스코프 치환 원칙에 따르면, 어떤 타입에 있어 중요한 속성이라면 그 하위 타입에서도 마찬가지로 중요하다
따라서, 그 타입의 모든 메서드가 하위 타입에서도 똑같이 잘 작동해야 한다
1.5 equals 규약을 지키면서 값 추가하기
그렇다면 equals를 어떻게 작성해야 할까?
구체 클래스의 하위 클래스에서 값을 추가할 방법은 없지만 괜찮은 우회 방법이 하나 있다
바로 상속 대신 컴포지션을 사용하면 된다
Point를 상속하는 대신 Point를 ColorPoint의 private 필드로 두고, ColorPoint와 같은 위치의 일반 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 = Object.requireNonNull(color);
}
/*
* 이 ColorPoint의 Point 뷰를 반환한다
*/
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);
}
}
1.6 묵시적 null 검사
명시적 null 검사는 필요 없다
동치성을 검사하려면 equals는 건네받은 객체를 적절히 형변환한 후 필수 필드들의 값을 알아내야 한다그러려면 형변환에 앞서 instanceof 연산자로 입력 매개변수가 올바른 타입인지 검사해야 한다
/* 명시적 Null 검사 */
@Override
public boolean equals(Object obj) {
if(obj == null) {
return false;
}
return super.equals(obj);
}
/* 묵시적 Null 검사 */
@Override
public boolean equals(Object obj) {
if(!(obj instanceof MyType)) {
return false;
}
MyType myType = (MyType) obj;
return super.equals(myType);
}
입력이 null이면 타입 확인 단계에서 false를 반환하기 때문에 null 검사를 명시적으로 하지 않아도 된다
1.7 equals 메서드 구현 방법 단계
- == 연산자를 사용해 입력이 자기 자신의 참조인지 확인한다
자기 자신이면 true를 반환
이는 단순한 성능 최적화용으로, 비교 작업이 복잡한 상황일 때 값어치를 할 것이다 - instanceof 연산자로 입력이 올바른 타입인지 확인한다
그렇지 않다면 false를 반환한다
이 때의 올바른 타입은 equals가 정의된 클래스인 것이 보통이지만, 가끔은 그 클래스가 구현한 특정 인터페이스가 될 수도 있다
어떤 인터페이스는 자신을 구현한 (서로 다른) 클래스끼리도 비교할 수 있도록 equals 규약을 수정하기도 한다
이런 인터페이스를 구현한 클래스라면 equals에서 (클래스가 아닌) 해당 인터페이스를 사용해야 한다
Set, List, Map, Map.Entry 등의 컬렉션 인터페이스들이 여기 해당한다 - 입력을 올바른 타입으로 형변환한다
앞서 2번에서 instanceof 검사를 했기 때문에 이 단계는 100% 성공한다 - 입력 객체와 자기 자신의 대응되는 '핵심' 필드들이 모두 일치하는지 하나씩 검사한다
모든 필드가 일치하면 true, 하나라도 다르면 false를 반환한다
2단계에서 인터페이스를 사용했다면 입력의 필드 값을 가져올 때도 그 인터페이스의 메서드를 사용해야 한다
타입이 클래스라면 (접근 권한에 따라) 해당 필드에 직접 접근할 수도 있다
1.8 float와 double
float와 double을 제외한 기본 타입 필드는 == 연산자로 비교하고, 참조 타입필드는 각각의 equals 메서드로 비교한다
float와 double 필드는 각각 정적 메서드 compare()를 사용한다
Float.compare(값1, 값2)
Double.compare(값1, 값2)
Float.equals, Double.equals를 사용할 수도 있지만, 이 메서드들은 오토박싱을 수반할 수 있으니 성능성 좋지 않다
1.9 어떤 필드를 먼저 비교하느냐가 equals의 성능을 좌우하기도 한다
최상의 성능을 바란다면 다를 가능성이 더 크거나 비교하는 비용이 싼 필드를 비교하자
동기화용 락(lock) 필드 같이 객체의 논리적 상태와 관련 없는 필드는 비교하면 안 된다
1.10 전형적인 equals 메서드의 예
equals를 다 구현했다면 세 가지만 자문해 보자. 대칭적인가? 추이성이 있는가? 일관적인가?
자문에서 끝내지 말고 단위 테스트를 작성해 돌려보자
단, equals 메서드를 AutoValue(Google 프레임워크, 다른 예로 Lombok)를 이용해 작성했다면 테스트를 생략해도 안심할 수 있다
물론 나머지 요건인 반사성과 null-아님도 만족해야 하지만, 이 둘이 문제 되는 경우는 별로 없다
다음은 이상의 비법에 따라 작성해본 PhoneNumber 클래스용 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;
}
}
1.11 마지막 주의사항
- equals를 재정의 할 땐 hashCode도 반드시 재정의하자
- 너무 복잡하게 해결하려 들지 말자.
필드들의 동치성만 검사해도 equals 규약을 어렵지 않게 지킬 수 있다 - Object 외의 타입을 매개변수로 받는 equals 메서드는 선언하지 말자
많은 프로그래머가 equals를 다음과 같이 작성해 놓고 문제의 원인을 찾아 헤맨다
// 입력 타입은 반드시 Object여야 한다! 컴파일 되지 않음!
@Override
public boolean equals(MyClass obj) {
...
}
- 핵심 정리
꼭 필요한 경우가 아니면 equals를 재정의하지 말자
많은 경우에 Object의 equals가 여러분이 원하는 비교를 정확히 수행해준다
재정의해야 할 때는 그 클래스의 핵심 필드 모두를 빠짐없이, 다섯 가지 규약을 확실히 지켜가며 비교해야 한다
2. equals를 재정의하려거든 hashCode도 재정의하라
equals를 재정의한 클래스 모두에서 hashCode도 재정의해야 한다
그렇지 않으면 hashCode 일반 규약을 어기게 되어 해당 클래스의 인스턴스를 HashMap이나 HashSet 같은 컬렉션의 원소로 사용할 때 문제를 일으킨다
다음은 Object 명세에서 발췌한 규약이다
- equals 비교에 사용되는 정보가 변경되지 않았다면, 애플리케이션 실행되는 동안 그 객체의 hashCode 메서드는 몇 번을 호출해도 일관된 값을 반환해야 한다
단, 애플리케이션이 재실행되면 달라져도 상관없다 - equals(Object)가 두 객체를 같다고 판단했다면, 두 객체의 hashCode는 똑같은 값을 반환해야 한다
- equals(Object)가 두 객체를 다르다고 판단했더라도, 두 객체의 hashCode가 서로 다른 값을 반환할 필요는 없다
단, 다른 객체에 대해서는 다른 값을 반환해야 해시테이블의 성능이 좋아진다
hashCode 재정의를 잘못했을 때 크게 문제가 되는 조항이 두 번째이다
논리적으로 같은 객체는 같은 hashCode를 반환해야 한다
위에서 작성했던 PhoneNumber 클래스로 예를 들어보자
Map<PhoneNumber, String> m = new HashMap<>();
m.put(new PhoneNumber(707, 867, 5309), "제니");
m.get(new PhoneNumber(707, 867, 5309)) // null 반환
m.get(new PhoneNumber(707, 867, 5309)) 를 실행하면 "제니"가 나와야할 것 같지만,
실제로는 null을 반환한다
여기에는 2개의 PhoneNumber 인스턴스가 사용되었다
첫 번째는 HashMap에 "제니"를 넣을 때 사용됐고,
(논리적 동치인)두 번째는 이를 꺼내려할 때 사용됐다
PhoneNumber 클래스는 hashCode를 재정의하지 않았기 때문에 논리적 동치인 두 객체가 서로 다른 해시코드를 반환하여 두 번째 규약을 지키지 못한다
HashMap은 해시코드가 다른 엔트리끼리는 동치성 비교를 시도조차 하지 않도록 최적화되어 있기 때문이다
아래와 같이 PhoneNumber 클래스 안에 equals와 hashCode를 구현해 주면 기대한 값을 받을 수 있다
@Override
public int hashCode() {
int result = Short.hashCode(areaCode);
result = 31 * result + Integer.hashCode(prefix);
result = 31 * result + Integer.hashCode(lineNum);
return result;
}
위 방법 말고도 Objects 클래스는 임의의 개수만큼 객체를 받아 해시코드를 계산해주는 정적 메서드인 hash를 제공한다
이 메서드를 활용하면 앞서의 요령대로 구현한 코드와 비슷한 수준의 hashCode 함수를 단 한 줄로 작성할 수 있다
하지만 아쉽게도 속도는 더 느리다
/* 한 줄로 작성이 가능하지만, 성능이 살짝 아쉽다 (속도 저하) */
@Override
public int hashCode() {
return Objects.hash(lineNum, prefix, areaCode);
}
클래스가 불변이고 해시코드를 계산하는 비용이 크다면, 매번 새로 계산하기 보다는 캐싱하는 방식을 고려해야 한다
이 타입의 객체가 주로 해시의 키로 사용될 것 같다면 인스턴스가 만들어질 때 해시코드를 계산해 둔다
해시 키로 사용되지 않는 경우라면 hashCode가 처음 불릴 때 계산하는 지연 초기화 전략을 사용하면 어떨까?
이러한 지연 초기화 전략을 사용하려면 클래스의 스레드를 안전하게 만들도록 신경 써야 한다
즉, 스레드 안정성까지 고려해야 한다
private int hashCode; // 자동으로 0으로 초기화
public int hashCode() {
int result = hashCode;
if(result == 0) {
Integer.hashCode(areaCode);
result = 31 * result + Integer.hashCode(prefix);
result = 31 * result + Integer.hashCode(lineNum);
hashCode = result;
}
return result;
}
성능을 높이기 위해 hashCode 계산에서 핵심 필드를 생략해서는 안 된다
속도야 빨라지지만, hash 품질이 저하되어 hashTable의 성능을 심각하게 떨어뜨릴 수 있다.
hashCode가 반환하는 값의 생성 규칙을 API 사용자에게 자세히 공표하지 말자
그래야 클라이언트가 이 값에 의지하지 않게 되고, 추후에 계산 방식을 바꿀 수도 있다
- 핵심 정리
equals를 재정의할 때는 hashCode도 반드시 재정의해야 한다
그렇지 않으면 프로그램이 제대로 동작하지 않을 것이다
재정의한 hashCode는 Object의 API 문서에 기술된 일반 규약을 따라야 하며, 서로 다른 인스턴스라면 되도록 해시코드도 서로 다르게 구현해야 한다
3. toString을 항상 재정의하라
Object의 기본 toString 메서드는 우리가 작성한 클래스에 적합한 문자열을 반환하는 경우가 거의 없다
이 메서드는 PhoneNumber@adbbd 처럼 단순히 '클래스_이름@16진수로_표시한_해시코드'를 반환할 뿐이다
toString의 일반 규약에 따르면 '간결하면서 사람이 읽기 쉬운 형태의 유익한 정보'를 반환해야 한다
equals와 hashCode 규약 만큼 대단히 중요하진 않지만,
toString을 잘 구현한 클래스는 사용하기에 훨씬 즐겁고, 그 클래스를 사용한 시스템 디버깅하기 쉽다
실전에서 toString은 객체가 가진 주요한 정보를 모두 반환하는 게 좋고,
포맷을 명시하든 아니든 의도는 명확히 밝혀야 한다
/**
* 폰 번호를 문자열로 반환
* PhoneNumber{areaCode=82, prefix=10, lineNum=1234}
*/
@Override
public String toString() {
return "PhoneNumber{" +
"areaCode=" + areaCode +
", prefix=" + prefix +
", lineNum=" + lineNum +
'}';
}
포맷 명시 여부와 상관없이 toString이 반환한 값에 포함된 정보를 얻어올 수 있는 API를 제공하자
예로 PhoneNumber 클래슨느 각각 지역코드, 프리픽스, 가입자 번호용 접근자를 제공해야 한다
그렇지 않으면 이 정보가 필요한 프로그래머는 toString의 반환값을 파싱 할 수밖에 없다
정적 유틸리티 클래스는 toString을 제공할 이유가 없다
또한, 대부분의 열거 타입도 자바가 이미 완벽한 toString을 제공하여 따로 재정의 하지 않아도 된다
하지만 하위 클래스들이 공유해야 할 문자열 표현이 있는 추상 클래스라면 toString을 재정의 해줘야 한다
예로 대다수의 컬렉션 구현체는 추상 컬렉션 클래스들의 toString 메서드를 상속해 쓴다
- 핵심 정리
모든 구체 클래스에서 Object의 toString을 재정의하자
상위 클래스에서 이미 알맞게 재정의한 경우는 예외다
toString을 재정의한 클래스는 사용하기도 즐겁고 그 클래스를 사용한 시스템을 디버깅하기 쉽게 해 준다
toString은 해당 객체에 관한 명확하고 유용한 정보를 읽기 좋은 형태로 반환해야 한다
4. clone 재정의는 주의해서 진행하라
Cloneable은 복제해도 되는 클래스임을 명시하는 용도의 믹스인 인터페이스지만
Cloneable을 구현하는 것만으로는 외부 객체에서 clone 메서드를 호출할 수 없다
clone 메서드가 선언된 곳이 Cloneable이 아닌 Object이고, 그마저도 protected라는데 있다
그래서 Cloneable을 구현하는 것만으로는 외부 객체에서 clone 메서드를 호출할 수 없다
리플렉션을 사용하면 가능하지만, 100% 성공하는 것도 아니다
해당 객체가 접근이 허용된 clone 메서드를 제공한다는 보장이 없기 때문이다
clone 메서드의 일반 규약은 허술하다
Object 명세에서 가져온 다음 설명을 보자
이 객체의 복사본을 생성해 반환한다
'복사'의 정확한 뜻은 그 객체를 구현한 클래스에 따라 다를 수 있다
일반적인 의도는 다음과 같다
어떤 객체 x에 대해 다음 식은 참이다
x.clone() != x
또한 다음 식도 참이다
x.clone().getClass() == x.getClass()
하지만 이상의 요구를 반드시 만족해야 하는 것은 아니다
한편 다음 식도 일반적으로 참이지만, 역시 필수는 아니다
x.clone().equals(x)
관례상, 이 메서드가 반환하는 객체는 super.clone을 호출해 얻어야 한다
이 클래스와 (Object를 제외한) 모든 상위 클래스가 이 관례를 따른다면 모든 식은 참이다
x.clone().getClass() == x.getClass()
관례상, 반환된 객체와 원본 객체는 독립적이어야 한다
이를 만족하려면 super.clone으로 얻은 객체의 필드 중 하나 이상을 반환 전에 수정해야 할 수도 있다
강제성이 없다는 점만 빼면 생성자 연쇄와 살짝 비슷한 메커니즘이다
즉, clone 메서드가 super.clone이 아닌, 생성자를 호출해 얻은 인스턴스를 반환해도 컴파일러는 불평하지 않을 것이다
하지만 이 클래스의 하위 클래스에서 super.clone을 호출한다면 잘못된 클래스의 객체가 만들어져,
결국 하위 클래스의 clone 메서드가 제대로 동작하지 않게 된다
clone을 재정의한 클래스가 final이라면 걱정해야할 하위 클래스가 없으니 이 관례는 무시해도 안전하다
하지만 final 클래스의 clone 메서드가 super.clone을 호출하지 않는다면 Cloneable을 구현할 이유도 없다
Object의 clone 구현의 동작 방식에 기댈 필요가 없기 때문이다
모든 필드가 기본 타입이거나 불변 객체를 참조한다면 이 객체는 완벽히 우리가 원하는 상태라 더 손볼 곳이 없다
쓸데없는 복사를 지양한다는 관점에서 보면 불변 클래스는 굳이 clone 메서드를 제공하지 않는 게 좋다
/* 가변 상태를 참조하지 않는 클래스용 clone 메서드 */
@Override
public PhoneNumber clone() {
try {
return (PhoneNumber) super.clone();
} catch (CloneNotSupportedException e) {
throw new AssertionError(); // 일어날 수 없는 일이다.
}
}
재정의한 메서드의 반환 타입은 상위 클래스의 메서드가 반환하는 하위 타입일 수 있다
이 방식으로 클라이언트가 형변환 하지 않아도 되게끔 해주자
이를 위해 앞 코드에서는 super.clone에서 얻은 객체를 반환하기 전에 PhoneNumber로 형변환 하였다
따라서, 절대 실패하지 않는다
그럼에도 super.clone을 try-catch로 감싼 이유는
Object의 clone 메서드가 검사 예외인 CloneNotSupportedExceptoin을 던지도록 선언되어있기 때문이다
PhoneNumber가 Cloneable을 구현하니, 우리는 super.clone이 성공할 것임을 안다
이 거추장스러운 코드는 CloneNotSupportedExceptoin이 사실은 비검사 예외였어야 했다는 신호다
다음은 클래스가 가변 객체를 참조하는 순간 재앙으로 돌변하는 예를 살펴보자
public class Stack {
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;
}
// 원소를 위한 공간을 적어도 하나 이상 확보한다.
private void ensureCapacity() {
if (elements.length == size)
elements = java.util.Arrays.copyOf(elements, 2 * size + 1);
}
}
clone 메서드가 단순히 super.clone의 결과를 그대로 반환한다면 어떻게 될까?
반환된 Stack 인스턴스의 size 필드는 올바른 값을 갖겠지만
elements 필드는 원본 Stack 인스턴스와 똑같은 배열을 참조할 것이다
즉, 원본이나 본제본 중 하나를 수정하면 다른 하나도 수정되어 불변식을 해친다는 이야기다
Stack 클래스의 하나뿐인 생성자를 호출한다면 이러한 상황은 절대 일어나지 않는다
clone 메서드는 사실상 생성자와 같은 효과를 낸다
즉, clone은 원본 객체에 아무런 해를 끼치지 않는 동시에 복제된 객체의 불변식을 보장해야 한다
그래서 Stack의 clone 메서드를 제대로 동작하려면 스택 내부 정보를 복사해야 하는데
가장 쉬운 방법은 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.clone() 에서 Object[]로 형변환할 필요는 없다
배열의 clone은 런타임 타입과 컴파일타임 타입 모두가 원본 배열과 똑같은 배열을 반환한다
따라서 배열을 복제할 때는 배열의 clone 메서드를 사용하라고 권장한다
사실, 배열은 clone 기능을 제대로 사용하는 유일한 예라 할 수 있다
한편, elements 필드가 final이었다면 앞서의 방식은 작동하지 않는다
final 필드에는 새로운 값을 할당할 수 없기 때문이다
이는 근본적인 문제로, 직렬화와 마찬가지로 Cloneable 아키텍처는 '가변 객체를 참조하는 필드는 final로 선언하라'는 일반 용법과 충돌한다
그래서 복제할 수 있는 클래스를 만들기 위해 일부 필드에서 final 한정자를 제거해야 할 수도 있다
- clone을 재귀적으로 호출하는 것만으로는 충분하지 않을 때도 있다
clone을 재귀적으로 호출하는 것만으로는 충분하지 않을 때도 있다
이번에는 해시테이블용 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을 재귀적으로 호출해보자
/* 잘못된 clone 메서드 - 가변 상태를 공유한다! */
@Override
public HashTable clone() {
try {
HashTable result = (HashTable) super.clone();
result.buckets = buckets.clone();
return result;
} catch (CloneNotSupportedException e) {
throw new AssertionError();
}
}
복제본은 자신만의 버킷 배열을 갖지만, 이 배열은 원본과 같은 연결 리스트를 참조하여,
원본과 복제본 모두 예기치 않게 동작할 가능성이 생긴다
이를 해결하려면 각 버킷을 구성하는 연결 리스트를 복사해야 한다
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();
}
}
}
private 클래스인 HashTable.Entry는 깊은복사(deep copy)를 지원하도록 보강 되었다
HashTable의 clone 메서드는 먼저 적절한 크기의 새로운 버킷 배열을 할당한 다음
원래의 버킷 배열을 순회하며 비지 않은 각 버킷에 대해 깊은 복사를 수행한다
이 때 Entry의 deepCopy 메서드는 자신이 가리키는 연결 리스트 전체를 복사하기 위해 자신을 재귀적으로 호출한다
하지만 연결 리스트를 복제하는 방법으로는 그다지 좋지 않다
재귀 호출 때문에 리스트의 원소 수만큼 스택 프레임을 소비하여,
리스트가 길면 스택 오버플로를 일으킬 위험이 있기 때문이다
이 문제를 피하려면 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;
}
이제 복잡한 가변 객체를 복제하는 마지막 방법을 살펴보겠다
생성자에서는 재정의될 수 있는 메서드를 호출하지 않아야 하는데 clone 메서드도 마찬가지다
만약 clone이 하위 클래스에서 재정의한 메서드를 호출하면,
하위 클래스는 복제 과정에서 자신의 상태를 교정할 기회를 잃게 되어 원본가 복제본의 상태가 달라질 가능성이 크다
Object의 clone 메서드는 CloneNotSupportedException을 던진다고 선언했지만 재정의한 메서드는 그렇지 않다
public인 clone 메서드에서는 throws 절을 없애야 한다
검사 예외를 던지지 않아야 그 메서드를 사용하기 편하기 때문이다
상속해서 쓰기 위한 클래스 설계 방식 두 가지 중 어느 쪽에서든, 상속용 클래스는 Cloneable을 구현해서는 안 된다
우리는 Object의 방식을 모방할 수도 있다
제대로 작동하는 clone 메서드를 구현해 protected로 두고 CloneNotSupportedException도 던질 수 있다고 선언하는 것이다
이 방식은 마치 Object를 바로 상속할 때처럼 Cloneable 구현 여부를 하위 클래스에서 선택하도록 해준다
다른 방법으로는, clone을 동작하지 않게 구현해놓고 하위 클래스에서 재정의하지 못하게 할 수도 있다
다음과 같이 clone을 퇴화시켜 놓으면 된다
@Override
protected final Object clone() throws CloneNotSupportedException {
return new CloneNotSupportedException();
}
기억해둬야 할 게 하나 더 남았다
Cloneable을 구현한 스레드 안전 클래스를 작성할 때는 clone 메서드 역시 적절히 동기화해줘야 한다
Object의 clone메서드는 동기화를 신경 쓰지 않는다
그러니 super.clone 호출 외에 다른 할 일이 없더라도 clone을 재정의하고 동기화해줘야 한다
Cloneable을 이미 구현한 클래스를 확장한다면 어쩔 수 없이 clone을 잘 작동하도록 구현해야 한다
그렇지 않은 상황에서는 복사 생성자와 복사 팩터리라는 더 나은 객체 복사 방식을 제공할 수 있다
복사 생성자란 단순히 자신과 같은 클래스의 인스턴스를 인수로 받는 생성자를 말한다
/* 복사 생성자 */
public Yum(Yum yum) {
...
}
/* 복사 팩터리 */
public static Yum newInstance(Yum yum) {
...
return new Yum(yum);
}
복사 생성자와 그 변형인 복사 팩터리는 Cloneable/clone 방식보다 나은 면이 많다
언어 모순적이고 위험천만한 객체 생성 메커니즘(생성자를 쓰지 않는 방식)을 사용하지 않으며,
엉성하게 문서화된 규약에 기대지 않고,
정상적인 final 필드 용법과도 충돌하지 않으며,
불필요한 검사 예외를 던지지 않고,
형변환도 필요치 않다
또한, 복사 생성자와 복사 팩터리는 해당 클래스가 구현한 '인터페이스' 타입의 인스턴스를 인수로 받을 수 있다
예컨대 관례상 모든 범용 컬렉션 구현체는 Collection이나 Map 타입을 받는 생성자를 제공한다
인터페이스 기반 복사 생성자와 복사 팩터리의 더 정확한 이름은
변환 생성자(Conversion Constructor)와 변환 팩터리(Conversion Factory) 이다
이들을 이용하면 클라이언트는 원본의 구현 타입에 얽매이지 않고 복제본의 타입을 직접 선택할 수 있다
예를 들어 HashSet 객체 s를 TreeSet 타입으로 복제할 수 있다
clone으로는 불가능한 이 기능을 변환 생성자로는 간단히 new TreeSet<>(s)로 처리할 수 있다
- 핵심 정리
Cloneable이 몰고 온 모든 문제를 되짚어 봤을 때,
새로운 인터페이스를 만들 때는 절대 Cloneable을 확장해서는 안 되며,
새로운 클래스도 이를 구현해서는 안 된다
final 클래스라면 Cloneable을 구현해도 위험이 크지 않지만,
성능 최적화 관점에서 검토한 후 별다른 문제가 없을 때만 드물게 허용해야 한다
기본 원칙은 '복제 기능은 생성자와 팩터리를 이용하는 최고'라는 것이다
단, 배열만은 clone 메서드 방식이 가장 깔끔한 이 규칙의 예외라 할 수 있다
5. Comparable을 구현할지 고려하라
compareTo는 이번 장에서 다룬 다른 메서드들과 달리 Object의 메서드가 아니다
성격은 두 가지만 빼면 Object의 equals와 같다
- 그렇다면 무엇이 다를까?
compareTo는 단순 동치성 비교에 더해 순서까지 비교할 수 있으며, 제네릭하다
Comparable을 구현했다는 것은 그 클래스의 인스턴스들에는 자연적인 순서가 있음을 뜻한다
그래서 comparable을 구현한 객체들의 배열은 다음처럼 손쉽게 정렬할 수 있다
Arrays.sort(a);
사실상 자바 플랫폼 라이브러리의 모든 값 클래스와 열거 타입이 Comparable을 구현했다
알파벳, 숫자, 연대 같이 순서가 명확한 값 클래스를 작성한다면 반드시 Comparable 인터페이스를 구현하자
public interface Comparable<T> {
int compareTo(T t);
}
comparaTo 메서드의 일반 규약은 equals의 규약과 비슷하다
이 객체와 주어진 객체의 순서를 비교한다
이 객체가 주어진 객체보다 작으면 음의 정수를, 같으면 0을, 크면 양의 정수를 반환한다
이 객체와 비교할 수 없는 타입의 객체가 주어지면 ClassCastException을 던진다
다음 설명에서 sgn(표현식) 표기는 수학에서 말하는 부호 함수(signum function)를 뜻하며, 표현식의 값이 음수, 0, 양수 일 때 -1, 0, 1을 반환하도록 정의했다
- Comparable을 구현한 클래스는 모든 x, y에 대해 sgn(x.compareTo(y)) == -sgn(y.compareTo(x)) 여야 한다
(따라서 x.compareTo(y)는 y.compareTo(x)가 예외를 던질 때에 한해 예외를 던져야 한다) - Comparable을 구현한 클래스는 추이성을 보장해야 한다
즉, (x.compareTo(y) > 0 && y.compareTo(x) > 0) 이면 x.compareTo(z) > 0 이다 - Comparable을 구현한 클래스는 모든 z에 대해 x.compareTo(y) == 0 이면 sgn(x.compareTo(z)) == sgn(y.compareTo(z)) 다
- 이번 권고가 필수는 아니지만 꼭 지키는 게 좋다(x.compareTo(y) == 0) == (x.equals(y)) 여야 한다Comparable을 구현하고 이 권고를 지키지 않는 모든 클래스는 그 사실을 명시해야 한다
다음과 같이 명시하면 적당할 것이다
"주의: 이 클래스의 순서는 equals 메서드와 일관되지 않다."
compareTo 메서드 작성 요령은 equals와 비슷하다
몇 가지 차이점만 주의하면 된다
Comparable은 타입을 인수로 받는 제네릭 인터페이스이므로 compareTo 메서드의 인수 타입은 컴파일 타임에 정해진다
입력 인수의 타입을 확인하거나 형변환할 필요가 없다는 뜻이다
compareTo 메서드는 각 필드가 동치인지를 비교하는 게 아니라 그 순서를 비교한다
Comparable을 구현하지 않은 필드나 표준이 아닌 순서로 비교해야 한다면 비교자( Comparator)를 대신 사용한다
비교자는 직접 만들거나 Java가 제공하는 것 중 골라 쓰면 된다
- 클래스에 핵심 필드가 여러 개 일 경우 필드 compareTo() 구현 방법
클래스에 핵심 필드가 여러 개 일 경우 가장 핵심적인 필드부터 비교한다
@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 0;
}
이 방식의 간결함에 매혹되지만, 약간의 성능 저하가 뒤따른다
자바의 정적 임포트 기능을 이용하면,
정적 비교자 생성 메서드들을 그 이름만으로 사용할 수 있어 코드가 훨씬 깔끔해진다
/* 비교자 생성 메서드를 활요한 비교자 */
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);
}
이따금 '값의 차'를 기준으로
첫 번째 값이 두 번째 값보다 작으면 음수를, 두 값이 같으면 0을, 첫 번째 값이 크면 양수를 반환하는 compareTo나 compare 메서드와 마주할 것이다
/* 해시코드 값의 차를 기준으로 하는 비교자 - 추이성을 위배한다! */
static Comparator<Object> hashCodeOrder = new Comparator<>() {
public int compare(Object o1, Object o2) {
return o1.hashCode() - o2.hashCode();
}
};
이 방식은 사용하면 안 된다!
이 방식은 정수 오버플로를 일으키거나 IEEE 754 부동소수점 계산 방식에 따른 오류를 낼 수 있다
아래 두 방법 중 하나를 사용하자
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());
- 핵심 정리
순서를 고려해야 하는 값 클래스를 작성한다면 꼭 Comparable 인터페이스를 구현하여,
그 인스턴스들을 쉽게 정렬하고, 검색하고, 비교 기능을 제공하는 컬렉션과 어우러지도록 해야 한다
compareTo 메서드에서 필드의 값을 비교할 때 '<' 와 '>' 연산자는 쓰지 말아야 한다
그 대신 박싱된 기본 타입 클래스가 제공하는 정적 compare 메서드나 Comparator 인터페이스가 제공하는 비교자 생성 메서드를 사용하자
마무리 느낀점
연관관계 설정 후 순환참조로 무한 호출되는 경우나
가끔 eqauls, compareTo 정도를 제외하고는 재정의에 대해 크게 신경 써본적이 별로 없는 것 같다
아직도 공부할게 너무 많다...
자주 사용하게 될 내용들인 것 같아서 더 열심히 정리했다 까먹지 말자!
'개발 서적 > 이펙티브 자바' 카테고리의 다른 글
이펙티브 자바 3/E - 5.제네릭(1) (0) | 2023.07.14 |
---|---|
이펙티브 자바 3/E - 4.클래스와 인터페이스(2) (3) | 2023.06.18 |
이펙티브 자바 3/E - 4.클래스와 인터페이스(1) (1) | 2023.06.10 |
이펙티브 자바 3/E - 2.객체 생성과 파괴 (0) | 2023.04.30 |
이펙티브 자바 3/E - 1.들어가기 (0) | 2023.04.19 |