변경 가능한 키 사용 피하기
변경 가능한 키를 사용하는 것은 패턴에 반하는 행위이므로 절대 피해야 합니다. 그렇게 할 경우 발생할 수 있는 부작용은 끔찍합니다. 결국 Map의 콘텐츠에 도달할 수 없게 될 수 있습니다.
이를 보여주기 위해 예제를 설정하는 것은 아주 쉽습니다. 다음은 String의 변경 가능한 래퍼인 Key 클래스입니다. equals() 및 hashCode() 메서드는 IDE에서 생성할 수 있는 코드로 재정의되었음을 참고하세요.
//
// !!!!! This an example of an antipattern !!!!!!
// !!! do not do this in your production code !!!
//
class Key {
private String key;
public Key(String key) {
this.key = key;
}
public String getKey() {
return key;
}
public void setKey(String key) {
this.key = key;
}
@Override
public String toString() {
return key;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Key key = (Key) o;
return Objects.equals(this.key, key.key);
}
@Override
public int hashCode() {
return key.hashCode();
}
}이 래퍼를 사용하여 키-값 쌍을 넣을 Map을 만들 수 있습니다.
Key one = new Key("1");
Key two = new Key("2");
Map<Key, String> map = new HashMap<>();
map.put(one, "one");
map.put(two, "two");
System.out.println("map.get(one) = " + map.get(one));
System.out.println("map.get(two) = " + map.get(two));지금까지 이 코드는 정상이며 다음과 같이 출력됩니다:
map.get(one) = one
map.get(two) = two누군가 키를 변이시키면 어떻게 되나요? 변이에 따라 달라집니다. 다음 예시를 통해 값을 다시 가져오려고 할 때 어떤 일이 발생하는지 확인해 보세요.
다음 예에서는 기존 키 중 하나를 이미 존재하는 키와 일치하지 않는 새로운 값으로 변경하는 경우입니다.
one.setKey("5");
System.out.println("map.get(one) = " + map.get(one));
System.out.println("map.get(two) = " + map.get(two));
System.out.println("map.get(new Key(1)) = " + map.get(new Key("1")));
System.out.println("map.get(new Key(2)) = " + map.get(new Key("2")));
System.out.println("map.get(new Key(5)) = " + map.get(new Key("5")));결과는 다음과 같습니다. 동일한 객체를 사용하더라도 더 이상 키에서 값을 가져올 수 없습니다. 또한 원래 값을 보유하고 있는 키에서 값을 가져오는 것도 실패합니다. 이 키-값 쌍이 손실됩니다.
map.get(one) = null
map.get(two) = two
map.get(new Key(1)) = null
map.get(new Key(2)) = two
map.get(new Key(5)) = null키를 다른 기존 키에 사용되는 값으로 변경하면 결과가 달라집니다.
one.setKey("2");
System.out.println("map.get(one) = " + map.get(one));
System.out.println("map.get(two) = " + map.get(two));
System.out.println("map.get(new Key(1)) = " + map.get(new Key("1")));
System.out.println("map.get(new Key(2)) = " + map.get(new Key("2")));이제 결과는 다음과 같습니다. 변이된 키에 바인딩된 값을 가져오면 다른 키에 바인딩된 값이 반환됩니다. 그리고 이전 예제에서와 마찬가지로 더 이상 변이된 키에 바인딩된 값을 가져올 수 없습니다.
map.get(one) = two
map.get(two) = two
map.get(new Key(1)) = null
map.get(new Key(2)) = two아주 간단한 예시에서도 알 수 있듯이, 첫 번째 키는 더 이상 올바른 값에 액세스할 수 없으며 그 과정에서 값이 손실될 수 있습니다.
요컨대, 정말 변경 가능한 키 사용을 피할 수 없다면 변경하지 마세요. 하지만 최선의 선택은 변경 불가능한 키를 사용하는 것입니다.
HashSet의 구조 자세히 알아보기
이 섹션에서 HashSet 클래스에 대해 이야기하는 것이 왜 흥미로운지 궁금할 수 있습니다. 사실 HashSet 클래스는 내부의 HashMap 위에 구축된 것으로 밝혀졌습니다. 따라서 두 클래스는 몇 가지 공통된 기능을 공유합니다.
다음은 HashSet 클래스의 add(element) 코드입니다:
private transient HashMap<E,Object> map;
private static final Object PRESENT = new Object();
public boolean add(E e) {
return map.put(e, PRESENT)==null;
}보시다시피 HashSet은 실제로 객체를 HashMap에 저장합니다(transient 키워드는 관련이 없습니다). 객체는 이 HashMap의 키이며 값은 아무런 의미가 없는 객체, 즉 플레이스홀더에 불과합니다.
여기서 기억해야 할 중요한 점은 객체를 Set에 추가한 후 객체를 변경하면 애플리케이션에서 수정하기 어려운 이상한 버그가 발생할 수 있다는 것입니다.
변경 가능한 Key 클래스가 있는 이전 예제를 다시 살펴봅시다. 이번에는 이 클래스의 인스턴스를 Set에 추가하겠습니다.
Key one = new Key("1");
Key two = new Key("2");
Set<Key> set = new HashSet<>();
set.add(one);
set.add(two);
System.out.println("set = " + set);
// You should never mutate an object once it has been added to a Set!
one.setKey("3");
System.out.println("set.contains(one) = " + set.contains(one));
boolean addedOne = set.add(one);
System.out.println("addedOne = " + addedOne);
System.out.println("set = " + set);이 코드를 실행하면 다음과 같은 결과가 생성됩니다:
set = [1, 2]
set.contains(one) = false
addedOne = true
set = [3, 2, 3]실제로 첫 번째 요소와 세트의 마지막 요소가 동일하다는 것을 알 수 있습니다:
List<Key> list = new ArrayList<>(set);
Key key0 = list.get(0);
Key key2 = list.get(2);
System.out.println("key0 = " + key0);
System.out.println("key2 = " + key2);
System.out.println("key0 == key2 ? " + (key0 == key2));이 마지막 코드를 실행하면 다음과 같은 결과를 얻을 수 있습니다:
key0 = 3
key2 = 3
key0 == key2 ? true이 예제에서는 개체를 한 번 Set에 추가한 후 변경하면 동일한 개체가 이 Set에 두 번 이상 포함될 수 있다는 것을 보았습니다. 간단히 말해서 그렇게 하지 마세요!