Open sandboxFocusImprove this doc

Equality comparison example, step 4: Customizing equality comparers

In previous articles, we demonstrated how to automatically implement an equality comparison pattern. We covered handling type inheritance and enhancing our API to allow users to select specific equality members. However, our implementation had strict limitations: we always used the default comparer (EqualityComparer) and employed alphabetical ordering of members.

Let's dig deeper into the rabbit hole and add two new features to the aspect library:

  • The ability to specify or optimize the order in which members are compared.
  • The ability to use different equality comparers, such as StringComparer.OrdinalIgnoreCase.

Our goal is to support code constructs as illustrated below:

Source Code



1namespace Metalama.Samples.Comparison4;
2
3public partial class Entity
4{

5    [StringEqualityMember( StringComparison.InvariantCultureIgnoreCase )]
6    public required string EntityType { get; init; }
7
8    [EqualityMember]
9    public int Id { get; init; }
10
11    public Guid ObjectId { get; } = Guid.NewGuid();

















































12}
Transformed Code
1using System;
2using System.Collections.Generic;
3
4namespace Metalama.Samples.Comparison4;
5
6public partial class Entity
7: IEquatable<Entity>
8{
9    [StringEqualityMember( StringComparison.InvariantCultureIgnoreCase )]
10    public required string EntityType { get; init; }
11
12    [EqualityMember]
13    public int Id { get; init; }
14
15    public Guid ObjectId { get; } = Guid.NewGuid();
16public virtual bool Equals(Entity other)
17    {
18        if (other == null)
19        {
20            return (bool)false;
21        }
22
23        if (object.ReferenceEquals(this, other))
24        {
25            return (bool)true;
26        }
27
28        if (!EqualityComparer<int>.Default.Equals(this.Id, other.Id))
29        {
30            return (bool)false;
31        }
32
33        if (!StringComparer.InvariantCultureIgnoreCase.Equals(this.EntityType, other.EntityType))
34        {
35            return (bool)false;
36        }
37
38        return true;
39    }
40    public override bool Equals(object? other)
41    {
42        if (object.ReferenceEquals(this, other))
43        {
44            return (bool)true;
45        }
46
47        return other is Entity typed && Equals(typed);
48    }
49    public override int GetHashCode()
50    {
51        var hashCode = default(HashCode);
52        hashCode.Add(this.Id, EqualityComparer<int>.Default);
53        hashCode.Add(this.EntityType, StringComparer.InvariantCultureIgnoreCase);
54        return hashCode.ToHashCode();
55    }
56    public static bool operator ==(Entity a, Entity b)
57    {
58        return a == null && b == null || a != null && a.Equals(b);
59    }
60
61    public static bool operator !=(Entity a, Entity b)
62    {
63        return a == null ^ b == null || a != null && !a.Equals(b);
64    }
65}
Source Code


1namespace Metalama.Samples.Comparison4;
2
3public sealed partial class Person
4{

5    [StringEqualityMember( StringComparison.InvariantCultureIgnoreCase, true )]
6    public string? Name { get; init; }
7
8    [DateEqualityMember]
9    public DateTime DateOfBirth { get; init; }

















































10}
Transformed Code
1using System;
2
3namespace Metalama.Samples.Comparison4;
4
5public sealed partial class Person
6: IEquatable<Person>
7{
8    [StringEqualityMember( StringComparison.InvariantCultureIgnoreCase, true )]
9    public string? Name { get; init; }
10
11    [DateEqualityMember]
12    public DateTime DateOfBirth { get; init; }
13public bool Equals(Person other)
14    {
15        if (other == null)
16        {
17            return (bool)false;
18        }
19
20        if (object.ReferenceEquals(this, other))
21        {
22            return (bool)true;
23        }
24
25        if (!DateComparer.Instance.Equals(this.DateOfBirth, other.DateOfBirth))
26        {
27            return (bool)false;
28        }
29
30        if (!TrimmingStringEqualityComparer.InvariantCultureIgnoreCase.Equals(this.Name, other.Name))
31        {
32            return (bool)false;
33        }
34
35        return true;
36    }
37    public override bool Equals(object? other)
38    {
39        if (object.ReferenceEquals(this, other))
40        {
41            return (bool)true;
42        }
43
44        return other is Person typed && Equals(typed);
45    }
46    public override int GetHashCode()
47    {
48        var hashCode = default(HashCode);
49        hashCode.Add(this.DateOfBirth, DateComparer.Instance);
50        hashCode.Add(this.Name, TrimmingStringEqualityComparer.InvariantCultureIgnoreCase);
51        return hashCode.ToHashCode();
52    }
53    public static bool operator ==(Person a, Person b)
54    {
55        return a == null && b == null || a != null && a.Equals(b);
56    }
57
58    public static bool operator !=(Person a, Person b)
59    {
60        return a == null ^ b == null || a != null && !a.Equals(b);
61    }
62}

