Before we start, let's examine why and when it is beneficial to use an equality comparison aspect, as opposed to using the default .NET or C# implementation.
Default implementation
In .NET, the default approach to equality comparison varies depending on the type: class, struct, and record each have their own strategy.
class: By default, two class instances are never considered equal. When you compare twoclassinstances, only the references are compared, equivalent to calling the ReferenceEquals method.struct: The default .NET implementation checks the values of all fields and automatic properties, using the Equals(object) method for bothclassandstructfields. However, GetHashCode() is invoked for classes but not always for structs. This can lead to inconsistencies between Equals and GetHashCode() in edge cases where a structAhas a field of typeB,Bis astructwith a custom equality implementation, and not all fields ofBare identity members.record: All fields and automatic properties, whether ofclassorstructtypes, are compared using Default, whereTis the field type. This meansrecordtypes perform a deep comparison by default. The strongly-typed Equals and custom GetHashCode() methods are used in both cases.
Advantages of a custom implementation
For struct types
- Performance: You can achieve a significant performance boost with a custom equality implementation, often by two orders of magnitude. If you frequently compare custom structs, a custom implementation is essential.
- Fixing edge cases: As mentioned earlier, there's a slight chance you might encounter the edge cases described above.
- Different string comparison: The default comparison mode for strings is Ordinal. If you need a different mode, like case-insensitive, you'll need to provide a custom equality comparison implementation.
For class types
- Different equality behaviors: Sometimes, altering the default equality behavior is desirable for certain object families. For example, if you have an
Entityclass withEntityTypeandEntityIdfields, along with other data fields, you might want the default comparison to consider only these two fields, ignoring others. This means two distinct instances with the sameEntityTypeandEntityIdbut different data fields would be considered equal.
For record types
- In general, overriding the default equality implementation should be done cautiously. The identity contract is a core feature of
recordtypes, unlikeclasstypes, and modifying this behavior can contradict the principle of least surprise. - Ignoring irrelevant fields: A valid reason for overriding might be to ignore an irrelevant record field. For instance, you might have an
ObjectIdfield used only for debugging, not stored or serialized over the network, and shouldn't affect equality comparison. In such cases, overriding the equality implementation is justified. - Different string comparison: As with structs, you'll need a custom equality contract if you want a different string comparison mode than Ordinal.
Why not manually implement the custom implementation?
Considering that the default implementation can be less than ideal, you might think about manually implementing the IEquatable<T> interface, including the operators.
There are two problems with this approach:
- It's repetitive, boilerplate code that takes time and money.
- The implementation must stay synchronized with the list of fields and automatic properties of the class. If you add a field to a struct, it's easy to forget to update both the Equals and GetHashCode() methods. This is an unnecessary source of human errors.
To avoid repetitive work and reduce maintenance errors, it's much better to implement the equality contract automatically during compilation.
Why not Roslyn source generators?
To be fair, let's mention that you could use the Roslyn source generators API to implement the equality pattern because it doesn't require modifying any hand-written members, only adding new ones. However, Roslyn source generators are low-level APIs and can require a lot of code to make them work.
In contrast, Metalama aspects are much easier to build. Metalama itself uses Roslyn source generators, but it adds several high-level features to dramatically improve your productivity:
- code templates,
- aspect inheritance,
- code validation (Roslyn generators cannot report errors, you need an additional analyzer),
- much simpler extensibility,
- and more.