F# tips weekly #8: Custom equality and comparison (2)

F# tips weekly #8: Custom equality and comparison (2)

This week, we continue with custom comparison. Let's deep dive into writing custom comparisons.

If we examine the last example of custom comparison:

[<CustomEquality; CustomComparison>]
type Record = { Id: int; Name: string; ActivityLog: string list }
with    
    member private x.Compare y =
        compare {| Id = x.Id; Name = x.Name |} {| Id = y.Id; Name = y.Name |}
    override x.Equals y =
        match y with
        | :? Record as y -> x.Compare y = 0
        | _ -> false
    override x.GetHashCode() =
         hash {| Id = x.Id; Name = x.Name |}

    interface System.IComparable with
        member x.CompareTo y =
            match y with
            | :? Record as y -> x.Compare y
            | _ -> -1

We can simplify it a little more by creating a function for the anonymous record we are using for comparison:

[<CustomEquality; CustomComparison>]
type Record = { Id: int; Name: string; ActivityLog: string list }
with    
    member private x.CompareRepr = {| Id = x.Id; Name = x.Name |}
    member private x.Compare y =
        compare x.CompareRepr y.CompareRepr
    override x.Equals y =
        match y with
        | :? Record as y -> x.Compare y = 0
        | _ -> false
    override x.GetHashCode() =
         hash x.CompareRepr

    interface System.IComparable with
        member x.CompareTo y =
            match y with
            | :? Record as y -> x.Compare y
            | _ -> -1

This way, we can specify which fields we want to compare, easily allowing us to ignore some fields in both equality and comparison. We can reuse this template for any type where we want to define custom comparison. The only things we need to change are the CompareRepr method, the Record type annotation, and the two occurrences of Record type check.

🔎 The only reason why we need to do that :? Record type checking is that the compiler expects the Equals and CompareTo methods to accept parameters of type obj.

We can also use the same template to change what is compared for some fields - for example, easily implement structural comparison for some fields that don't support it:

[<CustomEquality; CustomComparison>]
type Record = { Id: int; Name: string; ActivityLog: System.Collections.Generic.List<string> }
with    
    member private x.CompareRepr = {| Id = x.Id; Name = x.Name; ActivityLog = x.ActivityLog |> Seq.toList |}
    member private x.Compare (y: Record) =
        compare x.CompareRepr y.CompareRepr
    override x.Equals y =
        match y with
        | :? Record as y -> x.Compare y = 0
        | _ -> false
    override x.GetHashCode() =
         hash x.CompareRepr

    interface System.IComparable with
        member x.CompareTo y =
            match y with
            | :? Record as y -> x.Compare y
            | _ -> -1

Performance

This works fine but has a performance problem — we are creating new anonymous record objects for every comparison. That could be slow when sorting big collections of these objects. The best way to solve this is to write Compare and GetHashCode in a way that avoids new object allocations. To do this in a reusable way, we need to write some helper combinator functions.

Compare function

Comparison in .NET works so that it returns 0 if items are equal, less than zero if the first item is smaller, and more than zero if the second item is smaller. The result of these functions cannot be easily combined out-of-the-box, but we can write a simple combinator function:

let inline compareBy f x y = compare (f x) (f y)
let inline compareComb g f x y =
    let r = f x y
    if r <> 0 then r else g x y

compareBy creates a compare function from a projection function, allowing us to create a compare function for one field. compareComb combines two compare functions together in a pipeline-friendly way. By using inline, we avoid the performance cost of calling these functions.

We can rewrite Compare like this:

    member private x.Compare (y: Record) =
        let compareFun = 
            (compareBy <| fun x -> x.Id)
            |> compareComb (compareBy <| fun x -> x.Name) 
            |> compareComb (compareBy <| fun x -> Seq.toList x.ActivityLog)
        compareFun x y

Hash function

Combining two hash codes is more complicated (as shown in this StackOverflow answer), but thankfully .NET has a helper method System.HashCode.Combine:

let inline hashCombine y x = System.HashCode.Combine(hash x, hash y)
    override x.GetHashCode() =
        x.Id
        |> hashCombine x.Name
        |> hashCombine (x.ActivityLog |> Seq.toList)

Conclusion

This way we can create efficient custom comparison by combining simple functions, without need to write hash or compare functions from scratch.