Record Class Inheritance

While there is no inheritance on record structs since they are sealed, record classes do support inheritance. The main restriction is that record classes can inherit only from other record classes, not even from standard classes. Listing 9.7 provides an example.

Listing 9.7: Record Class Inheritance
public record class NamedCoordinate(
    Angle Longitude, Angle Latitude, string Name)
    : Coordinate(Longitude, Latitude);
 

Notice that in defining the inheritance relationship, you can also identify how the base constructor will be invoked with arguments following the base class name. In this case, we pass Longitude and Latitude into the positional parameter–generated constructor for Coordinate.

Records

In this section, we delve into the details of the code behind records, highlight the differences between record classes and record structs, and, most importantly, dig into the details of implementing value equivalence. We begin the discussion with a record’s data storage.

Data Storage with Properties

Notice how the positional parameters Degree, Minutes, and Seconds map into autogenerated properties on the Angle struct. It’s similar with Longitude and Latitude on Coordinate. Properties are read-only (init-only setters) once initialization is complete. The fact that they are read-only is the default for record classes or caused by the readonly modifier on the record struct declaration—the modifier that causes the value type to be immutable once initialization is complete.

The one minor peculiarity with the record is that the positional parameters are, by convention, PascalCase, and, therefore, so are the parameters for the constructor. While not required by the compiler, using PascalCase for positional parameters ensures that that the corresponding properties will also have PascalCase—the latter being a more visible part of the type’s API.

To customize the default implementation, you can define your own version of each property using the same name—perhaps using a backing field and custom validation. You could even replace the property with a field with the same name. Furthermore, you can code additional properties and fields, even if they were not included as positional parameters. The only restriction is that with the struct’s readonly modifier, all properties and fields must also be read-only.

Note that there is no validation associated with the generated properties. Therefore, a non-nullable reference type positional parameter will not include a check for null. For this reason, consider always defining reference type positional parameters as nullable unless you provide a custom implementation that checks for null.

