동등 연산자 - 두 개체가 같은지(또는 다른지) 확인하기
동등 연산자 - 두 개체가 같은지(또는 다른지) 확인하기
두 개체가 같은지, 아니면 다른지를 확인할 때 쓰는 연산자가 바로 동등 연산자예요. C#에서는 ==(equality, 같음)와 !=(inequality, 같지 않음) 두 개가 있는데, 이 연산자는 피연산자가 서로 같은지 아닌지 판단해요. 여기서 '같다'의 기준이 그냥 딱 하나만 있는 게 아니라, 값 타입이냐 참조 타입이냐에 따라 달라진답니다. 값 타입은 내용(값)이 같으면 같다고 보고, 참조 타입은 두 변수가 같은 저장 공간(storage)을 가리킬 때 같다고 봐요. 이 차이를 이해하는 게 오늘 내용의 핵심이에요.
상수 값을 비교할 때는 == 대신 is 패턴 매칭 연산자를 쓸 수도 있어요 (상수 패턴 참고). is 연산자는 모든 값 타입과 참조 타입에 기본 동등 의미(semantics)를 사용해요.
출처: Equality operators - test if two objects are equal or not
본문
==(동등) 연산자
동등 연산자 ==는 피연산자가 같으면 true를, 다르면 false를 돌려줘요. 기준이 타입마다 조금씩 다르니 하나씩 살펴볼게요.
값 타입(value type)의 동등성
기본 제공 값 타입의 피연산자는 그 값이 같으면 같다고 해요.
:::code language="csharp" source="snippets/shared/EqualityOperators.cs" id="ValueTypesEquality":::
[!NOTE]
==,<,>,<=,>=연산자에서 어느 한 피연산자가 숫자가 아닐 때(xref:System.Double.NaN?displayProperty=nameWithType 또는 xref:System.Single.NaN?displayProperty=nameWithType)에는 연산 결과가false예요. 즉NaN값은 다른 어떤double(또는float) 값과 비교해도 크지도, 작지도, 같지도 않아요.NaN자신과 비교해도 마찬가지랍니다. 자세한 내용과 예제는 xref:System.Double.NaN?displayProperty=nameWithType 또는 xref:System.Single.NaN?displayProperty=nameWithType 문서를 보세요.
같은 enum 타입의 피연산자 두 개는, 그 기반이 되는 정수 타입의 값이 서로 같을 때 같다고 봐요.
사용자가 정의한 struct 타입은 기본적으로 == 연산자를 지원하지 않아요. == 연산자를 쓰려면 직접 오버로드해야 해요.
C#의 튜플은 ==와 != 연산자를 기본으로 지원해요. 자세한 내용은 튜플 타입 문서의 튜플 동등성 섹션을 참고하세요.
참조 타입(reference type)의 동등성
기본적으로, record를 제외한 참조 타입의 피연산자는 같은 개체를 가리킬 때 같다고 봐요.
:::code language="csharp" source="snippets/shared/EqualityOperators.cs" id="ReferenceTypesEquality":::
위 예제에서 볼 수 있듯이, 사용자가 정의한 참조 타입은 기본적으로 == 연산자를 지원해요. 다만 참조 타입이 == 연산자를 오버로드할 수도 있지요. 만약 어떤 참조 타입이 ==를 오버로드했다면, 그 타입의 두 참조가 같은 개체를 가리키는지 확인하려면 xref:System.Object.ReferenceEquals*?displayProperty=nameWithType 메서드를 쓰면 돼요.
record 타입의 동등성
record 타입은 기본적으로 값 동등(value equality) 의미를 제공하는 ==와 != 연산자를 지원해요. 다시 말해, 두 record 피연산자가 모두 null이거나, 모든 필드와 자동 구현 프로퍼티의 값이 서로 같을 때 두 record는 같다고 봐요.
:::code language="csharp" source="snippets/shared/EqualityOperators.cs" id="RecordTypesEquality":::
위 예제에서 볼 수 있듯이, record의 참조 타입 멤버의 동등성은 그 멤버가 가진 각자의 동등 구현을 사용해 비교해요.
문자열(string)의 동등성
두 string 피연산자가 같으려면 둘 다 null이거나, 두 문자열 인스턴스가 같은 길이이면서 각 문자 위치의 문자가 모두 같아야 해요.
:::code language="csharp" source="snippets/shared/EqualityOperators.cs" id="StringEquality":::
문자열 동등 비교는 대소문자를 구분하는 서수(ordinal) 비교예요. 문자열 비교에 대한 자세한 내용은 C#에서 문자열을 비교하는 방법을 참고하세요.
대리자(delegate)의 동등성
같은 런타임 타입의 두 대리자 피연산자가 같으려면 둘 다 null이거나, 두 **호출 목록(invocation list)**의 길이가 같고 각 위치의 항목(entry)이 같아야 해요.
:::code language="csharp" source="snippets/shared/EqualityOperators.cs" id="DelegateEquality":::
[!IMPORTANT] 호출 목록에서 '같은 항목'에는 호출에 포함된 모든 고정 매개 변수(수신자 receiver 포함)가 포함돼요. 여기서 수신자는 항목이 호출될 때
this가 가리키는 개체 인스턴스를 말해요.
:::code language="csharp" source="snippets/shared/EqualityOperators.cs" id="SnippetCheckReceiver":::
자세한 내용은 C# 언어 사양의 대리자 동등 연산자 섹션을 보세요.
의미적으로 동일한 람다 식으로 만들어진 대리자라도 서로 같지 않아요. 아래 예제가 바로 그 경우예요.
:::code language="csharp" source="snippets/shared/EqualityOperators.cs" id="IdenticalLambdas":::
!=(부등) 연산자
부등 연산자 !=는 피연산자가 서로 같지 않으면 true를, 같으면 false를 돌려줘요. 기본 제공 타입의 피연산자에 대해서는 x != y가 !(x == y)와 같은 결과를 내요. 타입별 동등성에 대한 자세한 내용은 위의 동등 연산자 섹션을 참고하세요.
아래 예제는 != 연산자의 사용법을 보여줘요.
:::code language="csharp" source="snippets/shared/EqualityOperators.cs" id="NonEquality":::
클래스 계층 구조에서의 동등성
record는 상속을 손대지 않아도 올바르게 처리해 줘요. 컴파일러가 생성하는 동등 코드는 런타임 타입과 선언된 모든 프로퍼티를 모두 확인하기 때문에, **대칭성(symmetry)**과 전이성(transitivity) 요구 사항을 자동으로 만족시켜요. 값 동등성이 목표라면 수동으로 unsealed 계층 구조를 만들기보다 record를 쓰는 게 낫습니다.
[!IMPORTANT] 가능하면 언제나
record를 쓰세요. 컴파일러가 필요한 모든 동등 멤버를 생성해 주니까요. 수동 구현이 필요해지는 경우는 타입이 record가 아닌 클래스에서 상속을 받아야 하거나, record를 못 쓰게 만드는 다른 제약이 있을 때뿐이에요.
record가 될 수 없을 때 직접 동등성을 구현하기
record가 될 수 없는 값 타입을 위한 최소한의 수동 구현을 볼게요.
:::code language="csharp" source="snippets/EqualityHierarchies/Program.cs" id="ColorDefinition":::
이 구현은 반드시 가져야 할 세 멤버를 제공해요. 핵심 비교 로직인 Equals(T?), 개체 레벨 호출을 위한 override Equals(object?), 그리고 해시 기반 컬렉션이 올바르게 동작하도록 하는 override GetHashCode()가 그것이에요. HashCode.Combine은 Equals가 쓰는 값들로 하나의 해시를 만들어 주는 라이브러리 헬퍼예요. xref:System.IEquatable`1을 구현하는 것(Equals(T?) 오버로드)은 선택이지만, 호출하는 쪽에서 이미 구체 타입을 알고 있을 때 **박싱(boxing)**을 피할 수 있게 해줘요.
==와 !=도 함께 정의한다면, C# 언어는 이 둘을 한 쌍으로 요구해요. 그러니까 둘 중 하나만 정의할 수는 없지요. 경고 CS0660과 CS0661이 네 멤버를 모두 일관되게 유지하라고 알려줘요.
위의 세 멤버만 있다면 Equals는 값 동등을 반영하는데, ==는 아직 선언되지 않았으니 **동일성(identity)**만 검사해요.
:::code language="csharp" source="snippets/EqualityHierarchies/Program.cs" id="IEquatableUsage":::
올바른 구현은 **동등 계약(equivalence contract)**도 만족해야 해요(x, y, z는 모두 null이 아니라고 가정해요).
- 반사성(Reflexive):
x.Equals(x)는true를 돌려줘요. - 대칭성(Symmetric):
x.Equals(y)는y.Equals(x)와 같은 값을 돌려줘요. - 전이성(Transitive):
x.Equals(y)와y.Equals(z)가 둘 다true라면x.Equals(z)도 반드시true여야 해요. - 일관성(Consistent): 어느 개체도 변하지 않는 한
x.Equals(y)를 반복 호출해도 같은 값을 돌려줘요. - null 동작(Null behavior):
x.Equals(null)은false를 돌려주고, null이 아닌x에 대해 호출한x.Equals(y)는 예외를 던지면 안 돼요.
unsealed 클래스 계층 구조에서의 값 동등은 sealed 클래스에서보다 신경 쓸 게 많아요. 대칭성과 전이성을 만족시키기가 더 까다롭기 때문이에요. 여기서 문제가 되는 지점이 하나 있어요. IEquatable<T>.Equals(T? other)의 디스패치는 변수의 런타임 타입이 아니라 선언된 타입(변수 선언에 적힌 타입)을 따라간다는 점이에요. Shape가 virtual이 아닌 Equals(Shape? other)를 선언한다면, Shape 타입 변수에 Circle 인스턴스가 들어 있어도 Shape.Equals가 호출되면서 Circle만의 필드는 조용히 무시돼요. 그 결과 반지름(radius)이 서로 다른 Circle 개체 두 개가 Shape 변수로 접근하면 같다고 비교될 수 있어요.
올바른 패턴은 두 요구 사항이 함께 맞물려야 해요. 타입 있는 Equals 메서드를 virtual로 만들어 각 파생 클래스가 비교를 확장할 수 있게 하고, 기반 클래스 구현에 GetType() == other.GetType() 가드를 추가해서 런타임 타입이 다른 개체끼리는 절대 같다고 판정되지 않게 하는 거예요.
기반 클래스(base class) 구현
:::code language="csharp" source="snippets/EqualityHierarchies/Program.cs" id="HierarchyShapeDefinition":::
핵심 포인트를 짚어볼게요.
virtual타입 있는Equals: 각 파생 클래스가 이 메서드를 오버라이드해서 자기만의 필드를 비교에 더해요.GetType()가드:GetType() == other.GetType()이 같은 색의Circle과Shape가 같다고 판정되는 걸 막고, 서로 다른 파생 타입의 개체끼리 같아지는 것도 막아줘요.GetHashCode에GetType()포함: 두 개체가 같으려면 런타임 타입이 일치해야 하니까,GetHashCode도 데이터 필드뿐 아니라 런타임 타입을 해시에 넣어야 해요. 여기서GetType()을 빼먹으면Dictionary<TKey,TValue>나HashSet<T>에서 잘못 동작해요.==는Equals를 위임: 연산자와 메서드의 동등성을 일관되게 유지해줘요.
파생 클래스(derived class) 구현
필드를 추가하는 파생 클래스는 타입 있는 Equals를 오버라이드해서, 자기 타입으로 캐스트하고 base.Equals를 호출한 다음 자기 필드를 비교해요.
:::code language="csharp" source="snippets/EqualityHierarchies/Program.cs" id="HierarchyCircleDefinition":::
base.Equals(c)가 GetType() 가드를 적용하고 공통 필드를 확인해줘요. 그리고 other is Circle c를 통한 캐스트는 인자가 다른 파생 타입의 Shape일 때 즉시 실패(fail fast)해요.
기반 타입 변수를 통한 사용
:::code language="csharp" source="snippets/EqualityHierarchies/Program.cs" id="HierarchyUsage":::
sealed 클래스는 더 단순해요
sealed 클래스는 서브클래스를 만들 수 없으니, 컴파일 타임 타입과 런타임 타입이 항상 일치해요. 그래서 GetType() 가드나 virtual 디스패치가 필요 없어요. record가 될 수 없을 때 직접 동등성을 구현하기에서 보여준 IEquatable<T> 패턴이 sealed 클래스에서는 그대로 완전하고 정확해요.
연산자 오버로드 가능 여부
사용자 정의 타입에서 ==와 != 연산자는 오버로드할 수 있어요. 다만 이 두 연산자 중 하나를 오버로드하면 반드시 다른 하나도 함께 오버로드해야 해요.
record 타입에서는 ==와 != 연산자를 명시적으로 오버로드할 수 없어요. record 타입 T의 ==와 != 동작을 바꾸려면, 다음 시그니처로 xref:System.IEquatable`1.Equals*?displayProperty=nameWithType 메서드를 구현하면 돼요.
public virtual bool Equals(T? other);