C# Record's auto equality comparison
I always thought that when you write a record:
public record MyRecord(int Id, string Name)
{
public string Description { get; set; }
}
The Description property won’t matter for equality because it is mutable. I always thought about records as immutable structures: once declared and put in a collection, their value will not change - and so their equality will not change just because you set a property. So it was very surprising, and it kind of defeats the whole purpose of a record.
Why is it like that?
Background
Roslyn source generators rely on Equals and value equality heavily. The value of an [IncrementalValuesProvider<T>](https://learn.microsoft.com/en-za/dotnet/api/microsoft.codeanalysis.incrementalvaluesprovider-1?view=roslyn-dotnet-4.3.0) is compared to the value the pipeline held in the previous compilation; when they are equal, the step is not computed again. This way, when you have in your code:
[LoggerMessage(EventId = 2, Level = LogLevel.Information, Message = "No token was provided!")]
private partial void NoTokenProvided();
the compiler does not emit a new logging method on every build in your IDE. It only does it when the attribute changes.
Because I work with Roslyn source generators, at work and in my spare time (because I think metaprogramming is cool ), I use records a lot. This is how you optimize Roslyn generators and analyzers. I wrote my own library, FluentCodeGenerators , to help me with that. But even when you think you know the subject, things can still surprise you.
Record implementation
Going back to MyRecord: every auto-property has its own field, and the auto-generated equality members compare those fields, whatever they came from. So the MyRecord type above has 3 fields:
.field private initonly int32 '<Id>k__BackingField'
.custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor()
= (01 00 00 00 )
.custom instance void [System.Runtime]System.Diagnostics.DebuggerBrowsableAttribute::.ctor(valuetype [System.Runtime]System.Diagnostics.DebuggerBrowsableState)
= (01 00 00 00 00 00 00 00 ) // ........
// int32(0) // 0x00000000
.field private initonly string '<Name>k__BackingField'
.custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor()
= (01 00 00 00 )
.custom instance void [System.Runtime]System.Diagnostics.DebuggerBrowsableAttribute::.ctor(valuetype [System.Runtime]System.Diagnostics.DebuggerBrowsableState)
= (01 00 00 00 00 00 00 00 ) // ........
// int32(0) // 0x00000000
.field private string '<Description>k__BackingField'
.custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor()
= (01 00 00 00 )
.custom instance void [System.Runtime]System.Diagnostics.DebuggerBrowsableAttribute::.ctor(valuetype [System.Runtime]System.Diagnostics.DebuggerBrowsableState)
= (01 00 00 00 00 00 00 00 ) // ........
// int32(0) // 0x00000000
This is not surprising at all, and it does make sense when you think about it: after all, properties are just fancy instance methods (get_Description and set_Description) and they need to store their value somewhere.
The record’s auto equality implementation also has to rely on something, and the fields are the obvious choice:
.method public hidebysig virtual newslot instance bool
Equals(
class HamsterWheel.FluentCodeGenerators.UnitTests.External.TypeTokenUnitTests/MyRecord other
) cil managed
{
.custom instance void [System.Runtime]System.Runtime.CompilerServices.NullableContextAttribute::.ctor(unsigned int8)
= (01 00 02 00 00 ) // .....
// unsigned int8(2) // 0x02
.custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor()
= (01 00 00 00 )
.maxstack 3
IL_0000: ldarg.0 // this
IL_0001: ldarg.1 // other
IL_0002: beq.s IL_0065
IL_0004: ldarg.1 // other
IL_0005: brfalse.s IL_0062
IL_0007: ldarg.0 // this
IL_0008: callvirt instance class [System.Runtime]System.Type HamsterWheel.FluentCodeGenerators.UnitTests.External.TypeTokenUnitTests/MyRecord::get_EqualityContract()
IL_000d: ldarg.1 // other
IL_000e: callvirt instance class [System.Runtime]System.Type HamsterWheel.FluentCodeGenerators.UnitTests.External.TypeTokenUnitTests/MyRecord::get_EqualityContract()
IL_0013: call bool [System.Runtime]System.Type::op_Equality(class [System.Runtime]System.Type, class [System.Runtime]System.Type)
IL_0018: brfalse.s IL_0062
IL_001a: call class [System.Collections]System.Collections.Generic.EqualityComparer`1<!0/*int32*/> class [System.Collections]System.Collections.Generic.EqualityComparer`1<int32>::get_Default()
IL_001f: ldarg.0 // this
IL_0020: ldfld int32 HamsterWheel.FluentCodeGenerators.UnitTests.External.TypeTokenUnitTests/MyRecord::'<Id>k__BackingField'
IL_0025: ldarg.1 // other
IL_0026: ldfld int32 HamsterWheel.FluentCodeGenerators.UnitTests.External.TypeTokenUnitTests/MyRecord::'<Id>k__BackingField'
IL_002b: callvirt instance bool class [System.Collections]System.Collections.Generic.EqualityComparer`1<int32>::Equals(!0/*int32*/, !0/*int32*/)
IL_0030: brfalse.s IL_0062
IL_0032: call class [System.Collections]System.Collections.Generic.EqualityComparer`1<!0/*string*/> class [System.Collections]System.Collections.Generic.EqualityComparer`1<string>::get_Default()
IL_0037: ldarg.0 // this
IL_0038: ldfld string HamsterWheel.FluentCodeGenerators.UnitTests.External.TypeTokenUnitTests/MyRecord::'<Name>k__BackingField'
IL_003d: ldarg.1 // other
IL_003e: ldfld string HamsterWheel.FluentCodeGenerators.UnitTests.External.TypeTokenUnitTests/MyRecord::'<Name>k__BackingField'
IL_0043: callvirt instance bool class [System.Collections]System.Collections.Generic.EqualityComparer`1<string>::Equals(!0/*string*/, !0/*string*/)
IL_0048: brfalse.s IL_0062
IL_004a: call class [System.Collections]System.Collections.Generic.EqualityComparer`1<!0/*string*/> class [System.Collections]System.Collections.Generic.EqualityComparer`1<string>::get_Default()
IL_004f: ldarg.0 // this
IL_0050: ldfld string HamsterWheel.FluentCodeGenerators.UnitTests.External.TypeTokenUnitTests/MyRecord::'<Description>k__BackingField'
IL_0055: ldarg.1 // other
IL_0056: ldfld string HamsterWheel.FluentCodeGenerators.UnitTests.External.TypeTokenUnitTests/MyRecord::'<Description>k__BackingField'
IL_005b: callvirt instance bool class [System.Collections]System.Collections.Generic.EqualityComparer`1<string>::Equals(!0/*string*/, !0/*string*/)
IL_0060: br.s IL_0063
IL_0062: ldc.i4.0
IL_0063: br.s IL_0066
IL_0065: ldc.i4.1
IL_0066: ret
} // end of method MyRecord::Equals
This is the default implementation of the MyRecord class above. As you can see at the bottom:
IL_004f: ldarg.0 // this
IL_0050: ldfld string HamsterWheel.FluentCodeGenerators.UnitTests.External.TypeTokenUnitTests/MyRecord::'<Description>k__BackingField'
It loads the backing field of the Description property, then loads it again for the second instance, and then:
IL_005b: callvirt instance bool class [System.Collections]System.Collections.Generic.EqualityComparer`1<string>::Equals(!0/*string*/, !0/*string*/)
compares both with EqualityComparer<string>.
The surprise
If you think about how this is implemented, it does make sense. All of it is based on fields: the primary constructor parameters are fields, the properties are fields, and the compiler uses the fields for record equality. All of this makes sense.
But somehow I thought that only the primary constructor is part of equality and properties are not. I even remember writing code that relied on this - apparently incorrect - behavior.
Immutable but mutable
There is no way to change the default behavior. There is no [NotInEquality] attribute that takes a member out of equality - unless the property is computed only:
public record MyRecord(int Id, string Name, string Surname)
{
public string FullName => Name + " " + Surname;
}
This does not add FullName to the equality comparison.
In retrospect, this means you really should not use a record to store mutable data - unless you override Equals and GetHashCode yourself.
But then what is the point of using it in the first place? You can have a class with primary constructor parameters and write the equality yourself. A bit more work defining your own properties - that is true - but records are much less appealing now.
So if you do want to store mutable data in Record:
- Do not. - it will bite you at some point.
- Hand-written
EqualsandGetHashCode. Store what you want, compare what you want. But this is complicated and you may have bugs that will bite you at some point. - Keep the volatile data outside the record entirely - a dictionary with the record as key and the data that belongs to it as the values, say. Doable, but not elegant.
Summary
- A record’s equality compares all fields, regardless of how they are declared.
- This includes properties with private, internal or public setters; only computed properties, which have no field, stay out.
- Usually this is not what you intended, so to keep a record’s immutability, remove those mutable fields or override
EqualsandGetHashCodeyourself.