Guidelines
DO use PascalCase for the positional parameters of the record (C# 9.0).
DO define all reference type positional parameters as nullable if not providing a custom property implementation that checks for null.
DO implement custom non-nullable properties for all non-nullable positional parameters.
Immutable Value Types

An important guideline is for all value types and record classes to be immutable: Once you have instantiated either of these, you should not be able to modify the same instance. There are three good reasons for this guideline.

First, with structs, value types should represent values. One does not think of adding two integers together as mutating either of them; rather, the two addends are immutable, and a third value is produced as the result. Second, because value types are copied by value, not by reference, it is easy to get confused and incorrectly believe that a mutation in one value type variable can be observed to cause a mutation in another, as it would with a reference type.

Third, for both structs and record classes, is the fact that hash codes are calculated from the data stored within the type, and hash codes should never change. (See “Advanced Topic: Overriding GetHashCode()” later in the chapter.) If the data within these types changed and a new hash code value is calculated, searches for the new hash code value in collections where the old hash code was present could result in values never getting found and the resulting incorrect behavior.

Note that the Angle struct in Listing 9.3 is immutable because all properties are automatically implemented read-only properties2 declared using init-only setters rather than set. To verify conformance to the immutable guideline, you can use the readonly modifier with a struct definition3 (for both record structs and standard structs). See Listing 9.8:

Listing 9.8: Using Read-Only Modifier on Structs
readonly struct Angle { }

Like with a record, now the compiler will verify that the entire struct is immutable, reporting an error if there is a field that is not read-only or a property that has a setter. However, using the readonly modifier on a record class or a standard class is not supported.

In the irregular case where you need finer-grained control than declaring the entire struct as read-only, C# 8.0 allows you to define any struct (not class) member as read-only (including methods and even getters—which potentially may modify an object’s state even though they shouldn’t). For example, a Move() method can include a readonly modifier (see Listing 9.9):

Listing 9.9: Defining a struct Member as Read-Only
public readonly Angle Move(int degrees, int minutes, int seconds)
{
    return new Angle(
        Degrees + degrees,
        Minutes + minutes,
        Seconds + seconds);
}

Note that in members where modifying a struct is seemingly desirable, you should instead create a new instance as the Move() method demonstrates by returning a new Angle.

Any readonly modified members that modify a struct’s data (properties or fields), or invoke a non-read-only member, will report a compile-time error. By supporting the concept of read-only access to members, developers declare the behavioral intent of whether a member can modify the object instance. Note that properties that are not automatically implemented can use the readonly modifier on either the getter or the setter (although the latter would be strange). To decorate both, the readonly modifier would be placed on the property itself, rather than on the getter and setter individually.

While allowable, using the readonly modifier on struct members is redundant when the struct is read-only. However, favor read-only ({get;}) or init-only setter ({get;init;}) automatically implemented properties over fields within structs.

Guidelines
DO use the readonly modifier on a struct definition, making value types immutable.
DO use read-only or init-only setter automatically implemented properties rather than fields within structs.

Remarkably, the tuple (System.ValueTuple) is one example that breaks the immutable guideline. To understand why it is an exception, see https://intellitect.com/WhyTupleBreaksTheImmutableRules

Cloning Records Using the with Operator

Since most records, especially record structs, are immutable, in place of changing them you will create new instances with the modified data. The easiest way to achieve this is using the C# 9.0–introduced with operator. As demonstrated at the end of Listing 9.3, the with operator clones the existing instance into a new copy. However, you are not restricted to an exact replica. Rather, you can use an object initializer type syntax to create the new instance with modified data (see Listing 9.10).

Listing 9.10: Cloning Records via the With Operator
Angle angle = new(90, 0, 0, null);
 
 // The with operator is the equivalent of
// Angle copy = new(degrees, minutes, seconds);
Angle copy = angle with { };
Trace.Assert(angle == copy);
 
// The with operator has object initializer type
// syntax for instantiating a modified copy.
Angle modifiedCopy = angle with { Degrees = 180 };
Trace.Assert(angle != modifiedCopy);

The source (left) operand for the with operator is the source instance. And while you could create an exact clone, using the object initializer syntax allows you to modify any accessible member and assign it a different value during instantiation.

Cloning a record struct is a memory copy, the same as when creating a copy to invoke a method with pass by value. For this reason, you can’t change the implementation for cloning a record struct.

For record classes, the process is slightly different. The C# compiler generates a hidden Clone() method (shown in Listing 9.5) that in turn invokes the copy constructor with a parameter for the source instance. You can also view that same code in Listing 9.11:

Listing 9.11: Cloning Record Classes via the Clone Method
// 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;
}

The clone method in turn calls the copy constructor.4 Note that in the case where the object initializer syntax is used, the assigned property cannot be read-only (either a setter or an init-only setter is required). Also, the Clone() method uses a special non-C#–compliant name, making it accessible only via the with operator. If you want to customize the clone behavior, however, you can provide your own implementation of the copy constructor.

Record Constructors

Note that the record declaration of Listing 9.1 looks virtually identical to the constructor signature in Listing 9.3. Similarly, with the record class associated code in Listing 9.4 and its equivalent C#-generated equivalent in Listing 9.5, a record declaration and its positional parameters provide the structure for the C# compiler to generate a constructor with an equivalent-looking signature.

As with the properties themselves, one minor peculiarity with the record is that the positional parameters are, by convention, PascalCase, and, therefore, so are the parameters for the constructor. (Consequently, when initializing the properties, the generated constructor code uses the this qualifier to distinguish the parameters from the properties.)

You can add additional constructors to the record’s definition. For example, you could provide a constructor that has strings rather than integers as parameters, as shown in Listing 9.12:

Listing 9.12: Adding Additional Record Constructors
public Angle(
    string degrees, string minutes, string seconds)
    : this(int.Parse(degrees),
           int.Parse(minutes), 
           int.Parse(seconds))
{ }

The implementation of an additional constructor uses the same syntax as any other constructor. The only additional constraint is that it must invoke the this constructor initializer—it must call the record-generated constructor or another constructor that calls the record-generated constructor. This ensures that the initialization of the positional parameter–generated properties are all initialized.

