In the previous step, we implemented a basic version of the equality comparison aspect, but we deliberately avoided addressing the scenario where a class with an equality contract has derived classes that can add members to the equality contract. In this article, we'll address this limitation.
We'll use the notation T to refer to the type being advised, and TBase for the closest ancestor type that implements the equality pattern. Note that TBase is not necessarily the immediate base class, because it's valid for an intermediate class not to add any equality member. In this case, we need to look further into the ancestors.
Let's outline the different changes we need to make to the aspect.
Regarding the Equals methods, here's how we need to modify the aspect:
- From the perspective of the base class, we must make the
Equals(T)methodvirtualso it can be overridden in a derived class. - From the perspective of the child class, we must:
- Override the
Equals(TBase)method so that it calls ourEquals(T), ensuring our new equality members are taken into account. - Call
base.Equals(TBase)fromEquals(T), so our method considers the base equality members.
- Override the
For GetHashCode(), we also need to call the base method to integrate the base members into the hash code. However, we should be careful not to call the root GetHashCode() because it returns a hash of the object's address.
Step 1. Making the aspect inheritable
The first thing we need to do is mark our aspect as [Inheritable]. This allows the aspect to be applied to derived classes automatically when it's applied to a base class.
10[Inheritable]
11public class ImplementEquatableAttribute : TypeAspect
12To learn more, see Applying aspects to derived types.
Step 2. Identifying the base methods
Remember that we decided, in the previous article, to split the BuildAspect method in two parts: first information gathering, then adding advice. We need to enhance the first part and identify artifacts from the base classes.
The first thing we need to do is identify the base Equals(TBase) method. We iterate through ancestor types and look for a method named Equals with a single parameter of the ancestor type. It uses the IsAccessibleFrom method to check that the method is accessible from the current type.
29// Find the base Equals method.
30IMethod? baseEqualsMethod = null;
31
32for ( var parent = builder.Target.BaseType;
33 parent != null && parent.SpecialType != SpecialType.Object;
34 parent = parent.BaseType )
35{
36 baseEqualsMethod = parent.Methods
37 .OfName( "Equals" )
38 .SingleOrDefault( m => m.Parameters.Count == 1
39 && m.Parameters[0].Type.Equals( m.DeclaringType )
40 && m.IsAccessibleFrom( targetType ) );
41
42 if ( baseEqualsMethod != null )
43 {
44 if ( !CheckMethodOverridable( baseEqualsMethod, targetType, builder ) )
45 {
46 return;
47 }
48
49 break;
50 }
51}
52Note that this snippet calls CheckMethodOverridable to verify that the base Equals method can be overridden. If not, this method reports an error and does not implement the aspect. Let's look at the implementation of CheckMethodOverridable:
127private static bool CheckMethodOverridable(
128 IMethod? method,
129 INamedType targetType,
130 IAspectBuilder<INamedType> builder )
131{
132 if ( method != null && !method.IsOverridable() )
133 {
134 builder.Diagnostics.Report(
135 DiagnosticDefinitions.BaseMethodMustBeVirtual.WithArguments( (method, targetType) ) );
136
137 return false;
138 }
139
140 return true;
141}
142The CheckMethodOverridable method calls builder.Diagnostics.Report to report an error if the base method cannot be overridden. The error itself must be declared as a static field or property of a compile-time type. Reporting an error does not stop the execution of BuildAspect, so we need an explicit return statement. However, any advice provided by BuildAspect will be ignored if any error is reported.
1using Metalama.Framework.Aspects;
2using Metalama.Framework.Code;
3using Metalama.Framework.Diagnostics;
4
5namespace Metalama.Samples.Comparison2;
6
7[CompileTime]
8internal static class DiagnosticDefinitions
9{
10 public static readonly DiagnosticDefinition<(IMethod BaseMethod, INamedType CurrentType)>
11 BaseMethodMustBeVirtual
12 = new(
13 "EQU001",
14 Severity.Error,
15 "The '{0}' method must be virtual and non-sealed because it must be overridden by the '{1}' type." );
16}
To learn more about reporting errors and warnings, see Reporting and suppressing diagnostics.
To identify the base Equals method, the easiest approach is to use the AllMethods collections of the base type, which includes all methods of the base type, including all inherited methods.
We did not use this approach to find the base Equals method because it would have been more difficult: filtering AllMethods for methods whose only parameter is of the same type as the declaring type, would potentially return several methods, one for each ancestor, and we would still need to choose the closest ancestor.
56// Find the base GetHashCode method.
57var baseGetHashCodeMethod =
58 targetType.BaseType?.AllMethods.OfName( nameof(object.GetHashCode) ).SingleOrDefault();
59
60if ( baseGetHashCodeMethod?.DeclaringType.Equals( SpecialType.Object ) == true )
61{
62 // Do not call the GetHashCode method defined on System.Object because it returns
63 // a hash of the reference, which is irrelevant to us.
64 baseGetHashCodeMethod = null;
65}
66
67if ( !CheckMethodOverridable( baseGetHashCodeMethod, targetType, builder ) )
68{
69 return;
70}
71Step 3. Updating the Equals(T) method
Now that we've identified the base methods, we can update the code that introduces the members. Let's start with the strongly-typed Equals(T) method, where T is the current type.
The code snippet in the BuildAspect method is updated to the following:
79// Introduce the Equals methods.
80builder.IntroduceMethod(
81 nameof(this.TypedEqualsTemplate),
82 args: new { T = targetType, fields, baseEqualsMethod },
83 buildMethod: m => m.IsVirtual = !targetType.IsSealed );
84Note two changes in this snippet:
- We are passing
baseEqualsMethodto the template. - We are marking the method as
virtual, unless the type is sealed.
To the TypedEqualsTemplate method, we add the IMethod baseEqualsMethod parameter, plus the following snippet:
155// Call the base strongly-typed Equals method, which typically has a parameter of the base type, but
156// is overridden in the current type by the BaseTypeEqualsTemplate template.
157if ( baseEqualsMethod != null )
158{
159 if ( !baseEqualsMethod.With( InvokerOptions.Base ).Invoke( other ) )
160 {
161 return false;
162 }
163}
164Note the use of With(InvokerOptions.Base). By default, invoking a method (using Invoke) results in calling the final override (i.e., generating this.Equals) of this method, even if the method is in a base type. You must use the Base option so that Metalama generates base.Equals.
Step 4. Overriding the Equals(TBase) method
We must now override the Equals method of the base type, if any.
We add the following snippet to the BuildAspect method:
93if ( baseEqualsMethod != null )
94{
95 builder.IntroduceMethod(
96 nameof(this.BaseTypeEqualsTemplate),
97 whenExists: OverrideStrategy.Override,
98 args: new { TBase = baseEqualsMethod.DeclaringType, T = targetType } );
99}
100Here is the template for the Equals(TBase) override:
196// Templates for the new method hiding the base typed Equals method.
197[Template( Name = "Equals", IsSealed = true )]
198public bool BaseTypeEqualsTemplate<[CompileTime] TBase, [CompileTime] T>( TBase? other )
199{
200 // First check for reference equality because this is very fast.
201 if ( ReferenceEquals( meta.This, other ) )
202 {
203 return true;
204 }
205
206 return (other is T typed && meta.This.Equals( typed ));
207}
208Step 5. Updating the GetHashCode method
The last step is to update the GetHashCode method to make it call baseGetHashCodeMethod, which we previously identified.
This requires the following changes:
- In
BuildAspect, passbaseGetHashCodeMethodto IntroduceMethod. - Add an IMethod?
baseGetHashCodeMethodparameter to the template for the GetHashCode() method. - Add the following snippet to the template:
232if ( baseGetHashCodeMethod != null )
233{
234 hashCode.Add( baseGetHashCodeMethod.With( InvokerOptions.Base ).Invoke() );
235}
236Result
Our aspect can now properly handle type inheritance.
To demonstrate this, let's extract the Entity class as a base for the Person class. You can check that the equality pattern now properly takes class inheritance into account.
1namespace Metalama.Samples.Comparison2;
2
3[ImplementEquatable]
4public partial class Entity
5{
6 public string? Name { get; init; }
7}
1using System;
2using System.Collections.Generic;
3
4namespace Metalama.Samples.Comparison2;
5
6[ImplementEquatable]
7public partial class Entity
8: IEquatable<Entity>
9{
10 public string? Name { get; init; }
11public virtual bool Equals(Entity other)
12 {
13 if (other == null)
14 {
15 return (bool)false;
16 }
17
18 if (object.ReferenceEquals(this, other))
19 {
20 return (bool)true;
21 }
22
23 if (!EqualityComparer<string?>.Default.Equals(this.Name, other.Name))
24 {
25 return (bool)false;
26 }
27
28 return true;
29 }
30 public override bool Equals(object? other)
31 {
32 if (object.ReferenceEquals(this, other))
33 {
34 return (bool)true;
35 }
36
37 return other is Entity typed && Equals(typed);
38 }
39 public override int GetHashCode()
40 {
41 var hashCode = default(HashCode);
42 hashCode.Add(this.Name, EqualityComparer<string?>.Default);
43 return hashCode.ToHashCode();
44 }
45 public static bool operator ==(Entity a, Entity b)
46 {
47 return a == null && b == null || a != null && a.Equals(b);
48 }
49
50 public static bool operator !=(Entity a, Entity b)
51 {
52 return a == null ^ b == null || a != null && !a.Equals(b);
53 }
54}
1namespace Metalama.Samples.Comparison2;
2
3public sealed partial class Person : Entity
4{
5 public int Age { get; init; }
6}
1using System;
2using System.Collections.Generic;
3
4namespace Metalama.Samples.Comparison2;
5
6public sealed partial class Person :Entity
7,IEquatable<Person>
8{
9 public int Age { get; init; }
10public bool Equals(Person other)
11 {
12 if (!base.Equals((Entity)other))
13 {
14 return (bool)false;
15 }
16
17 if (!EqualityComparer<int>.Default.Equals(this.Age, other.Age))
18 {
19 return (bool)false;
20 }
21
22 return true;
23 }
24 public override bool Equals(object? other)
25 {
26 if (object.ReferenceEquals(this, other))
27 {
28 return (bool)true;
29 }
30
31 return other is Person typed && Equals(typed);
32 }
33 public override sealed bool Equals(Entity other)
34 {
35 if (object.ReferenceEquals(this, other))
36 {
37 return (bool)true;
38 }
39
40 return other is Person typed && Equals(typed);
41 }
42 public override int GetHashCode()
43 {
44 var hashCode = default(HashCode);
45 hashCode.Add(base.GetHashCode());
46 hashCode.Add(this.Age, EqualityComparer<int>.Default);
47 return hashCode.ToHashCode();
48 }
49 public static bool operator ==(Person a, Person b)
50 {
51 return a == null && b == null || a != null && a.Equals(b);
52 }
53
54 public static bool operator !=(Person a, Person b)
55 {
56 return a == null ^ b == null || a != null && !a.Equals(b);
57 }
58}
In the next article, we will make it possible to add individual fields or properties to the equality contract.