////
Search

3장 - 모든 객체의 공통 메서드

Created
2022/09/03 11:38
Tags
Java
모든 객체는 최상위의 Object를 상속해서 사용하도록 설계되어 있다.
Object의 final이 아닌 메서드는 모두 재사용을 염두해 두고 설계된 것이다.
equals, hashCode, toString, clone, finalize
해당 장은 해당 메서드들을 언제 어떻게 재정의 해야하는지 알아보는 장이다.

10. equals는 일반 규약을 지켜 재정의하라

equals 메서드는 재정의하기 쉬워보이지만 곳곳에 함정이 도사리고 있어 끔찍한 결과를 초래하기도 한다.

아래 열겨한 상황에 하나라도 해당한다면 재정의 하지 말아라.

각 인스턴스가 본질적으로 고유할 때
값을 표현하는게 아닌 동작하는 객체를 표현하는 클래스
인스턴스의 논리적 동치성(logical equality)을 검사할일이 없을 때
java.util.regax.Pattern은 equals를 재정의해 두 Pattern의 정규표현식을 비교
아니 이게 무슨말이람…
상위 클래스에서 재정의한 equals가 하위 클래스에도 딱 들어맞을 때
Set은 AbstractSet이 구현한 equals를 상속, List는 AbstractListMap은 AbstractMap
클래스가 private/package-private이고 equals를 호출할 일이 없을 때
equals호출을 방지하고싶다면 오버라이드 해서 throw하도록 하자
@Override public boolean equals(Object object) { throw new AssertionError(); }
Java
복사
해당 규약을 어길 경우 프로그램이 이상하게 작동할 수 있다.

Object 명세에 말하는 동치관계란?

쉽게 말해, 집합을 서로 같은 원소들로 이뤄진 부분집합으로 나누는 연산이다.
이 부분집합을 동치류(동치 클래스)라 한다.
equals 메서드가 쓸모 있으려면 모든 원소가 같은 동치류에 속한 어떤 원소와도 서로 교환할 수 있어야 한다.

동치관계를 만족시키기 위한 5가지 조건

반사성

객체는 자기 자신과 같아야한다.
@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; } ... }
Java
복사
대칭성 위배 코드
비교 대상(String)의 equals를 호출했을때는 false를 받을것임
때문에 동일한 객체로 비교한다고 해도 동치여부가 다름

추이성

첫 번째 객체와 두 번째 객체가 같고, 두 번째 객체와 세 번째 객체가 같아면, 첫 번째 객체와 세 번째 객체도 같아야 한다.
구체 클래스를 확장해 새로운 값을 추가하면서 equals 규약을 만족시킬 방법은 존재하지 않는다.
추이성을 어길 시 발생할 수 있는 문제
대칭성에 위배할 수 있다.
@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; }
Java
복사

일괄성

두 객체가 같다면 (어느 하나 혹은 두 객체 모두가 수정되지 않는 한) 앞으로도 영원히 같아야 한다.
가변 객체의 경우 비교 시점에 따라 서로 다를 수도 혹은 같을 수도 있다.
불변 객체는 한번 다르면 끝까지 달라야 한다.
클래스가 불변이든 가변이든 equals의 판단에 신뢰할 수 없는 자원이 끼어들게 해서는 안 된다.
equals는 항시 메모리에 존재하는 객체만을 사용한 결정적(deterministic) 계산만 수행해야 한다.

null 아님

모든 객체가 null과 같지 않아야 한다.
@Override public boolean equals(Object o) { if(!(o instanceof MyType)) { // 묵시적으로 NULL을 검사하자! return false; } MyType myType = (MyType) o; }
Java
복사

양질의 equals 메서드 구현방법

1.
==연산자를 사용해 입력이 자기 자신의 참조인지 확인한다. 자기 자신이면 true를 반환한다. 단순한 성능 최적화용으로 비교 작업이 복잡한 상황일 때 값어치를 한다.
2.
instanceof 연산자로 입력이 올바른 타입인지 확인한다. 가끔 해당 클래스가 구현한 특정 인터페이스를 비교할 수도 있다. 이런 인터페이스를 구현한 클래스라면 equals에서 (클래스가 아닌) 해당 인터페이스를 사용해야한다.
3.
입력을 올바른 타입으로 형변환 한다. 2번에서 instanceof 연산자로 입력이 올바른 타입인지 검사 했기 때문에 이 단계는 100% 성공한다.
4.
입력 객체와 자기 자신의 대응되는 핵심 필드들이 모두 일치하는지 하나씩 검사한다. 모두 일치해야 true를 반환한다.
@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); }
Java
복사
올바른 equals 코드 작성

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 프레임워크

