Search code examples
f#equalitydiscriminated-union

F# equality behavior on union type with function case


I'm trying to understand this equality behavior. The record equality test fails, but the equality test of the sole property of the records passes. Is this a bug? Or can someone explain this behavior?

type TestUnion =
    | Case1
    | Case2 of (int -> string)

type TestType =
    {
        Foo : TestUnion
    }

open Microsoft.VisualStudio.TestTools.UnitTesting

[<TestClass>]
public Testing() =

    let a = { Foo = Case1 }
    let b = { Foo = Case1 }

    [<TestMethod>]
    member __.ThisFails () =
        Assert.AreEqual(a, b)

    [<TestMethod>]
    member __.ThisPasses () =
        Assert.AreEqual(a.Foo, b.Foo)

I know the reason it fails is because one of the cases is a function. If I change it to a simple value, both tests pass. But it is odd to me that a) equality fails at all because the simple case with no value is used and b) the record equality fails while the property equality passes.

Note: The record equality will fail when other simple properties are present also. IOW, the union type poisons equality for the whole record, even though the union type property tests as equal.


Solution

  • The Assert.AreEqual method tries to be clever and of course fails. Given two objects, the first thing this method will do is test for reference equality: obj.ReferenceEquals( Case1, Case1 ). This works right away, because all Case1 values are the same object.

    Now, if the Assert.AreEqual's parameters don't turn out to be the same object, it will go ahead and call obj.Equals. For your record, the implementation of Equals will always return false, because the F# compiler did not implement an equality for it. Why? Because types of some fields (namely, TestUnion) do not have equality. Why doesn't TestUnion have equality? Because it has at least one case with a type that does not have equality - namely, int -> string.

    If you change Case1 to something like Case1 of int and then try Assert.AreEqual( Case1 42, Case1 42 ), the test will fail. This will happen, because the two instantiations of Case1 42 will no longer be the same object (unless you compile with optimizations), and the Equals implementation for TestUnion will always return false.

    If you really want this to work (and you really know how to compare functions), you can always just implement Equals yourself:

    [<CustomEquality; NoComparison>]
    type TestType = { Foo: TestUnion }
        with 
            override this.Equals other = (* whatever *)
            override this.GetHashCode() = (* whatever *)
    

    Note that you have to do quite a dance to accomplish this: you have to add a CustomEquality and NoComparison (or CustomComparison) attributes, and also implement GetHashCode. If you don't do any of that, the compiler will complain that your implementation of equality is inconsistent.

    The "correct" solution, however, is to always use F# facilities as much as possible. In this specific case, this would mean using the = operator for comparison:

    Assert.IsTrue( Case1 = Case1 )
    

    This way, if you're missing something, the compiler will always tell you:

    Assert.IsTrue( a = b )
    // The type 'TestType' does not support the 'equality' constraint because blah-blah-blah
    

    The F# compiler is generally more correct and consistent than the underlying .NET CLR.