반응형

자바에서 최상위 객체인 Object는 하위 객체들이 오버라이딩하여 사용하도록 설계된 메서드들이 있다. (equals, hashCode, toString, clone, finalize) 그리고 이 메서드들은 일반 규약이 존재하는데 이를 따르지 않으면 자바에서 제공하는 클래스와 함께 사용할 때 제대로 동작하지 않는다.

 

이번 장에서는 hashCode 메서드에 대해 설명한다.

 

Object.hashCode() 메서드

해시 코드란 객체를 식별하는 하나의 정수 값을 말한다. Object.hashCode() 메서드는 해당 객체의 해시 코드 값을 반환한다. 해시 코드는 java.util.HashMap과 같은 해시(hash) 기반 컬렉션에서 사용된다.

 

Object.hashCode() 메서드는 아래와 같다.

public class Object {
	 ...
	
	public native int hashCode();
}

 

Object의 hashCode는 native함수로 C언어로 작성되어 있다. 좀 더 자세히 알고 싶다면 다음을 참고한다. link to hashCode.

 

Object.hashCode() 메서드를 오버라이딩하여 재정의할 때 준수해야 하는 일반 규약이 Object 클래스 명세서에 작성되어 있다.

  • Whenever it is invoked on the same object more than once during an execution of a Java application, the hashCode method must consistently return the same integer, provided no information used in equals comparisons on the object is modified. This integer need not remain consistent from one execution of an application to another execution of the same application. 
  • If two objects are equal according to the equals(Object) method, then calling the hashCode method on each of the two objects must produce the same integer result.
  • It is not required that if two objects are unequal according to the java.lang.Object.equals(java.lang.Object) method, then calling the hashCode method on each of the two objects must produce distinct integer results. However, the programmer should be aware that producing distinct integer results for unequal objects may improve the performance of hash tables.

 

해시 코드는 반드시 구현해야 하는 것은 아니다. 하지만 두 번째 규약에 의하면 Object.equals() 메서드를 재정의 했다면 hashCode도 반드시 재정의해야 한다.

 

만약 두 번째 규약을 지키지 않으면 어떻게 되는지 보자.

 

두 번째 규약을 지키지 않았을 경우

아래는 equals() 메서드를 규약에 맞춰 작성한 PhoneNumer 클래스의 코드이다.

public class PhoneNumber {
	private final int areaCode;
	private final int prefix;
	private final int lineNumber;

	public PhoneNumber(int areaCode, int prefix, int lineNumber) {
		this.areaCode = areaCode;
		this.prefix = prefix;
		this.lineNumber = lineNumber;
	}

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

		PhoneNumber phoneNumber = (PhoneNumber) obj;

		// Since lineNumber may be the most different, check first.
		return phoneNumber.lineNumber == lineNumber && phoneNumber.prefix == prefix
		        && phoneNumber.areaCode == phoneNumber.areaCode;
	}

	public static void main(String[] args) {
		Map<PhoneNumber, String> map = new HashMap<PhoneNumber, String>();

		PhoneNumber p1 = new PhoneNumber(1, 2, 3);
		PhoneNumber p2 = new PhoneNumber(1, 2, 3);

		System.out.println(p1.equals(p2)); // true

		map.put(p1, "Phone");

		System.out.println(map.get(p1)); // Phone
		System.out.println(map.get(p2)); // null

		System.out.println(p1.hashCode()); // 366712642
		System.out.println(p2.hashCode()); // 1829164700
	}

}

p1과 p2는 논리적으로 동일하다. 즉, 새롭게 정의한 equals 메서드에서 두 객체는 동일하다고 판단한다. 그다음 HashMap에 p1을 키로 하여 데이터를 삽입하였다. 그 후, HashMap에서 데이터를 꺼낼 때 p1과 p2를 실험해본 결과 p1은 정상적으로 출력되지만 p2는 null을 반환한다.

 

