Equals 와 GetHashCode 는 반드시 같이 고친다
좌표를 키로 쓰는 코드를 짜 봅니다.
class 칸
{
public int X;
public int Y;
public 칸(int x, int y) { X = x; Y = y; }
}
var 지도 = new Dictionary<칸, string>();
지도[new 칸(1, 2)] = "보물";
Console.WriteLine(지도[new 칸(1, 2)]); // KeyNotFoundException
방금 넣었는데 없다고 합니다. (1, 2) 로 넣고 (1, 2) 로 꺼냈는데요.
Dictionary 는 키가 같은지 물어보기 전에 먼저 해시로 자리를 정합니다. 그 자리가 다르면 비교할 기회조차 없습니다.
기본 Equals 는 내용을 안 본다
class 로 만든 타입은 아무것도 안 해 주면 주소로 비교합니다.
칸 a = new 칸(1, 2);
칸 b = new 칸(1, 2);
Console.WriteLine(a == b); // False
Console.WriteLine(a.Equals(b)); // False
new 를 두 번 했으니 힙에 상자가 둘입니다. 주소가 다르니 다른 것입니다.
안에 든 값이 같은지는 아무도 안 봤습니다.
이게 기본값인 데는 이유가 있습니다. 컴파일러는 X 와 Y 가 이 객체의 정체성인지 알 수 없습니다.
Dog 라면 이름이 같아도 다른 개일 수 있습니다. 그래서 판단을 우리에게 넘깁니다.
struct 는 조금 다릅니다. 기본으로 필드를 하나씩 비교해 줍니다.
그래서 앞에서 만든 점 은 Equals 를 안 고쳐도 (1,2) 와 (1,2) 가 같다고 나옵니다.
다만 그 기본 구현은 안에서 리플렉션을 쓰거나 박싱을 하는 경우가 있어 느립니다.
값 타입도 자주 비교할 것이면 직접 적어 주는 편이 낫습니다.
그래서 Equals 를 고쳤는데도 못 찾는다
내용으로 비교하게 고쳐 봅니다.
public override bool Equals(object obj)
{
if (obj is 칸 other)
return X == other.X && Y == other.Y;
return false;
}
이제 a.Equals(b) 는 True 입니다. 그런데 Dictionary 는 여전히 못 찾습니다.
앞에서 해시 테이블을 봤습니다. 값을 넣을 때 키를 훑어보는 것이 아니라, 키로 숫자를 계산해서 그 번호의 칸에 넣었습니다. 찾을 때도 마찬가지로 계산부터 합니다.
Equals 는 3번에서만 쓰입니다. 자리를 정하는 것은 1번이고, 거기서는 GetHashCode 만 봅니다.
기본 GetHashCode 는 객체마다 다른 숫자를 돌려줍니다. new 를 두 번 했으니 숫자가 다릅니다.
숫자가 다르면 다른 칸을 보고, 그 칸은 비어 있고, 없다고 답합니다.
Equals 까지 가지도 못했습니다.
둘은 짝이다
여기서 규칙 하나가 나옵니다. 외우는 규칙이 아니라 위 순서에서 저절로 나오는 것입니다.
같다고 판단한 둘은 해시도 반드시 같아야 합니다.
같은데 해시가 다르면 서로 다른 칸에 가서 영영 못 만납니다.
반대는 성립하지 않아도 됩니다. 해시가 같은데 다른 것은 괜찮습니다.
같은 칸에 둘이 들어오면 그때 Equals 로 갈라내면 되니까요.
해시는 숫자 하나로 줄이는 일이라 겹치는 것을 아예 막을 수는 없습니다.
그래서 둘을 같이 고칩니다.
public override bool Equals(object obj)
{
if (obj is 칸 other)
return X == other.X && Y == other.Y;
return false;
}
public override int GetHashCode()
{
return HashCode.Combine(X, Y);
}
HashCode.Combine 은 여러 값을 하나의 숫자로 잘 섞어 줍니다. 직접 만들 이유가 없습니다.
없는 환경이면 이렇게 씁니다.
public override int GetHashCode()
{
unchecked
{
return X * 397 ^ Y;
}
}
unchecked 는 넘쳐도 그냥 두라는 뜻입니다. 정수는 원이라 한 바퀴 돌아도 상관없습니다.
해시는 넘치는 것이 정상입니다.
무엇으로 해시를 만드나
Equals 에서 본 필드만 씁니다. 그리고 그 필드는 넣은 뒤에 안 바뀌어야 합니다.
이걸 어기면 이런 일이 생깁니다.
칸 k = new 칸(1, 2);
지도[k] = "보물";
k.X = 5; // 키로 쓴 객체를 고쳤습니다
Console.WriteLine(지도[k]); // 못 찾습니다
넣을 때는 (1,2) 의 해시로 계산한 칸에 들어갔습니다.
지금 찾을 때는 (5,2) 의 해시로 계산하니 다른 칸을 봅니다.
값은 분명 안에 있는데 아무도 꺼낼 수 없습니다. 지우지도 못합니다.
그래서 키로 쓸 타입은 만든 뒤에 안 바뀌게 만드는 편이 안전합니다.
readonly 로 잠그거나, 아예 record 로 만들면 됩니다.
그냥 record 를 쓰면 된다
C# 9 부터는 이 셋을 한 줄로 끝낼 수 있습니다.
record 칸(int X, int Y);
Equals, GetHashCode, ToString, ==, != 를 전부 만들어 줍니다. 내용으로 비교하는 쪽으로요.
var 지도 = new Dictionary<칸, string>();
지도[new 칸(1, 2)] = "보물";
Console.WriteLine(지도[new 칸(1, 2)]); // 보물
record struct 라고 쓰면 값 타입 record 가 됩니다.
그럼 직접 쓸 일이 없느냐 하면 그렇지는 않습니다.
필드 일부만 정체성으로 삼고 싶을 때가 있습니다. 예를 들어 아이디가 같으면 같은 사용자로 보고 싶은데,
record 는 모든 필드를 비교하니 이름이 다르면 다른 사람이 됩니다.
그때는 직접 적어야 합니다.
정리
- 기본
Equals는class에서 주소를 봅니다. 내용이 같아도 다른 것으로 나옵니다 - Dictionary 는
GetHashCode로 자리를 먼저 정하고 그 칸 안에서만Equals를 씁니다 - 그래서
Equals만 고치면 못 찾습니다.GetHashCode를 안 고쳐 자리가 어긋납니다 - 같으면 해시도 같아야 합니다. 해시가 같은데 다른 것은 괜찮습니다
- 해시는 안 바뀌는 필드로 만듭니다. 키로 쓴 객체를 고치면 영영 못 찾습니다
- 대부분은
record한 줄로 끝납니다