구글에서 개발한 프레임워크로 equalshashCode를 자동으로 작성해주는 프레임워크
IDE보다 퀄리티 높은 코드를 만들어준다.

결론

꼭 필요한 경우가 아니면 equals를 재정의하지 말자.
많은 경우에 Object의 equals가 여러분이 원하는 비교를 정확히 수행해준다.
재정의해야 할 때는 그 클래스의 핵심 필드 모두를 빠짐없이, 다섯 가지 규약을 확실히 지켜가며 비교해야 한다.

11. equals를 재정의하려거든 hashCode도 재정의하라

equals를 재정의한 클래스는 hashCode도 재정의 해야 한다. 그렇지 않으면 인스턴스를 HashMap이나 HashSet 같은 컬렉션의 원소로 사용할 때 문제가 발생한다.

Object 명세에서의 hashCode 규약

equals 비교에 사용되는 정보가 변경되지 않았다면, hashCode도 변하면 안 된다.
애플리케이션을 다시 실행한다면 이 값이 달라져도 상관 없음
equals가 두 객체가 같다고 판단했다면, 두 객체의 hashCode는 똑같은 값을 반환한다.
hashCode 재정의 시 문제가 될 수 있는 규약
equals가 두 객체를 다르다고 판단했더라도, hashCode는 꼭 다를 필요는 없다.
다른 객체에 대해서는 다른 값을 반환해야 해시테이블의 성능이 좋아진다.

같은 객체는 같은 해시 값을 반환해야 한다.

Map<PhoneNumber, String> map = new HashMap<>(); map.put(new PhoneNumber(010, 1234, 5678), new Person("리치")); map.get(new PhoneNumber(010, 1234, 5678)) // ??? 이 값이 제대로 나오기 위해서 hashCode가 필요하다.
Java
복사
해당 코드에서 HashMapPhoneNumber객체를 키로 Person객체를 Value로서 사용하기로 했다.
@Override public int hashCode() { return 42; }
Java
복사
최악의 hashCode 재정의
작동은 하겠지만 해시충돌 때문에 내부적으로 LinkedList로 사용되기 때문에 O(1)로 사용할 수 있는 HashMapO(n)으로 이용하게 하는 기적의 코딩을 할 수 있다.
좋은 해시함수란? 서로다른 인스턴스에 다른 해시코드를 반환한다. 이상적인 해시함수는 주어진 인스턴스들을 32비트 정수 범위에 균일하게 분배해야한다.

hashCode를 작성하는 간단한 요령

1.
int 변수인 result를 선언한 후 값을 c로 초기화한다.
이 때, c는 해당 객체의 첫번째 핵심 필드를 단계 2.1 방식으로 계산한 해시코드이다.
여기서 핵심 필드는 equals 비교에 사용되는 필드를 말한다.
2.
해당 객체의 나머지 핵심 필드인 f 각각에 대해 다음 작업을 수행한다.
a.
해당 필드의 해시코드 c 를 계산한다.
기본 타입 필드라면, Type.hashCode(f)를 수행한다. 여기서 Type은 해당 기본타입의 박싱 클래스다.
참조 타입 필드면서, 이 클래스의 equals 메소드가 이 필드의 equals를 재귀적으로 호출하여 비교한다면, 이 필드의 hashCode를 재귀적으로 호출한다.
필드가 배열이라면, 핵심 원소 각각을 별도 필드처럼 다룬다.모든 원소가 핵심 원소라면 Arrays.hashCode를 사용한다.
b.
단계 2.1에서 계산한 해시코드 c로 result를 갱신한다.
result = 31 * result + c;
3.
result를 반환한다.
@Override public int hashCode() { int result = Integer.hashCode(areaCode); result = 31 * result + Integer.hashCode(prefix); result = 31 * result + Integer.hashCode(lineNum); return result; }
Java
복사
전형적인 hashCode 메서드
equals비교에 사용되는 필드에 대해서만 해시코드를 계산한다. 31 * result는 필드를 곱하는 순서에 따라 result 값이 달라지게 만들어준다. 그 결과 클래스에 비슷한 필드가 여러 개일 때 해시 효과를 크게 높혀준다.