Map은 동일한 키를 사용한다면 동일한 값을 반환해야 한다. 따라서 우리는 map.get(p2)도 Phone이 출력되어야 한다고 생각한다. 왜냐하면 p1과 p2는 논리적으로 동일하다고 판단하기 때문이다. 하지만 HashMap은 동일하다는 기준을 hashCode 값을 사용하여 판단한다. 따라서 HashMap에 동일성의 기준과 사람의 동일성의 기준을 같게 하기 위해서 equals 메서드를 재정의하였으면 hashCode 메서드도 재정의해야 한다.

 

hashCode()를 재정의 하기만 하면 된다?

이제 PhoneNumber의 문제를 발견했으니 hashCode() 메서드를 아래와 같이 재정의해보자. 

public class PhoneNumberOnlyAreaCode {
	private final int areaCode;
	private final int prefix;
	private final int lineNumber;

	...

	@Override
	public int hashCode() {
		return areaCode;
	}
    
	public static void main(String[] args) {
		Map<PhoneNumberOnlyAreaCode, String> map = new HashMap<PhoneNumberOnlyAreaCode, String>();

		PhoneNumberOnlyAreaCode p1 = new PhoneNumberOnlyAreaCode(1, 2, 3);
		PhoneNumberOnlyAreaCode p2 = new PhoneNumberOnlyAreaCode(1, 2, 3);

		System.out.println(p1.equals(p2)); // true

		map.put(p1, "Phone");

		System.out.println(map.get(p1)); // Phone
		System.out.println(map.get(p2)); // Phone

		System.out.println(p1.hashCode()); // 1
		System.out.println(p2.hashCode()); // 1
	}

}

 

위 결과를 보면 원하는 결과가 나왔다. 해시 코드를 areaCode를 리턴하도록 하였다.

 

그러나 위 코드는 areaCode가 같은 모든 객체는 같은 해시 코드를 가지게 되는데, 이는 해시 기반의 API를 사용할 때 끔찍한 결과를 가져올 것이다. areaCode가 같은 객체는 전부 같은 버킷에 해시되므로, 해시 테이블은 아주 긴 링크드 리스트가 많이 생기게 될 것이므로 원하는 성능이 나타나지 않는다.

 

따라서 hashCode의 세 번째 규약처럼 해시 코드가 꼭 다를 필요는 없지만, 해시 코드가 값이 다를수록 해시 테이블의 성능이 향상될 수 있다고 언급한 것이다.

 

hashCode 메서드 구현 순서

세 번째 규약에서 동일하지 않는 객체들끼리는 hashCode가 꼭 다를 필요는 없지만 다르면 성능적으로 좋다고 하였다. 서로 다른 객체들을 모든 가능한 해시 값에 균등하게 배분해야 하는데 수학자들이 그러한 이상적인 hashCode 메서드를 만드는 방법을 정의하였다.

 

  1. Create a int result and assign a non-zero value.
  2. For every field f tested in the equals() method, calculate a hash code c by:
    • If the field f is a boolean: calculate (f ? 0 : 1);
    • If the field f is a byte, char, short or int: calculate (int)f;
    • If the field f is a long: calculate (int)(f ^ (f »> 32));
    • If the field f is a float: calculate Float.floatToIntBits(f);
    • If the field f is a double: calculate Double.doubleToLongBits(f) and handle the return value like every long value;
    • If the field f is an object: Use the result of the hashCode() method or 0 if f == null;
    • If the field f is an array: see every field as separate element and calculate the hash value in a recursive fashion and combine the values as described next.
  3. Combine the hash value c with result:
    • result = 37 * result + c
  4. Return result

 

위 PhoneNumber에 구현 예제는 다음과 같다.

public class PhoneNumberWithHashCode {
	...

	@Override
	public int hashCode() {
		int result = 17;

		result = 31 * result + areaCode;
		result = 31 * result + prefix;
		result = 31 * result + lineNumber;

		return result;
	}

}

 

