Open sandboxFocusImprove this doc

Equality comparison example, step 1: a minimal implementation

In this article, we'll create the simplest possible aspect to automatically implement the equality comparison pattern. We'll call it [ImplementEquatable] because it implements the IEquatable<T> interface.

Of course, just implementing the IEquatable<T> interface isn't enough for a complete equality comparison pattern. For a type T, a full equality comparison pattern involves the following operations:

  1. Add the IEquatable<T> interface to the class.
  2. Add a method public bool Equals(T? other) that compares the current object with another one, field by field.
  3. Override the Equals(object) method.
  4. Override the GetHashCode() method.
  5. Add the == and != operators to the class.

Once this aspect is complete, it will be capable of generating code like this:

Source Code



1namespace Metalama.Samples.Comparison1;
2
3[ImplementEquatable]
4internal partial class Person
5{

6    public string? Name { get; init; }
7
8    public DateTime DateOfBirth { get; init; }

















































9}
Transformed Code
1using System;
2using System.Collections.Generic;
3
4namespace Metalama.Samples.Comparison1;
5
6[ImplementEquatable]
7internal partial class Person
8: IEquatable<Person>
9{
10    public string? Name { get; init; }
11
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 (!EqualityComparer<DateTime>.Default.Equals(this.DateOfBirth, other.DateOfBirth))
26        {
27            return (bool)false;
28        }
29
30        if (!EqualityComparer<string?>.Default.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, EqualityComparer<DateTime>.Default);
50        hashCode.Add(this.Name, EqualityComparer<string?>.Default);
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}
Source Code



1namespace Metalama.Samples.Comparison1;
2
3[ImplementEquatable]
4internal partial struct EntityKey
5{

6    public string Type { get; }
7
8    public int Id { get; }
9
10    public EntityKey( string type, int id )
11    {
12        this.Type = type;
13        this.Id = id;


































14    }
15}
Transformed Code
1using System;
2using System.Collections.Generic;
3
4namespace Metalama.Samples.Comparison1;
5
6[ImplementEquatable]
7internal partial struct EntityKey
8: IEquatable<EntityKey>
9{
10    public string Type { get; }
11
12    public int Id { get; }
13
14    public EntityKey( string type, int id )
15    {
16        this.Type = type;
17        this.Id = id;
18    }
19public bool Equals(EntityKey other)
20    {
21        if (!EqualityComparer<int>.Default.Equals(this.Id, other.Id))
22        {
23            return (bool)false;
24        }
25
26        if (!EqualityComparer<string?>.Default.Equals(this.Type, other.Type))
27        {
28            return (bool)false;
29        }
30
31        return true;
32    }
33    public override bool Equals(object? other)
34    {
35        return other is EntityKey typed && Equals(typed);
36    }
37    public override int GetHashCode()
38    {
39        var hashCode = default(HashCode);
40        hashCode.Add(this.Id, EqualityComparer<int>.Default);
41        hashCode.Add(this.Type, EqualityComparer<string?>.Default);
42        return hashCode.ToHashCode();
43    }
44    public static bool operator ==(EntityKey a, EntityKey b)
45    {
46        return a.Equals(b);
47    }
48
49    public static bool operator !=(EntityKey a, EntityKey b)
50    {
51        return !a.Equals(b);
52    }
53}

To keep things simple, we'll ignore type inheritance in this article.

Step 1. Create the ImplementEquatableAttribute class

To start off, we'll create the ImplementEquatableAttribute class and have it derive from Metalama's TypeAspect class, making it both a custom attribute and a type-level Metalama aspect.

The entry point of any Metalama aspect is the BuildAspect method, so that's where we begin. Remember, this method must perform all operations to implement the pattern as listed above.

