책 읽고 정리하기/2022

Effective Java 3/E - 3장 모든 객체의 공통 메서드

TwinParadox 2022. 1. 9. 19:54
728x90

Object는 객체를 만들 수 있는 구체 클래스로, 상속하여 사용하는 것을 기본으로 하고 있다.

Object의 final이 아닌 메서드들은 모두 재정의를 염두에 두고 설계되어 있어 이에 맞게 재정의해야 한다.

 

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

equals를 잘못 재정의하면 문제를 발생시키므로, 대응하기 위한 방법 중 가장 쉬운 것은 하지 않는 것이다.

다음 케이스에 대해서는 재정의하지 않는 것이 좋다.

 

재정의하지 않아도 되는 경우

  • 각 인스턴스가 본질적으로 고유한 경우
    • Thread 클래스 같은, 어떤 행위를 하는 클래스들이 이에 해당한다.
  • 인스턴스의 논리적 동치성을 검사할 필요가 없는 경우
    • java.util.regex.Pattern은 두 인스턴스가 같은 regex인지 확인하도록 재정의했다.
    • 이런 동작이 필요한 경우가 아니면, 그냥 Object의 equals를 그대로 쓰는 것이 좋다.
  • 상위 클래스에서 재정의한 equals가 하위 클래스에도 들어맞는 경우
    • 대부분의 Set, List, Map의 구현체들은 AbstractXXX로부터 상속받아 사용한다.
  • 클래스가 private이거나 package-private이고 equals를 호출할 일이 없을 경우
    • 실수로 호출되는 것을 방지하려면 아예 Exception을 equals에 발생시켜서 방지할 수도 있다.

 

재정의를 써야 하는 경우

  • 값이 같은 인스턴스가 둘 이상 만들어지는 경우면서,
  • 논리적 동치성을 확인해야 하면서,
  • 상위 클래스의 equals가 논리적 동치성을 비교하도록 재정의되지 않은 경우

 

Object.equals의 명세

반사성(reflexivity)

null이 아닌 모든 참조 값 x에 대해, x.equals(x)는 true
x = x

객체는 자기 자신과 같아야 한다.

 

대칭성(symmetry)

null이 아닌 모든 참조 값 x, y에 대해, x.equals(y)가 true면, y.equals(x)도 true
x = y면, y = x

두 객체는 서로에 대한 동치 여부에 동일하게 답해야 한다.

이를 위반하게 되면 객체를 사용하는 다른 객체의 반응을 예측할 수 없게 된다.

 

추이성(transitivity)

null이 아닌 모든 참조 값 x, y, z에 대해, x.equals(y)가 true이고, y.equals(z)도 true면, x.equals(z) 또한 true
x = y이고 y = z면, x = z

객체 x와 y가 같고, y와 z가 같으면 x와 z도 같다.

구체 클래스를 확장해서 새로운 값을 추가하는 방식으로는 해결할 수 없는 문제다.

상속이 아니라 컴포지션을 사용하는 방식으로 상속 대상을 private 필드로 두어서 우회할 수 있다.

상속을 통해 값을 추가하여 대응한 대표적인 예시가 java.sql.Timestamp로, 대칭성을 위배한다.

실제로 javadoc에는 equals 메서드가 대칭성을 위배하고 있다고 명시하고 있다.

The Timestamp.equals(Object) method never returns true when passed an object that isn't an instance of java.sql.Timestamp, because the nanos component of a date is unknown. As a result, the Timestamp.equals(Object) method is not symmetric with respect to the java.util.Date.equals(Object) method.

 

일관성(consistency)

null이 아닌 모든 참조 값 x, y에 대해, x.equals(y)를 반복해서 호출해도 항상 true거나 false
x = y면 어떤 경우에도 x = y,
x != y면 어떤 경우에도 x != y

두 객체가 같거나 다르다면, 그것이 영원해야 한다.

