동등

동등성 에서 C#

5개의 연습 문제

동등성 소개

코딩 연습 문제는 C#에서 동등성이 갖는 여러 속성을 보여줘요.

Object.Equals()

코딩 연습 문제에서 다루는 주제

  • 단순 타입(문자열과 기본형)은 보통 ==와 !=로 동등성을 검사해요. 이는 이런 타입에서도 사용할 수 있는 Equals() 메서드를 쓰는 것보다 더 관용적인 방식으로 여겨져요. Java 프로그래머는 문자열을 다룰 때, C#에서는 ==가 값으로 비교하지만 Java에서는 참조로 비교한다는 점에 주의해야 해요.
  • 참조 타입(클래스의 인스턴스)은 object에서 상속받은 Equals() 메서드로 비교해요. 동등성 검사의 목적이 두 객체가 정확히 같은 인스턴스인지 확인하는 것이라면 object의 구현을 그대로 쓰면 충분해요. 그렇지 않다면 object.Equals()를 재정의해야 해요.
  • 클래스의 모든 인스턴스가 한 곳에서 만들어진다고 확신할 수 있다면, 예를 들어 게임이나 시뮬레이션 속 캐릭터처럼 말이죠, 참조 동등성만으로 충분해요. 하지만 같은 실세계 개체에 대해 여러 인스턴스가 만들어지는 경우가 많아요(예: 데이터베이스, 사용자 입력, 웹 요청을 통해). 이럴 때는 개체를 고유하게 식별하는 값들을 대상으로 동등성을 검사해야 해요. 따라서 Equals()를 재정의해야 하죠.
  • 재정의한 Equals()에는 단순 타입 멤버에 대한 == 비교와 참조 타입 멤버에 대한 재귀적 Equals() 호출이 들어가요.
class StatusBar
{
    private readonly int width = 200, height = 20;

    public override bool Equals(object other)
    {
        // ... null and type checks and performance optimisations
        return width == (other as StatusBar).width && height == (other as StatusBar).height;
    }
}
class Window
{
    private readonly string title = "Main";
    private readonly StatusBar statusBar = new StatusBar();

    public override bool Equals(object other)
    {
        // ... null and type checks and performance optimisations
        return title == (other as Window).title && statusBar.Equals((other as Window).statusBar);
    }
}

  • 정적 메서드 object.ReferenceEquals()는 두 객체가 같은 인스턴스인지 판별할 때 사용해요. 이는 코드를 명확하게 해 주고, Equals()와 ==를 재정의하거나 오버로드한 경우에는 꼭 필요해요.
var winA = new Window(); // above code shows that all windows are equal
var winB = new Window();
ReferenceEquals(winA, winB);
// => false
var winC = winA;
ReferenceEquals(winA, winC);
// => true

부수적인 주제

  • public override bool Equals(object obj) 외에도, IDE는 보통 상속이 관련될 때 사용할 protected bool Equals(FacialFeatures other) 오버로드를 생성해요. 파생 클래스는 기반 클래스의 Equals()를 호출한 뒤 자신만의 검사를 추가할 수 있어요.
  • == 연산자와 클래스의 Equals() 메서드를 모두 오버로드했거나(operator-overloading 연습 문제를 참고하세요), 참조가 같은지만 확인하면 되는 경우가 아니라면 ==는 쓰지 않는 게 좋아요.
  • struct의 동등성 검사는 structs 연습 문제에서 다뤄요.
  • tuple의 동등성은 tuples 연습 문제에서 다뤄요.
  • 많은 개발자는 동등성 메서드 구현을 IDE에 맡겨요. IDE가 동등성과 관련된 자잘한 부분을 모두 처리해 주니까요. 예를 들어 JetBrains의 RIDER(버전 2020.1)는 클래스에 대해 다음과 같은 동등성 메서드를 생성해요:
protected bool Equals(T other)
{
    return field1 == other.field1 && field2.Equals(other.field2);
}

public override bool Equals(object obj)
{
    if (ReferenceEquals(null, obj)) return false;
    if (ReferenceEquals(this, obj)) return true;
    if (obj.GetType() != this.GetType()) return false;
    return Equals((T) obj);
}
  • IDE가 생성한 코드를 직접 개선하려 한다면 주의하세요. 그 코드는 보통 null이나 타입이 잘못된 객체에도 견고하고, 어느 정도 최적화도 되어 있어요.
  • delegate의 동등성 검사는 이 연습 문제에서 특별히 다루지 않아요.
  • 배열과 대부분의 컬렉션에는 기본으로 제공되는 동등성 검사가 없어요. LINQ(뒤의 연습 문제에서 다뤄요)가 SequenceEquals()를 제공하지만, LINQ가 없다면 두 컬렉션을 순회하며 항목을 하나씩 비교하는 수밖에 없어요.
  • 직접 만든 클래스에서 ==와 !=를 어떻게 사용하는지는 operator-overloading 연습 문제를 참고하세요.