Record Struct Initialization

Prior to C# 10.0, no default constructor could be defined. Regardless, if not explicitly instantiating a struct via the new operator’s call to the constructor, all data contained within the struct is implicitly initialized to that data’s default value. The default value is null for a field of reference type data, a zero value for a field of numeric type, and false for a field of Boolean type, including fields backing an automatically implemented property. Similarly, since field and property initialization during declaration5 (such as string Description { get; init; } = "") is injected into the constructor by the compiler, it also will not execute without the new operator invocation.

It is important to be aware of this because, unlike with reference types, it is common for value types to be instantiated without the new operator. Assigning a value type to default or not initializing a value type field on a class, for example, will not execute any constructor, and neither will an uninitialized array item such as the one displayed in Listing 9.13:

Listing 9.13: Uninitialized Array Item
Angle[] angles = new[42];

In both cases, regardless of what the default constructor initializes data members to, they will end up with default values. For this reason, do not rely on either default constructors or member initialization at declaration time.

Prior to C# 11.0, every constructor in a struct must initialize all fields (and read-only, automatically implemented properties6) within the struct. Failure to initialize all data within the struct causes a compile-time error in C# 10.0 and earlier:

CS0171: Field must be fully assigned before control is returned to

  the caller. Consider updating to language version '11.0'

  to auto-default the field.

Because of the struct’s field initialization requirement, the succinctness of read-only field declaration, automatically implemented property support, and the guideline to avoid accessing fields from outside of their wrapping property, you should favor read-only, automatically implemented properties over fields within structs.

Guidelines
DO ensure that the default value of a struct is valid; encapsulation cannot prevent obtaining the default “all zero” value of a struct.
DO NOT rely on either default constructors or member initialization at declaration to run on a value type.
AdVanced Topic
Using new with Value Types

Invoking the new operator with a reference type causes the runtime to create a new instance of the object on the garbage-collected heap, initialize all of its fields to their default values, and call the constructor, passing a reference to the instance as this. The result is the reference to the instance, which can then be copied to its final destination. In contrast, invoking the new operator with a value type causes the runtime to create a new instance of the object on the temporary storage pool, initialize all of its fields to their default values, and call the constructor (passing the temporary storage location as a ref variable through this), resulting in the value being stored in the temporary storage location, which can then be copied to its final destination.

Unlike classes, structs do not support finalizers. Structs are copied by value; they do not have referential identity, as reference types do. Therefore, it is hard to know when it would be safe to execute the finalizer and free an unmanaged resource owned by the struct. The garbage collector knows when there are no “live” references to an instance of reference type and can choose to run the finalizer for an instance of reference type at any time after there are no more live references. Nevertheless, no part of the runtime tracks how many copies of a given value type exist at any moment.

Language Contrast: C++—struct Defines Type with Public Members

In C++, the difference between a type declared with struct and one declared with class is whether the default accessibility is public or private. The contrast is far greater in C#, where the difference is whether instances of the type are copied by value or by reference.

Record Deconstructors

In addition to the properties and constructor, the C# compiler also generates a positional parameter–corresponding deconstructor, as seen in Listing 9.14:

Listing 9.14: Record Deconstructor
public void Deconstruct(
    out int Degrees, out int Minutes, out int Seconds)
{
    Degrees = this.Degrees;
    Minutes = this.Minutes;
    Seconds = this.Seconds;
}

This enables pattern matching like that used in Listing 9.2 (displayed again in Listing 9.15):

Listing 9.15: Pattern Matching
if (angle is (intintintstring) angleData)
{
    // ...
}

________________________________________

2. Automatically implemented read-only properties became available in C# 6.0.
3. Starting in C# 7.2.
4. A constructor that takes single parameter or the containing type. See Chapter 6.
5. Enabled starting in C# 10.0
6. Initialization via a read-only, automatically implemented property is sufficient starting in C# 6.0, because the backing field is unknown and its initialization would not be possible otherwise.
{{ snackbarMessage }}
;