클래스가 불변이든, 가변이든 equals에 신뢰할 수 없는 다른 내용이 끼어들어선 안 된다.

 

null 아님

null이 아닌 모든 참조 x에 대해 x.equals(null)은 false

모든 객체는 null과 같지 않아야 한다.

보통 null과 같게 하지 않지만 equals에서 NPE를 발생시키는 것은 허용하지 않는 부분이다.

null 체킹을 해서 방어적으로 false를 내보낼 수도 있지만, instanceof로 타입 검사를 하는 것이 더 타당하다.

// null을 직접 체크하는 방법
if (o == null) {
	return false;
}

// istanceof로 null을 체크하는 방법
if (!(o instanceof CustomObject)) {
	return false; 
}

 

좋은 equals를 위한 가이드라인

  • == 연산자를 사용해 입력이 자기 자신의 참조인지 확인하기
    • 성능 최적화가 가능하다.
  • instacneof 연산자로 타입 체크하기
  • 입력이 올바른 타입으로 형변환하기
    • instanceof 체크를 통과하면, 이는 무조건 성공한다.
  • 입력 객체와 자기 자신의 대응되는 핵심 필드가 모두 일치하는지 검사하기
    • 모든 필드가 일치하면 true, 아니면 false
    • primitive 타입은 == 연산자, 참조 타입은 각각의 equals, Float와 Double은 comapre
    • primitive 중 float와 double는 특수한 값 처리를 수행해야 한다.(Float.equals, Double.equals 참조)
  • 대칭성, 추이성, 일관성 확인하기
  • equals 재정의하는 경우, hasCode도 반드시 재정의하기
  • 너무 복잡한 해결 방법보다는 쉽게 가기
    • 예를 들어 File 클래스에서 심볼릭 링크까지 체크하는 로직 등이 이에 해당한다.
  • Object 외의 타입을 매개변수로 받지 않기
    • 타입을 구체화하면 재정의(Overriding)가 아닌 다중 정의(Overloading)가 되어버린다.
    • 이러한 경우를 방지하려면, 어노테이션을 적극적으로 활용하자.

가이드라인대로 생성한 equals의 예시

더보기
public final class PhoneNumber {
	private final short areaCode;
	private final short prefix;
	private final short 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, 999, "Line Number");
	}

	private short rangeCheck(int val, int max, String args) {
		if (val < 0 || val > max) {
			throw new IllegalArgumentException(args + ":" + val);
		}
		return (short)val;
	}

	@Override
	public boolean equals(Object o) {
		if (this == o) {
			return true;
		}
		if (!(o instanceof PhoneNumber)) {
			return false;
		}

		PhoneNumber that = (PhoneNumber)o;
		return areaCode == that.areaCode &&
			prefix == that.prefix &&
			lineNum == that.lineNum;
	}
}

 

 

11. equals를 재정의한다면, hashCode도 재정의하라

앞선 주제에서 잠시 언급되었던 부분으로, equals를 재정의하면서 발생하는 hashCode 규약 위반을 해소하기 위해 hashCode 또한 재정의할 것을 권장한다.

 

Object 명세

  • equals 비교에 사용되는 정보가 변경되지 않은 상태면, hashCode는 몇 번을 호출해도 동일해야 한다.
  • equals가 동일한 객체라고 판단하면, hashCode도 똑같은 결과를 반환해야 한다.
  • equals가 다른 객체로 판단해도, hashCode가 다른 값을 반환하지 않아도 된다.

 

hashCode 재정의에 오류가 있는 경우, 두 번째 규약에 문제가 발생한다.

논리적으로 같은 객체는 같은 해시 코드를 반환해야 한다. 하지만, equals가 물리적으로 동일하다고 판단한 것을 hashCode는 다르다고 판단하면서, 명세와 다른 상황이 발생한다. 적절한 hashCode를 정의해서 명세와 다른 상황을 해결해야 한다.

 

좋은 hashCode를 위한 가이드라인

