Structs

All the C# built-in types, such as bool and int, are value types, except for string and object, which are reference types. Numerous additional value types are provided within the framework. Developers can also define their own value types.

To define a custom value type, you use a syntax similar to the syntax you would use to define class and interface types. The key difference in the syntax is that value types use the keyword struct (Listing 9.1).

Listing 9.1: Example struct
public struct Angle
{
    // ...
}

Unfortunately, the commented-out ellipses represent a host of complexity as we will come to see later in the chapter.

Declaring a Record Struct

Fortunately, starting with C# 9.0, a lot of the complexity associated with creating a struct is eliminated with a new contextual keyword—record—that triggers the compiler into generating much of the important and complex code related to value type behavior. Listing 9.2 provides an example. With this simple syntax, we have a value type that describes a high-precision angle in terms of its degrees, minutes, and seconds. (A minute is one-sixtieth of a degree, and a second is one-sixtieth of a minute.) This system is used in navigation because it has the nice property that an arc of one minute over the surface of the ocean at the equator is exactly one nautical mile.

Listing 9.2: Declaring a struct
// Use the record struct construct to declare a value type
public readonly record struct Angle(
    int Degrees, int Minutes, int Seconds, string? Name = null);

The syntax, which uses a primary constructor, is all that is needed to define the entire type. Unlike regular primary constructors explained in Chapter 6 (and not available until C# 12.0), however, a record’s primary constructor also generates an initial set of properties from the (optional1) positional parameters. As a result, this simple code construct, called a record struct, has all the requisites of what you need to build a value type including value type declaration, data storage with properties, immutability, constructor initialization, deconstruction, equivalence behavior, and even ToString() diagnostic capabilities. Listing 9.3 demonstrates working with the Angle type.

Listing 9.3: Working with the Record Struct
using System.Diagnostics;
 
public class Program
{
    public static void Main()
    {
        (int degrees, int minutes, int seconds) = (90, 0, 0);
 
        // The constructor is generated using positional parameters
        Angle angle = new(degrees, minutes, seconds);
 
        // Records include a ToString() implementation
        // that returns:
        //   "Angle { Degrees = 90, Minutes = 0, Seconds = 0, Name =  }"
        Console.WriteLine(angle.ToString());
        
        // Records have a deconstructor using the 
        // positional parameters.
        if (angle is (intintintstring) angleData)
        {
            Trace.Assert(angle.Degrees == angleData.Degrees);
            Trace.Assert(angle.Minutes == angleData.Minutes);
            Trace.Assert(angle.Seconds == angleData.Seconds);
        }
 
        Angle copy = new(degrees, minutes, seconds);       
        // Records provide a custom equality operator.
        Trace.Assert(angle == copy);
 
        // The with operator is the equivalent of
        // Angle copy = new(degrees, minutes, seconds);
        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);
    }
}

You will notice from the Listing 9.2 that there is both a generated constructor and a deconstructor based solely on the positional parameters. This is in addition to the properties (not shown) defined for the same. Also, two instances of the Angle are evaluated as equal if the properties are equivalent, an equivalence implemented according to the guidelines for overriding the equality operator.

Record Struct Code Generation

All the functionality of a record struct is generated by the C# compiler at compile time. The C# equivalent of the generated code appears in Listing 9.4.

Listing 9.4: Equivalent Record Struct Generated C# Code
using System.Runtime.CompilerServices;
using System.Text;
 
[CompilerGenerated]
public readonly struct Angle : IEquatable<Angle>
{
    public int Degrees { getinit; }
    public int Minutes { getinit; }
    public int Seconds { getinit; }
    public string? Name { getinit; }
    public Angle(int Degrees, int Minutes, int Seconds, string? Name)
    {
        this.Degrees = Degrees;
        this.Minutes = Minutes;
        this.Seconds = Seconds;
        this.Name = Name;
    }
    
    public override string ToString()
    {
        StringBuilder stringBuilder = new();
        stringBuilder.Append("Angle");
        stringBuilder.Append(" { ");
        if (PrintMembers(stringBuilder))
        {
            stringBuilder.Append(' ');
        }
        stringBuilder.Append('}');
        return stringBuilder.ToString();
    }
 
    private bool PrintMembers(StringBuilder builder)
    {
        builder.Append("Degrees = ");
        builder.Append(Degrees);
        builder.Append(", Minutes = ");
        builder.Append(Minutes);
        builder.Append(", Seconds = ");
        builder.Append(Seconds);
        builder.Append(", Name = ");
        builder.Append(Name);
        return true;
    }
 
    public static bool operator !=(Angle left, Angle right) =>
        !(left == right);
 
    public static bool operator ==(Angle left, Angle right) =>
        left.Equals(right);
 
    public override int GetHashCode()
    {
        static int GetHashCode(int integer) => 
            EqualityComparer<int>.Default.GetHashCode(integer);
 
        return GetHashCode(Degrees) * -1521134295
            + GetHashCode(Minutes) * -1521134295
            + GetHashCode(Seconds) * -1521134295
            + EqualityComparer<string>.Default.GetHashCode(Name!);
    }
 
    public override bool Equals(object? obj) =>
        obj is Angle angle && Equals(angle);
 
    public bool Equals(Angle other) =>
        EqualityComparer<int>.Default.Equals(Degrees, other.Degrees)
        && EqualityComparer<int>.Default.Equals(Minutes, other.Minutes)
        && EqualityComparer<int>.Default.Equals(Seconds, other.Seconds)
        && EqualityComparer<string>.Default.Equals(Name, other.Name);
 
    public void Deconstruct(out int Degrees, out int Minutes,
        out int Seconds, out string? Name)
            => (Degrees, Minutes, Seconds, Name) =
                (this.Degrees, this.Minutes, this.Seconds, this.Name);
}

From the one line of code in Listing 9.1, Listing 9.3 shows a significant amount of code is generated. To start, the Angle is not a class but a struct, and the record contextual keyword dropped. In C#, to define a custom value type it must be a struct. And, while C# allows you to write the entire struct from scratch as demonstrated by Listing 9.3, C# 9.0’s record keyword jumpstarts much of the boilerplate code such that there is no longer any reason not to use the record keyword to start, especially since you can define custom versions of all the generated code, thus overriding the default generated implementations as needed.

Note that in C# 9.0, the record keyword without the struct keyword was all that the compiler allowed. However, in C# 10.0 (with the introduction of records for structs), the explicit declaration of record class was added. For clarity, we recommend using record class always, rather than the abbreviated record-only syntax.

Guidelines
DO use record struct when declaring a struct (C# 10.0).
DO use record class (C# 10.0) for clarity, rather than the abbreviated record-only syntax.

All value types are implicitly sealed (you can’t derive from them). In addition, all non-enum value types (enums are discussed later in the chapter) derive from System.ValueType. Consequently, the inheritance chain for structs is always from object to System.ValueType to the custom struct.

System.ValueType brings with it the behavior of value types by overriding all the virtual methods of object: Equals(), ToString(), and GetHashCode(). However, this implantation is generally not sufficient, leaving the developer with the responsibility of further specializing each of these methods—of course, that is until C# 10.0 and the introduction of the record modifier, where now the C# compiler generates custom overrides that are much more suited for production code. Much of the same record functionality is also available on classes.

________________________________________

1. Both the positional parameters and the parentheses surrounding them (when there are no positional parameters) are optional. Although it would be peculiar to declare with only parenthesis, as doing so would be similar to declaring an empty struct or class.
{{ snackbarMessage }}
;