C#의 컴파일타임 상등 비교와 런타임 상등 비교
C#에서 각 타입의 상등 비교를 지원하기 위해 구현할 수 있는 것은 크게 세 가지이다.
==와!=연산자Equals메서드 (virtual object.Equals,static object.Equals)IEquatable<T>인터페이스
==, != 연산자
==와 !=는 연산자이므로 호출할 메서드가 컴파일 타임에 정적 바인딩(정적으로 결정)된다. 실제로 이 연산자 구현체는 static 메서드로 정의된다. 컴파일러는 피연산자의 컴파일 타임 정적 형식을 기준으로 비교 로직을 결정한다.
기본 동작으로 값 형식은 값 상등을, 참조 형식은 참조 상등을 평가한다. (단, string이나 record처럼 참조 형식이어도 연산자를 오버로딩하여 값 상등을 평가하도록 재정의할 수 있다.)
1
2
int x = 5, y = 5;
Console.WriteLine(x == y); // True (값 상등)
값 상등
1
2
3
4
5
6
7
class Foo { public int x; }
Foo foo = new Foo { x = 5 };
Foo bar = new Foo { x = 5 };
Console.WriteLine(foo == bar); // False (참조 상등)
foo = bar;
Console.WriteLine(foo == bar); // True (참조 상등)
참조 상등
가상 Object.Equals 메서드와 정적 object.Equals 메서드
System.Object에는 Equals 가상 메서드가 정의되어 있다. 즉 C#의 모든 형식이 이 메서드를 상속받거나 재정의할 수 있다.
Equals는 연산자와 달리 런타임에 실제 객체의 형식을 기반으로 가상 디스패치(Virtual Dispatch)되어 상등을 평가한다.
1
2
3
4
5
6
object x = -1, y = -1;
Console.WriteLine(object.Equals(x, y)); // True
x = null;
Console.WriteLine(object.Equals(x, y)); // False
y = null;
Console.WriteLine(object.Equals(x, y)); // True
== 연산자는 정적 형식을 기준으로 평가되고, Equals 메서드는 런타임 인스턴스 형식을 기준으로 평가되므로 동일한 피연산자라도 결과가 상이할 수 있다.
1
2
3
object x = 5, y = 5;
Console.WriteLine(x == y); // False (정적 형식인 object의 == 호출 -> 서로 다른 박싱된 참조 비교)
Console.WriteLine(x.Equals(y)); // True (런타임 형식인 int.Equals(object)가 가상 호출됨)
상등 비교 처리가 다른 이유
상등 연산자 ==를 가상 메서드로 설계하지 않고 정적 연산자와 가상 메서드로 이원화한 것에는 나름의 이유가 있다. 상황에 따라 참조 식별과 값의 일치라는 서로 다른 의미의 상등 비교가 필요하기 때문이다.
1. 부동소수점(double.NaN)의 사례
1
2
3
double x = double.NaN;
Console.WriteLine(x == x); // False: IEEE 754 표준 준수
Console.WriteLine(x.Equals(x)); // True: .NET 컬렉션의 반사성(Reflexivity) 원칙 준수
==연산자는 IEEE 754 부동소수점 표준을 준수한다. 표준에 따라NaN은 어떤 값과도(심지어 자기 자신과도) 같지 않으므로false를 반환한다.- 반면
Equals는 .NET의 동등성 핵심 원칙인 반사성(x.Equals(x) == true)을 만족해야 한다. 만약double.Equals마저false를 반환한다면Dictionary<double, string>이나HashSet<double>에NaN을 저장했을 때 키를 영영 찾을 수 없는 문제가 발생하기 때문이다.
2. StringBuilder의 사례
1
2
3
4
var sba = new StringBuilder("foo");
var sbb = new StringBuilder("foo");
Console.WriteLine(sba == sbb); // False: 참조 상등 (== 연산자가 오버로딩되지 않음)
Console.WriteLine(sba.Equals(sbb)); // True: 값 상등 (StringBuilder.Equals가 문자열 내용을 비교)
두 상등이 서로 다른 의미로 사용될 때는 일반적으로 연산자는 기본 참조 상등을, 메서드는 내용(값) 상등을 평가하도록 분리한다.
그 외
Null 안전성: 가상 인스턴스 메서드
x.Equals(y)는x가null일 경우NullReferenceException이 발생한다. 반면==연산자나 정적 메서드object.Equals(x, y)는 피연산자가null이어도 예외 없이 안전하게 비교할 수 있다.호출 비용:
==연산자는 정적으로 바인딩되므로 가상 메서드 테이블(vtable) 조회를 거치지 않고 인라인화(Inlining)될 가능성이 높아 오버헤드가 적다.
IEquatable<T> 인터페이스
object.Equals(object)는 매개변수 타입이 object이므로, 값 형식을 비교할 때 힙 할당을 유발하는 박싱(Boxing)이 발생한다. 박싱은 GC 압력을 증가시키고 성능을 저하시킨다. IEquatable<T>는 제네릭 환경에서 박싱 없는 타입 안정적인 값 비교를 지원하기 위해 제공된다.
1
2
3
4
public interface IEquatable<T>
{
bool Equals(T other);
}
1
2
3
4
5
6
7
8
9
// 값 형식 T가 IEquatable<T>를 구현하면 박싱 없이 Equals(T)가 직접 호출된다.
class Example<T> where T : IEquatable<T>
{
public bool IsEqual(T a, T b)
{
// null 안전성을 포함한 EqualityComparer<T>.Default.Equals(a, b) 사용이 권장된다.
return a != null && a.Equals(b);
}
}