아이템 11. equals를 재정의하려거든 hashCode도 재정의하라 #23
-
아이템 11. equals를 재정의하려거든 hashCode도 재정의하라이펙티브 자바 책에서 '고려해봐'가 아닌 equals를 재정의한 클래스 모두에서 hashCode도 재정의해야 한다.!!라고 못을 박고 시작한다. 그만큼 무조건 지켜야 하는 부분이다. 이유equals를 재정의한 클래스에서 hashCode를 재정의하지 않을 시 hashCode 일반 규약을 어기게 되어 해당 클래스의 인스턴스를 HashMap이나 HashSet 같은 Collection 원소로 사용할 때 문제를 일으키키 때문 Object 명세
위의 이유와 명세의 설명을 뒷받침 하기 위해 Java의 HashMap 자료구조에 대해 알아보고자 한다. Java의 HashMapHashMap이 해싱을 이용하여 어떻게 랜덤 읽기(V get(K))가 빠른 이유HashMap의 경우 get(K) 경우 평균적으로 O(1)인 시간복잡도를 가진다. 그럼 어떻게 이렇게 빨리 해당 Key(k)에 매칭되는 Value를 찾을 수 있을까? Hash Map의 내부구조를 보면 배열로 이루어 져 있으며 Key가 배열의 인덱스가 될 수 있는데 이 것을 버킷이라 한다. Key가 어떤 원리로 내부 배열의 인덱스가 될 수 있게 되었는지 알아보기 위해 java에서 구현되어 있는 HashMap 클래스를 살펴보았다. java.util 패키지에서 구현 되어있는 HashMap 클래스에서 get 메서드를 보면 key에 해당하는 value를 찾기 위해 getNode 메서드를 이용하여 해당 key의 쌍의 node를 찾고 이때 hash(key) 값을 필요로 하는데 hash() 메서드에서 key.hashCode() 값을 이용하는 것을 확인할 수 있다. 해시함수의 인자를 key를 넣어 구한 해시값이 배열(Node<K, V>)의 인덱스가 되고 그 위치에 <key, value>가 저장되는 것이다. 해시 충돌이란 ?간단하게 말해서 해시 함수에 다른 키 값을 넣어도 같은 해시 값이 나올 수 있는데 이를 해시충돌이라 한다. 해시 충돌의 해결 방법이러한 문제를 자바의 HashTable 에서는 Seperate Changing 방식으로 처리하는데 동일한 해시 값이 있을 경우 LinkedList로 관리하고 8개 이상인 경우 Tree로 변경하여 관리하는 방법을 이용한다. Node 클래스를 보면 Node<K, V>를 이용해 LinkedList로 구현되어 있음을 알 수 있다. 만약 first.hash == hash 즉 배열에 저장된 node의 해시값(인덱스) 매개변수로 받아온 hash값이 같고 first.key == key 즉 배열에 저장된 node의 key와 매개변수로 받아온 key가 같아야 first를 반환한다. 해시값이 같으면서 key 값 까지 같아야 원하는 value를 찾는 것이다. reference : https://commons.wikimedia.org/wiki/File:Hash_table_5_0_1_1_1_1_0_LL.svg 정리HashMap의 get(K) 메서드 경우 <key, value> 쌍에서 어떤 key에 해당하는 value를 찾기 위해서 해시 함수에 해당 key를 넣어 얻은 해시함수를 통해 <key, value>가 저장되어있는 배열에서의 해당 인덱스를 바로 구할 수 있어 평균 O(1) 시간 복잡도로 매우 빠르게 원하는 value를 찾을 수 있다. 그러나 해시 충돌로 인하여 같은 해시값을 가지는 키 값들 경우 그것들 끼리 연결리스트로 연결 되어 있어 그 연결리스트를 순회하며 원하는 value를 찾아야 하기 때문에 시간이 더 걸리게 된다. 그럼에도 불구하고 해시 충돌이 일어나는 <key, value>간의 연결리스트 들만 순회하면 되기 때문에 여전히 매우 빠르게 원하는 결과를 찾을 수 있다. HashMap에서 중복체크를 왜 못하지?public final class PhoneNumber {
private final short areaCode;
private final short prefix;
private final short lineNum;
public PhoneNumber(short areaCode, short prefix, short lineNum) throws IllegalAccessException {
this.areaCode = rangeCheck(areaCode, 999, "지역코드");
this.prefix = rangeCheck(prefix, 999, "프리픽스");
this.lineNum = rangeCheck(lineNum, 9999, "가입자 번호");
}
private static short rangeCheck(int val, int max, String arg) throws IllegalAccessException {
if (val < 0 || val > max) {
throw new IllegalAccessException(arg + ": " + val);
}
return (short) val;
}
} 위의 코드는 equals, hashCode 모두 override를 하지 않은 클래스이다. 해당 클래스의 인스턴스를 HashMap의 키로 한다면 무슨일이 발생할까? public final class PhoneNumber {
private final short areaCode;
private final short prefix;
private final short lineNum;
public PhoneNumber(short areaCode, short prefix, short lineNum) throws IllegalAccessException {
this.areaCode = rangeCheck(areaCode, 999, "지역코드");
this.prefix = rangeCheck(prefix, 999, "프리픽스");
this.lineNum = rangeCheck(lineNum, 9999, "가입자 번호");
}
private static short rangeCheck(int val, int max, String arg) throws IllegalAccessException {
if (val < 0 || val > max) {
throw new IllegalAccessException(arg + ": " + val);
}
return (short) val;
}
@Override
public String toString() {
return "PhoneNumber{" +
"areaCode=" + areaCode +
", prefix=" + prefix +
", lineNum=" + lineNum +
'}';
}
} HashMap의 key에 PhoneNumber 객체 2개를 넣었다. 객체 인스턴스 변수가 모두 같게 하여 ker가 중복이 되어 map의 size가 1이 되길 희망했다. 하지만 결과는 당연히 중복이라고 판단하지 않았고 size는 2가 나왔다. 해결책 1 : equals를 재정의하여 해당 객체의 인스턴스 변수 값들이 모두 같으면 같은 객체로 보게 하자!따라서 PhoneNumber 에서 equals를 재정의 하였다. public final class PhoneNumber {
private final short areaCode;
private final short prefix;
private final short lineNum;
public PhoneNumber(short areaCode, short prefix, short lineNum) throws IllegalAccessException {
this.areaCode = rangeCheck(areaCode, 999, "지역코드");
this.prefix = rangeCheck(prefix, 999, "프리픽스");
this.lineNum = rangeCheck(lineNum, 9999, "가입자 번호");
}
private static short rangeCheck(int val, int max, String arg) throws IllegalAccessException {
if (val < 0 || val > max) {
throw new IllegalAccessException(arg + ": " + val);
}
return (short) val;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
PhoneNumber that = (PhoneNumber) o;
return areaCode == that.areaCode && prefix == that.prefix && lineNum == that.lineNum;
}
@Override
public String toString() {
return "PhoneNumber{" +
"areaCode=" + areaCode +
", prefix=" + prefix +
", lineNum=" + lineNum +
'}';
}
} 이후 다시 결과를 보면 여전히 map의 size가 2로 중복 체크가 되지 않았다. 해결책2 : equals를 재정의 했으므로 hashCode도 재정의하라!!equals 재정의 만으로는 중복체크가 안되었으니 HashMap의 put 메서드를 보자. 이전에 HashMap은 get을 할때 해시코드 값으로 위치를 찾아서 값을 가져왔다는 것을 설명하였다. 하지만 이미 존재한다면 ?? 여기서 중요하다 만약 해시 코드 값이 같고!!! (k = p.key) == key 여기서 equals를 쓰지 않았다. 즉 주소값 자체가 같다면 중복으로 보겠다는 것이다. public final class PhoneNumber {
private final short areaCode;
private final short prefix;
private final short lineNum;
public PhoneNumber(short areaCode, short prefix, short lineNum) throws IllegalAccessException {
this.areaCode = rangeCheck(areaCode, 999, "지역코드");
this.prefix = rangeCheck(prefix, 999, "프리픽스");
this.lineNum = rangeCheck(lineNum, 9999, "가입자 번호");
}
private static short rangeCheck(int val, int max, String arg) throws IllegalAccessException {
if (val < 0 || val > max) {
throw new IllegalAccessException(arg + ": " + val);
}
return (short) val;
}
@Override
public int hashCode() {
return Objects.hash(areaCode, prefix, lineNum);
}
@Override
public String toString() {
return "PhoneNumber{" +
"areaCode=" + areaCode +
", prefix=" + prefix +
", lineNum=" + lineNum +
'}';
}
} equals는 재정의하지 않고 hashCode만 재정의해도 결과는 여기서 || (key != null && key.equals(k) 즉 key가 null이 아니고 equals메서드로 판단했을 경우 같다면 같다고 중복으로 판단하겠다는 의미가 된다. 정리
따라서 equals, hashCode 메서드 모두 재정의해야만 제대로된 HashMap을 사용할 수 있게 된다. (중복 체크도 잘 되고) 결론으로 equals, hashCode 모두 재정의를 했을 경우 public final class PhoneNumber {
private final short areaCode;
private final short prefix;
private final short lineNum;
public PhoneNumber(short areaCode, short prefix, short lineNum) throws IllegalAccessException {
this.areaCode = rangeCheck(areaCode, 999, "지역코드");
this.prefix = rangeCheck(prefix, 999, "프리픽스");
this.lineNum = rangeCheck(lineNum, 9999, "가입자 번호");
}
private static short rangeCheck(int val, int max, String arg) throws IllegalAccessException {
if (val < 0 || val > max) {
throw new IllegalAccessException(arg + ": " + val);
}
return (short) val;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
PhoneNumber that = (PhoneNumber) o;
return areaCode == that.areaCode && prefix == that.prefix && lineNum == that.lineNum;
}
@Override
public int hashCode() {
return Objects.hash(areaCode, prefix, lineNum);
}
@Override
public String toString() {
return "PhoneNumber{" +
"areaCode=" + areaCode +
", prefix=" + prefix +
", lineNum=" + lineNum +
'}';
}
} size를 1로 보는 것으로 보아 중복체크를 잘 한 것을 확인할 수 있다. hashCode 메서드를 어떻게 재정의 할 것인가 ?@Override
public int hashCode() {
return Objects.hash(areaCode, prefix, lineNum);
} 위의 예시에서 hashCode 메서드의 재정의를 이렇게 하였다. 왜 이렇게 만들었을까? 좋은 해시 함수라면 서로 다른 인스턴스에 다른 해시코드를 반환해야 한다.!이상적인 해시 함수는 주어진 (서로 다른) 인스턴스들을 32비트 정수 범위에 균일하게 분배해야 한다.이상적인 해시 함수를 완벽히 실현하기는 어렵지만 hashCode 메서드를 이상과 최대한 비슷하게 만드는 규칙이 존재한다.
hashCode를 다 구현했다면 이 메서드가 동치인 인스턴스에 대해 똑같은 해시코드를 반환할지 꼭 확인해봐야 한다!!equals 비교에 사용되지 않은 필드는 무조건, 반드시 hashCode 구현에 있어서 제외해야함 !!public final class PhoneNumber {
private final short areaCode;
private final short prefix;
private final short lineNum;
public PhoneNumber(short areaCode, short prefix, short lineNum) throws IllegalAccessException {
this.areaCode = rangeCheck(areaCode, 999, "지역코드");
this.prefix = rangeCheck(prefix, 999, "프리픽스");
this.lineNum = rangeCheck(lineNum, 9999, "가입자 번호");
}
private static short rangeCheck(int val, int max, String arg) throws IllegalAccessException {
if (val < 0 || val > max) {
throw new IllegalAccessException(arg + ": " + val);
}
return (short) val;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
PhoneNumber that = (PhoneNumber) o;
return areaCode == that.areaCode && prefix == that.prefix && lineNum == that.lineNum;
}
@Override
public String toString() {
return "PhoneNumber{" +
"areaCode=" + areaCode +
", prefix=" + prefix +
", lineNum=" + lineNum +
'}';
}
} 위 상황에서 hashCode 메서드를 만드는 규칙을 이용해서 hashCode 메서드를 재정의 해보면 @Override
public int hashCode() {
int result = Short.hashCode(areaCode);
result = 31 * result + Short.hashCode(prefix);
result = 31 * result + Short.hashCode(lineNum);
return result;
} 위 hashCode 메서드는 위 규칙대로 인스턴스의 핵심 필드 3개만 사용한 간단한 계산만 수행한다. 해당 과정에서 비결정적(undetermined) 요소가 전혀 없기에 동치인 객체는 무조건 같은 해시코드 값을 가질 것!! 충분히 괜찮은 hashCode 메서드 이다.(단순하고, 충분히 빠르고, 서로 다른 전화번호들을 다른 해시 버킷들로 훌륭히 배분)그러나 무언가 귀찮지 않나? 간단하게 하는 법이 없을까? Objects 클래스가 제공해주는 해시코드를 계산해주는 정적 메서드인 hash 메서드Objects 클래스는 임의의 갯수 만큼 객체를 받아 해시코드를 계산해주는 정적 메서드인 hash를 제공 이 메서드 사용시 앞에서 규칙대로 구현한 hashCode 메서드와 비슷한 수준의 결과를 단 한 줄로 작성이 가능하다. @Override
public int hashCode() {
return Objects.hash(areaCode, prefix, lineNum);
} 하지만 항상 편리한 것이 무조건 좋을 수는 없는 법!! 단점Objects가 제공해주는 hash 메서드는 아쉽게도 속도가 느리다 (이전에 규칙대로 직접 작성한 hashCode 메서드에 비해)
-> 따라서 hash 메서드는 성능에 민감하지 않은 상황에서만 사용하길 바람!! 캐싱
Lazy Initialization (지연 초기화)
PhoneNumber 클래스는 굳이 이렇게 할 이유는 없지만 예시를 위해 해보면 private final short areaCode;
private final short prefix;
private final short lineNum;
private int hashCode; // PhoneNumber에 핵심 필드가 아닌 필드가 존재한다면
@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 사용자에게 자세히 공표하지 말아야 한다.그래야만 클라이언트가 이 값에 의지하지 않게 되고 추후에 계산 방식을 바꿀 수 있게 된다. 정리equals를 재정의 하거든 hashCode 또한 무조건 재정의 해라!!!!!!!!!!!! |
Beta Was this translation helpful? Give feedback.
Replies: 3 comments 3 replies
-
@JoisFe 님
👍 HashMap을 완전 분석(?) 하셨군요 |
Beta Was this translation helpful? Give feedback.
-
equals랑 hashCode를 재정의할 때는,, 직접 구현하는 것도 좋지만 IDE의 도움을 받거나 lombok의 도움을 받으면 좋을 거 같네요.
public class Foo {
int foo;
String bar;
@Override
public boolean equals(Object o) {
if (this == o)
return true;
if (o == null || getClass() != o.getClass())
return false;
Foo foo1 = (Foo)o;
return foo == foo1.foo && Objects.equals(bar, foo1.bar);
}
@Override
public int hashCode() {
return Objects.hash(foo, bar);
}
}
@EqualsAndHashCode
public class Foo {
int foo;
String bar;
} public class Foo {
int foo;
String bar;
public Foo() {
}
public boolean equals(Object o) {
if (o == this) {
return true;
} else if (!(o instanceof Foo)) {
return false;
} else {
Foo other = (Foo)o;
if (!other.canEqual(this)) {
return false;
} else if (this.foo != other.foo) {
return false;
} else {
Object this$bar = this.bar;
Object other$bar = other.bar;
if (this$bar == null) {
if (other$bar != null) {
return false;
}
} else if (!this$bar.equals(other$bar)) {
return false;
}
return true;
}
}
}
protected boolean canEqual(Object other) {
return other instanceof Foo;
}
public int hashCode() {
int PRIME = true;
int result = 1;
int result = result * 59 + this.foo;
Object $bar = this.bar;
result = result * 59 + ($bar == null ? 43 : $bar.hashCode());
return result;
}
} 요즘에는 lombok 사용이 거의 필수인데요,, |
Beta Was this translation helpful? Give feedback.
-
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
public class Test {
@EqualsAndHashCode.Include
private int num1;
private int num2;
protected boolean canEqual(final Object other) {
return other instanceof Test;
}
} @EqualsAndHashCode 해당 어노테이션의 default 값은 @EqualsAndHashCode(onlyExplicitlyIncluded = false) 입니다. |
Beta Was this translation helpful? Give feedback.
@EqualsAndHashCode.Exclude
,@EqualsAndHashCode.Include
저도 해당 어노테이션은 처음봐서 조금 더 찾아 봤습니다.@EqualsAndHashCode 해당 어노테이션의 default 값은 @EqualsAndHashCode(onlyExplicitlyIncluded = false) 입니다.
해당 경우는 클래스의 필드에
@EqualsAndHashCode.Include
를 필드에 정의하지 않아도 핵심 필드로서 동작을 합니다.하지만 위의 코드처럼 @EqualsAndHashCode(onlyExplicitlyIncluded = true) 값을 주게 된다면 반드시 핵심 필드를
@EqualsAndHashCode.Include
로 정의 해주어야 한다는 사실을 알게 되었습니다.Lombok이 굉장히 강력하네요.
앞으로 Lombok 어노테이션 사용할 때 상태 값으로 줄 수있는 경우…