한줄로 hashCode 만들기

@Override public int hashCode() { return Object.hash(lineNum, prefix, areaCode); // 성능이 살짝 아쉽다. }
Java
복사

해시코드를 지연 초기화하는 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; }
Java
복사
스레드 안정성까지 고려해야한다.
성능을 높인답시고 해시코드를 계산할 때 핵심 필드를 생략해서는 안 된다.
hash로 반환하는 값의 생성 규칙을 API 사용자에게 자세히 공표하지 말자.
그래야 클라이언트가 이 값에 의지하지 않게 되고, 추후에 계산 방식을 바꿀 수도 있다.

12. toString을 항상 재정의하라

Object의 기본 toString클래스_이름@16진수로_표시한_해시코드를 반환할 뿐이다.
toString을 잘 규현한 클래스는 사용하기에 훨씬 즐겁고, 그 클래스를 사용한 시스템은 디버깅하기 쉽다.
toString 메서드는 객체를 printlnprintf, 문자열 연결 연산자 (+), 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 메서드

@Override public PhoneNumber clone() { try { return (PhoneNumber) super.clone(); } catch (CloneNotSupportedException e) { throw new AssertionsError(); // 일어날 수 없는 일이다. } }
Java
복사
가변상태를 참고하지 않는 클래스용 clone 메서드
이 메서드가 동작하게 하려면 PhoneNumber의 클래스 선언에 Cloneable을 구현 한다고 추가해야 한다.
Objectclone메서드는 Object를 반환하지만 PhoneNumberclone 메서드는 PhoneNumber를 반환하게 했다.
클라이언트가 형변환을 하지 않아도 되게끔 형변환 해주도록 한다.

간단한 구현이 가변 객체를 참조한다면?

재앙으로 돌변한다.
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); } }
Java
복사
Stack예제 클래스

해당 클래스에서 clone메서드를 구현한다면

clone 메서드가 단순히 super.clone의 결과를 그대로 반환한다면?
Stack의 size 필드는 동일하지만 elements 필드는 원본과 동일한 배열을 바라보기 때문에 수정이 가해질 시 원본이 함께 수정된다.
즉 불변식을 해치게 된다.

가변 상태를 참조하는 clone 메서드

clone 메서드는 사실상 생성자와 같은 효과를 낸다.
즉, clone은 원본 객체에 아무런 해를 끼치지 않는 동시에 복제된 객체의 불변식을 보장해야 한다.
때문에 스택의 내부 정보도 복사해야 한다.
elementsfinal 이었다면 아래 코드는 통하지 않는다.
Cloneable 아키텍처는 가변 객체를 잠조하는 필드는 final로 선언하라는 일반 용법과 충돌한다.
복사를 위해서 일부 필드에서 final한정자를 제거해야 할 수도 있다.
@Override public Stack clone() { try { Stack result = (Stack) super.clone(); result.elements = elements.clone(); return result; } catch (CloneNotSupportedException e) { throw new AssertionError(); } }
Java
복사

주의사항

상속용 클래스는 Cloneable을 구현해서는 안 된다.
Clonable을 구현한 스레드 안전 클래스를 작성할 때는 clone 메서드 역시 적절히 동기화 해줘야 한다.

더 나은 방법

복사 생성자와 복사 팩터리라는 더 나은 객체 복사 방식을 제공할 수 있다.
public Yum(Yum yum) { .. }; public static Yum newInstance(Yum yum) { ... };
Java
복사

14. Comparable을 구현할지 고려하라

equalscompareTo의 차이점

compareTo는 단순 동치성 비교에 더해 순서까지 비교할 수 있으며, 제네릭하다.
Comparable을 구현했다는 것은 그 클래스의 인스턴스들에게는 자연적인 순서가 있음을 뜻한다. 그래서 손쉽게 정렬할 수 있다.
Arrays.sort(a);
Java
복사
알파벳, 숫자, 연대 같이 순서가 명확한 값 클래스를 작성한다면 반드시 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 구현

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); }
Java
복사
비교자 생성 메서드를 활용한 비교자