Open sandboxFocusImprove this doc

Equality comparison example, step 2: Support type inheritance

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) method virtual so 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 our Equals(T), ensuring our new equality members are taken into account.
    • Call base.Equals(TBase) from Equals(T), so our method considers the base equality members.

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
12

To 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}
52

Note 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}
142

The 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}
71

Step 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 );
84

Note two changes in this snippet:

  1. We are passing baseEqualsMethod to the template.
  2. 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}
164

Note 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}
100

Here 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}
208

Step 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:

  1. In BuildAspect, pass baseGetHashCodeMethod to IntroduceMethod.
  2. Add an IMethod? baseGetHashCodeMethod parameter to the template for the GetHashCode() method.
  3. Add the following snippet to the template:

232if ( baseGetHashCodeMethod != null )
233{
234    hashCode.Add( baseGetHashCodeMethod.With( InvokerOptions.Base ).Invoke() );
235}
236

Result

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.

Source Code



1namespace Metalama.Samples.Comparison2;
2
3[ImplementEquatable]
4public partial class Entity
5{

6    public string? Name { get; init; }











































7}
Transformed Code
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}
Source Code



1namespace Metalama.Samples.Comparison2;
2
3public sealed partial class Person : Entity
4{

5    public int Age { get; init; }
















































6}
Transformed Code
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.