Step 1. Designing member ordering

At first glance, it might seem that we can compare equality members in any order, as the result should be identical regardless. However, optimizing the order of comparison can lead to significant performance improvements.

Ideally, we want to first evaluate members that are faster to compare and more likely to differ.

Consider an EntityKey type with two members: string EntityName and int EntityId, a typical data object design from the early 2000s. Since it's much cheaper to compare two int values than two string values, and since values are more likely to differ (with thousands of rows per table but dozens of tables), we will always want to compare EntityId first.

Our aspect could make part of this decision on its own: it's easy to hardcode that some comparisons are faster than others and prioritize those.

However, our aspect won't be able to determine the probability of equality. This means we also need the ability for the user to specify the order of evaluation.

Therefore, we chose the following design:

  • The EqualityMemberAttribute class will have an Order property, which can be set manually. Its default value will be 1000.
  • When two members have the same order, we order them by execution cost, based on hard-coded rules that specify, for instance, that it's cheaper to compare two int than two string. The cost will be returned by the GetCost method of the EqualityMemberAttribute class.

16public class EqualityMemberAttribute : FieldOrPropertyAspect
17{
18protected const int DefaultOrder = 1000;
19
20public EqualityMemberAttribute( int order = DefaultOrder )
21{
22    this.Order = order;
23}
24
25public int Order { get; }
26
27internal virtual int GetCost( IFieldOrProperty field )
28{
29    // TODO: Base on benchmarks.
30
31    return field.Type.SpecialType switch
32    {
33        SpecialType.None => 10,
34        SpecialType.Object => 10,
35        SpecialType.Void => 0,
36        SpecialType.Boolean => 1,
37        SpecialType.Char => 1,
38        SpecialType.SByte => 1,
39        SpecialType.Byte => 1,
40        SpecialType.Int16 => 1,
41        SpecialType.UInt16 => 1,
42        SpecialType.Int32 => 1,
43        SpecialType.UInt32 => 1,
44        SpecialType.Int64 => 1,
45        SpecialType.UInt64 => 1,
46        SpecialType.Decimal => 2,
47        SpecialType.Single => 2,
48        SpecialType.Double => 2,
49        SpecialType.String => 5,
50
51        _ => field.Type switch
52        {
53            { TypeKind: TypeKind.Struct or TypeKind.Class }
54                when HasEqualsImplementation( (INamedType) field.Type ) => 10,
55            { TypeKind: TypeKind.RecordStruct or TypeKind.RecordClass } => 20,
56            { TypeKind: TypeKind.Struct } => 200,
57            { TypeKind: TypeKind.Class } => 1,
58            _ => 100
59        }
60    };
61
62    bool HasEqualsImplementation( INamedType type )
63        => type.AllMethods.OfName( "Equals" ).Any( m => m.Parameters.Count == 1 )
64           || HasEqualsAspect( type );
65
66    // TODO: The following will not work reliably if the target type is processed after the current type.
67    // However, the impact is limited to performance.
68    bool HasEqualsAspect( INamedType type )
69        => !type.DeclaringAssembly.IsExternal
70           && type.Enhancements().HasAspect<ImplementEquatableAttribute>();
71}
72

As you can see, the GetOrder implementation can become arbitrarily complex, and we will not explore this further here.

Step 2. Designing equality comparer customization

We want to allow users to specify their own IEqualityComparer<T> implementation. We chose a mechanism where the EqualityMemberAttribute class can be derived. We will provide, as examples, two implementations: StringComparerAttribute, which will allow the choice of StringComparison mode, and DateComparerAttribute, which will compare the date component of a DateTime, ignoring the time component.

For the ImplementEquatable aspect, the equality comparer for every member is just an IExpression. Therefore, we will define a GetComparerExpression virtual method on EqualityMemberAttribute:

90protected internal virtual IExpression GetComparerExpression( IFieldOrProperty field )
91{
92    return ((INamedType) TypeFactory.GetType( typeof(EqualityComparer<>) ))
93        .WithTypeArguments( field.Type )
94        .Properties["Default"];
95}

Let's look at the implementation of [StringEqualityMember]:

1using Metalama.Framework.Code;
2using Metalama.Framework.Eligibility;
3
4namespace Metalama.Samples.Comparison4;
5
6public partial class StringEqualityMemberAttribute : EqualityMemberAttribute
7{
8    private readonly StringComparison _stringComparison;
9    private readonly bool _trim;
10
11    public StringEqualityMemberAttribute( int order = DefaultOrder ) : base( order ) { }
12
13    public StringEqualityMemberAttribute(
14        StringComparison stringComparison = StringComparison.Ordinal,
15        bool trim = false )
16    {
17        this._trim = trim;
18        this._stringComparison = stringComparison;
19    }
20
21    public override void BuildEligibility( IEligibilityBuilder<IFieldOrProperty> builder )
22    {
23        base.BuildEligibility( builder );
24        builder.Type().MustEqual( typeof(string) );
25    }
26
27    protected internal override IExpression GetComparerExpression( IFieldOrProperty field )
28    {
29        var comparerType =
30            this._trim ? typeof(TrimmingStringEqualityComparer) : typeof(StringComparer);
31
32        return ((INamedType) TypeFactory.GetType( comparerType ))
33            .Properties[this._stringComparison.ToString()];
34    }
35}

As you can see from the code:

  • The main code is the GetComparerExpression override, which returns an expression representing the comparer (for instance, Ordinal).
  • BuildEligibility restricts this aspect to fields and properties of type string.
  • If you want a custom, non-system comparer, you can provide your own as a public class with a public static property. Look at TrimmingStringEqualityComparer in the source code for details.

Step 3. Updating BuildAspect

Now that we have the elements of our new API in place, we can update the aspect, starting from the information-gathering step in the BuildAspect method.

In previous articles, all the templates needed to do their job was a list of IFieldOrProperty. Now, for each member, we will also need a reference to the aspect instance, so we can call the GetComparerExpression method. Therefore, we define the compile-time tuple EqualityMemberInfo to represent this data:

1using Metalama.Framework.Aspects;
2using Metalama.Framework.Code;
3
4namespace Metalama.Samples.Comparison4;
5
6[CompileTime]
7internal record EqualityMemberInfo( IFieldOrProperty Field, EqualityMemberAttribute Aspect );

We can now update the logic that collects the fields. We will obtain the EqualityMemberAttribute aspect instance and sort the aspects by explicit order and cost.

19var fields = targetType.FieldsAndProperties.Where( f =>
20                                                       f.IsAutoPropertyOrField == true
21                                                       && f is { IsStatic: false, IsImplicitlyDeclared: false }
22                                                       && f.Attributes.Any( typeof(EqualityMemberAttribute) ) )
23    .Select( f => new EqualityMemberInfo( f, f.Enhancements().GetAspects<EqualityMemberAttribute>().Single() ) )
24    .Select( m => (EqualityMember: m, Cost: m.Aspect.GetCost( m.Field )) )
25    .OrderBy( m => m.EqualityMember.Aspect.Order )
26    .ThenBy( m => m.Cost )
27    .ThenBy( m => m.EqualityMember.Field.Name )
28    .Select( m => m.EqualityMember )
29    .ToList();

Step 4. Updating the templates

The templates for Equals(T) and GetHashCode() must be updated to take the comparer instance from the GetComparerExpression method.

First, we update the signatures to accept a list of EqualityMemberInfo instead of just IFieldOrProperty.

Then, we can call the GetComparerExpression method to get the IEqualityComparer<T>. Here is how we do it in Equals(T):

185// Compare fields of the current type one by one.
186foreach ( var field in fields )
187{
188    var equalityComparer = field.Aspect.GetComparerExpression( field.Field );
189
190    if ( !equalityComparer.Value!.Equals( field.Field.Value, field.Field.With( other ).Value ) )
191    {
192        return false;
193    }
194}

There should be no surprises in this code!

Conclusion

This article has opened the door to a whole new level of meta-programming, where you can define extensibility APIs for your aspects. We showed how to design aspect libraries by providing compile-time extension points, such as the IEqualityComparer<T>. Incidentally, this is something that's very difficult to achieve with pure Roslyn source generators. So, in this article, we've found another reason to use Metalama.

As you can imagine, there is much more you can do to customize the equality comparison aspect. We've just scratched the surface.

As an aspect developer, remember that your role is to maximize the productivity of your team, not to confuse them. It's your job to create well-designed, well-tested aspects, with complete error reporting, and not force other team members to engage in meta-programming unless they really want to. In this spirit, you should maintain a collection of equality member attributes covering all the needs of your team, ensuring they interact nicely and intuitively with each other.