ch3 모든 객체의 공통 메소드 - LenKIM/everyone-is-effective-java-study GitHub Wiki
3장 모든 객체의 공통 메서드
Object는 객체를 만들 수 있는 구체 클래스지만 기본적으로는 상속해서 사용하도록 설계되었다. Object에서 final
이 아닌 메서드 (equals, hashCode, toString, clone, finalize)는 모두 재정의 overriding
을 염두에 두고 설계되었다.
일반 규약에 맞게 재정의 해야 규약을 준수한다고 가정하는 클래스들(HashMap, HashSet등)이 오작동을 하지 않는다.
이번 장에서는 Object
메서드들을 언제 어떻게 재정의해야 하는지를 다룬다.
Comparable.compareTo
의 Object의 메서드는 아니지만 성격이 비슷하여 함께 다룬다.
아이템 10. equals는 일반 규약을 지켜 재정의하라
equals 메서드는 재정의하기 쉬워 보이지만 함정이 도사리고 있다.
가장 베스트 플랙티스는 equals를 선언하지 않는 것이다.
그냥 두면 그 클래스의 인스턴스는 오직 자기 자신과만 같게 한다.
그러므로, 다음 상황중 하나에 해당하면 재정의 안하도록 해야 한다.
- 각 인스턴스가 본질적으로 고유하다. 값을 표현하는 게 아니라, 동작하는 개체를 표현하는 클래스가 여기에 해당(Thread)
- 인스턴스의
논리적 동치성(logical equality)
을 검사할 일이 없다. - 상위 클래스에서 재정의한
equals
가 하위 클래스에도 딱 들어맞는다. - 클래스가
private
이거나package-private
이고equals
메서드를 호출할 일이 없다.
equals가 실수로라도 호출되는걸 막고싶다면
@Override public boolean equals(Object o){
throw new AssertionError(); //호출금지!
}
그렇다면 재정의 해야 할때?
객체 식별성(Object identity; 물리적으로 같은가)이 아니라 논리적 동치성을 확인해야 하는데, 상위 클래스의 equals가 논리적 동치성을 비교하도록 재정되지 않았을 때.
주로 값 클래스가 해당된다.
값 클래스들(Integer, String처럼 값을 표현하는 클래스) 값이 같은지를 알고싶어 할테니까!
그러나, 값 클래스여도 값이 같은 인스턴스가 둘 이상 만들어지지 않음을 보장하는 인스턴스 통제클래스라면 재정의 안해도 된다.
ex) Enum 논리적 동치성 = 객체 식별성
Object 명세에 적힌 규약
equals
메서드는 동치관계(equivalence relation)를 구현하며 다음을 만족한다.
- 반사성: 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)를 반복해서 호출하면 항상 같은 결과를 반환한다.
- null 아님: null이 아닌 모든 참조 값 x에 대해 x.equals(null)은 false다.
동치관계란?
집합을 서로 같은 원소들로 이루어진 부분집합으로 나누는 연산이다.
equals메소드가 쓸모있으려면 모든 원소가 같은 동치류에 속한 어떤 원소와도 서로 교환할 수 있어야 한다.
대칭성 위배되는 코드
// 대칭성 위배!
@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;
}
// 문제 시연 (55쪽)
public static void main(String[] args) {
CaseInsensitiveString cis = new CaseInsensitiveString("Polish");
String s = "polish";
List<CaseInsensitiveString> list = new ArrayList<>();
list.add(cis);
System.out.println(list.contains(s));
}
자칫하면, 여기서 list.contains(s) 를 호출하는 부분에서 false가 나올 수 있다. JDK에 달려있음.
그러므로, 이를 잘 풀어내기 위해서는?
// 수정한 equals 메서드 (56쪽)
@Override public boolean equals(Object o) {
return o instanceof CaseInsensitiveString &&
((CaseInsensitiveString) o).s.equalsIgnoreCase(s);
}
이런 식으로 equals를 해야 한다.
추이성이 위배되는 코드
// 코드 10-3 잘못된 코드 - 추이성 위배! (57쪽)
@Override public boolean equals(Object o) {
if (!(o instanceof Point))
return false;
// o가 일반 Point면 색상을 무시하고 비교한다.
if (!(o instanceof ColorPoint))
return o.equals(this);
// o가 ColorPoint면 색상까지 비교한다.
return super.equals(o) && ((ColorPoint) o).color == color;
}
// 두 번째 equals 메서드(코드 10-3)는 추이성을 위배한다. (57쪽)
ColorPoint p1 = new ColorPoint(1, 2, Color.RED);
Point p2 = new Point(1, 2);
ColorPoint p3 = new ColorPoint(1, 2, Color.BLUE);
System.out.printf("%s %s %s%n",
p1.equals(p2), p2.equals(p3), p1.equals(p3));
결과물은 false, true, false
양질의 equals 메서드 구현 방법 정리
- == 연산자를 사용해 입력이 자기 자신의 참조인지 확인한다.
- instanceof 연산자로 입력이 올바른 타입인지 확인한다.
- 입력을 올바른 타입으로 형변환한다.
- 입력 객체와 자기 자신의 대응되는 ‘핵심’필드들이 모두 일치하는지 하나씩 검사한다.
추가적으로, float와 double을 제외한 기본 타입 필드는 == 연산자로 비교하고, 참조 타입필드는 각각의 equals메서드로, float와 double 필드는 각각 정적 메서드인 Float.compare(float, float)와 Double.compare(double, double)로 비교
때론 null도 정상 값으로 취급하는 참조타입 필드의 경우에는 Objects.equasl(object, object)로 비교해 NullPointerException 발생 예방
어떤 필드를 먼저 비교하느냐가 equals의 성능을 좌우하기도 한다.
equals를 다 구현했다면 세 가지만 자문해보자. 대칭적인가? 추이성이 있는가? 일관적인가?
자문에서 끝내지 말고 단위 테스트를 작성해 돌려보자. (AutoValue 프레임워크 이용 추천)
https://github.com/google/auto/blob/master/value/userguide/index.md
https://www.baeldung.com/introduction-to-autovalue
마지막 주의사항
-
equals를 재정의할 땐 hashCode도 반드시 재정의하자.
-
너무 복잡하게 해결하려 들지 말자.(ex, 별칭은 사용하지 말자, 파일의 심볼릭 링크와 같은 것)
-
Object 외의 타입을 매개변수로 받는 equals 메서드는 선언하지 말자.
// 잘못된 예 - 입력 타입은 반드시 Object 여야 한다! // 이 메서드는 Object.equals를 재정의 한게 아니라 다중정의한 것이다. // 기본 equals를 그대로 둔 채 추가한 것일지라도, 타입을 구체적으로 명시한 equals는 오히려 해가 된다. public boolean equals(MyClass o){ ... }
아이템 11. equals를 재정의하려거든 hashCode도 재정의하라
equals를 재정의한 클래스 모두에서 hashCode도 재정의해야 한다.
즉, 논리적으로 같은 객체는 같은 해시코드를 반환해야 한다.
Object 명세에서 발췌한 규약
- equlas 비교에 사용되는 정보가 변경되지 않았다면, 애플리케이션이 실행되는 동안 한 객체에 대한 hashCode 메서드는 몇번을 호출해도 일관되게 항상 같은 값을 반환해야 한다.
- equals(Object)가 두 객체를 같다고 판단했다면 두 객체의 hashCode는 똑같은 값을 반환해야 한다.
- 두 객체가 다르다고 판단했더라도, 서로 다른 hashCode를 반환할 필요는 없다. but, 좋은 해시 함수는 서로 다른 인스턴스에 다른 해시코드를 반환한다. 그게 성능도 더 좋다.
hashCode
를 잘못 재정의 했을 때 크게 문제가 되는 조항은 위에 두번째 조항. (equals(Object)가 두 객체를 같다고 판단했다면 두 객체의 hashCode는 똑같은 값을 반환해야 한다.)
예제코드
Map<PhoneNumber, String> m = new HashMap<>();
m.put(new PhoneNumber(707, 867, 5309), "제니");
System.out.println(m.get(new PhoneNumber(707, 867, 5309)));
결과는 null 나온다. null이 반환되는 이유는 무엇일까?
바로, 논리적 동치성이 맞지 않기 때문!
그렇다면 올바른 hashcode 메서드란 어떤 모습일까?
좋은 hashCode를 작성하는 간단한 요령
-
int 변수 result를 선언한 후 값 c로 초기화한다.
-
해당 객체의 나머지 핵심 필드 f 각각에 대해 다음 작업을 수행한다.
a. 해당 필드의 해시코드(Type.hashcode(f)) c를 계산한다. 배열이라면 Arrays.hashcode 를 사용
b. 위에서 계산한 해시코드 c로 result를 갱신한다. result = 31* result + c;
-
result를 반환한다.
// 코드 11-2 전형적인 hashCode 메서드 (70쪽)
@Override public int hashCode() {
int result = Short.hashCode(areaCode);
result = 31 * result + Short.hashCode(prefix);
result = 31 * result + Short.hashCode(lineNum);
return result;
}
-
다 구현했다면 이 메서드가 동치인 인스턴스에 대해 똑같은 해시 코드를 반환할지 자문하고 테스트를 해보자.(AutoValue 로 구현했다면 Pass)
-
파생필드는 해시코드 계산에서 제외OK. equals 비교에 사용되지 않은 필드는 반드시 제외해야 한다.
-
클래스가 불변이고 해시코드를 계산하는 비용이 크다면 캐싱하는 방식을 고려해야 한다.
-
해시의 키로 사용되지 않는 경우라면 hashCode가 처음 불릴 때 계산하는 지연 초기화 전략도 있다.(스레드 안전성 고려)
// 해시코드를 지연 초기화하는 hashCode 메서드 - 스레드 안정성까지 고려해야 한다. (71쪽) private int hashCode; // 자동으로 0으로 초기화된다. @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; }
-
성능을 높인답시고 해시코드를 계산할 때 핵심 필드를 생략해서는 안된다.
-
hashCode가 반환하는 값의 생성 규칙을 API사용자에게 자세히 공표하지 말자. 그래야 클라이언트가 이 값에 의지하지 않게 되고 추후에 계산 방식을 바꿀 수도 있다.
아이템 12. toString을 항상 재정의하라
기본 toString메서드는 단순히 클래스_이름@16진수로_표현한_해시코드 를 반환한다.
간결하면서 사람이 읽기 쉬운 형태의 유익한 정보 를 반환한다.
toString의 규약은, 모든 하위 클래스에서 이 메서드를 재정의하라고 한다.
why?
toString을 잘 구현한 클래스는 사용하기에 훨씬 즐겁고, 그 클래스를 사용한 시스템은 디버깅하기 쉬워진다.
toString메서드는 객체를 println, printf, 문자열 연결 연산자(+), assert 구문에 넘길 때, 혹은 디버거가 객체를 출력할 때 자동으로 불린다. (어딘가에서 쓰인다!)
// PhoneNumber용 toString을 제대로 재정의했다면 이 코드만으로 충분하다.
System.out.println(phoneNumber + "에 연결할 수 없습니다.");
실전에서 toString은 그 객체가 가진 주요 정보 모두를 반환하는게 좋다.
-
toString의 반환값의 포맷을 문서화할지 정해야 한다.
-
포맷을 명시하기로 했다면, 명시한 포맷에 맞는 문자열과 객체를 상호 전환할 수 있는 정적 팩터리나 생성자를 함께 제공해주면 좋다.(ex 값 클래스 BigInteger, BigDecimal와 대부분의 기본 타입 클래스)
-
단점으로, 포맷을 한번 명시하면 평생 그 포맷에 얽매이게 된다.
포맷을 명시하든 아니든 의도는 명확히 밝혀야한다.
-
포맷 명시 여부와 상관없이 toString이 반환한 값에 포함된 정보를 얻어올 수 있는 API를 제공하자.
-
접근자를 제공하지 않으면 변경될 수 있다고 문서화했더라도 그 포맷이 사실상 준-표준 API나 다름없어진다.
정적 유틸리티 클래스(아이템4)는 toString을 제공할 이유가 없다. 대부분의 열거타입(아이템34)도 이미 완벽한 toString이 제공되니 재정의 하지 않아도 된다.
하지만 하위 클래스들이 공유해야 할 문자열 표현이있는 추상클래스라면 재정의해줘야 한다. (컬렉션 구현체는 추상 컬렉션 클래스들의 toString메서드를 상속해 쓴다.)
AutoValue 프레임 워크는 toString도 생성해준다. 아무것도 알려주지 않는 Object의 toString보다 훨씬 유용하다.
아이템13. clone 재정의는 주의해서 진행하라
Cloneable은 복제해도 되는 클래스임을 명시하는 용도의 믹스인 인터페이스(mixin interface)지만, 아쉽게도 의도한 목적을 제대로 이루지 못했다. 가장 큰 문제는 clone 메서드가 선언된 곳이 Cloneable이 아닌 Object이고, 그마저도 protected라는 데 있다.
그럼, 메서드 하나 없는 Clonable 인터페이스는 무얼하나?
= Object의 protected 메서드인 clone의 동작 방식을 결정한다. Cloneable을 구현한 클래스의 인스턴스에서 clone을 호출하면 그 객체의 필드들을 하나하나 복사한 객체를 반환하며, 그렇지 않은 클래스의 인스턴스에서 호출하면 CloneNotSupportedException 을 던진다.
실무에서 Cloneable을 구현한 클래스는 clone 메서드를 public으로 제공하며, 사용자는 당연히 복제가 제대로 이뤄지리라 기대한다.
이 기대를 만족시키려면 그 클래스와 모든 상위 클래스는 복잡하고, 강제할 수 없고, 허술하게 기술된 프로토콜을 지켜야만 하는데, 그 결과로 깨지기 쉽고, 위엄하고, 모순적인 메커니즘이 탄생한다. 생성자를 호출하지 않고도 객체를 생헝할 수 있게 되는 것이다. clone 메서드의 일반 규약은 허술하다. 다음 설명을 보자
객체의 복사본을 생성해 반환한다. '복사'의 정확한 뜻은 그 객체를 구현한 클래스에 따라 다를 수 있다. 일반적인 의도는 다음과 같다. 어떤 객체 x에 대해 다음 식은 참이다.
x.clone() != x
또한 다음 식도 참이다.
x.clone().getClass() == x.getClass()
하지만 이상의 요구를 반드시 만족해야 하는 것은 아니다.
한편 다음 식도 일반적으로 참이지만, 역시 필수는 아니다.
x.clone().equals(x)
Clone 메서드는 사실상 생성자와 같은 효과를 낸다. 즉, clone은 원본 객체에 아무런 해를 끼치지 않는 동시에 복제된 객체의 불변식을 보장해야 한다.
요약하자면..
Cloneable을 구현하는 모든 클래스는 clone을 재정의하고 동기화해야한다. -> 객체의 내부 '깊은 구조'에 숨어 있는 모든 가변 객체를 복사하고, 복제본이 가진 객체 참조 모두가 복사된 객체들을 가리키게 한다.
how? 주로 clone을 재귀적으로 호출해 구현하지만 항상 최선은 아니다.
package chapter3.item13;
import java.util.Arrays;
// Stack의 복제 가능 버전 (80-81쪽)
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;
}
// 코드 13-2 가변 상태를 참조하는 클래스용 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);
}
// clone이 동작하는 모습을 보려면 명령줄 인수를 몇 개 덧붙여서 호출해야 한다.
public static void main(String[] args) {
Stack stack = new Stack();
for (String arg : args)
stack.push(arg);
Stack copy = stack.clone();
while (!stack.isEmpty())
System.out.print(stack.pop() + " ");
System.out.println();
while (!copy.isEmpty())
System.out.print(copy.pop() + " ");
}
}
위 코드에서 문제는 Stack을 clone하게 될 때, 원본에 있는 생성자에 생성된 배열의 크기와 복제본 중 하나를 수정하면 다른 하나도 수정되어 불변식을 해친다는 말.
기본 원칙은 '복제 기능은 복사 생성자와 복사 팩터리를 이용하는게 최고' 라는 것이다. 여기서 복사 생성자란 단순히 자신과 같은 클래스의 인스턴스를 인수로 받는 생성자를 말한다.
//복사 생성자
public Yum(Yum yum) { ... }
//복사 팩토리
public static Yum newInstance(Yum yum) { ... }
결론. Cloneable이 몰고 온 모든 문제를 되짚어봤을 때, 새로운 인터페이스를 만들 때는 절대 Cloneable을 확장해서는 안 되며, 새로운 클래스도 이를 구현해서는 안 된다. final 클래스라면 Cloneablel을 구현해도 위험이 크지 않지만, 성능 최적화 관점에서 검토한 후 별다른 문제가 없을 때만 드물게 허용해야 한다. 기본 원칙은 '복제 기능은 생성자와 팩토리를 이용하는 게 최고' 라는 것이다. 단, 기본 배열만은 clone메서드 방식이 가장 깔끔한, 규칙의 합당한 예외이다.
아이템14. Comparable을 구현할지 고려하라
Comparable 인터페이스의 유일무이한 메서드인 compareTo
를 알아보자.
Object.equals와 다른점 ?
compareTo
는 단순 동치성 비교에 더해 순서까지 비교할 수 있으며, 제네릭하다. 그 클래스의 인스턴스들에는 자연적인 순서가 있음을 뜻한다.
Arrays.sort(a); // comparable을 구현한 객체들의 배열 정렬
순서가 명확한 값 클래스
를 작성한다면 반드시 Comparable 인터페이스를 구현하자.
Comparable 을 구현하여 이 인터페이스를 활용하는 수많은 제네릭 알고리즘과 컬렉션의 힘을 누릴 수 있다. 즉, 좁쌀만 한 노력으로 코끼리만 한 큰 효과를 얻는 것.
-
자바 플랫폼 라이브러리의 모든 값 클래스와 열거타입이 Comparable 을 구현, 그러므로 알파벳, 숫자, 연대 같이 순서가 명확한 값 클래스를 작성한다면 반드시 Comparable 인터페이스를 구현하자.
-
compareTo 메서드 작성 요령은 equals와 비슷하다.
-
관계 연산자 < >를 사용하는 이전 방식은 거추장스럽고 오류를 유발한다. 그러므로, 박싱된 기본 타입 클래스에 새로 추가된 정적 메서드인
comapre
을 이용하면 된다. (Integer.compare(a,b)
) -
Comparable은 타입을 인수로 받는 제네릭 인터페이스이므로 compareTo 메서드의 인수 타입은 컴파일타임에 정해진다.(확인하거나 형변환 필요하지 않다.)
Comparable은 타입을 인수로 받는 제네릭 인터페이스이므로 compareTo 메서드의 인수 타입은 컴파일타임에 정해진다.(확인하거나 형변환 필요X)
-
compareTo 메서드는 필드가 동치인지 비교하는게 아니라 그 순서를 비교한다.
-
핵심 필드가 여러개라면 어느것을 먼저 비교하느냐가 중요해진다. 가장 핵심적인 필드부터 비교하자. Comparator는 수많은 보조 생성 메서드들로 중무장하고 있다. 자바의 숫자용 기본 타입을 모두 커버한다.
그러나 이런 방식은 약간의 성능 저하가 뒤따른다.public static final Comparator<PhoneNumber> COMPARATOR = comparingInt((PhoneNumber pn) -> pn.areaCode) .thenComparingInt(pn -> pn.prefix) .thenComparingInt(pn -> pn.lineNum); public int compareTo(PhoneNumber pn){ return COMPARATOR.compare(this, pn); }