int 변수를 선언한 후, 그 값을 해당 객체의 첫 번째 핵심 필드(equals 비교에 사용하는 필드)를 기반으로 생성한 해시 코드로 계산한다. 해시 코드는 아래의 방법으로 계산한다.

  • primitive 자료형에 대해서는 해당 타입의 박싱 클래스가 가지고 있는 Type.hashCode(f)로 수행한다.
  • 참조 타입 필드면서, 클래스의 equals가 필드의 equals를 재귀적으로 호출해 비교하는 경우 hashCode도 재귀적으로 호출한다.
    • 복잡해지면, 필드의 표준형을 만들어 해당 표준형의 hashCode를 사용한다.
    • 필드가 null인 경우에는 0을 사용한다.
  • 필드가 배열이라면, 핵심 원소 각각에 대해 필드처럼 다룬다.
  • 다음 조건에 해당하는 필드는 hashCode에서 무시해도 된다.
    • 다른 필드로부터 계산할 수 있는 파생 필드
    • equals에서 사용하지 않는 필드는 반드시 제외

위 방법으로 계산된 코드로 result를 갱신하고, 이를 반환한다.

 

@Override
public int hashCode() {
	int result = Short.hashCode(areaCode);
	result = 31 * result + Short.hashCode(prefix);
	result = 31 * result + Short.hashCode(lineNum);
	return result;
}

result 계산 과정에서 소수를 곱하면, 클래스에 비슷한 필드가 여럿일 경우 해시 효과를 극대화할 수 있으며, 책에서는 홀수이면서 소수인 31을 예시로 들고 있다. 가이드라인대로만 생성해줘도 대부분의 경우에 문제없는 hashCode를 구현할 수 있지만, 해시 충돌을 적극적으로 회피하려면 Guava의 Hashing을 참고한다.

 

Objects.hash

Objects 클래스의 hash 메서드는 임의의 개수만큼 객체를 받아서 해시 코드를 계산한다.

@Override
public int hashCode() {
	return Objects.hash(areaCode, prefix, lineNum);
}

앞서 가이드라인에서 hashCode 함수를 한 줄로 작성할 수 있지만, 입력을 위한 배열 생성 및 박싱/언박싱 등으로 인해서 기존의 방식보다 성능 상 손해를 보게 된다. 성능에 민감한 경우라면(또는 민감해질 만큼 계산 비용이 크다면), 매번 계산하지 말고 캐싱을 고려하라.

 

 

12. toString을 항상 재정의하라

Object의 toString은 클래스명@16진수_해시코드 형태로 제공하여 원하는 문자열을 반환하지 않는다. toString을 적절히 재정의하면 유익한 정보를 담고 있어서 로깅 과정에서 유용하게 사용할 수 있다. 이를 재정의하게 된다면, 그 객체가 가진 주요 정보 모두를 반환하는 것이 좋다.

 

주요 정보를 전달하는 데 있어 고려해야 할 부분은, 반환 값의 포맷이다.

예시로 들었던 PhoneNumber 같은 클래스나, 숫자와 관련된 클래스라면 문서화하는 것이 좋다. 이런 숫자 값은 그냥 나열하기보다는 실제 활용하려고 했던 포맷으로 표기해주면 더 명확하고 가독성을 높일 수 있다. 하지만, 포맷 명시가 오히려 그렇게 사용하게끔 사용을 제한하는 문제가 있을 수 있다는 점을 고려해야 한다.

명시화하기로 결정했다면, 포맷에 맞는 문자열과 객체 간 상호 전환이 가능한 정적 팩토리나 생성자를 만들어주는 것도 생각해보자. 이에 대한 내용은 BigInteger, BigDecimal 등과 같은 기본 타입 클래스를 참고하도록 하자. 포맷 명시화와 무관하게 의도는 명확해야 한다는 점은 변함이 없다.

 

