언어/C#

C# 의 타입 - 4. 객체의 식별 (Equals, GetHashCode)

tsyang 2020. 11. 14. 21:08

Equals


System.Object 타입은 Equals 가상 메서드를 제공한다. 매개변수로 지정한 객체가 현재 객체와 동일하다고 판단하면 true를 반환한다. 

 

다음과 같은 Equals 메서드가 있다고 하자

 

public virtual bool Equals(Object obj)
{
	if(this == obj) return true;
    
    return false;
}

 

두 객체의 포인터 주소가 동일하다면 같은 객체를 가리키는 것이므로 참을 반환하는 것은 옳다. 그러나 이것은 객체들의 값들을 비교하지는 못하므로 때에 따라서 적절하지 못할 수 있다.

 

그래서 프로그래머는 때때로 Equals를 재정의해서 사용해야 하는데, 이 때 객체가 같은 값을 가리키는지 확인하는 게 불가능해지므로 Object타입 내에서 ReferenceEquals라는 정적 메서드를 제공한다. (같은 객체를 가리키면 참을 반환)

 

단, System.ValueType를 상속하는 개체들의 Equals는 모든 필드의 값들을 비교하여 객체의 동일 여부를 판단한다. (이 과정에서 Object.Equals는 호출되지 않는다.)

 

내부적으로 ValueType의 Equals 메서드는 리플렉션을 이용하여 동일 여부를 판단할 수 있는데, CLR의 리플렉션 메커니즘 자체가 느리기 때문에 프로그래머가 값 타입을 새로 만든다면 가급적 Equals를 재정의해주는 게 좋다. 물론 이 경우 base.Equals를 호출하지 않아야 한다.

 

Equals를 재정의하기로 했다면 다음의 4가지 기준을 충족하는지 확인하자.

 

  1. 재귀적(Reflexive)이어야 함 :  x.Equals(x)가 true를 반환해야 한다.
  2. 대칭성(Symmetric)이 있어야함 : x.Equals(y)와 y.Equals(x)의 반환 값은 같아야 한다.
  3. 전이성(Transitive)이 있어야 함 : x.Equals(y)가 참이고 y.Equals(z)가 참이면 x.Equals(z)는 참이어야 한다.
  4. 일관성이 있어야 함 : 비교되는 두 값 사이에 변화가 일어나지 않았다면 언제나 같은 값을 반환해야 함.

추가적으로 다음의 것들을 구현해볼 만하다

 

  1. System.IEquatable<T> 인터페이스의 Equals 메서드를 구현

    보통은 Object 매개변수를 받아서 메서드를 구현하고 내부적으로 타입 안정성을 부합하도록 하지만 이걸 구현하면 타입 안정성을 보장할 수 있다.

  2. == 연산자와 != 연산자 메서드를 재정의하기 

추가적으로 정렬을 위한 비교를 수행하기 위해서는 System.Icomparable의 CompareTo 나 System.IComparable<T>의 타입 안정성을 보장하는 CompareTo 를 구현할 수 있으며 추가적으로 <,<=,>,>= 연산자에 대한 재정의도 수행할 수 있다.

 

 

객체 해시 코드


해시 테이블에 객체를 넣어서 사용할 수 있게 하기 위해 System.Object 타입에는 GetHashCode 가상 메서드가 있으며 이 메서드는 Int32 타입의 해시 코드를 만들어주며 이를 따로 구현하지 않아도 자동으로 해시 코드를 생성하여 반환하도록 구현이 되어있다.

 

그러나 만약 Equals 메서드를 재정의했다면, 반드시 GetHashCode메서드에 대해서도 재정의를 해줘야 한다. 마이크로소프트의 C# 컴파일러는 Equals만 재정의한 경우 경고 메시지를 보낸다.

 

그렇다면 왜 Equals를 재정의한 경우 GetHashCode 메서드도 재정의를 해야 할까?

 

System.Collections.HashTable타입이나 System.Collections.Generic.Dictionary 타입을 비롯한 모든 컬렉션에서는 두 객체가 동일한지 살피기 위해 같은 해시 코드를 계산할 수 있는지 살피기 때문이다. 

 

GetHashCode를 재정의할 때는 다음을 고려하자

 

  1. 충분히 임의 분포적인 알고리즘을 사용하자
  2. 상위 타입의 GetHashCode를 사용할 수 있다. 그러나 Object.GetHashCode는 성능이 충분히 좋지 않으므로 사용하지 않는 편이 좋다.
  3. 최소한 하나 이상의 인스턴스 필드를 활용해야 한다.
  4. 이상적으로, 해쉬 값을 만들어내는데 쓰이는 필드들은 변경 불가 상태여야 한다
  5. 빠르면 좋다.
  6. 같은 값을 가지는 객체는 항상 같은 해시 코드를 반환해야 한다. (Ex. 문자열)
  7. 해시 코드 값을 절대 재사용하지 마라.(DB 같은데 넣지 말란 뜻인 듯) (Ex. 아이디와 비번을 해쉬에 저장하는데, 비밀번호 문자열에 대해 GetHashCode를 호출하고 이를 DB에 저장한다... 만약 CLR 버전이 바뀌어 다른 해쉬 코드가 나온다면..?)

System.Object에는 필드가 없기 때문에 기본 GetHashCode 메서드는 필드에 대한 정보가 없다. 따라서 Object.GetHashCode는 그저 객체의 생명주기 동안 변치 않는 임의의 고정된 숫자를 하나 반환한다.

 


 

Example (2021.7.15 추가)


Rider가 자동으로 생성한 Equality Members를 보면 감을 좀 잡을 수 있다. (특히 GetHashCode)

 

예를 들어, 다음과 같은 타입이 있다. (3차 에르밋 곡선의 계수를 모아놓은 타입이다.)

    public struct Curve 
    {
        public Vector3 a3, a2, a1, a0;
    }

 

IEquatable을 추가할 수도 안 할 수도 있지만 나는 추가했다. 그 뒤 나머지는 라이더에게 맡기면,

    public struct Curve : System.IEquatable<Curve>
    {
        public Vector3 a3, a2, a1, a0;

        public bool Equals(Curve other)
        {
            return a3.Equals(other.a3) && a2.Equals(other.a2) && a1.Equals(other.a1) && a0.Equals(other.a0);
        }

        public override bool Equals(object obj)
        {
            return obj is Curve other && Equals(other);
        }

        public override int GetHashCode()
        {
            unchecked
            {
                var hashCode = a3.GetHashCode();
                hashCode = (hashCode * 397) ^ a2.GetHashCode();
                hashCode = (hashCode * 397) ^ a1.GetHashCode();
                hashCode = (hashCode * 397) ^ a0.GetHashCode();
                return hashCode;
            }
        }
    }

위와 같은 코드가 생긴다. Operator까지 오버로드 해주면 더 좋을 것이다. 참고로 unchecked 키워드는 해당 Scope 내에서 정수 연산의 오버플로를 검사하지 않겠다는 뜻이다.