Record Classes

Importantly, the record modifier is not limited to creating value types. Starting in C# 9.0, support was added for record classes, as demonstrated by Listing 9.5.

Listing 9.5: Declaring a Record Class
// Use the record class construct to declare a reference type
public record class Coordinate(
    Angle Longitude, Angle Latitude)

In this record class, we have used the Angle type as positional parameters into the Coordinate to represent longitude and latitude.

To distinguish record structs and classes from their non-record versions, we will refer to them as standard structs and standard classes within this chapter. One of the most critical questions that the record class feature introduces is, when should you use a record class rather than a standard class, and vice versa? The key, and perhaps the only reason to define a record class, is if the type needs to have value equality behavior. In fact, whenever a custom type needs to implement value equality (always the case for a value type), the record construct should be used if possible. An example of when it might not be possible is working with a C# version prior to C# 9.0 (for a reference type) or C# 10.0 (for a value type).

Another time when the record construct cannot be used on a class is when the base class is not a record. A record class can inherit only from another record class, while a standard class can inherit only from another standard class. The inheritance chain cannot mix and match between standard classes and records.

note
Reference types should be implemented as standard classes by default and record classes only when requiring a value equality implementation.

Many of the features associated with record struct are also used in record classes, including succinct declaration, data storage with properties, constructor initialization, deconstruction, ToString() diagnostic capabilities, and, perhaps most importantly because of the complexities, value equivalence, not just reference equivalence. The equivalent record class generated C# code is shown in Listing 9.6.

Listing 9.6: Equivalent Record Class–Generated C# Code
using System.Runtime.CompilerServices;
using System.Text;
 
[CompilerGenerated]
public class Coordinate : IEquatable<Coordinate>
{
 
    public Angle Longitude { getinit; }
    public Angle Latitude { getinit; }
 
    public Coordinate(Angle Longitude, Angle Latitude) : base()
    {
        this.Longitude = Longitude;
        this.Latitude = Latitude;
    }
 
    public override string ToString()
    {
        StringBuilder stringBuilder = new ();
        stringBuilder.Append("Coordinate");
        stringBuilder.Append(" { ");
        if (PrintMembers(stringBuilder))
        {
            stringBuilder.Append(' ');
        }
        stringBuilder.Append('}');
        return stringBuilder.ToString();
    }    
 
    protected virtual bool PrintMembers(StringBuilder builder)
    {
        RuntimeHelpers.EnsureSufficientExecutionStack();
        builder.Append("Longitude = ");
        builder.Append(Longitude.ToString());
        builder.Append(", Latitude = ");
        builder.Append(Latitude.ToString());
        return true;
    }
 
    public static bool operator !=(
        Coordinate? left, Coordinate? right) =>
            !(left == right);
 
    public static bool operator ==(
        Coordinate? left, Coordinate? right) =>
            ReferenceEquals(left, right) ||
                (left?.Equals(right) ?? false);
 
    public override int GetHashCode()
    {
        static int GetHashCode(Angle angle) =>
            EqualityComparer<Angle>.Default.GetHashCode(angle);
        
        return (EqualityComparer<Type>.Default.GetHashCode(
            EqualityContract()) * -1521134295 
            + GetHashCode(Longitude)) * -1521134295 
            + GetHashCode(Latitude);
    }
 
    public override bool Equals(object? obj) => 
        Equals(obj as Coordinate);
 
    public virtual bool Equals(Coordinate? other) => 
        (object)this == other || (other is not null
            && EqualityContract() == other!.EqualityContract()
            && EqualityComparer<Angle>.Default.Equals(
                Longitude, other!.Longitude)
            && EqualityComparer<Angle>.Default.Equals(
                Latitude, other!.Latitude));
 
    public void Deconstruct(
        out Angle Longitude, out Angle Latitude)
    {
        Longitude = this.Longitude;
        Latitude = this.Latitude;
    }
 
    protected virtual Type EqualityContract() => typeof(Coordinate);
 
    public Type ExternalEqualityContract => EqualityContract();
 
    // Actual name in IL is "<Clone>$". However, 
    // you can't add a Clone method to a record.
    public Coordinate Clone() => new(this);
 
    protected Coordinate(Coordinate original)
    {
        Longitude = original.Longitude;
        Latitude = original.Latitude;
    }
}

There are several expected differences in the records struct versus record class–generated code.

First, several members are decorated with virtual modifiers (PrintMembers(StringBuilder builder), Equals(Coordinate? Other), and EqualityContract()). virtual was never used in the record struct since all structs are sealed, making virtual nonsensical in the record struct case.

Second, a record class needs to account for the possibility of a null value. Thus, Equals(), the == operator, and both of the Equals() methods need to check for a null parameter value.

In the next two sections, we will explore records in detail; we’ll discuss each feature, its implementation where noteworthy, and the unique differences between the record struct and record class implementations.

Guidelines
DO use records, where possible, if you want equality based on data rather than identity.
{{ snackbarMessage }}
;