7public class ImplementEquatableAttribute : TypeAspect
8{
9public override void BuildAspect( IAspectBuilder<INamedType> builder )
10{
11    base.BuildAspect( builder );
12

Step 2. Identifying the equatable members

When authoring aspects, it's good practice to split the BuildAspect implementation into two parts: data gathering and analysis (i.e., the code model), followed by adding transformations (advice) to the compilation pipeline.

So first, we'll identify the members that need to be considered in the equality comparison. This includes all instance fields and automatic properties, except those implicitly defined by the compiler (like fields for automatic properties, hidden from C# code but visible in the Metalama code model).

An aspect should be fully deterministic, generating identical outputs from identical inputs in any situation. Therefore, we must order the collection of fields and automatic properties. For simplicity, we'll order by member name, but we'll revisit this in Equality comparison example, step 4: Customizing equality comparers.

Let's add the following code to the BuildAspect method:

18// Identify the field and automatic properties that will be part of the comparison.
19var fields = targetType.FieldsAndProperties.Where( f =>
20                                                       f.IsAutoPropertyOrField == true && f is
21                                                           { IsStatic: false, IsImplicitlyDeclared: false } )
22    .OrderBy( f => f.Name )
23    .ToList();
24

Step 3. Adding the IEquatable interface

Let's start adding advice (i.e., code transformations) to the type.

Our first piece of advice is to add the IEquatable<T> interface to the target type, where the generic parameter T is replaced with the target type itself. For this, we use the builder.ImplementInterface method.

This is done with the following code:

28// Add the IEquatable interface to the type (members will be added lower).
29builder.ImplementInterface( TypeFactory.GetNamedType( typeof(IEquatable<>) ).WithTypeArguments( targetType ) );
30

Note that this doesn't implement the interface members. We'll do that next by adding the public Equals method.

To learn more about implementing interfaces, see Implementing interfaces.

Step 4. Adding the Equals(T) method

Now, we want to introduce the strongly-typed Equals(T) method, where T is the target type of our aspect. We'll have two code snippets for this: a template method and a call to IntroduceMethod in BuildAspects.

First, we define a template method. Here's its definition:

78// Template for the Equals(T) method.
79[Template( Name = "Equals" )]
80public bool TypedEqualsTemplate<[CompileTime] T>( T? other, IReadOnlyList<IFieldOrProperty> fields )
81{
82    // The following `if` is evaluated at compile time, so the block is only
83    // emitted for reference types.
84    if ( meta.Target.Type.IsReferenceType == true )
85    {
86        if ( other == null )
87        {
88            return false;
89        }
90
91        if ( ReferenceEquals( meta.This, other ) )
92        {
93            return true;
94        }
95    }
96
97    // Compare all fields.
98    foreach ( var field in fields )
99    {
100        var defaultComparer = TypeFactory.GetNamedType( typeof(EqualityComparer<>) )
101            .WithTypeArguments( field.Type )
102            .Properties["Default"];
103
104        if ( !defaultComparer.Value!.Equals( field.Value, field.WithObject( other ).Value ) )
105        {
106            return false;
107        }
108    }
109
110    return true;
111}
112

Notice the TemplateAttribute custom attribute on the method. As the name suggests, it instructs Metalama to treat the method as a template that can include both compile-time and run-time code. The compile-time code is executed at compile time, generating the code that will run at run time.

This method has three parameters:

  • A compile-time type parameter T representing the target type of the aspect. This parameter only exists at compile time and won't appear in the generated code.
  • A run-time parameter other that's part of the IEquatable method signature.
  • A compile-time parameter fields representing the list of fields and automatic properties to be compared.

The rest of the implementation is straightforward and documented with inline comments.

Note that we're relying on the default EqualityComparer<T> for each field type. In the fourth article, we'll explore how to customize this logic.

This template doesn't automatically add itself to the target type. We must add the following code to the BuildAspect method:

78// Template for the Equals(T) method.
79[Template( Name = "Equals" )]
80public bool TypedEqualsTemplate<[CompileTime] T>( T? other, IReadOnlyList<IFieldOrProperty> fields )
81{
82    // The following `if` is evaluated at compile time, so the block is only
83    // emitted for reference types.
84    if ( meta.Target.Type.IsReferenceType == true )
85    {
86        if ( other == null )
87        {
88            return false;
89        }
90
91        if ( ReferenceEquals( meta.This, other ) )
92        {
93            return true;
94        }
95    }
96
97    // Compare all fields.
98    foreach ( var field in fields )
99    {
100        var defaultComparer = TypeFactory.GetNamedType( typeof(EqualityComparer<>) )
101            .WithTypeArguments( field.Type )
102            .Properties["Default"];
103
104        if ( !defaultComparer.Value!.Equals( field.Value, field.WithObject( other ).Value ) )
105        {
106            return false;
107        }
108    }
109
110    return true;
111}
112

We call the IntroduceMethod method from our BuildAspect method:

34// Introduce the Equals(T) methods.
35builder.IntroduceMethod( nameof(this.TypedEqualsTemplate), args: new { T = targetType, fields } );
36

Let's look at the arguments we pass to this method:

  1. The first argument is the name of the template method we defined above.
  2. args are the arguments we're binding to T and fields, the compile-time parameters (both type and normal parameters) of the template method.

By definition, the run-time parameter of the template method must not (and cannot) be bound at compile time.

You can learn more about introducing methods in Introducing members, and about template parameters in Template parameters and type parameters.

Step 5. Overriding the default Equals(object) method

Next, let's override the default Equals method, so facilities that don't support the IEquatable<T> interface use the correct equality implementation.

As with any member introduction, two steps are involved: implementing the template and calling IntroduceMethod from BuildAspect.

The template should be straightforward:

113// Template for the Equals(object) method.
114[Template( Name = "Equals" )]
115public bool UntypedEqualsTemplate<[CompileTime] T>( object? other )
116{
117    // If we have a reference type, first check for reference equality because this is very fast.
118    if ( meta.Target.Type.IsReferenceType == true )
119    {
120        if ( ReferenceEquals( meta.This, other ) )
121        {
122            return true;
123        }
124    }
125
126    return other is T typed && meta.This.Equals( typed );
127}
128

Note that we're using meta.This, which compiles into this, i.e., the current instance at run time. By contrast, this in the template refers to the compile-time aspect instance.

Here's the code snippet in the BuildAspect method:

40// Introduce the Equals(object) methods.
41builder.IntroduceMethod(
42    nameof(this.UntypedEqualsTemplate),
43    whenExists: OverrideStrategy.Override,
44    args: new { T = targetType, fields } );
45

The whenExists parameter determines the strategy if the member already exists in the target type or an ancestor type. We use OverrideStrategy to specify that we want to override the member in this case.

Step 6. Overriding the GetHashCode method

To implement the GetHashCode() method, we chose to rely on the HashCode system class, which offers a robust mechanism to combine different values into a single hash.

The rest of the implementation follows the principles we've already explained.

Here's the template:

129// Template for the GetHashCode method.
130[Template( Name = "GetHashCode" )]
131public int GetHashCodeTemplate( IReadOnlyList<IFieldOrProperty> fields )
132{
133    var hashCode = default(HashCode);
134
135    foreach ( var field in fields )
136    {
137        var defaultComparer = TypeFactory.GetNamedType( typeof(EqualityComparer<>) )
138            .WithTypeArguments( field.Type )
139            .Properties["Default"];
140
141        hashCode.Add( field.Value, defaultComparer.Value );
142    }
143
144    return hashCode.ToHashCode();
145}
146

And here's the snippet to add to BuildAspect:

49// Introduce the GetHashCode method.
50builder.IntroduceMethod(
51    nameof(this.GetHashCodeTemplate),
52    whenExists: OverrideStrategy.Override,
53    args: new { T = targetType, fields } );
54

Step 7. Adding the operators

The finishing touch, and a best practice, is to introduce the == and != operators. This can be done by calling the IntroduceBinaryOperator method from BuildAspect.

Let's first define the templates:

148// Template for the == operator.
149[Template]
150public bool EqualityOperatorTemplate<[CompileTime] T>( T a, T b )
151{
152    if ( meta.Target.Type.IsReferenceType == true )
153    {
154        return (a == null && b == null) || (a != null && a.Equals( b ));
155    }
156    else
157    {
158        return a!.Equals( b );
159    }
160}
161
162// Template for the != operator.
163[Template]
164public bool InequalityOperatorTemplate<[CompileTime] T>( T a, T b )
165{
166    if ( meta.Target.Type.IsReferenceType == true )
167    {
168        return ((a == null) ^ (b == null)) || (a != null && !a.Equals( b ));
169    }
170    else
171    {
172        return !a!.Equals( b );
173    }
174}
175

We can now advise the type:

58// Introduce the operators.
59builder.IntroduceBinaryOperator(
60    nameof(this.EqualityOperatorTemplate),
61    targetType,
62    targetType,
63    TypeFactory.GetType( typeof(bool) ),
64    OperatorKind.Equality,
65    args: new { T = targetType } );
66
67builder.IntroduceBinaryOperator(
68    nameof(this.InequalityOperatorTemplate),
69    targetType,
70    targetType,
71    TypeFactory.GetType( typeof(bool) ),
72    OperatorKind.Inequality,
73    args: new { T = targetType } );
74

Summary

Implementing an equality contract involves a lot of boilerplate code, but fortunately, most of it can be automatically generated by an aspect.

In this article, we've shown how to implement all the components required by this pattern: the Equals(T), Equals(object), GetHashCode(), and the == and != operators.

We used compile-time template parameters (including type parameters) to pass data from the BuildAspect method to the templates. We generated slightly different code for value and reference types using compile-time conditions.

However, we've limited ourselves to very simple cases. In the next article, we'll explore how to handle type inheritance in reference types.

In the Equality comparison example, step 2: Support type inheritance, we will add support for type inheritance.