Object.GetHashCode()

  • object.GetHashCode()는 32비트 정수 형태의 해시 코드를 반환해요. 해시 코드는 Dictionary<T>나 HashSet<T> 같은 딕셔너리와 집합 클래스가 객체를 효율적으로 저장하고 조회하는 데 사용돼요. 딕셔너리의 경우 해싱은 키와 관련이 있어요.
  • C# 개발자들 사이에는 Equals()를 재정의하면 GetHashCode()도 함께 재정의해야 한다는 기대가 있어요. Equals()와 GetHashCode() 사이에는 관계가 있는데, 이는 딕셔너리와 해시 집합 클래스, 그리고 해시 코드를 사용하는 다른 클래스들이 올바르게 동작하려면 반드시 지켜져야 해요. 나중에 해시 코드 기반 컬렉션을 추가할지도 모르는 유지보수자에게 함정을 남기지 않도록 이 메서드를 구현해야 해요.
  • 해시 코드와 동등성의 관계는 이래요. 두 객체가 같다면(Equal()이 true를 반환한다면) 두 객체의 GetHashCode()는 같은 값을 반환해야 해요. 하지만 그 반대 방향은 성립하지 않아요. 대칭적이지 않죠. 해시 코드를 기준으로 먼저 "버킷"으로 간 다음, 동등성 검사를 통해 특정 항목을 골라내는 조회 함수를 떠올려 보세요.
  • 해시 코드를 만드는 가장 쉬운 방법은 동등성 검사에 사용한 값들(또는 그 일부)을 넘겨 HashCode.Combine()을 호출하는 거예요. Combine()에 전달하는 정보가 많을수록 해시 구현의 성능이 더 좋아질 가능성이 크다는 점을 기억하세요.
public class Assessment
{
    private int rating;
    private Person boss;

    public override int GetHashCode()
    {
        return HashCode.Combine(rating, boss);
    }
}
  • 동등성 검사에 사용하는 값들은 해시 컬렉션이 사용되는 동안 변하지 않아야 해요. 객체를 어떤 값들로 컬렉션에 추가한 뒤 그 값들을 바꾸면, 해시 코드가 더 이상 올바른 "버킷"을 가리키지 않게 돼요. 실제로는 객체가 불변이어야 한다는 뜻이에요. 다른 방식은 유지보수자에게 골칫거리를 만들 위험이 있어요. 불변성은 다른 연습 문제에서 다뤄요.
  • 라이브러리 루틴이 만들어 내는 것보다 더 나은 해시 코드를 설계할 수도 있어요. 하지만 그건 데이터의 특성을 잘 알고 있거나, 해싱 없이 값을 그대로 써도 될 만큼 아주 단순한 컬렉션일 때뿐이에요. 추가로 드는 노력이 가치가 없을 수도 있고요.

성능 향상

성능을 조금 향상하려면, 특히 객체가 컬렉션에 속하는 경우 public bool Equals(T other) 오버로드 멤버를 추가할 수 있어요.

이렇게 하면 참조 타입에서는 타입 검사를 어느 정도 줄일 수 있고, 값 타입에서는 public override bool Equals(object other)의 인자로 전달되기 위해 객체로 변환(박싱)될 필요가 없어 박싱 단계를 하나 줄일 수 있어요.

클래스에 IEquatable<T> 인터페이스를 추가하면 이 오버로드를 반드시 구현해야 해요. 코드에 IEquatable<T> 타입의 객체를 받는 루틴(아마도 구현 클래스와 무관하게 동등성과 관련된 흥미로운 작업을 하는 루틴)이 없다면, 인터페이스를 포함시킬 만한 다른 설득력 있는 이유는 사실상 없어요.

IEqualityComparer<T>

두 가지 서로 다른 방식으로 고유하게 식별할 수 있는 클래스가 있다고 해봐요. 예를 들어 SSID와 고유한 이메일 주소를 가진 Person 클래스라면, .NET은 두 개의 서로 다른 컬렉션이 각기 다른 해시 코드와 동등성 검사를 사용하도록 하는 방법을 제공해요. 각 컬렉션은 자체 Equals()와 GetHashCode() 메서드를 가진 서로 다른 IEqualityComparer<T> 구현을 가질 수 있어요. SSID를 키로 하는 딕셔너리와 이메일 주소를 키로 하는 딕셔너리를 각각 만들 수 있죠.

IEqualityComparer<T>를 사용하더라도, 컬렉션 클래스 밖에서 생길 수 있는 문제를 피하려면 보통 항목 클래스에서 Equals()와 GetHashCode()를 여전히 재정의해요.

IEqualityComparer<T>를 사용할 때 고려할 점은, private 메서드 등은 동등성 검사에 사용할 수 없다는 거예요.

해시 컬렉션이 하나뿐이라면 IEqualityComparer<T>를 피하고 동등성과 해시 코드를 객체 자체에 캡슐화하는 편이 더 나을 수 있어요. 객체와 해시 컬렉션 사이의 핵심적인 의존 관계를 컴파일러가 강제할 수 없다는 것 자체가 이상적이지는 않지만, 해싱과 비교 로직이 다른 곳에 있으면 유지보수자가 컬렉션에 미칠 영향을 고려하지 않고 클래스 멤버를 변경하기가 훨씬 쉬워져요.

부동소수점 동등성에 관한 참고

부주의한 코더를 곤란하게 만들 수 있는 기본형 중 하나는 부동소수점 값의 동등성을 검사하는 거예요. 이는 floating-point-numbers 개념의 about.md 문서에서 다뤄요.

동등성과 상속

이 글은 동등성과 상속과 관련해 내려야 하는 몇 가지 결정을 보여줘요. 이는 클래스와 파생 클래스의 인스턴스를 함께 다루면서 둘 사이의 동등성을 검사해야 할 때만 해당하는 이야기예요.

GitHub에서 편집 링크가 새 창이나 탭에서 열려요

동등성 배우기

연습이 잠겨 있어요

동등성 개념을 연습하려면 연습 문제 4개를 더 잠금 해제해요