InternetException

C# Record's auto equality comparison

· 1028 words · ~5 min read · in .NET

#C# #records #gotchas

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:

  1. Do not. - it will bite you at some point.
  2. Hand-written Equals and GetHashCode. Store what you want, compare what you want. But this is complicated and you may have bugs that will bite you at some point.
  3. 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 Equals and GetHashCode yourself.