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}
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; }
13public bool Equals(EntityKey other)
14    {
15        if (!EqualityComparer<int>.Default.Equals(this.Id, other.Id))
16        {
17            return (bool)false;
18        }
19
20        if (!EqualityComparer<string?>.Default.Equals(this.Type, other.Type))
21        {
22            return (bool)false;
23        }
24
25        return true;
26    }
27    public override bool Equals(object? other)
28    {
29        return other is EntityKey typed && Equals(typed);
30    }
31    public override int GetHashCode()
32    {
33        var hashCode = default(HashCode);
34        hashCode.Add(this.Id, EqualityComparer<int>.Default);
35        hashCode.Add(this.Type, EqualityComparer<string?>.Default);
36        return hashCode.ToHashCode();
37    }
38    public static bool operator ==(EntityKey a, EntityKey b)
39    {
40        return a.Equals(b);
41    }
42
43    public static bool operator !=(EntityKey a, EntityKey b)
44    {
45        return !a.Equals(b);
46    }
47}

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.

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

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:

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

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:

30// Add the IEquatable interface to the type (members will be added lower).
31builder.ImplementInterface(
32    ((INamedType) TypeFactory.GetType( typeof(IEquatable<>) )).WithTypeArguments( targetType ) );
33

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:

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

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:

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

We call the IntroduceMethod method from our BuildAspect method:

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

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:

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

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:

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

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:

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

And here's the snippet to add to BuildAspect:

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

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:

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

We can now advise the type:

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

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.