ATBG: inconsistent equality

UPDATE from 2019: This post was originally on Coverity’s “Ask The Bug Guys” developer blog but that was taken down; an archived version is here. The formatting is still a bit messed up; I’ll fix it later.


Today’s episode of Ask The Bug Guys features a C# question from reader Jan:

Hi Bug Guys! We recently had a bug in our code comparing ints and shorts, where calling Equals() produced different results from using the == operator. Also, the behaviour of Equals() was not symmetric. Here’s a code snippet that reproduces the behaviour we observed:

int myInt = 1;
short myShort = 1;
Console.WriteLine(myInt == myShort);      // true
Console.WriteLine(myShort == myInt);      // true
Console.WriteLine(myInt.Equals(myShort)); // true
Console.WriteLine(myShort.Equals(myInt)); // false!

We were quite surprised when we found this. What explains this difference? Is it better to use ==instead of Equals() when comparing integer types?

Hi Jan! Thanks for the great question.

C# was designed to be a “pit of success” language: that is, a language where the obvious technique and the correct technique are the same. And for the most part, that’s true. Unfortunately, equality is one of the areas where there are significant pits of failure, and you’ve fallen right into one of them.

I’m going to add some additional cases to your program to illustrate a number of different equalities.

int myInt = 1;
short myShort = 1;
object objInt1 = myInt;
object objInt2 = myInt;
object objShort = myShort;
Console.WriteLine(myInt == myShort);      // scenario 1 true
Console.WriteLine(myShort == myInt);      // scenario 2 true
Console.WriteLine(myInt.Equals(myShort)); // scenario 3 true
Console.WriteLine(myShort.Equals(myInt)); // scenario 4 false!
Console.WriteLine(objInt1 == objInt1);    // scenario 5 true
Console.WriteLine(objInt1 == objShort);   // scenario 6 false!!
Console.WriteLine(objInt1 == objInt2);    // scenario 7 false!!!
Console.WriteLine(Equals(objInt1, objInt2));// scenario 8 true
Console.WriteLine(Equals(objInt1, objShort));// scenario 9 false!?!

What the heck? As it turns out, we have many different kinds of equality demonstrated here.

In scenarios one and two we must first determine what the == operator means. C# defines over a dozen different built-in == operators:

object == object
string == string
int == int
uint == uint
long == long
ulong == ulong
...

There is no int == short or short == int operators, so the unique best match on the list of built-in operators must be determined. It turns out that the best match is int == int. So the short is converted to int and then the two values are compared as numbers. They are therefore equal.

In scenario three we must first solve an overload resolution problem to determine what Equals means. The receiver is of type int and it has three methods named Equals:

Equals(objectobject// static method from object
Equals(object)         // virtual method from object
Equals(int)            // Implements IEquatable.Equals(int)

The first one we can eliminate because there are not enough arguments. Of the other two, the unique best method is the one that takes an int; it is always better to convert the short argument to intthan to object. Therefore we call Equals(int), which then compares the two integers again using value equality, so this is true.

In scenario four we again must determine what Equals means. The receiver is of type short which again has three methods named Equals

Equals(objectobject// static method from object
Equals(object)         // virtual method from object
Equals(short)          // Implements IEquatable.Equals(short)

Overload resolution eliminates the first because there are too few arguments and eliminates the third because there is no implicit conversion from int to short. That leaves short.Equals(object), which has the moral equivalent of this implementation:

bool Equals(object z)
{
  return is short && (short)z == this;
}

That is, for this method to return true the argument passed in must be a boxed short, and when unboxed it must be equal to the receiver. Since the argument is a boxed int, this returns false. There is no special gear in this implementation that says “well, what if I were to convert myself to the type of the argument and then compare?”

In scenarios five, six and seven operator overload resolution chooses the object == objectform, which is equivalent to a call to Object.ReferenceEquals. Clearly the two references are equal in case five and unequal in cases six and seven. Whether the values of the objects when interpreted as numbers are equal does not come into play at all; only reference equality is relevant.

In scenarios eight and nine operator overload resolution chooses the static method Object.Equals, which you can think of as being implemented like this:

public static bool Equals(object x, object y)
{
    if (ReferenceEquals(x, y)) return true;
    if (ReferenceEquals(x, null)) return false;
    if (ReferenceEquals(y, null)) return false;
    return x.Equals(y);
}

In scenario eight we have two references that are unequal and not null; therefore we call int.Equals(object), which as you would expect from our previous discussion of short.Equals(object) is implemented as the moral equivalent of:

bool Equals(object z)
{
  return is int && (int)z == this;
}

Since the argument is of type int it is unboxed and compared by value. In scenario nine the argument is a boxed short and so the type check fails and this is false.

Summing up: I’ve shown nine different ways that two things can be compared for equality; despite the fact that clearly in every case we have the number one on both sides of the equality, equality is true in only half the cases. If you think this is crazy and confusing, you’re right! Equality is tricky in C#.

I’ve been looking a lot at confusing cases for equality over my last year at Coverity; two of our checkers are specifically designed to find situations where you used the wrong kind of equality. But this article is long enough already and I’ve answered your question (I hope!), so I’ll discuss the specific cases that our checkers look for in another posting.