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:
- Add the IEquatable<T> interface to the class.
- Add a method
public bool Equals(T? other)that compares the current object with another one, field by field. - Override the Equals(object) method.
- Override the GetHashCode() method.
- Add the
==and!=operators to the class.
Once this aspect is complete, it will be capable of generating code like this:
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}
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}
1namespace Metalama.Samples.Comparison1;
2
3[ImplementEquatable]
4internal partial struct EntityKey
5{
6 public string Type { get; }
7
8 public int Id { get; }
9}
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 );
14Step 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();
26Step 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 ) );
33Note 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}
115Notice 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
Trepresenting 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
otherthat's part of the IEquatable method signature. - A compile-time parameter
fieldsrepresenting 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}
115We call the IntroduceMethod method from our BuildAspect method:
37// Introduce the Equals(T) methods.
38builder.IntroduceMethod( nameof(this.TypedEqualsTemplate), args: new { T = targetType, fields } );
39Let's look at the arguments we pass to this method:
- The first argument is the name of the template method we defined above.
argsare the arguments we're binding toTandfields, 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}
131Note 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 } );
48The 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}
149And 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 } );
57Step 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}
178We 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 } );
77Summary
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.