In the first article of this series, we created an aspect that automatically implements equality comparison for types, including all type fields and automatic properties in the comparison.
In this article, we'll show you how to modify the aspect so you can hand-pick the members that should be part of Equals and GetHashCode(), using a new custom attribute [EqualityMember].
Step 1: Adding EqualityMemberAttribute
If we wanted to keep it simple, EqualityMemberAttribute could be a plain C# custom attribute class. However, we're adding a bit of complexity to our aspect library to improve the user experience:
- We want the
[EqualityMember]aspect to automatically add theImplementEquatableaspect to the type. - We want to ensure that
[EqualityMember]is only added to non-static fields or properties and report an error if otherwise.
To make this happen, we derive the EqualityMemberAttribute class from TypeAspect.
In BuildAspect, we call the RequireAspect method to implicitly add the ImplementEquatable aspect to the type, if it isn't already added.
To define valid targets for this attribute, we implement the BuildEligibility method.
1using Metalama.Framework.Advising;
2using Metalama.Framework.Aspects;
3using Metalama.Framework.Code;
4using Metalama.Framework.Eligibility;
5
6namespace Metalama.Samples.Comparison3;
7
8public class EqualityMemberAttribute : FieldOrPropertyAspect
9{
10 public override void BuildAspect( IAspectBuilder<IFieldOrProperty> builder )
11 {
12 base.BuildAspect( builder );
13
14 // Automatically (and implicitly) add the ImplementEquatableAttribute aspect to the declaring type.
15 builder.With( builder.Target.DeclaringType ).RequireAspect<ImplementEquatableAttribute>();
16 }
17
18 public override void BuildEligibility( IEligibilityBuilder<IFieldOrProperty> builder )
19 {
20 base.BuildEligibility( builder );
21
22 builder.MustNotBeStatic();
23 builder.MustBeExplicitlyDeclared();
24 }
25}
You can learn more about these techniques in Adding child aspects and Defining the eligibility of aspects.
For the RequireAspect method to work, we must ensure that Metalama processes EqualityMemberAttribute aspects before ImplementEquatableAttribute; otherwise, it would be too late for EqualityMemberAttribute to add an ImplementEquatableAttribute aspect. This is done by using the [assembly: AspectOrder] custom attribute:
1using Metalama.Framework.Aspects;
2using Metalama.Samples.Comparison3;
3
4[assembly:
5 AspectOrder(
6 AspectOrderDirection.CompileTime,
7 typeof(EqualityMemberAttribute),
8 typeof(ImplementEquatableAttribute) )]
To read more about aspect ordering, see Ordering aspects.
Step 2: Modifying the BuildAspect method
Now, we can update the logic that selects equality members in the BuildAspect method. Naturally, we'll only select those that have the [EqualityMember] attribute.
16// Identify the field and automatic properties that might be part of the comparison, look for custom attributes.
17var targetType = builder.Target;
18
19var fields = targetType.FieldsAndProperties.Where( f =>
20 f.IsAutoPropertyOrField == true
21 && f is { IsStatic: false, IsImplicitlyDeclared: false }
22 && f.Attributes.Any( typeof(EqualityMemberAttribute) ) )
23 .ToList();
24What should happen when this query returns an empty set, i.e., the user did not tag any field or property with [EqualityMember]? The answer depends on the situation:
- If the user explicitly used the
[ImplementEquatable]attribute on the type but omitted to mark any field, this is certainly an error that should be reported. - If the
[ImplementEquatable]aspect was inherited from a base type, and the current field does not add any equality member, then there's nothing to do—no error to report, nor any code transformation to perform. We can just ignore the aspect.
This is implemented by the following code in BuildAspect:
28// If there are no members, do not implement the aspect.
29if ( fields.Count == 0 )
30{
31 // Write an error unless the aspect was applied through inheritance.
32 if ( builder.AspectInstance.Predecessors[0].Kind != AspectPredecessorKind.Inherited )
33 {
34 builder.Diagnostics.Report( DiagnosticDefinitions.NoEqualityMemberError.WithArguments( targetType ) );
35 }
36
37 return;
38}
39Here is the error definition:
17public static readonly DiagnosticDefinition<INamedType>
18 NoEqualityMemberError
19 = new(
20 "EQU002",
21 Severity.Error,
22 "The type '{0}' does not have any field or property annotated with the [EqualityMember] attribute." );And that's all! There's nothing else to change. This is the beauty of separating analysis from advising: you can seamlessly change the collection of equality members, and the rest of the aspect will simply consume it. As you can see, standard best practices also apply to meta-programming, especially separation of concerns!
Summary
We've defined a new custom attribute [EqualityMember]. We made it a TypeAspect more for convenience than necessity, to utilize validation and implicitly add the "parent" aspect.
Then, we simply changed the logic that built the collection of equality members so that it checks for the presence of a custom attribute of type EqualityMemberAttribute.
In the next article, we'll supercharge the EqualityMemberAttribute aspect to make it possible to customize the equality contract.