toString이 반환한 값에 포함된 정보를 얻어올 수 있는 API 제공하기

API를 제공하지 않으면, 정보가 필요한 때마다 프로그래머가 해당 반환 값을 파싱해야 한다. 파싱이라는 필요 없는 작업에 성능이 떨어지고, 포맷이 망가지는 문제가 발생할 수 있다.

 

toString을 재정의하지 않아도 되는 경우

  • 정적 유틸 클래스는 당연히 필요 없다.
  • Enum 타입도 이미 완벽한 toString을 제공해서 재정의할 필요 없다.

 

 

13. clone 재정의는 주의해서 진행하라

Cloneable은 복제해도 되는 클래스임을 명시하는 용도의 mixin interface이다.

하지만, 의도대로 되지 않았는데 clone 메서드가 선언된 곳이 Cloneable이 아닌 Object인 데다가 protected 메서드라는 점이다. 이로 인해, Cloneable의 구현만으로는 외부 객체에서 이 메서드를 호출할 수 없다.

 

Cloneable 인터페이스는 Object의 clone의 동작 방식을 결정한다?

Cloneable 인터페이스를 구현한 클래스의 인스턴스에서 Object의 protected clone을 호출한다고 하면, 그 객체의 필드를 일일이 복사한 객체를 반환한다. 그렇지 않은 클래스의 인스턴스를 호출하면 Exception을 던진다. 이러한 동작은 인터페이스를 구현한다는 관점에서 벗어나, 상위 클래스에서 정의된 메서드의 동작 방식을 변경하는 작업이니 이례적인 케이스다.

 

이를 개선하려면, Cloneable을 구현한 클래스는 clone을 public으로 제공하도록 구현한다. 당연히 복제가 이뤄진다고 기대하게 되지만, 이것이 복잡하고 허술한 규약을 따르게 되어 모순적인 문제를 발생시킬 수 있다. 

 

Object의 clone 명세

  • 객체의 복사본을 생성해 반환한다. 다음 사항에 대해 참이다.
    • x.clone() != x
    • x.clone().getClass() == x.getClass()
  • 다음 사항은 일반적으로 참이지만, 필수가 아니다.
    • x.clone().equals(x)
  • 관례상, 메서드가 반환하는 객체는 super.clone()으로 얻어야 한다. 모든 상위 클래스가 이 관례를 따르면, 다음은 참이다.
    • x.clone().getClass() == x.getClass()
  • 관례상 반환된 것과 원본은 독립적이어야 하므로, super.clone()으로 얻은 객체의 필드 중 하나 이상을 반환 전에 수정하는 것도 고려해야 한다.

강제성이 없다는 점을 고려하면 생성자 연쇄와 유사한 메커니즘으로, clone 메서드가 super.clone이 아닌 생성자를 호출해 얻은 인스턴스를 반환해도 무방해 보이겠지만, 하위 클래스에서 super.clone을 호출하면 잘못된 클래스의 객체가 만들어지면서 문제가 발생한다.

 

PhoneNumber의 clone 메서드 구현하기

@Override
public PhoneNumber clone() {
	try {
		return (PhoneNumber) super.clone();
	} catch (CloneNotSupportedException e) {
		throw new AssertionError();
	}
}

위 코드에서 CloneNotSupprotedException이 Checked Exception이기 때문에 try-catch로 AssertionError를 발생시키게끔 했다. Cloneable을 구현하고 있다고 하면, super.clone의 성공을 알고 있는데 이것은 Unchecked Exception였어야 하는 신호이기도 하다.

 

클래스가 가변 객체를 참조하고, super.clone을 그대로 반환했을 때의 문제

단순히 super.clone을 그대로 반환하면, 동작에 문제가 발생할 수 있다. Stack 클래스를 예시로 들어보자.

public class Stack {
	private Object[] elements;
	private int size;
	public static final int DEFAULT_CAPACITY = 16;