PhoneNumber는 필드가 세 개뿐이므로 해시 코드 값을 계산하는데 비용이 크지 않다. 하지만 해시 코드 계산 비용이 높은 클래스를 만들 때는 필요할 때마다 해시 코드를 재계산하는 대신 객체 안에 캐시 해 두어야 할 수도 있다. 우리가 자주 사용하는 String 클래스의 코드를 보자.

 public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {
    /** The value is used for character storage. */
    private final char value[];

    /** Cache the hash code for the string */
    private int hash; // Default to 0

	...
 	
	public int hashCode() {
        int h = hash;
        if (h == 0 && value.length > 0) {
            char val[] = value;

            for (int i = 0; i < value.length; i++) {
                h = 31 * h + val[i];
            }
            hash = h;
        }
        return h;
    }

 

String 클래스의 hashCode는 글자의 길이만큼 반복이 발생하면서 hashCode 값을 계산한다. 아주 긴 문자열이라면 해시 코드를 계산하는데 비용이 크므로 String 클래스는 해시 코드를 계산한 후 캐싱해서 사용한다.

 

다만, 이렇게 캐시를 사용할 경우에는 변경 불가능 클래스여야 한다. 왜냐하면 중요 필드가 변경될 경우, 해시값도 달라져야 하는데 캐시를 해두고 위 로직처럼 한다면 동일한 해시값을 계속 반환하기 때문이다.

 

해시 코드를 구현할 때 주의할 점은 객체의 중요한 변수를 일부 빼고 해시 코드를 계산하면 문제가 발생할 수 있다. JDK 1.2 이전의 String의 hashCode는 문자열의 첫 번째 문자부터 일정 간격으로 열두 개 문자를 추출해서 해시 값을 계산했다. 추출한 12개의 문자들이 같은 경우가 많을 경우에는 해시 테이블이 끔찍한 성능을 보였다.

 

equals, hashCode를 자동으로 생성해주는 라이브러리

규칙 2에서 builder 패턴을 쉽게 생성해주는 lombok에 대해서 간단하게 설명했다. 마찬가지로 equals와 hashCode는 구현은 쉽지만 작성하기 귀찮으며 코드의 양이 많아져 보기가 싫다.

 

따라서 lombok에서는 equals와 hashCode에 대해서도 어노테이션을 제공한다.

@EqualsAndHashCode
public class EqualsAndHashCodeExample {
	private transient int transientVar = 10;
	private String name;
	private double score;
	@EqualsAndHashCode.Exclude
	private Shape shape = new Square(5, 10);
	private String[] tags;
	@EqualsAndHashCode.Exclude
	private int id;
}

 

@EqualsAndHashCode.Exclude를 사용하여 equals와 hashCode에서 배제할 필드를 선택할 수 있다.

 

아래는 최종적으로 생성되는 코드이다.

public class EqualsAndHashCodeExample {
	private transient int transientVar = 10;
	private String name;
	private double score;
	private Shape shape = new Square(5, 10);
	private String[] tags;
	private int id;

	public String getName() {
		return this.name;
	}

	@Override
	public boolean equals(Object o) {
		if (o == this) {
			return true;
		}
		if (!(o instanceof EqualsAndHashCodeExample)) {
			return false;
		}
		EqualsAndHashCodeExample other = (EqualsAndHashCodeExample) o;
		if (!other.canEqual(this)) {
			return false;
		}
		if (this.getName() == null ? other.getName() != null : !this.getName().equals(other.getName())) {
			return false;
		}
		if (Double.compare(this.score, other.score) != 0) {
			return false;
		}
		if (!Arrays.deepEquals(this.tags, other.tags)) {
			return false;
		}
		return true;
	}

	@Override
	public int hashCode() {
		final int PRIME = 59;
		int result = 1;
		final long temp1 = Double.doubleToLongBits(this.score);
		result = (result * PRIME) + (this.name == null ? 43 : this.name.hashCode());
		result = (result * PRIME) + (int) (temp1 ^ (temp1 >>> 32));
		result = (result * PRIME) + Arrays.deepHashCode(this.tags);
		return result;
	}

	protected boolean canEqual(Object other) {
		return other instanceof EqualsAndHashCodeExample;
	}
}

 

반응형

+ Recent posts