3장 - 모든 객체의 공통 메서드
- Created
- 2022년 9월 3일 PM 8:38
- 모든 객체는 최상위의 Object를 상속해서 사용하도록 설계되어 있다.
- Object의
final이 아닌 메서드는 모두 재사용을 염두해 두고 설계된 것이다.equals,hashCode,toString,clone,finalize
- 해당 장은 해당 메서드들을 언제 어떻게 재정의 해야하는지 알아보는 장이다.
10. equals는 일반 규약을 지켜 재정의하라#
equals 메서드는 재정의하기 쉬워보이지만 곳곳에 함정이 도사리고 있어 끔찍한 결과를 초래하기도 한다.
아래 열겨한 상황에 하나라도 해당한다면 재정의 하지 말아라.#
- 각 인스턴스가 본질적으로 고유할 때
- 값을 표현하는게 아닌 동작하는 객체를 표현하는 클래스
- 인스턴스의 논리적 동치성(logical equality)을 검사할일이 없을 때
java.util.regax.Pattern은equals를 재정의해 두Pattern의 정규표현식을 비교아니 이게 무슨말이람…
- 상위 클래스에서 재정의한 equals가 하위 클래스에도 딱 들어맞을 때
Set은AbstractSet이 구현한equals를 상속,List는AbstractList,Map은AbstractMap
- 클래스가 private/package-private이고 equals를 호출할 일이 없을 때
equals호출을 방지하고싶다면 오버라이드 해서throw하도록 하자
java@Override public boolean equals(Object object) { throw new AssertionError(); }
해당 규약을 어길 경우 프로그램이 이상하게 작동할 수 있다.
Object 명세에 말하는 동치관계란?#
- 쉽게 말해, 집합을 서로 같은 원소들로 이뤄진 부분집합으로 나누는 연산이다.
- 이 부분집합을 동치류(동치 클래스)라 한다.
- equals 메서드가 쓸모 있으려면 모든 원소가 같은 동치류에 속한 어떤 원소와도 서로 교환할 수 있어야 한다.
동치관계를 만족시키기 위한 5가지 조건#
반사성#
객체는 자기 자신과 같아야한다.
java
@Override
public boolean equals(final Object obj) {
if (this == obj) {
return true; // 레퍼런스 주소가 같으면 자기 자신과 같다.
}
return false;
}대칭성#
두 객체는 서로에 대한 동치 여부에 똑같이 답해야 한다.
- 난 이 대칭성을 자신이 모르는 클래스를 비교하면 안된다는 규약으로 이해했음
java
public class CaseInsensitiveString {
private final String s;
public CaseInsensitiveString(final String s) {
this.s = Objects.requireNonNull(s);
}
@Override
public boolean equals(final Object obj) {
if(obj instanceof CaseInsensitiveString){
return s.equalsIgnoreCase(((CaseInsensitiveString) obj).s);
}
if(obj instanceof String){ // 한 방향으로만 작동
return s.equalsIgnoreCase((String) obj);
}
return false;
}
...
}- 비교 대상(String)의 equals를 호출했을때는 false를 받을것임
- 때문에 동일한 객체로 비교한다고 해도 동치여부가 다름
추이성#
첫 번째 객체와 두 번째 객체가 같고, 두 번째 객체와 세 번째 객체가 같아면, 첫 번째 객체와 세 번째 객체도 같아야 한다.
- 구체 클래스를 확장해 새로운 값을 추가하면서 equals 규약을 만족시킬 방법은 존재하지 않는다.
추이성을 어길 시 발생할 수 있는 문제
- 대칭성에 위배할 수 있다.java
@Override public boolean equals(Object o) { if(!o instanceof ColorPoint) return false; return super.equals(o) && ((ColorPoint) o).color == color; } - 리스코프 치환 원칙을 위배할 수 있다.java
@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; }- 리스코프 치환 원칙: 어떤 타입에 있어 중요한 속성이라면 그 하위 타입에서도 마찬가지로 중요하다.
- 무한루프가 발생할 수 있다.
추이성을 해결할 수 있는 방법
- 상속대신 컴포지션을 활용한다.
- 추상 클래스의 하위 클래스를 사용한다.
java
@Override
public boolean equals(final Object obj) {
if (this == obj) {
return true; // 레퍼런스 주소가 같으면 자기 자신과 같다.
}
if (!(o instanceof Point)) {
return false; // null 검사와 함께 타입 검사
}
Point p = (Point) o;
return p.x == x && p.y == y;
}일괄성#
두 객체가 같다면 (어느 하나 혹은 두 객체 모두가 수정되지 않는 한) 앞으로도 영원히 같아야 한다.
- 가변 객체의 경우 비교 시점에 따라 서로 다를 수도 혹은 같을 수도 있다.
- 불변 객체는 한번 다르면 끝까지 달라야 한다.
- 클래스가 불변이든 가변이든
equals의 판단에 신뢰할 수 없는 자원이 끼어들게 해서는 안 된다.equals는 항시 메모리에 존재하는 객체만을 사용한 결정적(deterministic) 계산만 수행해야 한다.
null 아님#
모든 객체가 null과 같지 않아야 한다.java
@Override
public boolean equals(Object o) {
if(!(o instanceof MyType)) { // 묵시적으로 NULL을 검사하자!
return false;
}
MyType myType = (MyType) o;
}양질의 equals 메서드 구현방법#
==연산자를 사용해 입력이 자기 자신의 참조인지 확인한다.
자기 자신이면true를 반환한다. 단순한 성능 최적화용으로 비교 작업이 복잡한 상황일 때 값어치를 한다.instanceof연산자로 입력이 올바른 타입인지 확인한다.
가끔 해당 클래스가 구현한 특정 인터페이스를 비교할 수도 있다.
이런 인터페이스를 구현한 클래스라면equals에서 (클래스가 아닌) 해당 인터페이스를 사용해야한다.- 입력을 올바른 타입으로 형변환 한다.
2번에서instanceof연산자로 입력이 올바른 타입인지 검사 했기 때문에 이 단계는 100% 성공한다. - 입력 객체와 자기 자신의 대응되는 핵심 필드들이 모두 일치하는지 하나씩 검사한다.
모두 일치해야true를 반환한다.
java
@Override
public boolean equals(Object o) {
// 1번
if (this == o) return true;
// 2번
if (!(o instanceof Class)) return false;
// 3번
Class c = (Class) c;
// 4번 원시타입(==), 레퍼런스타입(equals)
return this.integer == c.integer && this.String.equals(o.String);
}equals 구현 시 주의할 추가 사항#
- 기본 타입:
==연산자 비교 - 참조 타입:
equals메서드로 비교 - float, double 필드: 정적 메서드
Float.compare(float, float)와Double.compare(double, double)로 비교Float.equals(float)나Double.equals(double)은 오토 박싱을 수반해 성능상 좋지 않다. - 배열 필드: 원소 각각을 지침대로 비교한다. 모두가 핵심 필드라면
Arrays.equals()를 사용한다. - null 정상값 취급 방지:
Object.equals(object, object)로 비교하여NullPointException발생을 예방한다. - 비교하기 복잡한 필드를 가진 클래스: 필드의 표준형(canonical form)을 저장한 후 표준형끼리 비교
- 필드의 비교 순서는
equals성능을 좌우한다.: 다를 가능성이 크거나 비교하는 비용이 싼 필드부터 비교파생 필드가 객체 전체 상태를 대표하는 경우, 파생 필드부터 비교 equals를 재정의할 땐hashCode도 반드시 재정의하자- 너무 복잡하게 해결하려 들지 말자.
- Object 외의 타입을 매개변수로 받는
equals메서드는 선언하지 말자.
AutoValue 프레임워크#
구글에서 개발한 프레임워크로 equals나 hashCode를 자동으로 작성해주는 프레임워크
- IDE보다 퀄리티 높은 코드를 만들어준다.
결론#
- 꼭 필요한 경우가 아니면
equals를 재정의하지 말자. - 많은 경우에
Object의equals가 여러분이 원하는 비교를 정확히 수행해준다. - 재정의해야 할 때는 그 클래스의 핵심 필드 모두를 빠짐없이, 다섯 가지 규약을 확실히 지켜가며 비교해야 한다.
11. equals를 재정의하려거든 hashCode도 재정의하라#
equals를 재정의한 클래스는 hashCode도 재정의 해야 한다. 그렇지 않으면 인스턴스를 HashMap이나 HashSet 같은 컬렉션의 원소로 사용할 때 문제가 발생한다.
Object 명세에서의 hashCode 규약#
equals비교에 사용되는 정보가 변경되지 않았다면,hashCode도 변하면 안 된다.- 애플리케이션을 다시 실행한다면 이 값이 달라져도 상관 없음
equals가 두 객체가 같다고 판단했다면, 두 객체의hashCode는 똑같은 값을 반환한다.hashCode재정의 시 문제가 될 수 있는 규약
equals가 두 객체를 다르다고 판단했더라도,hashCode는 꼭 다를 필요는 없다.- 다른 객체에 대해서는 다른 값을 반환해야 해시테이블의 성능이 좋아진다.
같은 객체는 같은 해시 값을 반환해야 한다.#
java
Map<PhoneNumber, String> map = new HashMap<>();
map.put(new PhoneNumber(010, 1234, 5678), new Person("리치"));
map.get(new PhoneNumber(010, 1234, 5678)) // ??? 이 값이 제대로 나오기 위해서 hashCode가 필요하다.해당 코드에서 HashMap에 PhoneNumber객체를 키로 Person객체를 Value로서 사용하기로 했다.
java
@Override
public int hashCode() {
return 42;
}작동은 하겠지만 해시충돌 때문에 내부적으로 LinkedList로 사용되기 때문에 O(1)로 사용할 수 있는 HashMap을 O(n)으로 이용하게 하는 기적의 코딩을 할 수 있다.
좋은 해시함수란? 서로다른 인스턴스에 다른 해시코드를 반환한다. 이상적인 해시함수는 주어진 인스턴스들을 32비트 정수 범위에 균일하게 분배해야한다.
hashCode를 작성하는 간단한 요령#
- int 변수인
result를 선언한 후 값을 c로 초기화한다.- 이 때, c는 해당 객체의 첫번째 핵심 필드를 단계 2.1 방식으로 계산한 해시코드이다.
- 여기서 핵심 필드는
equals비교에 사용되는 필드를 말한다.
- 해당 객체의 나머지 핵심 필드인 f 각각에 대해 다음 작업을 수행한다.
- 해당 필드의 해시코드 c 를 계산한다.
- 기본 타입 필드라면,
Type.hashCode(f)를 수행한다. 여기서Type은 해당 기본타입의 박싱 클래스다. - 참조 타입 필드면서, 이 클래스의
equals메소드가 이 필드의equals를 재귀적으로 호출하여 비교한다면, 이 필드의hashCode를 재귀적으로 호출한다. - 필드가 배열이라면, 핵심 원소 각각을 별도 필드처럼 다룬다.모든 원소가 핵심 원소라면
Arrays.hashCode를 사용한다.
- 단계 2.1에서 계산한 해시코드 c로
result를 갱신한다.result= 31 *result+ c;
result를 반환한다.
java
@Override
public int hashCode() {
int result = Integer.hashCode(areaCode);
result = 31 * result + Integer.hashCode(prefix);
result = 31 * result + Integer.hashCode(lineNum);
return result;
}equals비교에 사용되는 필드에 대해서만 해시코드를 계산한다.31 * result는 필드를 곱하는 순서에 따라result값이 달라지게 만들어준다. 그 결과 클래스에 비슷한 필드가 여러 개일 때 해시 효과를 크게 높혀준다.
한줄로 hashCode 만들기#
java
@Override
public int hashCode() {
return Object.hash(lineNum, prefix, areaCode); // 성능이 살짝 아쉽다.
}해시코드를 지연 초기화하는 hashCode 메서드#
java
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;
}- 성능을 높인답시고 해시코드를 계산할 때 핵심 필드를 생략해서는 안 된다.
- hash로 반환하는 값의 생성 규칙을 API 사용자에게 자세히 공표하지 말자.
- 그래야 클라이언트가 이 값에 의지하지 않게 되고, 추후에 계산 방식을 바꿀 수도 있다.
12. toString을 항상 재정의하라#
Object의 기본toString은클래스_이름@16진수로_표시한_해시코드를 반환할 뿐이다.toString을 잘 규현한 클래스는 사용하기에 훨씬 즐겁고, 그 클래스를 사용한 시스템은 디버깅하기 쉽다.toString메서드는 객체를println,printf, 문자열 연결 연산자 (+),assert구문에 넘길 때, 디버거가 객체를 출력할 때 자동으로 사용된다.
toString을 제대로 재정의하지 않는다면 쓸모없는 메시지만 로그에 남을 것이다.- 좋은
toString은 인스턴스를 포함하는 객체에서 유용하게 쓰인다. - 실전에서
toString은 그 객체가 가진 주요 정보 모두를 반환하는게 좋다. toString을 구현할 때면 반환값의 포맷을 문서화할지 정해야 한다.- 포맷을 명시하면 그 객체는 표준적이고, 명확하고, 사람이 읽을 수 있게된다.
- 하지만 포맷을 한번 명시하면 평싱 그 포맷에 얽매이게 된다.
- 명시하면 명확성을 주는 것이 장점이고, 단점은 유연성을 잃는 것이다.
- 포맷을 명시하든 아니든 의도는 명확히 밝혀야 한다.
- 포맷 명시 여부와 상관없이
toString이 반환한 값에 포함된 정보를 얻어올 수 있는 API를 제공하자.
toString의 기본 규약#
- 간결하면서 사람이 읽기 쉬운 형태의 유익한 정보
- 모든 하위 클래스에서 이 메서드를 재정의하라
13. clone 재정의는 주의해서 진행하라#
Cloneable은 복제해도 되는 클래스임을 명시하는 용도의 믹스인 인터페이스지만, 아쉽게도 의도한 목적을 제대로 이루지 못했다.- 가장 큰 문제는 clone 메서드가 선언된 곳이
Cloneable이 아닌Object이고 그마저도protected라는데 있다. - 그래서 Cloenable을 구현하는 것만으로는 외부 객체에서 clone 메서드를 호출할 수 없다.
- 여러 문제점에도 불구하고
Clonealbe방식은 널리 쓰이고 있어서 잘 알아두는 것이 좋다.
메서드 하나 없는 Cloneable 인테페이스는 무슨 일을 할까?#
- Object의 proteced 메서드인 clone의 동작 방식을 결정한다.
- Cloneable을 구현한 클래스의 인스턴스에서 clone을 호출하면 그 객체의 필드들을 하나하나 복사한 객체를 반환한다.
- 그렇지 않은 클래스의 인스턴스에서 호출하면 CloneNotSupportedException을 던진다.
- 실무에서 Cloneable을 구현한 클래스는 clone 메서드를 public으로 제공하며, 사용자는 당연히 복제가 제대로 이뤄지리라 기대한다.
- 결과적으로 위험하고, 모순적인 메커니즘이 탄생한다고 한다.
- 생성자를 호출하지 않고도 객체를 생성할 수 있게 되는 것이다.
제대로 동작하는 clone 메서드#
java
@Override
public PhoneNumber clone() {
try {
return (PhoneNumber) super.clone();
} catch (CloneNotSupportedException e) {
throw new AssertionsError(); // 일어날 수 없는 일이다.
}
}- 이 메서드가 동작하게 하려면
PhoneNumber의 클래스 선언에Cloneable을 구현 한다고 추가해야 한다. Object의clone메서드는Object를 반환하지만PhoneNumber의clone메서드는PhoneNumber를 반환하게 했다.- 클라이언트가 형변환을 하지 않아도 되게끔 형변환 해주도록 한다.
간단한 구현이 가변 객체를 참조한다면?#
재앙으로 돌변한다.
java
public class Stack {
private Object[] elements;
private int size = 0;
private static final int DEFAULT_INITIAL_CAPACITY = 16;
public Stack() {
elements = new Object[DEFAULT_INITIAL_CAPACITY];
}
public void push(Object e) {
ensureCapacity();
elements[size++] = e;
}
public Object pop() {
if (size == 0)
throw new EmptyStackException();
return elements[--size]; // 정답은 여기!
}
/**
* 원소를 위한 공간을 적어도 하나 이상 확보한다.
* 배열 크기를 늘려야 할 때마다 대략 두 배씩 늘린다.
*/
private void ensureCapacity() {
if (elements.length == size)
elements = Arrays.copyOf(elements, 2 * size + 1);
}
}해당 클래스에서 clone메서드를 구현한다면#
clone메서드가 단순히super.clone의 결과를 그대로 반환한다면?- Stack의
size필드는 동일하지만elements필드는 원본과 동일한 배열을 바라보기 때문에 수정이 가해질 시 원본이 함께 수정된다. - 즉 불변식을 해치게 된다.
가변 상태를 참조하는 clone 메서드#
clone메서드는 사실상 생성자와 같은 효과를 낸다.- 즉,
clone은 원본 객체에 아무런 해를 끼치지 않는 동시에 복제된 객체의 불변식을 보장해야 한다. - 때문에 스택의 내부 정보도 복사해야 한다.
elements가final이었다면 아래 코드는 통하지 않는다.Cloneable아키텍처는 가변 객체를 잠조하는 필드는final로 선언하라는 일반 용법과 충돌한다.- 복사를 위해서 일부 필드에서
final한정자를 제거해야 할 수도 있다.
java
@Override
public Stack clone() {
try {
Stack result = (Stack) super.clone();
result.elements = elements.clone();
return result;
} catch (CloneNotSupportedException e) {
throw new AssertionError();
}
}주의사항#
- 상속용 클래스는
Cloneable을 구현해서는 안 된다. Clonable을 구현한 스레드 안전 클래스를 작성할 때는clone메서드 역시 적절히 동기화 해줘야 한다.
더 나은 방법#
복사 생성자와 복사 팩터리라는 더 나은 객체 복사 방식을 제공할 수 있다.
java
public Yum(Yum yum) { .. };
public static Yum newInstance(Yum yum) { ... };14. Comparable을 구현할지 고려하라#
equals와 compareTo의 차이점#
compareTo는 단순 동치성 비교에 더해 순서까지 비교할 수 있으며, 제네릭하다.- Comparable을 구현했다는 것은 그 클래스의 인스턴스들에게는 자연적인 순서가 있음을 뜻한다. 그래서 손쉽게 정렬할 수 있다.
java
Arrays.sort(a);- 알파벳, 숫자, 연대 같이 순서가 명확한 값 클래스를 작성한다면 반드시 Comparable 인터페이스를 구현하자.
compareTo 메서드의 일반 규약#
- compareTo 메서드의 일반 규약은 equals의 규약과 비슷하다.
이 객체와 주어진 객체의 순서를 비교한다.
이 객체가 주어진 객체보다 작으면 음의 정수를, 같으면 0을, 크면 양의 정수를 반환한다.
비교할 수 없는 타입의 객체가 주어지면ClassCastException을 던진다.다음 설명에서 sgn(표현식)은 부호 함수를 뜻한다. -1,0,1을 사용한다.
Comparable을 구현한 클래스는 모든 x, y에 대해sgn(x.compareTo(y)) == -sgn(y.compareTo(x)
예외도 마찬가지다. Comparable을 구현한 클래스는 추이성을 보장해야 한다.
즉,x.compareTo(y) > 0 && y.compareTo(z) > 0 이면, x.compareTo(z) > 0이다. Comparable을 구현한 클래스는 모든 z에 대해 ( x.compareTo(y) == 0 ) == ( x.equals(y)) 여야 한다.
이 권고를 지키지 않는 모든 클래스는 그 사실을 명시해야 한다.
equals메서드와 달리,compareTo는 타입이 다른 객체가 주어지면 간단히ClassCastException을 던져도 되며, 대부분 그렇게 한다.- 위의 세 규약은 주의 사항도
equals와 똑같다. 기존 클래스를 확장한 구체 클래스에서 새로운 값 컴포넌트를 추가했다면compareTo규약을 지킬 방법이 없다. - compareTo의 마지막 규약은 필수는 아니지만 꼭 지키길 권한다.
- 이를 잘 키지면
compareTo로 줄지은 순서와equals의 결과가 일관되게 된다. compareTo의 순서와equals의 결과가 일관되지 않은 클래스도 여전히 동작은 한다.- 단, 이 클래스의 객체를 정렬된 컬렉션에 넣으면 해당 컬렉션이 구현한 인터페이스(Collection, Set, 혹은 Map)에 정의된 동작과 엇박자를 낼 것이다.
- 이 인터페이스들은
equals메서드의 규약을 따른다고 되어 있지만, 놀랍게도 정렬된 컬렉션들은 동치성을 비교할 때equals대신compareTo를 사용하기 때문이다.
compareTo 작성 요령#
compareTo 작성 요령은 equals 와 비슷하지만 몇 가지 차이점만 주의하면 된다.
Comparable은 인수로 타입을 받는 제네릭 인터페이스이므로compareTo메서드의 인수 타입은 컴파일타임에 정해진다.- 입력 인수의 타입을 확인하거나 형변환할 필요가 없다는 뜻
- 객체 참조 필드를 비교하려면
compareTo메서드를 재귀적으로 호출한다.
Comparealbe 구현#
java
public int compareTo(PhoneNumber pn) {
int result = Short.compare(areaCode, pn.areaCode);
if (result == 0) {
result = Short.compare(prefix, pn.prefix);
if (result == 0) {
return = Short.compare(lineNum, pn.lineNum);
}
}
return result;
}java
private 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);
}