	public Stack() {
		this.elements = new Object[DEFAULT_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 = Arrays.copyOf(elements, 2 * size + 1);
		}
	}
}

반환된 인스턴스의 필드는 올바른 값을 가져도, elements 필드는 원본의 필드와 동일한 배열을 참조해서 원본이나 복제본 둘 중 하나가 수정되면 다른 하나도 수정되어 불변식이 훼손된다.

 

clone 메서드는 사실상 생성자와 같은 효과를 낸다?

원본 객체에 아무 해를 끼치지 않으면서 복제된 객체의 불변식을 보장해야 한다. 예시로 들었던 Stack 클래스의 clone이 제대로 동작하게 하려면 내부 정보를 복사해야 한다. 이 예제에 맞는 clone 메서드는 다음과 같이 재귀적인 호출로 대응이 가능하다.

@Override
public Stack clone() {
	try {
		Stack result = (Stack)super.clone();
		result.elements = elements.clone();
		return result;
	} catch (CloneNotSupportedException e) {
		throw  new AssertionError();
	}
}

재귀 호출 방식이 너무 깊어지는 문제가 발생할 것 같으면 순회하는 방식을 변경하는 등의 행위를 고려해야 하는데, 본질적으로 모든 요소들을 복사한다는 점은 변하지 않는다. 

 

조금 더 복잡해진다면?

super.clone을 호출해 얻은 객체의 모든 필드를 먼저 초기 상태로 설정하고, 원본 객체의 상태를 다시 생성하도록 하는 메서드를 호출하는 방식을 생각해볼 수 있다. 필드 단위 객체 복사를 우회하는 아키텍처와 맞지 않는 행위기 때문에 다른 방법을 생각해야 한다.

 

 

public인 clone 메서드는 throws가 없어야 한다.

예외를 던지지 않아야 사용이 편리하기 때문이다.

 

상속용 클래스는 Cloneable을 구현하면 안 된다.

Object의 방식을 모방하는 방법으로 우회해볼 수 있다. protected로 clone을 구현하고, CloneNotSupportedException 등의 예외를 던지는 방법이다. 이렇게 하면 Object를 바로 상속했던 다른 클래스처럼 Cloneable 구현 여부를 하위 클래스에서 선택할 수 있게 된다.

 

clone을 동작하지 않게 구현해놓고 하위 클래스에서 재정의하지 못하게 하기

하위 클래스가 Cloneable을 지원하지 못하도록 하는 메서드를 만들자

@Override
protected final Object clone() throws CloneNotSupportedException {
	throw new CloneNotSupportedException();
}

 

Cloneable을 구현한 Thread-safe 클래스를 작성 시, clone도 적절한 동기화가 필요하다.

Object의 clone에서는 이를 신경 쓰지 않았기 때문에, super.clone 호출 외에 다른 작업이 없다고 하더라도 clone을 재정의하고 동기화를 진행해줘야 한다.

 

결론은, Cloneable을 구현하는 모든 클래스는 clone을 재정의해야 한다.

  • 접근 제한자는 public으로, 반환 타입은 클래스 자신으로 가져가야 한다.
  • 가장 먼저 super.clone으로 호출한 후, 필요한 필드를 수정한다.
  • 구현한 클래스를 만약 확장할 경우, clone이 잘 작동하게 해야 한다.
  • 그렇지 않은 경우는 복사 생성자나, 복사 생성자를 모방하는 정적 팩토리인 복사 팩토리를 만드는 방법을 고려해야 한다.
    • 복사 생성자는 자신과 같은 클래스의 인스턴스를 인수로 받는 생성자
    • 복사 팩토리도 복사 생성자처럼 같은 클래스의 인스턴스를 인수로 받는다.

 

 

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

Comparable의 compareTo에 대해 생각해보면, 앞선 것들과 다르게 Object의 메서드가 아니다.

