C# and Equals() in V2.0 versus V1.0.

Here’s some wacky trivia: In v1.1, C# evaluates 3f.Equals(3) to False. In v2.0, it evaluates to True.  (Note that the f suffix lexes as floating point, so 3f is floating-point and 3 is integer).

Folks seem to like the v2.0 behavior a lot more, so this is arguably a good change.  (I stumbled across this when working on this C# Quiz. This is effectively the answer to #8. I’ll post the rest of the answers soon).


Why does it happen?

In v1.1, the only Equals() method was Object.Equals(object), although it’s virtual so many derived types implemented it. In v2.0, the CLR added new non-virtual overloads to the core numeric types like Int.Equals(int) and Single.Equals(Single). This introduction has some subtle yet significant consequences.


Anytime you add a new overload, you risk breaking things when you recompile because the compiler may bind against the new overload.

If new overload behaves differently than the old one, then the program will now behave differently. Note you have to recompile for this to be an issue. If you just bind a v1.1 app against v2.0, this won’t affect you.

It turns out the various Equal() overloads do indeed behave differently.


In this case, How should 3f.Equals(3) compile? Note that ‘3f’ is of type System.Single (float in C#),  whereas ‘3’ is of type System.Int32 (int in C#).  So the compiler needs to find a match for “Single.Equals(int)”.

1) In v1.1, C# generates this IL:

  IL_0000:  ldc.r4     3.

  IL_0005:  stloc.0

  IL_0006:  ldloca.s   CS$00000002$00000000

  IL_0008:  ldc.i4.3

  IL_0009:  box        [mscorlib]System.Int32

  IL_000e:  call       instance bool [mscorlib]System.Single::Equals(object)


This is because there’s only “Object.Equals(object)”, and so it must bind against that. It will call to Object.Equals(object) and box the int parameter. (The compiler is actually smart enough to resolve the virtual call at compile time and invoke the Single.Equals(object) directly,  but that’s a tangent here).

The call will go to “Single.Equals(object)”, which will ensure its parameter is of type ‘single’, unbox it, and then do the comparison. Since the caller passed in a boxed int and Single.Equals expects a float, the runtime-type check will fail and this will return false! There are no implicit numeric conversions here.


Note that Single.Equals(object) can’t really be much smarter. The numeric promotion rules are language specific, whereas the Base Class Libraries (BCL) is  cross-language. Thus Single.Equals(object) can’t try to emulate language specific numeric promotion rules such as checking if the parameter is an int and then promoting it. This would break some other .Net language that did have intàfloat as an implicit conversion.


2) In v2.0, C# generates this IL:
  IL_0000:  ldc.r4     3.

  IL_0005:  stloc.0

  IL_0006:  ldloca.s   CS$0$0000

  IL_0008:  ldc.r4     3.

  IL_000d:  call       instance bool [mscorlib]System.Single::Equals(float32)

(note float32 is an alias for System.Single)

This is because in v2.0,  there’s also a newly added “Single.Equals(Single)” in the BCL.  It turns out C# would rather do the intàSingle (numeric promotion) conversion instead of the intàobject (boxing).  Thus in V2.0, 3f.Equals(3) will bind to this new overload. It will call thus call the non-virtual Single.Equals(single) and do a numeric promotion on the int to single. When compared with the v1.1 path, this effectively converts a runtime-type check to a compiler-time type check.

If the numeric promotion doesn’t lose precision, the comparison will return true.


Quick recap:  In v1.1, C# will bind to Equals(object) which won’t promote the int because it boxes the parameter and later does a naïve run-time type check. In v2.0, it will bind to Equals(single) which will promote the int and then do a trivial comparison.

Also note that there’s nothing special about literals here, except that they remove precision loss issues. I just use literals to simplify the example. The same issue applies to f.Equals(x) if f is a float and x is an int.


What about 3.Equals(3f)?

Although there’s an implicit conversion from intàfloat, there’s not an implicit conversion from floatàint. This makes sense since intàfloat is accurate in many cases, whereas floatàint is almost guaranteed to lose precision.

So 3.Equals(3f) will bind against Object.Equals(object). That will do a runtime type check and return false because typeof(int) != typeof(single).


So one consequence is that in v2.0, 3f.Equals(3) is True,  yet 3.Equals(3f) is false. This is inconsistent, but a natural consequence of the rules already set up.

Recompile the same expression in v1.1 vs. v2.0 and see for yourself.

All that said, we believe people will want the new overloads and that they provide a more natural behavior. We also believe the odds of somebody getting bitten by this change to be very low. The only case I could imagine where somebody would even use constructs like this would be in a naïve code-generator.

Let us know if you find an interesting counter-example where this change could cause grief!

Comments (7)

  1. AT says:

    "very low"

    Pretty nice to see how you add possible bugs "By design".

    Take a look on somethat similar issue related to nullable.


    I feel somebody else in MS were thinking that probability that nullable Equals will fail will be very low too ;-)))

    Stop using tradeoffs. Make CS a science.

  2. What happens if the int is not representable as a single? Is 33554432f.Equals(33554433) going to be true because they both promote to the same single precision floating point value?

  3. I’d suggest there should be overloads on each of the numeric types for all of the other builtin numeric types. It’s not perfect, but it’s better than 3.Equals(3f) being false.

    I agree with the previous comment about (object) (T?) null == null being false. I’ve written my own hardcoded nullable Int type and I made it a class specifically so that this obvious truth should evaluate to true. I still run into problems on a regular basis caused by (Nint) (object) 1 and (int) (object) (Nint) 1 being errors rather than having the obvious right behavior. Why oh why can’t I override the cast to and from object?

    I also hate that condition ? 1 : null will remain an error in 2.0 rather than the obviously correct behavior of returning an "int?".

    I was so excited when nullable types were added to the language. Now I wish they hadn’t been, because I was able to do a better job by myself without any special syntax; I could write my own Nullable<T> that was much better than the builtin one, and the syntactic sugar isn’t in *any* of the places where syntactic sugar would actually help me.


  4. Anonymous says:

    I agree with the above, especially Stuart.

  5. Binary Compatibility means that when something is updated, you continue to work without needing to even

Skip to main content