Equals 와 GetHashCode 는 반드시 같이 고친다
Esc

    Equals 와 GetHashCode 는 반드시 같이 고친다

    –C#

    좌표를 키로 쓰는 코드를 짜 봅니다.

    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 한 줄로 끝납니다