compareTo는 Object.equals와 비교해볼 때 두 가지가 다르다.

  • 단순 동치성 비교에 더해 순서까지 비교한다.
  • 제네릭하다.

순서가 있기 때문에, Comparable을 구현하면 순서 정렬이 쉬워진다. 정렬 외에도, 검색, 극단값 계산 등의 컬렉션 관리도 쉬워진다. 자바의 거의 모든 값 클래스와 Enum이 Comparable을 구현해서 많은 곳에서 활용할 수 있다.

 

compareTo 메서드의 규약

해당 객체와 주어진 객체의 순서를 비교한다.

해당 객체가 주어진 객체보다 작으면 음의 정수, 같으면 0, 크면 양의 정수를 반환한다.

비교 불가능한 경우 ClassCastException

Comparable 구현한 클래스들에 대해서는 다음을 만족한다. (sgn는 signum function)

  • 모든 x, y에 대해 sgn(x.compareTo(y)) = -sgn(y.compareTo(x))
  • 추이성을 보장한다.
    • (x.compareTo(y) > 0 && y.compareTo(z) > 0)이면 x.compareTo(z) > 0 이다.
  • 모든 z에 대해 x.compareTo(y) == 0이면, sgn(x.compareTo(z)) == sgn(y.compareTo(z))이다.
  • 필수는 아니지만, (x.compareTo(y) == 0) == (x.equals(y))여야 한다.
    • 필수가 아니지만, 이 권고를 지키지 않는 모든 클래스는 그 사실을 명시해야 한다.

 

compareTo 작성 요령

equals와 동일하게 가져가고 차이점만 숙지하면 된다.

  • compareTo의 인수 타입은 컴파일 타임에 결정된다.
    • Comparable은 타입을 인수로 받는 제네릭 인터페이스이기 때문이다.
    • 이를 위한 타입 체크나 형변환이 필요하지 않다. 타입이 잘못된 경우 컴파일이 안될 것이고, null이라면 NPE가 될 것이다.
  • compareTo 메서드는 각 필드가 동일한지 동치인지 비교하는 것이 아닌, 순서를 비교한다.
    • 만약 객체 참조 필드를 비교한다면 이를 재귀적으로 호출해 비교해야 한다.
    • 참조 대상이 Comparable을 구현하지 않았거나 표준이 아닌 순서라면, Comparator를 대신 사용해야 한다.
  • compareTo 메서드 구현에 있어서 Java 7부터는 박싱 클래스들의 정적 메서드인 compare의 결과를 따르자.
  • 클래스에 핵심 필드가 여럿이면 그 순서를 중시해야 하고, 결과가 결정되면 즉시 반환해야 한다.

 

Comparator를 활용한 compareTo 구현 예시

// 기초적인 compareTo
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;
}

// Java 8에서는 메서드 체이닝으로 이런 코드도 가능하다. 성능에 손해를 보겠지만...
private static final Comparator<PhoneNumber> COMPARATOR = 
	Comparator.comparingInt((PhoneNumber pn) -> pn.areaCode)
			  .thenComparingInt(pn -> pn.prefix)
			  .thenComparingInt(pn -> pn.lineNum);

public int compareTo(PhoneNumber pn) {
	return COMPARATOR.compare(this, pn);
}

 

값의 차를 기준으로 하는 경우

// 정수 오버플로우나, 부동소수점 계산 방식에 따른 오류 유발
// 해당 방식은 사용하지 않아야 한다.
static Comparator<Object> hashCodeOrder = new Comparator<>() {
	public int compare(Object o1, Object o2) {
		return o2.hashCode() - o2.hashCode();
	}
}

// 1안
static Comparator<Object> hashCodeOrder = new Comparator<>() {
	public int compare(Object o1, Object o2) {
		return Integer.compare(o1.hashCode(), o2.hashCode());
	}
}

// 2안
static Comparator<Object> hashCodeOrder = Comparator.comparingInt(o -> o.hashCode());

 

728x90
728x90