포스트

C#의 컴파일타임 상등 비교와 런타임 상등 비교

C#에서 각 타입의 상등 비교를 지원하기 위해 구현할 수 있는 것은 크게 세가지입니다.

  • ==!= 연산자
  • Equals 메서드
  • IEquatable<T> 인터페이스

==, != 연산자

==!=는 연산자이므로 결과가 정적으로 결정되어야 합니다. 실제로 이 연산자 구현체는 static 키워드가 포함됩니다. 컴파일러는 컴파일 중 비교를 수행할 형식을 미리 결정해둡니다.
값 형식은 값 상등을, 참조 형식은 참조 상등을 평가합니다.

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 var = new Foo { x = 5 };
Console.WriteLine(foo == var); // False
foo = var;
Console.WriteLine(foo == var); // True

참조 상등

가상 Object.Equals 메서드와 정적 object.Equals 메서드

System.Object에는 Equals 가상 메서드가 정의되어 있습니다. 즉 모든 형식이 이 메서드를 갖습니다.
Equals는 상등 연산자와 달리 런타임에서 상등을 평가합니다. 따라서 형식에 구애받을 필요가 없습니다.

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
Console.WriteLine(x.Equals(y)); // True

상등 비교 처리가 다른 이유

프로그래밍을 학습하는 과정에서 값과 참조의 개념은 익숙하지 않은 사람에게 충분히 혼선을 줍니다. 이 개념을 혼동하면 코드가 의도와 달리 작동할 가능성이 농후하고, 잠재적으로 버그가 됩니다.

만약 상등 연산자 ==를 가상으로 구현해서 Equals와 같은 방식으로 런타임에서 상등을 평가했다면 이러한 문제를 피할 수 있었을 것입니다. 하지만 상등 연산자와 상등 메서드의 작동 방식을 구분한 것은 나름의 이유가 있습니다.

우선 두 상등 비교 수단이 같은 역할을 수행한다면 둘 중 하나는 존재하지 않아도 될 것입니다. 실제로 Equals를 상등 비교 수단으로 채택하지 않는 언어도 꽤 있습니다. 이 둘이 각자 다른 의미의 상등 비교를 수행하게 하는 것이 종종 유용한 경우가 있습니다.

1
2
3
double x = double.NaN;
Console.WriteLine(x == x); // False: 값 상등
Console.WriteLine(x.Equals(x)); // True: 참조 상등 (반사적 상등)

수학적으로 NaN은 다른 그 어떤 수와도 같지 않습니다. 즉, NaN의 값 상등 평가는 항상 false여야 합니다.
하지만 x 변수는 실제로 메모리 상의 x 변수를 참조하며로 xx의 참조 상등 평가는 true입니다. 실제로 두 변수는 같은 것이기 때문입니다.

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: 값 상등

NaN 사례처럼 연산자가 값 상등, 메서드가 참조 상등을 처리하는 경우는 꽤 드뭅니다. 두 상등이 서로 다른 의미로 적용된다면 이 사례처럼 연산자가 참조 상등, 메서드가 값 상등을 적용하는 것이 더 흔합니다.

연산자와 메서드를 달리 작동하도록 하는 또 다른 이유도 있습니다.

  • 당연하지만 상등 메서드는 첫번째 피연산자가 널이면 NullReferenceException이 발생합니다. null 아래에는 Equals가 없습니다.
  • 상등 연산자는 정적으로 처리되므로 실행 속도가 빠릅니다. 즉, 실행 시간이 이미 충분히 길 수 있는 코드에서, 혹은 Equals 처리가 상대적으로 오래 걸리는 형식에서 상등 연산자는 사용해도 성능상 피해가 없거나 미비함을 보장합니다.

IEquatable<T> 인터페이스

object.Equals는 값 형식 피연산자들에 박싱을 적용합니다. 박싱은 비싼 연산이므로 성능상 큰 피해를 입을 수 있습니다.
IEquatable<T> 인터페이스는 이 문제를 해결하고자 도입됩니다.

1
2
3
4
public interface IEquatable<T>
{
  bool Equals (T other);
}
1
2
3
4
class Example<T> where T: IEquatable<T>
{
  public bool IsEqual(T a, T b) { return a.Equals(b); }
}